diff --git a/ProjectLighthouse.Localization/StringLists/StatusStrings.cs b/ProjectLighthouse.Localization/StringLists/StatusStrings.cs index c4ad6b73..0022dc1d 100644 --- a/ProjectLighthouse.Localization/StringLists/StatusStrings.cs +++ b/ProjectLighthouse.Localization/StringLists/StatusStrings.cs @@ -3,7 +3,7 @@ namespace LBPUnion.ProjectLighthouse.Localization.StringLists; public static class StatusStrings { public static readonly TranslatableString CurrentlyOnline = create("currently_online"); - public static readonly TranslatableString LastOnline = create("last_online"); // TODO: implement + public static readonly TranslatableString LastOnline = create("last_online"); public static readonly TranslatableString Offline = create("offline"); private static TranslatableString create(string key) => new(TranslationAreas.Status, key); diff --git a/ProjectLighthouse.Servers.GameServer/Controllers/LoginController.cs b/ProjectLighthouse.Servers.GameServer/Controllers/LoginController.cs index 5296ff22..b41adee2 100644 --- a/ProjectLighthouse.Servers.GameServer/Controllers/LoginController.cs +++ b/ProjectLighthouse.Servers.GameServer/Controllers/LoginController.cs @@ -7,7 +7,6 @@ using LBPUnion.ProjectLighthouse.Match.Rooms; using LBPUnion.ProjectLighthouse.PlayerData; using LBPUnion.ProjectLighthouse.PlayerData.Profiles; using LBPUnion.ProjectLighthouse.Tickets; -using LBPUnion.ProjectLighthouse.Types; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -122,6 +121,8 @@ public class LoginController : ControllerBase // and so we don't pick the same token up when logging in later. token.Used = true; + user.LastLogin = TimeHelper.TimestampMillis; + await this.database.SaveChangesAsync(); // Create a new room on LBP2/3/Vita diff --git a/ProjectLighthouse.Servers.GameServer/Controllers/LogoutController.cs b/ProjectLighthouse.Servers.GameServer/Controllers/LogoutController.cs new file mode 100644 index 00000000..72dba258 --- /dev/null +++ b/ProjectLighthouse.Servers.GameServer/Controllers/LogoutController.cs @@ -0,0 +1,42 @@ +using LBPUnion.ProjectLighthouse.Extensions; +using LBPUnion.ProjectLighthouse.Helpers; +using LBPUnion.ProjectLighthouse.PlayerData; +using LBPUnion.ProjectLighthouse.PlayerData.Profiles; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace LBPUnion.ProjectLighthouse.Servers.GameServer.Controllers; + +[ApiController] +[Route("LITTLEBIGPLANETPS3_XML/goodbye")] +[Produces("text/xml")] +public class LogoutController : ControllerBase +{ + + private readonly Database database; + + public LogoutController(Database database) + { + this.database = database; + } + + [HttpPost] + public async Task OnPost() + { + GameToken? token = await this.database.GameTokenFromRequest(this.Request); + if (token == null) return this.StatusCode(403, ""); + + User? user = await this.database.Users.Where(u => u.UserId == token.UserId).FirstOrDefaultAsync(); + if (user == null) return this.StatusCode(403, ""); + + user.LastLogout = TimeHelper.TimestampMillis; + + this.database.GameTokens.RemoveWhere(t => t.TokenId == token.TokenId); + this.database.LastContacts.RemoveWhere(c => c.UserId == token.UserId); + await this.database.SaveChangesAsync(); + + return this.Ok(); + } + + +} \ No newline at end of file diff --git a/ProjectLighthouse.Servers.GameServer/Controllers/Matching/EnterLevelController.cs b/ProjectLighthouse.Servers.GameServer/Controllers/Matching/EnterLevelController.cs index 734aa54f..12aee5a0 100644 --- a/ProjectLighthouse.Servers.GameServer/Controllers/Matching/EnterLevelController.cs +++ b/ProjectLighthouse.Servers.GameServer/Controllers/Matching/EnterLevelController.cs @@ -1,7 +1,7 @@ #nullable enable +using LBPUnion.ProjectLighthouse.Helpers; using LBPUnion.ProjectLighthouse.Levels; using LBPUnion.ProjectLighthouse.PlayerData; -using LBPUnion.ProjectLighthouse.PlayerData.Profiles; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -19,12 +19,17 @@ public class EnterLevelController : ControllerBase this.database = database; } - [HttpPost("play/user/{slotId}")] - public async Task PlayLevel(int slotId) + [HttpPost("play/{slotType}/{slotId:int}")] + public async Task PlayLevel(string slotType, int slotId) { GameToken? token = await this.database.GameTokenFromRequest(this.Request); if (token == null) return this.StatusCode(403, ""); + if (SlotHelper.IsTypeInvalid(slotType)) return this.BadRequest(); + + // don't count plays for developer slots + if (slotType == "developer") return this.Ok(); + Slot? slot = await this.database.Slots.FirstOrDefaultAsync(s => s.SlotId == slotId); if (slot == null) return this.StatusCode(403, ""); @@ -84,16 +89,20 @@ public class EnterLevelController : ControllerBase } // Only used in LBP1 - [HttpGet("enterLevel/{id:int}")] - public async Task EnterLevel(int id) + [HttpPost("enterLevel/{slotType}/{slotId:int}")] + public async Task EnterLevel(string slotType, int slotId) { GameToken? token = await this.database.GameTokenFromRequest(this.Request); if (token == null) return this.StatusCode(403, ""); - Slot? slot = await this.database.Slots.FirstOrDefaultAsync(s => s.SlotId == id); + if (SlotHelper.IsTypeInvalid(slotType)) return this.BadRequest(); + + if (slotType == "developer") return this.Ok(); + + Slot? slot = await this.database.Slots.FirstOrDefaultAsync(s => s.SlotId == slotId); if (slot == null) return this.NotFound(); - IQueryable visited = this.database.VisitedLevels.Where(s => s.SlotId == id && s.UserId == token.UserId); + IQueryable visited = this.database.VisitedLevels.Where(s => s.SlotId == slotId && s.UserId == token.UserId); VisitedLevel? v; if (!visited.Any()) { @@ -101,7 +110,7 @@ public class EnterLevelController : ControllerBase v = new VisitedLevel { - SlotId = id, + SlotId = slotId, UserId = token.UserId, }; this.database.VisitedLevels.Add(v); diff --git a/ProjectLighthouse.Servers.GameServer/Controllers/Resources/PhotosController.cs b/ProjectLighthouse.Servers.GameServer/Controllers/Resources/PhotosController.cs index 5334261c..a3b09d9f 100644 --- a/ProjectLighthouse.Servers.GameServer/Controllers/Resources/PhotosController.cs +++ b/ProjectLighthouse.Servers.GameServer/Controllers/Resources/PhotosController.cs @@ -228,6 +228,20 @@ public class PhotosController : ControllerBase this.database.PhotoSubjects.RemoveWhere(p => p.PhotoSubjectId == subjectId); } + + HashSet photoResources = new(){photo.LargeHash, photo.SmallHash, photo.MediumHash, photo.PlanHash,}; + foreach (string hash in photoResources) + { + if (System.IO.File.Exists(Path.Combine("png", $"{hash}.png"))) + { + System.IO.File.Delete(Path.Combine("png", $"{hash}.png")); + } + if (System.IO.File.Exists(Path.Combine("r", hash))) + { + System.IO.File.Delete(Path.Combine("r", hash)); + } + } + this.database.Photos.Remove(photo); await this.database.SaveChangesAsync(); return this.Ok(); diff --git a/ProjectLighthouse.Servers.GameServer/Controllers/Resources/ResourcesController.cs b/ProjectLighthouse.Servers.GameServer/Controllers/Resources/ResourcesController.cs index 19c189e4..43ec1d48 100644 --- a/ProjectLighthouse.Servers.GameServer/Controllers/Resources/ResourcesController.cs +++ b/ProjectLighthouse.Servers.GameServer/Controllers/Resources/ResourcesController.cs @@ -85,6 +85,12 @@ public class ResourcesController : ControllerBase return this.Conflict(); } + if (!FileHelper.AreDependenciesSafe(file)) + { + Logger.Warn($"File has unsafe dependencies (hash: {hash}, type: {file.FileType}", LogArea.Resources); + return this.Conflict(); + } + string calculatedHash = file.Hash; if (calculatedHash != hash) { diff --git a/ProjectLighthouse.Servers.GameServer/Controllers/Slots/LevelTagsController.cs b/ProjectLighthouse.Servers.GameServer/Controllers/Slots/LevelTagsController.cs index a5ecd0bd..d71773fd 100644 --- a/ProjectLighthouse.Servers.GameServer/Controllers/Slots/LevelTagsController.cs +++ b/ProjectLighthouse.Servers.GameServer/Controllers/Slots/LevelTagsController.cs @@ -1,14 +1,24 @@ +using LBPUnion.ProjectLighthouse.Helpers; using LBPUnion.ProjectLighthouse.Levels; +using LBPUnion.ProjectLighthouse.PlayerData; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; namespace LBPUnion.ProjectLighthouse.Servers.GameServer.Controllers.Slots; [ApiController] -[Route("LITTLEBIGPLANETPS3_XML/tags")] +[Route("LITTLEBIGPLANETPS3_XML")] [Produces("text/plain")] public class LevelTagsController : ControllerBase { - [HttpGet] + private readonly Database database; + + public LevelTagsController(Database database) + { + this.database = database; + } + + [HttpGet("tags")] public IActionResult Get() { string[] tags = Enum.GetNames(typeof(LevelTags)); @@ -22,4 +32,32 @@ public class LevelTagsController : ControllerBase return this.Ok(string.Join(",", tags)); } + + [HttpPost("tag/{slotType}/{id:int}")] + public async Task PostTag([FromForm] string t, [FromRoute] string slotType, [FromRoute] int id) + { + GameToken? token = await this.database.GameTokenFromRequest(this.Request); + if (token == null) return this.StatusCode(403, ""); + + Slot? slot = await this.database.Slots.Where(s => s.SlotId == id).FirstOrDefaultAsync(); + if (slot == null) return this.BadRequest(); + + if (!LabelHelper.IsValidTag(t)) return this.BadRequest(); + + if (token.UserId == slot.CreatorId) return this.BadRequest(); + + if (slot.GameVersion != GameVersion.LittleBigPlanet1) return this.BadRequest(); + + if (slotType != "user") return this.BadRequest(); + + RatedLevel? rating = await this.database.RatedLevels.FirstOrDefaultAsync(r => r.UserId == token.UserId && r.SlotId == slot.SlotId); + if (rating == null) return this.BadRequest(); + + rating.TagLBP1 = t; + + await this.database.SaveChangesAsync(); + + return this.Ok(); + } + } \ No newline at end of file diff --git a/ProjectLighthouse.Servers.GameServer/Controllers/Slots/PublishController.cs b/ProjectLighthouse.Servers.GameServer/Controllers/Slots/PublishController.cs index 657e0c9c..7982ec01 100644 --- a/ProjectLighthouse.Servers.GameServer/Controllers/Slots/PublishController.cs +++ b/ProjectLighthouse.Servers.GameServer/Controllers/Slots/PublishController.cs @@ -146,6 +146,8 @@ public class PublishController : ControllerBase slot.GameVersion = slotVersion; if (slotVersion == GameVersion.Unknown) slot.GameVersion = gameToken.GameVersion; + slot.AuthorLabels = LabelHelper.RemoveInvalidLabels(slot.AuthorLabels); + // Republish logic if (slot.SlotId != 0) { diff --git a/ProjectLighthouse.Servers.GameServer/Controllers/Slots/ReviewController.cs b/ProjectLighthouse.Servers.GameServer/Controllers/Slots/ReviewController.cs index 3902017b..cfb9fc62 100644 --- a/ProjectLighthouse.Servers.GameServer/Controllers/Slots/ReviewController.cs +++ b/ProjectLighthouse.Servers.GameServer/Controllers/Slots/ReviewController.cs @@ -5,10 +5,8 @@ using LBPUnion.ProjectLighthouse.Extensions; using LBPUnion.ProjectLighthouse.Helpers; using LBPUnion.ProjectLighthouse.Levels; using LBPUnion.ProjectLighthouse.PlayerData; -using LBPUnion.ProjectLighthouse.PlayerData.Profiles; using LBPUnion.ProjectLighthouse.PlayerData.Reviews; using LBPUnion.ProjectLighthouse.Serialization; -using LBPUnion.ProjectLighthouse.Types; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -27,7 +25,7 @@ public class ReviewController : ControllerBase } // LBP1 rating - [HttpPost("rate/user/{slotId}")] + [HttpPost("rate/user/{slotId:int}")] public async Task Rate(int slotId, [FromQuery] int rating) { GameToken? token = await this.database.GameTokenFromRequest(this.Request); @@ -44,6 +42,7 @@ public class ReviewController : ControllerBase SlotId = slotId, UserId = token.UserId, Rating = 0, + TagLBP1 = "", }; this.database.RatedLevels.Add(ratedLevel); } @@ -62,7 +61,7 @@ public class ReviewController : ControllerBase GameToken? token = await this.database.GameTokenFromRequest(this.Request); if (token == null) return this.StatusCode(403, ""); - Slot? slot = await this.database.Slots.Include(s => s.Creator).Include(s => s.Location).FirstOrDefaultAsync(s => s.SlotId == slotId); + Slot? slot = await this.database.Slots.Include(s => s.Location).FirstOrDefaultAsync(s => s.SlotId == slotId); if (slot == null) return this.StatusCode(403, ""); RatedLevel? ratedLevel = await this.database.RatedLevels.FirstOrDefaultAsync(r => r.SlotId == slotId && r.UserId == token.UserId); @@ -73,6 +72,7 @@ public class ReviewController : ControllerBase SlotId = slotId, UserId = token.UserId, RatingLBP1 = 0, + TagLBP1 = "", }; this.database.RatedLevels.Add(ratedLevel); } @@ -113,7 +113,8 @@ public class ReviewController : ControllerBase this.database.Reviews.Add(review); } review.Thumb = newReview.Thumb; - review.LabelCollection = newReview.LabelCollection; + review.LabelCollection = LabelHelper.RemoveInvalidLabels(newReview.LabelCollection); + review.Text = newReview.Text; review.Deleted = false; review.Timestamp = TimeHelper.UnixTimeMilliseconds(); @@ -127,6 +128,7 @@ public class ReviewController : ControllerBase SlotId = slotId, UserId = token.UserId, RatingLBP1 = 0, + TagLBP1 = "", }; this.database.RatedLevels.Add(ratedLevel); } @@ -170,7 +172,7 @@ public class ReviewController : ControllerBase if (review == null) return current; RatedReview? yourThumb = this.database.RatedReviews.FirstOrDefault(r => r.ReviewId == review.ReviewId && r.UserId == token.UserId); - return current + review.Serialize(null, yourThumb); + return current + review.Serialize(yourThumb); } ); string response = LbpSerializer.TaggedStringElement @@ -183,7 +185,7 @@ public class ReviewController : ControllerBase "hint_start", pageStart + pageSize }, { - "hint", reviewList.LastOrDefault()!.Timestamp // not sure + "hint", reviewList.LastOrDefault()?.Timestamp ?? 0 }, } ); @@ -222,7 +224,7 @@ public class ReviewController : ControllerBase if (review == null) return current; RatedReview? ratedReview = this.database.RatedReviews.FirstOrDefault(r => r.ReviewId == review.ReviewId && r.UserId == token.UserId); - return current + review.Serialize(null, ratedReview); + return current + review.Serialize(ratedReview); } ); @@ -236,7 +238,7 @@ public class ReviewController : ControllerBase "hint_start", pageStart }, { - "hint", reviewList.LastOrDefault()!.Timestamp // Seems to be the timestamp of oldest + "hint", reviewList.LastOrDefault()?.Timestamp ?? 0 // Seems to be the timestamp of oldest }, } ); diff --git a/ProjectLighthouse.Servers.GameServer/Controllers/Slots/ScoreController.cs b/ProjectLighthouse.Servers.GameServer/Controllers/Slots/ScoreController.cs index ffdbd948..549f18aa 100644 --- a/ProjectLighthouse.Servers.GameServer/Controllers/Slots/ScoreController.cs +++ b/ProjectLighthouse.Servers.GameServer/Controllers/Slots/ScoreController.cs @@ -4,7 +4,6 @@ using System.Xml.Serialization; using LBPUnion.ProjectLighthouse.Helpers; using LBPUnion.ProjectLighthouse.Levels; using LBPUnion.ProjectLighthouse.PlayerData; -using LBPUnion.ProjectLighthouse.PlayerData.Profiles; using LBPUnion.ProjectLighthouse.Serialization; using Microsoft.AspNetCore.Mvc; @@ -39,6 +38,12 @@ public class ScoreController : ControllerBase Score? score = (Score?)serializer.Deserialize(new StringReader(bodyString)); if (score == null) return this.BadRequest(); + if (score.PlayerIds.Length == 0) return this.BadRequest(); + + if (score.Points < 0) return this.BadRequest(); + + if (score.Type is > 4 or < 1) return this.BadRequest(); + SanitizationHelper.SanitizeStringsInClass(score); if (slotType == "developer") id = await SlotHelper.GetPlaceholderSlotId(this.database, id, SlotType.Developer); @@ -60,6 +65,9 @@ public class ScoreController : ControllerBase case GameVersion.LittleBigPlanet3: slot.PlaysLBP3Complete++; break; + case GameVersion.LittleBigPlanetPSP: break; + case GameVersion.Unknown: break; + default: throw new ArgumentOutOfRangeException(); } // Submit scores from all players in lobby @@ -110,7 +118,6 @@ public class ScoreController : ControllerBase [SuppressMessage("ReSharper", "PossibleMultipleEnumeration")] public async Task TopScores(string slotType, int slotId, int type, [FromQuery] int pageStart = -1, [FromQuery] int pageSize = 5) { - // Get username GameToken? token = await this.database.GameTokenFromRequest(this.Request); if (token == null) return this.StatusCode(403, ""); diff --git a/ProjectLighthouse.Servers.GameServer/Controllers/Slots/SearchController.cs b/ProjectLighthouse.Servers.GameServer/Controllers/Slots/SearchController.cs index 401b3bd3..9addc61a 100644 --- a/ProjectLighthouse.Servers.GameServer/Controllers/Slots/SearchController.cs +++ b/ProjectLighthouse.Servers.GameServer/Controllers/Slots/SearchController.cs @@ -1,4 +1,5 @@ #nullable enable +using LBPUnion.ProjectLighthouse.Extensions; using LBPUnion.ProjectLighthouse.Levels; using LBPUnion.ProjectLighthouse.PlayerData; using LBPUnion.ProjectLighthouse.Serialization; @@ -20,7 +21,7 @@ public class SearchController : ControllerBase [HttpGet("searchLBP3")] public Task SearchSlotsLBP3([FromQuery] int pageSize, [FromQuery] int pageStart, [FromQuery] string textFilter) - => SearchSlots(textFilter, pageSize, pageStart, "results"); + => this.SearchSlots(textFilter, pageSize, pageStart, "results"); [HttpGet("search")] public async Task SearchSlots( @@ -41,8 +42,8 @@ public class SearchController : ControllerBase string[] keywords = query.Split(" "); - IQueryable dbQuery = this.database.Slots.Include(s => s.Creator) - .Include(s => s.Location) + IQueryable dbQuery = this.database.Slots.ByGameVersion(gameToken.GameVersion, false, true) + .Where(s => s.Type == SlotType.User) .OrderBy(s => !s.TeamPick) .ThenByDescending(s => s.FirstUploaded) .Where(s => s.SlotId >= 0); // dumb query to conv into IQueryable diff --git a/ProjectLighthouse.Servers.GameServer/Controllers/Slots/SlotsController.cs b/ProjectLighthouse.Servers.GameServer/Controllers/Slots/SlotsController.cs index 968699b5..010d622e 100644 --- a/ProjectLighthouse.Servers.GameServer/Controllers/Slots/SlotsController.cs +++ b/ProjectLighthouse.Servers.GameServer/Controllers/Slots/SlotsController.cs @@ -24,6 +24,19 @@ public class SlotsController : ControllerBase this.database = database; } + private string GenerateSlotsResponse(string slotAggregate, int start, int total) => + LbpSerializer.TaggedStringElement("slots", + slotAggregate, + new Dictionary + { + { + "hint_start", start + }, + { + "total", total + }, + }); + [HttpGet("slots/by")] public async Task SlotsBy([FromQuery] string u, [FromQuery] int pageStart, [FromQuery] int pageSize) { @@ -46,24 +59,9 @@ public class SlotsController : ControllerBase string.Empty, (current, slot) => current + slot.Serialize(token.GameVersion) ); - - return this.Ok - ( - LbpSerializer.TaggedStringElement - ( - "slots", - response, - new Dictionary - { - { - "hint_start", pageStart + Math.Min(pageSize, ServerConfiguration.Instance.UserGeneratedContentLimits.EntitledSlots) - }, - { - "total", await this.database.Slots.CountAsync(s => s.CreatorId == targetUserId) - }, - } - ) - ); + int start = pageStart + Math.Min(pageSize, ServerConfiguration.Instance.UserGeneratedContentLimits.EntitledSlots); + int total = await this.database.Slots.CountAsync(s => s.CreatorId == targetUserId); + return this.Ok(this.GenerateSlotsResponse(response, start, total)); } [HttpGet("slotList")] @@ -162,10 +160,9 @@ public class SlotsController : ControllerBase [FromQuery] int? page = null ) { - int _pageStart = pageStart; - if (page != null) _pageStart = (int)page * 30; + if (page != null) pageStart = (int)page * 30; // bit of a better placeholder until we can track average user interaction with /stream endpoint - return await this.ThumbsSlots(_pageStart, Math.Min(pageSize, 30), gameFilterType, players, move, "thisWeek"); + return await this.ThumbsSlots(pageStart, Math.Min(pageSize, 30), gameFilterType, players, move, "thisWeek"); } [HttpGet("slots")] @@ -184,24 +181,96 @@ public class SlotsController : ControllerBase .Take(Math.Min(pageSize, 30)); string response = Enumerable.Aggregate(slots, string.Empty, (current, slot) => current + slot.Serialize(gameVersion)); + int start = pageStart + Math.Min(pageSize, ServerConfiguration.Instance.UserGeneratedContentLimits.EntitledSlots); + int total = await StatisticsHelper.SlotCount(); + return this.Ok(this.GenerateSlotsResponse(response, start, total)); + } - return this.Ok - ( - LbpSerializer.TaggedStringElement - ( - "slots", - response, - new Dictionary - { - { - "hint_start", pageStart + Math.Min(pageSize, ServerConfiguration.Instance.UserGeneratedContentLimits.EntitledSlots) - }, - { - "total", await StatisticsHelper.SlotCount() - }, - } - ) - ); + [HttpGet("slots/like/{slotType}/{slotId:int}")] + public async Task SimilarSlots([FromRoute] string slotType, [FromRoute] int slotId, [FromQuery] int pageStart, [FromQuery] int pageSize) + { + GameToken? token = await this.database.GameTokenFromRequest(this.Request); + if (token == null) return this.StatusCode(403, ""); + + if (pageSize <= 0) return this.BadRequest(); + + if (slotType != "user") return this.BadRequest(); + + GameVersion gameVersion = token.GameVersion; + + Slot? targetSlot = await this.database.Slots.FirstOrDefaultAsync(s => s.SlotId == slotId); + if (targetSlot == null) return this.BadRequest(); + + string[] tags = targetSlot.LevelTags; + + List slotIdsWithTag = this.database.RatedLevels + .Where(r => r.TagLBP1.Length > 0) + .Where(r => tags.Contains(r.TagLBP1)) + .Select(r => r.SlotId) + .ToList(); + + IQueryable slots = this.database.Slots.ByGameVersion(gameVersion, false, true) + .Where(s => slotIdsWithTag.Contains(s.SlotId)) + .OrderByDescending(s => s.PlaysLBP1) + .Skip(Math.Max(0, pageStart - 1)) + .Take(Math.Min(pageSize, 30)); + + string response = Enumerable.Aggregate(slots, string.Empty, (current, slot) => current + slot.Serialize(gameVersion)); + int start = pageStart + Math.Min(pageSize, ServerConfiguration.Instance.UserGeneratedContentLimits.EntitledSlots); + int total = slotIdsWithTag.Count; + + return this.Ok(this.GenerateSlotsResponse(response, start, total)); + } + + [HttpGet("slots/highestRated")] + public async Task HighestRatedSlots([FromQuery] int pageStart, [FromQuery] int pageSize) + { + GameToken? token = await this.database.GameTokenFromRequest(this.Request); + if (token == null) return this.StatusCode(403, ""); + + if (pageSize <= 0) return this.BadRequest(); + + GameVersion gameVersion = token.GameVersion; + + IEnumerable slots = this.database.Slots.ByGameVersion(gameVersion, false, true) + .AsEnumerable() + .OrderByDescending(s => s.RatingLBP1) + .Skip(Math.Max(0, pageStart - 1)) + .Take(Math.Min(pageSize, 30)); + + string response = Enumerable.Aggregate(slots, string.Empty, (current, slot) => current + slot.Serialize(gameVersion)); + int start = pageStart + Math.Min(pageSize, ServerConfiguration.Instance.UserGeneratedContentLimits.EntitledSlots); + int total = await StatisticsHelper.SlotCount(); + + return this.Ok(this.GenerateSlotsResponse(response, start, total)); + } + + [HttpGet("slots/tag")] + public async Task SimilarSlots([FromQuery] string tag, [FromQuery] int pageStart, [FromQuery] int pageSize) + { + GameToken? token = await this.database.GameTokenFromRequest(this.Request); + if (token == null) return this.StatusCode(403, ""); + + if (pageSize <= 0) return this.BadRequest(); + + GameVersion gameVersion = token.GameVersion; + + List slotIdsWithTag = await this.database.RatedLevels.Where(r => r.TagLBP1.Length > 0) + .Where(r => r.TagLBP1 == tag) + .Select(s => s.SlotId) + .ToListAsync(); + + IQueryable slots = this.database.Slots.ByGameVersion(gameVersion, false, true) + .Where(s => slotIdsWithTag.Contains(s.SlotId)) + .OrderByDescending(s => s.PlaysLBP1) + .Skip(Math.Max(0, pageStart - 1)) + .Take(Math.Min(pageSize, 30)); + + string response = Enumerable.Aggregate(slots, string.Empty, (current, slot) => current + slot.Serialize(gameVersion)); + int start = pageStart + Math.Min(pageSize, ServerConfiguration.Instance.UserGeneratedContentLimits.EntitledSlots); + int total = slotIdsWithTag.Count; + + return this.Ok(this.GenerateSlotsResponse(response, start, total)); } [HttpGet("slots/mmpicks")] @@ -220,24 +289,10 @@ public class SlotsController : ControllerBase .Skip(Math.Max(0, pageStart - 1)) .Take(Math.Min(pageSize, 30)); string response = Enumerable.Aggregate(slots, string.Empty, (current, slot) => current + slot.Serialize(gameVersion)); + int start = pageStart + Math.Min(pageSize, ServerConfiguration.Instance.UserGeneratedContentLimits.EntitledSlots); + int total = await StatisticsHelper.TeamPickCount(); - return this.Ok - ( - LbpSerializer.TaggedStringElement - ( - "slots", - response, - new Dictionary - { - { - "hint_start", pageStart + Math.Min(pageSize, ServerConfiguration.Instance.UserGeneratedContentLimits.EntitledSlots) - }, - { - "total", await StatisticsHelper.TeamPickCount() - }, - } - ) - ); + return this.Ok(this.GenerateSlotsResponse(response, start, total)); } [HttpGet("slots/lbp2luckydip")] @@ -253,24 +308,10 @@ public class SlotsController : ControllerBase IEnumerable slots = this.database.Slots.ByGameVersion(gameVersion, false, true).OrderBy(_ => EF.Functions.Random()).Take(Math.Min(pageSize, 30)); string response = slots.Aggregate(string.Empty, (current, slot) => current + slot.Serialize(gameVersion)); + int start = pageStart + Math.Min(pageSize, ServerConfiguration.Instance.UserGeneratedContentLimits.EntitledSlots); + int total = await StatisticsHelper.SlotCount(); - return this.Ok - ( - LbpSerializer.TaggedStringElement - ( - "slots", - response, - new Dictionary - { - { - "hint_start", pageStart + Math.Min(pageSize, ServerConfiguration.Instance.UserGeneratedContentLimits.EntitledSlots) - }, - { - "total", await StatisticsHelper.SlotCount() - }, - } - ) - ); + return this.Ok(this.GenerateSlotsResponse(response, start, total)); } [HttpGet("slots/thumbs")] @@ -299,24 +340,10 @@ public class SlotsController : ControllerBase .Take(Math.Min(pageSize, 30)); string response = slots.Aggregate(string.Empty, (current, slot) => current + slot.Serialize(token.GameVersion)); + int start = pageStart + Math.Min(pageSize, ServerConfiguration.Instance.UserGeneratedContentLimits.EntitledSlots); + int total = await StatisticsHelper.SlotCount(); - return this.Ok - ( - LbpSerializer.TaggedStringElement - ( - "slots", - response, - new Dictionary - { - { - "hint_start", pageStart + Math.Min(pageSize, ServerConfiguration.Instance.UserGeneratedContentLimits.EntitledSlots) - }, - { - "total", await StatisticsHelper.SlotCount() - }, - } - ) - ); + return this.Ok(this.GenerateSlotsResponse(response, start, total)); } [HttpGet("slots/mostUniquePlays")] @@ -359,24 +386,10 @@ public class SlotsController : ControllerBase .Take(Math.Min(pageSize, 30)); string response = slots.Aggregate(string.Empty, (current, slot) => current + slot.Serialize(token.GameVersion)); + int start = pageStart + Math.Min(pageSize, ServerConfiguration.Instance.UserGeneratedContentLimits.EntitledSlots); + int total = await StatisticsHelper.SlotCount(); - return this.Ok - ( - LbpSerializer.TaggedStringElement - ( - "slots", - response, - new Dictionary - { - { - "hint_start", pageStart + Math.Min(pageSize, ServerConfiguration.Instance.UserGeneratedContentLimits.EntitledSlots) - }, - { - "total", await StatisticsHelper.SlotCount() - }, - } - ) - ); + return this.Ok(this.GenerateSlotsResponse(response, start, total)); } [HttpGet("slots/mostHearted")] @@ -405,24 +418,10 @@ public class SlotsController : ControllerBase .Take(Math.Min(pageSize, 30)); string response = slots.Aggregate(string.Empty, (current, slot) => current + slot.Serialize(token.GameVersion)); + int start = pageStart + Math.Min(pageSize, ServerConfiguration.Instance.UserGeneratedContentLimits.EntitledSlots); + int total = await StatisticsHelper.SlotCount(); - return this.Ok - ( - LbpSerializer.TaggedStringElement - ( - "slots", - response, - new Dictionary - { - { - "hint_start", pageStart + Math.Min(pageSize, ServerConfiguration.Instance.UserGeneratedContentLimits.EntitledSlots) - }, - { - "total", await StatisticsHelper.SlotCount() - }, - } - ) - ); + return this.Ok(this.GenerateSlotsResponse(response, start, total)); } // /slots/busiest?pageStart=1&pageSize=30&gameFilterType=both&players=1&move=true @@ -475,18 +474,10 @@ public class SlotsController : ControllerBase } string response = slots.Aggregate(string.Empty, (current, slot) => current + slot.Serialize(token.GameVersion)); + int start = pageStart + Math.Min(pageSize, ServerConfiguration.Instance.UserGeneratedContentLimits.EntitledSlots); + int total = playersBySlotId.Count; - return this.Ok(LbpSerializer.TaggedStringElement("slots", - response, - new Dictionary - { - { - "hint_start", pageStart + Math.Min(pageSize, ServerConfiguration.Instance.UserGeneratedContentLimits.EntitledSlots) - }, - { - "total", playersBySlotId.Count - }, - })); + return this.Ok(this.GenerateSlotsResponse(response, start, total)); } diff --git a/ProjectLighthouse.Servers.GameServer/Startup/GameServerStartup.cs b/ProjectLighthouse.Servers.GameServer/Startup/GameServerStartup.cs index e109bdb5..67ecd7bb 100644 --- a/ProjectLighthouse.Servers.GameServer/Startup/GameServerStartup.cs +++ b/ProjectLighthouse.Servers.GameServer/Startup/GameServerStartup.cs @@ -1,4 +1,3 @@ -using System.IO.Compression; using LBPUnion.ProjectLighthouse.Configuration; using LBPUnion.ProjectLighthouse.Helpers; using LBPUnion.ProjectLighthouse.Logging; @@ -76,17 +75,30 @@ public class GameServerStartup // Client digest check. if (!context.Request.Cookies.TryGetValue("MM_AUTH", out string? authCookie) || authCookie == null) authCookie = string.Empty; string digestPath = context.Request.Path; + #if !DEBUG + const string url = "/LITTLEBIGPLANETPS3_XML"; + string strippedPath = digestPath.Contains(url) ? digestPath[url.Length..] : ""; + #endif Stream body = context.Request.Body; bool usedAlternateDigestKey = false; if (computeDigests && digestPath.StartsWith("/LITTLEBIGPLANETPS3_XML")) { - string clientRequestDigest = await CryptoHelper.ComputeDigest - (digestPath, authCookie, body, ServerConfiguration.Instance.DigestKey.PrimaryDigestKey); + // The game sets X-Digest-B on a resource upload instead of X-Digest-A + string digestHeaderKey = "X-Digest-A"; + bool excludeBodyFromDigest = false; + if (digestPath.Contains("/upload/")) + { + digestHeaderKey = "X-Digest-B"; + excludeBodyFromDigest = true; + } - // Check the digest we've just calculated against the X-Digest-A header if the game set the header. They should match. - if (context.Request.Headers.TryGetValue("X-Digest-A", out StringValues sentDigest)) + string clientRequestDigest = await CryptoHelper.ComputeDigest + (digestPath, authCookie, body, ServerConfiguration.Instance.DigestKey.PrimaryDigestKey, excludeBodyFromDigest); + + // Check the digest we've just calculated against the digest header if the game set the header. They should match. + if (context.Request.Headers.TryGetValue(digestHeaderKey, out StringValues sentDigest)) { if (clientRequestDigest != sentDigest) { @@ -97,7 +109,7 @@ public class GameServerStartup body.Position = 0; clientRequestDigest = await CryptoHelper.ComputeDigest - (digestPath, authCookie, body, ServerConfiguration.Instance.DigestKey.AlternateDigestKey); + (digestPath, authCookie, body, ServerConfiguration.Instance.DigestKey.AlternateDigestKey, excludeBodyFromDigest); if (clientRequestDigest != sentDigest) { #if DEBUG @@ -108,11 +120,20 @@ public class GameServerStartup #endif // We still failed to validate. Abort the request. context.Response.StatusCode = 403; - context.Abort(); return; } } } + #if !DEBUG + // The game doesn't start sending digests until after the announcement so if it's not one of those requests + // and it doesn't include a digest we need to reject the request + else if (!ServerStatics.IsUnitTesting && !strippedPath.Equals("/login") && !strippedPath.Equals("/eula") + && !strippedPath.Equals("/announce") && !strippedPath.Equals("/status")) + { + context.Response.StatusCode = 403; + return; + } + #endif context.Response.Headers.Add("X-Digest-B", clientRequestDigest); context.Request.Body.Position = 0; @@ -140,6 +161,10 @@ public class GameServerStartup context.Response.Headers.Add("X-Digest-A", serverDigest); } + // Add a content-length header if it isn't present to disable response chunking + if(!context.Response.Headers.ContainsKey("Content-Length")) + context.Response.Headers.Add("Content-Length", responseBuffer.Length.ToString()); + // Copy the buffered response to the actual response stream. responseBuffer.Position = 0; await responseBuffer.CopyToAsync(oldResponseStream); @@ -175,4 +200,4 @@ public class GameServerStartup app.UseEndpoints(endpoints => endpoints.MapControllers()); app.UseEndpoints(endpoints => endpoints.MapRazorPages()); } -} \ No newline at end of file +} diff --git a/ProjectLighthouse.Servers.Website/Pages/ExternalAuth/AuthenticationPage.cshtml b/ProjectLighthouse.Servers.Website/Pages/ExternalAuth/AuthenticationPage.cshtml index 322c21db..083792b4 100644 --- a/ProjectLighthouse.Servers.Website/Pages/ExternalAuth/AuthenticationPage.cshtml +++ b/ProjectLighthouse.Servers.Website/Pages/ExternalAuth/AuthenticationPage.cshtml @@ -1,6 +1,5 @@ @page "/authentication" @using LBPUnion.ProjectLighthouse.PlayerData -@using LBPUnion.ProjectLighthouse.Types @model LBPUnion.ProjectLighthouse.Servers.Website.Pages.ExternalAuth.AuthenticationPage @{ @@ -42,7 +41,7 @@ else @foreach (AuthenticationAttempt authAttempt in Model.AuthenticationAttempts) { - DateTimeOffset timestamp = DateTimeOffset.FromUnixTimeSeconds(authAttempt.Timestamp); + DateTimeOffset timestamp = DateTimeOffset.FromUnixTimeSeconds(authAttempt.Timestamp).ToLocalTime();

A @authAttempt.Platform authentication request was logged at @timestamp.ToString("MM/dd/yyyy @ h:mm tt") UTC from the IP address @authAttempt.IPAddress.

diff --git a/ProjectLighthouse.Servers.Website/Pages/Partials/CommentsPartial.cshtml b/ProjectLighthouse.Servers.Website/Pages/Partials/CommentsPartial.cshtml index 1cfaef3f..4cb549c4 100644 --- a/ProjectLighthouse.Servers.Website/Pages/Partials/CommentsPartial.cshtml +++ b/ProjectLighthouse.Servers.Website/Pages/Partials/CommentsPartial.cshtml @@ -44,7 +44,7 @@ @for(int i = 0; i < Model.Comments.Count; i++) { Comment comment = Model.Comments[i]; - DateTimeOffset timestamp = DateTimeOffset.FromUnixTimeSeconds(comment.Timestamp / 1000); + DateTimeOffset timestamp = DateTimeOffset.FromUnixTimeSeconds(comment.Timestamp / 1000).ToLocalTime(); StringWriter messageWriter = new(); HttpUtility.HtmlDecode(comment.getComment(), messageWriter); diff --git a/ProjectLighthouse.Servers.Website/Pages/Partials/ReportPartial.cshtml b/ProjectLighthouse.Servers.Website/Pages/Partials/ReportPartial.cshtml index 413656a3..b8ad6a90 100644 --- a/ProjectLighthouse.Servers.Website/Pages/Partials/ReportPartial.cshtml +++ b/ProjectLighthouse.Servers.Website/Pages/Partials/ReportPartial.cshtml @@ -26,7 +26,7 @@
- Report time: @(DateTimeOffset.FromUnixTimeMilliseconds(Model.Timestamp).ToString("R")) + Report time: @(DateTimeOffset.FromUnixTimeMilliseconds(Model.Timestamp).ToLocalTime().ToString("R"))
Report reason: @Model.Type diff --git a/ProjectLighthouse.Servers.Website/Pages/SlotPage.cshtml b/ProjectLighthouse.Servers.Website/Pages/SlotPage.cshtml index cc929fb8..e25df416 100644 --- a/ProjectLighthouse.Servers.Website/Pages/SlotPage.cshtml +++ b/ProjectLighthouse.Servers.Website/Pages/SlotPage.cshtml @@ -3,6 +3,8 @@ @using LBPUnion.ProjectLighthouse.Administration @using LBPUnion.ProjectLighthouse.Configuration @using LBPUnion.ProjectLighthouse.Extensions +@using LBPUnion.ProjectLighthouse.Files +@using LBPUnion.ProjectLighthouse.Helpers @using LBPUnion.ProjectLighthouse.PlayerData @using LBPUnion.ProjectLighthouse.PlayerData.Reviews @using LBPUnion.ProjectLighthouse.Servers.Website.Extensions @@ -63,8 +65,19 @@

Tags

@{ - string[] authorLabels = Model.Slot?.AuthorLabels.Split(",") ?? new string[]{}; - if (authorLabels.Length == 1) // ..?? ok c# + + string[] authorLabels; + if (Model.Slot?.GameVersion == GameVersion.LittleBigPlanet1) + { + authorLabels = Model.Slot?.LevelTags ?? Array.Empty(); + } + else + { + authorLabels = Model.Slot?.AuthorLabels.Split(",") ?? Array.Empty(); + // Split() returns an array with an empty character for some reason + if (authorLabels.Length == 1) authorLabels = Array.Empty(); + } + if (authorLabels.Length == 0) {

This level has no tags.

} @@ -72,7 +85,7 @@ { foreach (string label in authorLabels.Where(label => !string.IsNullOrEmpty(label))) { -
@label.Replace("LABEL_", "")
+
@LabelHelper.TranslateTag(label)
} } } @@ -121,7 +134,7 @@ _ => throw new ArgumentOutOfRangeException(), }) ?? ""; - if (string.IsNullOrWhiteSpace(faceHash)) + if (string.IsNullOrWhiteSpace(faceHash) || !FileHelper.ResourceExists(faceHash)) { faceHash = ServerConfiguration.Instance.WebsiteConfiguration.MissingIconHash; } @@ -163,7 +176,7 @@ { @foreach (string reviewLabel in review.Labels) { -
@reviewLabel.Replace("LABEL_", "")
+
@LabelHelper.TranslateTag(reviewLabel)
} } @if (string.IsNullOrWhiteSpace(review.Text)) diff --git a/ProjectLighthouse/Database.cs b/ProjectLighthouse/Database.cs index ee00fdc7..64ff8ce9 100644 --- a/ProjectLighthouse/Database.cs +++ b/ProjectLighthouse/Database.cs @@ -451,7 +451,12 @@ public class Database : DbContext public async Task RemoveExpiredTokens() { - this.GameTokens.RemoveWhere(t => DateTime.Now > t.ExpiresAt); + foreach (GameToken token in this.GameTokens.Where(t => DateTime.Now > t.ExpiresAt).ToList()) + { + User? user = await this.Users.FirstOrDefaultAsync(u => u.UserId == token.UserId); + if(user != null) user.LastLogout = TimeHelper.TimestampMillis; + this.GameTokens.Remove(token); + } this.WebTokens.RemoveWhere(t => DateTime.Now > t.ExpiresAt); this.EmailVerificationTokens.RemoveWhere(t => DateTime.Now > t.ExpiresAt); this.EmailSetTokens.RemoveWhere(t => DateTime.Now > t.ExpiresAt); @@ -512,4 +517,4 @@ public class Database : DbContext if (saveChanges) await this.SaveChangesAsync(); } #nullable disable -} \ No newline at end of file +} diff --git a/ProjectLighthouse/Files/FileHelper.cs b/ProjectLighthouse/Files/FileHelper.cs index a43b38fa..a922b25c 100644 --- a/ProjectLighthouse/Files/FileHelper.cs +++ b/ProjectLighthouse/Files/FileHelper.cs @@ -1,6 +1,8 @@ #nullable enable using System; +using System.Buffers.Binary; using System.Collections.Concurrent; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; @@ -23,6 +25,28 @@ public static class FileHelper public static string GetResourcePath(string hash) => Path.Combine(ResourcePath, hash); + public static bool AreDependenciesSafe(LbpFile file) + { + // recursively check if dependencies are safe + List dependencies = ParseDependencyList(file); + foreach (ResourceDescriptor resource in dependencies) + { + if (resource.IsGuidResource()) continue; + + LbpFile? r = LbpFile.FromHash(resource.Hash); + // If the resource hasn't been uploaded yet then we just go off it's included resource type + if (r == null) + if (resource.IsScriptType()) + return false; + else + continue; + + if (!IsFileSafe(r)) return false; + } + + return true; + } + public static bool IsFileSafe(LbpFile file) { if (!ServerConfiguration.Instance.CheckForUnsafeFiles) return true; @@ -52,6 +76,57 @@ public static class FileHelper }; } + private static List ParseDependencyList(LbpFile file) + { + + List dependencies = new(); + if (file.FileType == LbpFileType.Unknown || file.Data.Length < 0xb || file.Data[3] != 'b') + { + return dependencies; + } + + int revision = BinaryPrimitives.ReadInt32BigEndian(file.Data.AsSpan()[4..]); + + // Data format is 'borrowed' from: https://github.com/ennuo/toolkit/blob/main/src/main/java/ennuo/craftworld/resources/Resource.java#L191 + + if (revision < 0x109) return dependencies; + + int curOffset = 8; + int dependencyTableOffset = BinaryPrimitives.ReadInt32BigEndian(file.Data.AsSpan()[curOffset..]); + if(dependencyTableOffset <= 0 || dependencyTableOffset > file.Data.Length) return dependencies; + + curOffset = dependencyTableOffset; + int dependencyTableSize = BinaryPrimitives.ReadInt32BigEndian(file.Data.AsSpan()[dependencyTableOffset..]); + curOffset += 4; + for (int i = 0; i < dependencyTableSize; ++i) + { + byte hashType = file.Data[curOffset]; + curOffset += 1; + ResourceDescriptor resource = new(); + switch (hashType) + { + case 1: + { + byte[] hashBytes = new byte[0x14]; + Buffer.BlockCopy(file.Data, curOffset, hashBytes, 0, 0x14); + curOffset += 0x14; + resource.Hash = BitConverter.ToString(hashBytes).Replace("-", ""); + break; + } + case 2: + { + resource.Hash = "g" + BinaryPrimitives.ReadUInt32BigEndian(file.Data.AsSpan()[curOffset..]); + curOffset += 4; + break; + } + } + resource.Type = BinaryPrimitives.ReadInt32BigEndian(file.Data.AsSpan()[curOffset..]); + curOffset += 4; + dependencies.Add(resource); + } + return dependencies; + } + public static GameVersion ParseLevelVersion(LbpFile file) { if (file.FileType != LbpFileType.Level || file.Data.Length < 16 || file.Data[3] != 'b') return GameVersion.Unknown; @@ -63,23 +138,16 @@ public static class FileHelper const ushort lbpVitaLatest = 0x3E2; const ushort lbpVitaDescriptor = 0x4431; // There are like 1600 revisions so this doesn't cover everything - uint revision = 0; - // construct a 32 bit number from 4 individual bytes - for (int i = 4; i <= 7; i++) - { - revision <<= 8; - revision |= file.Data[i]; - } + int revision = BinaryPrimitives.ReadInt32BigEndian(file.Data.AsSpan()[4..]); if (revision >= 0x271) { // construct a 16 bit number from 2 individual bytes - ushort branchDescriptor = (ushort) (file.Data[12] << 8 | file.Data[13]); + ushort branchDescriptor = BinaryPrimitives.ReadUInt16BigEndian(file.Data.AsSpan()[12..]); if (revision == lbpVitaLatest && branchDescriptor == lbpVitaDescriptor) return GameVersion.LittleBigPlanetVita; } - GameVersion version = GameVersion.Unknown; if (revision <= lbp1Latest) { @@ -319,4 +387,4 @@ public static class FileHelper #endregion -} \ No newline at end of file +} diff --git a/ProjectLighthouse/Files/ResourceDescriptor.cs b/ProjectLighthouse/Files/ResourceDescriptor.cs new file mode 100644 index 00000000..3816abe6 --- /dev/null +++ b/ProjectLighthouse/Files/ResourceDescriptor.cs @@ -0,0 +1,11 @@ +namespace LBPUnion.ProjectLighthouse.Files; + +public class ResourceDescriptor +{ + public string Hash; + public int Type; + + public bool IsScriptType() => this.Type == 0x11; + + public bool IsGuidResource() => this.Hash.StartsWith("g"); +} \ No newline at end of file diff --git a/ProjectLighthouse/Helpers/CryptoHelper.cs b/ProjectLighthouse/Helpers/CryptoHelper.cs index aef06419..d6b47f78 100644 --- a/ProjectLighthouse/Helpers/CryptoHelper.cs +++ b/ProjectLighthouse/Helpers/CryptoHelper.cs @@ -30,7 +30,7 @@ public static class CryptoHelper return BCryptHash(Sha256Hash(bytes)); } - public static async Task ComputeDigest(string path, string authCookie, Stream body, string digestKey) + public static async Task ComputeDigest(string path, string authCookie, Stream body, string digestKey, bool excludeBody = false) { MemoryStream memoryStream = new(); @@ -43,7 +43,10 @@ public static class CryptoHelper byte[] bodyBytes = memoryStream.ToArray(); using IncrementalHash sha1 = IncrementalHash.CreateHash(HashAlgorithmName.SHA1); - sha1.AppendData(bodyBytes); + // LBP games will sometimes opt to calculate the digest without the body + // (one example is resource upload requests) + if(!excludeBody) + sha1.AppendData(bodyBytes); if (cookieBytes.Length > 0) sha1.AppendData(cookieBytes); sha1.AppendData(pathBytes); sha1.AppendData(keyBytes); diff --git a/ProjectLighthouse/Helpers/LabelHelper.cs b/ProjectLighthouse/Helpers/LabelHelper.cs new file mode 100644 index 00000000..5bbb7d13 --- /dev/null +++ b/ProjectLighthouse/Helpers/LabelHelper.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using LBPUnion.ProjectLighthouse.Levels; + +namespace LBPUnion.ProjectLighthouse.Helpers; + +public static class LabelHelper +{ + + private static readonly Dictionary translationTable = new() + { + {"LABEL_DirectControl", "Controllinator"}, + {"LABEL_GrapplingHook", "Grappling Hook"}, + {"LABEL_JumpPads", "Bounce Pads"}, + {"LABEL_MagicBag", "Creatinator"}, + {"LABEL_LowGravity", "Low Gravity"}, + {"LABEL_PowerGlove", "Grabinator"}, + {"LABEL_ATTRACT_GEL", "Attract-o-Gel"}, + {"LABEL_ATTRACT_TWEAK", "Attract-o-Tweaker"}, + {"LABEL_HEROCAPE", "Hero Cape"}, + {"LABEL_MEMORISER", "Memorizer"}, + {"LABEL_WALLJUMP", "Wall Jump"}, + {"LABEL_SINGLE_PLAYER", "Single Player"}, + {"LABEL_SurvivalChallenge", "Survival Challenge"}, + {"LABEL_TOP_DOWN", "Top Down"}, + {"LABEL_CO_OP", "Co-Op"}, + {"LABEL_Sci_Fi", "Sci-Fi"}, + {"LABEL_INTERACTIVE_STREAM", "Interactive Stream"}, + {"LABEL_QUESTS", "Quests"}, + {"8_Bit", "8-bit"}, + {"16_Bit", "16-bit"}, + {"LABEL_SACKPOCKET", "Sackpocket"}, + {"LABEL_SPRINGINATOR", "Springinator"}, + {"LABEL_HOVERBOARD_NAME", "Hoverboard"}, + {"LABEL_FLOATY_FLUID_NAME", "Floaty Fluid"}, + {"LABEL_ODDSOCK", "Oddsock"}, + {"LABEL_TOGGLE", "Toggle"}, + {"LABEL_SWOOP", "Swoop"}, + {"LABEL_SACKBOY", "Sackboy"}, + {"LABEL_CREATED_CHARACTERS", "Created Characters"}, + }; + + public static bool IsValidTag(string tag) => Enum.IsDefined(typeof(LevelTags), tag.Replace("TAG_", "").Replace("-", "_")); + + public static bool IsValidLabel(string label) => Enum.IsDefined(typeof(LevelLabels), label); + + public static string RemoveInvalidLabels(string authorLabels) + { + List labels = new(authorLabels.Split(",")); + for (int i = labels.Count - 1; i >= 0; i--) + { + if (!IsValidLabel(labels[i])) labels.Remove(labels[i]); + } + return string.Join(",", labels); + } + + public static string TranslateTag(string tag) + { + if (tag.Contains("TAG_")) return tag.Replace("TAG_", "").Replace("_", "-"); + + // ReSharper disable once ConvertIfStatementToReturnStatement + if (translationTable.ContainsKey(tag)) return translationTable[tag]; + + return tag.Replace("LABEL_", "").Replace("_", " "); + } + +} \ No newline at end of file diff --git a/ProjectLighthouse/Helpers/SlotHelper.cs b/ProjectLighthouse/Helpers/SlotHelper.cs index b2c529f5..eb755b96 100644 --- a/ProjectLighthouse/Helpers/SlotHelper.cs +++ b/ProjectLighthouse/Helpers/SlotHelper.cs @@ -39,7 +39,7 @@ public static class SlotHelper }; } - private static readonly SemaphoreSlim semaphore = new(1, 1); + private static readonly SemaphoreSlim semaphore = new(1, 1); public static async Task GetPlaceholderSlotId(Database database, int guid, SlotType slotType) { diff --git a/ProjectLighthouse/Levels/LevelLabels.cs b/ProjectLighthouse/Levels/LevelLabels.cs new file mode 100644 index 00000000..8d19d8f7 --- /dev/null +++ b/ProjectLighthouse/Levels/LevelLabels.cs @@ -0,0 +1,96 @@ +using System.Diagnostics.CodeAnalysis; + +namespace LBPUnion.ProjectLighthouse.Levels; + +[SuppressMessage("ReSharper", "InconsistentNaming")] +[SuppressMessage("ReSharper", "UnusedMember.Global")] +// I would remove the LABEL prefix, but some of the tags start with numbers and won't compile +public enum LevelLabels +{ + LABEL_SinglePlayer, + LABEL_Multiplayer, + LABEL_Quick, + LABEL_Long, + LABEL_Challenging, + LABEL_Easy, + LABEL_Scary, + LABEL_Funny, + LABEL_Artistic, + LABEL_Musical, + LABEL_Intricate, + LABEL_Cinematic, + LABEL_Competitive, + LABEL_Fighter, + LABEL_Gallery, + LABEL_Puzzle, + LABEL_Platform, + LABEL_Race, + LABEL_Shooter, + LABEL_Sports, + LABEL_Story, + LABEL_Strategy, + LABEL_SurvivalChallenge, + LABEL_Tutorial, + LABEL_Collectables, + LABEL_DirectControl, + LABEL_Explosives, + LABEL_Glitch, + LABEL_GrapplingHook, + LABEL_JumpPads, + LABEL_MagicBag, + LABEL_LowGravity, + LABEL_Paintinator, + LABEL_PowerGlove, + LABEL_Sackbots, + LABEL_Vehicles, + LABEL_Water, + LABEL_Brain_Crane, + LABEL_Movinator, + LABEL_Paint, + LABEL_ATTRACT_GEL, + LABEL_ATTRACT_TWEAK, + LABEL_HEROCAPE, + LABEL_MEMORISER, + LABEL_WALLJUMP, + LABEL_Retro, + LABEL_SINGLE_PLAYER, + LABEL_RPG, + LABEL_TOP_DOWN, + LABEL_CO_OP, + LABEL_1st_Person, + LABEL_3rd_Person, + LABEL_Sci_Fi, + LABEL_Social, + LABEL_Arcade_Game, + LABEL_Board_Game, + LABEL_Card_Game, + LABEL_Mini_Game, + LABEL_Party_Game, + LABEL_Defence, + LABEL_Driving, + LABEL_Hangout, + LABEL_Hide_And_Seek, + LABEL_Prop_Hunt, + LABEL_Music_Gallery, + LABEL_Costume_Gallery, + LABEL_Sticker_Gallery, + LABEL_Movie, + LABEL_Pinball, + LABEL_Technology, + LABEL_Homage, + LABEL_8_Bit, + LABEL_16_Bit, + LABEL_Seasonal, + LABEL_Time_Trial, + LABEL_INTERACTIVE_STREAM, + LABEL_QUESTS, + LABEL_SACKPOCKET, + LABEL_SPRINGINATOR, + LABEL_HOVERBOARD_NAME, + LABEL_FLOATY_FLUID_NAME, + LABEL_ODDSOCK, + LABEL_TOGGLE, + LABEL_SWOOP, + LABEL_SACKBOY, + LABEL_CREATED_CHARACTERS, +} \ No newline at end of file diff --git a/ProjectLighthouse/Levels/RatedLevel.cs b/ProjectLighthouse/Levels/RatedLevel.cs index 265783e3..b9af4e1c 100644 --- a/ProjectLighthouse/Levels/RatedLevel.cs +++ b/ProjectLighthouse/Levels/RatedLevel.cs @@ -23,4 +23,6 @@ public class RatedLevel public int Rating { get; set; } public double RatingLBP1 { get; set; } + + public string TagLBP1 { get; set; } } \ No newline at end of file diff --git a/ProjectLighthouse/Levels/Slot.cs b/ProjectLighthouse/Levels/Slot.cs index e3f5e9b0..7171f473 100644 --- a/ProjectLighthouse/Levels/Slot.cs +++ b/ProjectLighthouse/Levels/Slot.cs @@ -1,4 +1,6 @@ #nullable enable +using System; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Linq; @@ -107,6 +109,25 @@ public class Slot [XmlElement("authorLabels")] public string AuthorLabels { get; set; } = ""; + public string[] LevelTags + { + get + { + if (this.GameVersion != GameVersion.LittleBigPlanet1) return Array.Empty(); + + // Sort tags by most popular + SortedDictionary occurrences = new(); + foreach (RatedLevel r in this.database.RatedLevels.Where(r => r.SlotId == this.SlotId && r.TagLBP1.Length > 0)) + { + if (!occurrences.ContainsKey(r.TagLBP1)) + occurrences.Add(r.TagLBP1, 1); + else + occurrences[r.TagLBP1]++; + } + return occurrences.OrderBy(r => r.Value).Select(r => r.Key).ToArray(); + } + } + [XmlElement("background")] [JsonIgnore] public string BackgroundHash { get; set; } = ""; @@ -216,9 +237,7 @@ public class Slot public double RatingLBP1 { get { IQueryable ratedLevels = this.database.RatedLevels.Where(r => r.SlotId == this.SlotId && r.RatingLBP1 > 0); - if (!ratedLevels.Any()) return 3.0; - - return Enumerable.Average(ratedLevels, r => r.RatingLBP1); + return ratedLevels.Any() ? ratedLevels.Average(r => r.RatingLBP1) : 3.0; } } @@ -297,8 +316,8 @@ public class Slot (fullSerialization ? LbpSerializer.StringElement("moveRequired", this.MoveRequired) : "") + (fullSerialization ? LbpSerializer.StringElement("crossControlRequired", this.CrossControllerRequired) : "") + (yourRatingStats != null ? - LbpSerializer.StringElement("yourRating", yourRatingStats.RatingLBP1) + - LbpSerializer.StringElement("yourDPadRating", yourRatingStats.Rating) + LbpSerializer.StringElement("yourRating", yourRatingStats.RatingLBP1, true) + + LbpSerializer.StringElement("yourDPadRating", yourRatingStats.Rating, true) : "") + (yourVisitedStats != null ? LbpSerializer.StringElement("yourlbp1PlayCount", yourVisitedStats.PlaysLBP1) + @@ -309,7 +328,8 @@ public class Slot LbpSerializer.StringElement("commentCount", this.Comments) + LbpSerializer.StringElement("photoCount", this.Photos) + LbpSerializer.StringElement("authorPhotoCount", this.PhotosWithAuthor) + - (fullSerialization ? LbpSerializer.StringElement("labels", this.AuthorLabels) : "") + + (fullSerialization ? LbpSerializer.StringElement("tags", string.Join(",", this.LevelTags), true) : "") + + (fullSerialization ? LbpSerializer.StringElement("labels", this.AuthorLabels, true) : "") + LbpSerializer.StringElement("firstPublished", this.FirstUploaded) + LbpSerializer.StringElement("lastUpdated", this.LastUpdated) + (fullSerialization ? diff --git a/ProjectLighthouse/Migrations/20220825212051_AddLevelTagToRatedLevel.cs b/ProjectLighthouse/Migrations/20220825212051_AddLevelTagToRatedLevel.cs new file mode 100644 index 00000000..988ad314 --- /dev/null +++ b/ProjectLighthouse/Migrations/20220825212051_AddLevelTagToRatedLevel.cs @@ -0,0 +1,31 @@ +using LBPUnion.ProjectLighthouse; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ProjectLighthouse.Migrations +{ + [DbContext(typeof(Database))] + [Migration("20220825212051_AddLevelTagToRatedLevel")] + public partial class AddLevelTagToRatedLevel : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "TagLBP1", + table: "RatedLevels", + type: "longtext", + defaultValue: "", + nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "TagLBP1", + table: "RatedLevels"); + } + } +} diff --git a/ProjectLighthouse/Migrations/20220826001101_AddLoginTimestampsToUser.cs b/ProjectLighthouse/Migrations/20220826001101_AddLoginTimestampsToUser.cs new file mode 100644 index 00000000..9064564c --- /dev/null +++ b/ProjectLighthouse/Migrations/20220826001101_AddLoginTimestampsToUser.cs @@ -0,0 +1,41 @@ +using LBPUnion.ProjectLighthouse; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ProjectLighthouse.Migrations +{ + [DbContext(typeof(Database))] + [Migration("20220826001101_AddLoginTimestampsToUser")] + public partial class AddLoginTimestampsToUser : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "LastLogin", + table: "Users", + type: "bigint", + nullable: false, + defaultValue: 0L); + + migrationBuilder.AddColumn( + name: "LastLogout", + table: "Users", + type: "bigint", + nullable: false, + defaultValue: 0L); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "LastLogin", + table: "Users"); + + migrationBuilder.DropColumn( + name: "LastLogout", + table: "Users"); + } + } +} diff --git a/ProjectLighthouse/PlayerData/Profiles/User.cs b/ProjectLighthouse/PlayerData/Profiles/User.cs index 0be2e24e..9dee10f3 100644 --- a/ProjectLighthouse/PlayerData/Profiles/User.cs +++ b/ProjectLighthouse/PlayerData/Profiles/User.cs @@ -141,6 +141,9 @@ public class User public string BooHash { get; set; } = ""; public string MehHash { get; set; } = ""; + public long LastLogin { get; set; } + public long LastLogout { get; set; } + [NotMapped] [JsonIgnore] public UserStatus Status => new(this.database, this.UserId); diff --git a/ProjectLighthouse/PlayerData/Profiles/UserStatus.cs b/ProjectLighthouse/PlayerData/Profiles/UserStatus.cs index a01a5f31..cfac696d 100644 --- a/ProjectLighthouse/PlayerData/Profiles/UserStatus.cs +++ b/ProjectLighthouse/PlayerData/Profiles/UserStatus.cs @@ -1,7 +1,7 @@ #nullable enable +using System; using System.Linq; using LBPUnion.ProjectLighthouse.Helpers; -using LBPUnion.ProjectLighthouse.Localization; using LBPUnion.ProjectLighthouse.Localization.StringLists; using LBPUnion.ProjectLighthouse.Match.Rooms; @@ -13,6 +13,8 @@ public class UserStatus public GameVersion? CurrentVersion { get; set; } public Platform? CurrentPlatform { get; set; } public Room? CurrentRoom { get; set; } + public long LastLogin { get; set; } + public long LastLogout { get; set; } public UserStatus() {} @@ -33,7 +35,29 @@ public class UserStatus this.CurrentPlatform = lastContact.Platform; } - this.CurrentRoom = RoomHelper.FindRoomByUserId(userId); + var loginTimestamps = database.Users.Where(u => u.UserId == userId) + .Select(u => new + { + u.LastLogin, + u.LastLogout, + }).FirstOrDefault(); + this.LastLogin = loginTimestamps?.LastLogin ?? 0; + this.LastLogout = loginTimestamps?.LastLogout ?? 0; + + this.CurrentRoom = RoomHelper.FindRoomByUserId(userId); + } + + private string FormatOfflineTimestamp(string language) + { + if (this.LastLogout <= 0 && this.LastLogin <= 0) + { + return StatusStrings.Offline.Translate(language); + } + + long timestamp = this.LastLogout; + if (timestamp <= 0) timestamp = this.LastLogin; + string formattedTime = DateTimeOffset.FromUnixTimeMilliseconds(timestamp).ToLocalTime().ToString("M/d/yyyy h:mm:ss tt"); + return StatusStrings.LastOnline.Translate(language, formattedTime); } public string ToTranslatedString(string language) @@ -44,8 +68,8 @@ public class UserStatus return this.StatusType switch { StatusType.Online => StatusStrings.CurrentlyOnline.Translate(language, - ((GameVersion)this.CurrentVersion).ToPrettyString(), ((Platform)this.CurrentPlatform)), - StatusType.Offline => StatusStrings.Offline.Translate(language), + ((GameVersion)this.CurrentVersion).ToPrettyString(), (Platform)this.CurrentPlatform), + StatusType.Offline => this.FormatOfflineTimestamp(language), _ => GeneralStrings.Unknown.Translate(language), }; } diff --git a/ProjectLighthouse/PlayerData/Reviews/Review.cs b/ProjectLighthouse/PlayerData/Reviews/Review.cs index be4035c2..1a9f3ab6 100644 --- a/ProjectLighthouse/PlayerData/Reviews/Review.cs +++ b/ProjectLighthouse/PlayerData/Reviews/Review.cs @@ -60,11 +60,7 @@ public class Review [XmlElement("thumbsdown")] public int ThumbsDown { get; set; } - public string Serialize - (RatedLevel? yourLevelRating = null, RatedReview? yourRatingStats = null) - => this.Serialize("review", yourLevelRating, yourRatingStats); - - public string Serialize(string elementOverride, RatedLevel? yourLevelRating = null, RatedReview? yourRatingStats = null) + public string Serialize(RatedReview? yourRatingStats = null) { string deletedBy = this.DeletedBy switch { @@ -86,6 +82,6 @@ public class Review LbpSerializer.StringElement("thumbsdown", this.ThumbsDown) + LbpSerializer.StringElement("yourthumb", yourRatingStats?.Thumb ?? 0); - return LbpSerializer.TaggedStringElement(elementOverride, reviewData, "id", this.SlotId + "." + this.Reviewer?.Username); + return LbpSerializer.TaggedStringElement("review", reviewData, "id", this.SlotId + "." + this.Reviewer?.Username); } } \ No newline at end of file diff --git a/ProjectLighthouse/ProjectLighthouse/Migrations/DatabaseModelSnapshot.cs b/ProjectLighthouse/ProjectLighthouse/Migrations/DatabaseModelSnapshot.cs index 92758398..1071afdc 100644 --- a/ProjectLighthouse/ProjectLighthouse/Migrations/DatabaseModelSnapshot.cs +++ b/ProjectLighthouse/ProjectLighthouse/Migrations/DatabaseModelSnapshot.cs @@ -16,7 +16,7 @@ namespace ProjectLighthouse.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "6.0.7") + .HasAnnotation("ProductVersion", "6.0.8") .HasAnnotation("Relational:MaxIdentifierLength", 64); modelBuilder.Entity("LBPUnion.ProjectLighthouse.Administration.CompletedMigration", b => @@ -208,6 +208,9 @@ namespace ProjectLighthouse.Migrations b.Property("SlotId") .HasColumnType("int"); + b.Property("TagLBP1") + .HasColumnType("longtext"); + b.Property("UserId") .HasColumnType("int"); @@ -730,6 +733,12 @@ namespace ProjectLighthouse.Migrations b.Property("IsAPirate") .HasColumnType("tinyint(1)"); + b.Property("LastLogin") + .HasColumnType("bigint"); + + b.Property("LastLogout") + .HasColumnType("bigint"); + b.Property("LevelVisibility") .HasColumnType("int");