From daca58acd6315872b9c47f178d95e8e6abdc3c04 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Mon, 22 Jun 2026 12:44:46 +0200 Subject: [PATCH 1/4] Improve photo asset validation --- .../Endpoints/PhotoEndpoints.cs | 82 +++++++++++++++---- 1 file changed, 66 insertions(+), 16 deletions(-) diff --git a/Refresh.Interfaces.Game/Endpoints/PhotoEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/PhotoEndpoints.cs index 144d2b82..1b043638 100644 --- a/Refresh.Interfaces.Game/Endpoints/PhotoEndpoints.cs +++ b/Refresh.Interfaces.Game/Endpoints/PhotoEndpoints.cs @@ -7,8 +7,11 @@ using Bunkum.Protocols.Http; using Refresh.Core.Authentication.Permission; using Refresh.Core.Configuration; +using Refresh.Core.Helpers; +using Refresh.Core.Importing; using Refresh.Core.RateLimits.Photos; using Refresh.Core.Services; +using Refresh.Core.Types.Assets.Validation; using Refresh.Core.Types.Data; using Refresh.Database; using Refresh.Database.Models; @@ -26,7 +29,7 @@ public class PhotoEndpoints : EndpointGroup [RequireEmailVerified] [RateLimitSettings(300, 30, 240, "upload-photo")] public Response UploadPhoto(RequestContext context, SerializedPhoto body, GameDatabaseContext database, - GameUser user, IDataStore dataStore, + GameUser user, IDataStore dataStore, AssetImporter importer, DataContext dataContext, AipiService aipi, GameServerConfig config) { if (user.IsWriteBlocked(config)) @@ -48,15 +51,6 @@ public Response UploadPhoto(RequestContext context, SerializedPhoto body, GameDa return Unauthorized; } } - - if (!dataStore.ExistsInStore(body.SmallHash) || - !dataStore.ExistsInStore(body.MediumHash) || - !dataStore.ExistsInStore(body.LargeHash) || - !dataStore.ExistsInStore(body.PlanHash)) - { - database.AddErrorNotification("Photo upload failed", "The required assets were not available.", user); - return BadRequest; - } if (body.PhotoSubjects.Count > 4) { @@ -79,13 +73,69 @@ public Response UploadPhoto(RequestContext context, SerializedPhoto body, GameDa return BadRequest; } - List hashes = [body.LargeHash, body.MediumHash, body.SmallHash]; - foreach (string hash in hashes.Distinct()) + // Plan + // TODO validate content + AssetValidationParameters planParams = new(body.PlanHash, dataContext, importer) { - GameAsset? gameAsset = dataContext.Cache.GetAssetInfo(hash, database); - if(gameAsset == null) continue; - if (aipi != null && aipi.ScanAndHandleAsset(dataContext, gameAsset)) - return Unauthorized; + MayBeBlank = false, + MayBeGuid = false, + AssetContextTypeStr = "metadata asset", + }; + ValidatedAssetResult planResult = ResourceValidationHelper.ValidateReference(planParams, context.Logger); + body.PlanHash = planResult.NewAssetRef; + + if (planResult.Status != OK) + { + if (planResult.ErrorMessage != null) database.AddErrorNotification("Photo upload failed", planResult.ErrorMessage, user); + return planResult.Status; + } + // should never be null at this point, but let's just be extra safe + else if (planResult.AssetInfo != null && planResult.AssetInfo.AssetType != GameAssetType.Plan) + { + database.AddErrorNotification("Photo upload failed", "The metadata asset was badly formatted.", user); + return BadRequest; + } + + // Don't just iterate because we might eventually want to go further and also validate each format's type + // (e.g. for mainline, small and medium are TEX, while large is JPEG), see https://github.com/LittleBigRefresh/Refresh/issues/977 + // Also thinking of dropping the callback param because it's useless after all... + // Small image + AssetValidationParameters imageParams = new(body.SmallHash, dataContext, importer, aipi) + { + MayBeBlank = false, + MayBeGuid = false, + MustBeTexture = true, + AssetContextTypeStr = "image", + }; + ValidatedAssetResult imageResult = ResourceValidationHelper.ValidateReference(imageParams, context.Logger); + body.SmallHash = imageResult.NewAssetRef; + + if (imageResult.Status != OK) + { + if (imageResult.ErrorMessage != null) database.AddErrorNotification("Photo upload failed", imageResult.ErrorMessage, user); + return imageResult.Status; + } + + // Medium image + imageParams.AssetRef = body.MediumHash; + imageResult = ResourceValidationHelper.ValidateReference(imageParams, context.Logger); + body.MediumHash = imageResult.NewAssetRef; + + if (imageResult.Status != OK) + { + if (imageResult.ErrorMessage != null) database.AddErrorNotification("Photo upload failed", imageResult.ErrorMessage, user); + return imageResult.Status; + } + + // Large image + imageParams.AssetRef = body.LargeHash; + imageResult = ResourceValidationHelper.ValidateReference(imageParams, context.Logger); + body.LargeHash = imageResult.NewAssetRef; + + if (imageResult.Status != OK) + { + if (imageResult.ErrorMessage != null) database.AddErrorNotification("Photo upload failed", imageResult.ErrorMessage, user); + return imageResult.Status; } GameLevel? level = body.Level == null ? null : database.GetLevelByIdAndType(body.Level.Type, body.Level.LevelId); From 978e8931fd03877649d4979f3cb25e992780d832 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Mon, 22 Jun 2026 12:45:25 +0200 Subject: [PATCH 2/4] Test photo asset validation --- .../Tests/Photos/PhotoEndpointsTests.cs | 154 +++++++++++++++--- 1 file changed, 130 insertions(+), 24 deletions(-) diff --git a/RefreshTests.GameServer/Tests/Photos/PhotoEndpointsTests.cs b/RefreshTests.GameServer/Tests/Photos/PhotoEndpointsTests.cs index 0a32206d..76fb5dc2 100644 --- a/RefreshTests.GameServer/Tests/Photos/PhotoEndpointsTests.cs +++ b/RefreshTests.GameServer/Tests/Photos/PhotoEndpointsTests.cs @@ -12,6 +12,7 @@ using Refresh.Database.Models; using System.Text; using System.Net; +using Refresh.Database.Models.Assets; namespace RefreshTests.GameServer.Tests.Photos; @@ -174,43 +175,148 @@ public void UploadPhotoAndValidateAttributes() Assert.That(subjects[1].DisplayName, Is.EqualTo("SecretAlt")); Assert.That(subjects[1].User, Is.Null); } - - [Test] - public void CantUploadPhotoWithMissingAssets() + + private void TryUploadPhotosWithInvalidImage(TestContext context, HttpClient client, GameUser user, string invalidHash, HttpStatusCode expectedStatus) { - using TestContext context = this.GetServer(); - GameUser user = context.CreateUser(); - GameLevel level = context.CreateLevel(user); + ReadOnlySpan imageData = "TEX "u8; + string imageHash = BitConverter.ToString(SHA1.HashData(imageData)).Replace("-", "").ToLower(); + context.GetDataStore().WriteToStore(imageHash, imageData); - using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); - + ReadOnlySpan planData = "PLNb"u8; + string planHash = BitConverter.ToString(SHA1.HashData(planData)).Replace("-", "").ToLower(); + context.GetDataStore().WriteToStore(planHash, planData); + + // bad small image SerializedPhoto photo = new() { Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), AuthorName = user.Username, - SmallHash = TEST_ASSET_HASH, - MediumHash = TEST_ASSET_HASH, - LargeHash = TEST_ASSET_HASH, - PlanHash = TEST_ASSET_HASH, + SmallHash = invalidHash, + MediumHash = imageHash, + LargeHash = imageHash, + PlanHash = planHash, Level = new SerializedPhotoLevel { - LevelId = level.LevelId, - Title = level.Title, - Type = "user", + LevelId = 0, + Title = "", + Type = "pod", }, - PhotoSubjects = new List + }; + + HttpResponseMessage message = client.PostAsync($"/lbp/uploadPhoto", new StringContent(photo.AsXML())).Result; + Assert.That(message.StatusCode, Is.EqualTo(expectedStatus)); + + // bad medium image + photo.SmallHash = imageHash; + photo.MediumHash = invalidHash; + + message = client.PostAsync($"/lbp/uploadPhoto", new StringContent(photo.AsXML())).Result; + Assert.That(message.StatusCode, Is.EqualTo(expectedStatus)); + + // bad large image + photo.MediumHash = imageHash; + photo.LargeHash = invalidHash; + + message = client.PostAsync($"/lbp/uploadPhoto", new StringContent(photo.AsXML())).Result; + Assert.That(message.StatusCode, Is.EqualTo(expectedStatus)); + } + + private void TryUploadPhotoWithInvalidPlan(TestContext context, HttpClient client, GameUser user, string invalidHash, HttpStatusCode expectedStatus) + { + ReadOnlySpan imageData = "TEX "u8; + string imageHash = BitConverter.ToString(SHA1.HashData(imageData)).Replace("-", "").ToLower(); + context.GetDataStore().WriteToStore(imageHash, imageData); + + // bad small image + SerializedPhoto photo = new() + { + Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + AuthorName = user.Username, + SmallHash = imageHash, + MediumHash = imageHash, + LargeHash = imageHash, + PlanHash = invalidHash, + Level = new SerializedPhotoLevel { - new() - { - Username = user.Username, - DisplayName = user.Username, - BoundsList = "1,1,1,1", - }, - } + LevelId = 0, + Title = "", + Type = "pod", + }, }; HttpResponseMessage message = client.PostAsync($"/lbp/uploadPhoto", new StringContent(photo.AsXML())).Result; - Assert.That(message.StatusCode, Is.EqualTo(BadRequest)); + Assert.That(message.StatusCode, Is.EqualTo(expectedStatus)); + } + + [Test] + [TestCase("")] + [TestCase("0")] + [TestCase("g1087")] + [TestCase("g18451")] + public void RejectPhotosWithNonHashAssets(string invalidHash) + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); + + this.TryUploadPhotosWithInvalidImage(context, client, user, invalidHash, BadRequest); + this.TryUploadPhotoWithInvalidPlan(context, client, user, invalidHash, BadRequest); + } + + [Test] + public void CantUploadPhotoWithMissingAssets() + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); + + ReadOnlySpan badImageData = "TEX m"u8; + string badImageHash = BitConverter.ToString(SHA1.HashData(badImageData)).Replace("-", "").ToLower(); + // Don't add to store + + // content doesn't matter here but let's test like this anyway + ReadOnlySpan badPlanData = "PLNb m"u8; + string badPlanHash = BitConverter.ToString(SHA1.HashData(badPlanData)).Replace("-", "").ToLower(); + // Don't add to store + + this.TryUploadPhotosWithInvalidImage(context, client, user, badImageHash, NotFound); + this.TryUploadPhotoWithInvalidPlan(context, client, user, badPlanHash, NotFound); + } + + [Test] + public void CantUploadPhotoWithDisallowedAssets() + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); + + ReadOnlySpan badImageData = "TEX d"u8; + string badImageHash = BitConverter.ToString(SHA1.HashData(badImageData)).Replace("-", "").ToLower(); + context.GetDataStore().WriteToStore(badImageHash, badImageData); + context.Database.DisallowAsset(badImageHash, GameAssetType.Texture, ""); + + ReadOnlySpan badPlanData = "PLNb m"u8; + string badPlanHash = BitConverter.ToString(SHA1.HashData(badPlanData)).Replace("-", "").ToLower(); + context.GetDataStore().WriteToStore(badPlanHash, badPlanData); + context.Database.DisallowAsset(badPlanHash, GameAssetType.Plan, ""); + + this.TryUploadPhotosWithInvalidImage(context, client, user, badImageHash, Unauthorized); + this.TryUploadPhotoWithInvalidPlan(context, client, user, badImageHash, Unauthorized); + } + + [Test] + public void CantUploadPhotoWithInvalidAssets() + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); + + ReadOnlySpan meshData = "MSHb"u8; + string meshHash = BitConverter.ToString(SHA1.HashData(meshData)).Replace("-", "").ToLower(); + context.GetDataStore().WriteToStore(meshHash, meshData); + + this.TryUploadPhotosWithInvalidImage(context, client, user, meshHash, BadRequest); + this.TryUploadPhotoWithInvalidPlan(context, client, user, meshHash, BadRequest); } [Test] From 6b4fc76921f9fa2a94e36239d146c2bbcc07180c Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Mon, 22 Jun 2026 12:54:22 +0200 Subject: [PATCH 3/4] A few photo test fixes --- RefreshTests.GameServer/Tests/Photos/PhotoEndpointsTests.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/RefreshTests.GameServer/Tests/Photos/PhotoEndpointsTests.cs b/RefreshTests.GameServer/Tests/Photos/PhotoEndpointsTests.cs index 76fb5dc2..3a6c674d 100644 --- a/RefreshTests.GameServer/Tests/Photos/PhotoEndpointsTests.cs +++ b/RefreshTests.GameServer/Tests/Photos/PhotoEndpointsTests.cs @@ -227,7 +227,7 @@ private void TryUploadPhotoWithInvalidPlan(TestContext context, HttpClient clien string imageHash = BitConverter.ToString(SHA1.HashData(imageData)).Replace("-", "").ToLower(); context.GetDataStore().WriteToStore(imageHash, imageData); - // bad small image + // bad plan SerializedPhoto photo = new() { Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), @@ -274,7 +274,6 @@ public void CantUploadPhotoWithMissingAssets() string badImageHash = BitConverter.ToString(SHA1.HashData(badImageData)).Replace("-", "").ToLower(); // Don't add to store - // content doesn't matter here but let's test like this anyway ReadOnlySpan badPlanData = "PLNb m"u8; string badPlanHash = BitConverter.ToString(SHA1.HashData(badPlanData)).Replace("-", "").ToLower(); // Don't add to store @@ -301,7 +300,7 @@ public void CantUploadPhotoWithDisallowedAssets() context.Database.DisallowAsset(badPlanHash, GameAssetType.Plan, ""); this.TryUploadPhotosWithInvalidImage(context, client, user, badImageHash, Unauthorized); - this.TryUploadPhotoWithInvalidPlan(context, client, user, badImageHash, Unauthorized); + this.TryUploadPhotoWithInvalidPlan(context, client, user, badPlanHash, Unauthorized); } [Test] From 216e4fb778fee11ed260e971ca63dc17fc42d24d Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Mon, 22 Jun 2026 13:30:14 +0200 Subject: [PATCH 4/4] Update other photo tests to use actual plan resources --- .../Tests/Photos/PhotoEndpointsTests.cs | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/RefreshTests.GameServer/Tests/Photos/PhotoEndpointsTests.cs b/RefreshTests.GameServer/Tests/Photos/PhotoEndpointsTests.cs index 3a6c674d..2e7da718 100644 --- a/RefreshTests.GameServer/Tests/Photos/PhotoEndpointsTests.cs +++ b/RefreshTests.GameServer/Tests/Photos/PhotoEndpointsTests.cs @@ -34,6 +34,11 @@ public void UploadAndDeletePhoto() HttpResponseMessage message = client.PostAsync($"/lbp/upload/{TEST_ASSET_HASH}", new ReadOnlyMemoryContent(TestAsset)).Result; Assert.That(message.StatusCode, Is.EqualTo(OK)); + //Upload out """plan""" + ReadOnlySpan planData = "PLNb"u8; + string planHash = BitConverter.ToString(SHA1.HashData(planData)).Replace("-", "").ToLower(); + context.GetDataStore().WriteToStore(planHash, planData); + SerializedPhoto photo = new() { Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), @@ -41,7 +46,7 @@ public void UploadAndDeletePhoto() SmallHash = TEST_ASSET_HASH, MediumHash = TEST_ASSET_HASH, LargeHash = TEST_ASSET_HASH, - PlanHash = TEST_ASSET_HASH, + PlanHash = planHash, Level = new SerializedPhotoLevel { LevelId = level.LevelId, @@ -104,6 +109,12 @@ public void UploadPhotoAndValidateAttributes() HttpResponseMessage message = client.PostAsync($"/lbp/upload/{TEST_ASSET_HASH}", new ReadOnlyMemoryContent(TestAsset)).Result; Assert.That(message.StatusCode, Is.EqualTo(OK)); DateTimeOffset takenAt = context.Time.Now; + + //Upload out """plan""" + ReadOnlySpan planData = "PLNb"u8; + string planHash = BitConverter.ToString(SHA1.HashData(planData)).Replace("-", "").ToLower(); + context.GetDataStore().WriteToStore(planHash, planData); + context.Time.TimestampMilliseconds += 2000; // Increase to differentiate between creation and publish date SerializedPhoto photo = new() @@ -113,7 +124,7 @@ public void UploadPhotoAndValidateAttributes() SmallHash = TEST_ASSET_HASH, MediumHash = TEST_ASSET_HASH, LargeHash = TEST_ASSET_HASH, - PlanHash = TEST_ASSET_HASH, + PlanHash = planHash, Level = new SerializedPhotoLevel { LevelId = level.StoryId, @@ -161,7 +172,7 @@ public void UploadPhotoAndValidateAttributes() Assert.That(gamePhoto!.SmallAssetHash, Is.EqualTo(TEST_ASSET_HASH)); Assert.That(gamePhoto!.MediumAssetHash, Is.EqualTo(TEST_ASSET_HASH)); Assert.That(gamePhoto!.LargeAssetHash, Is.EqualTo(TEST_ASSET_HASH)); - Assert.That(gamePhoto!.PlanHash, Is.EqualTo(TEST_ASSET_HASH)); + Assert.That(gamePhoto!.PlanHash, Is.EqualTo(planHash)); List subjects = context.Database.GetSubjectsInPhoto(gamePhoto).ToList(); @@ -331,6 +342,11 @@ public void CantUploadPhotoWithTooManySubjects() HttpResponseMessage message = client.PostAsync($"/lbp/upload/{TEST_ASSET_HASH}", new ReadOnlyMemoryContent(TestAsset)).Result; Assert.That(message.StatusCode, Is.EqualTo(OK)); + //Upload out """plan""" + ReadOnlySpan planData = "PLNb"u8; + string planHash = BitConverter.ToString(SHA1.HashData(planData)).Replace("-", "").ToLower(); + context.GetDataStore().WriteToStore(planHash, planData); + SerializedPhoto photo = new() { Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), @@ -338,7 +354,7 @@ public void CantUploadPhotoWithTooManySubjects() SmallHash = TEST_ASSET_HASH, MediumHash = TEST_ASSET_HASH, LargeHash = TEST_ASSET_HASH, - PlanHash = TEST_ASSET_HASH, + PlanHash = planHash, Level = new SerializedPhotoLevel { LevelId = level.LevelId, @@ -443,6 +459,11 @@ public void CantDeleteOthersPhoto() HttpResponseMessage message = client1.PostAsync($"/lbp/upload/{TEST_ASSET_HASH}", new ReadOnlyMemoryContent(TestAsset)).Result; Assert.That(message.StatusCode, Is.EqualTo(OK)); + //Upload out """plan""" + ReadOnlySpan planData = "PLNb"u8; + string planHash = BitConverter.ToString(SHA1.HashData(planData)).Replace("-", "").ToLower(); + context.GetDataStore().WriteToStore(planHash, planData); + SerializedPhoto photo = new() { Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), @@ -450,7 +471,7 @@ public void CantDeleteOthersPhoto() SmallHash = TEST_ASSET_HASH, MediumHash = TEST_ASSET_HASH, LargeHash = TEST_ASSET_HASH, - PlanHash = TEST_ASSET_HASH, + PlanHash = planHash, Level = new SerializedPhotoLevel { LevelId = level.LevelId,