diff --git a/Refresh.Core/Helpers/ResourceValidationHelper.cs b/Refresh.Core/Helpers/ResourceValidationHelper.cs new file mode 100644 index 00000000..2efb5b6e --- /dev/null +++ b/Refresh.Core/Helpers/ResourceValidationHelper.cs @@ -0,0 +1,120 @@ +using System.Diagnostics; +using Bunkum.Core; +using NotEnoughLogs; +using Refresh.Common.Verification; +using Refresh.Core.Types.Assets.Validation; +using Refresh.Database.Models.Assets; +using Refresh.Database.Models.Authentication; + +namespace Refresh.Core.Helpers; + +public abstract class ResourceValidationHelper +{ + /// + /// Validates the given asset reference (hash/guid/blank) using the given parameters, if necessary also by reading the referenced asset + /// or getting data about it from database (see and ). + /// + public static ValidatedAssetResult ValidateReference(AssetValidationParameters parameters, Logger logger) + { + string assetTypeStr = parameters.AssetContextTypeStr ?? (parameters.MustBeTexture ? "image asset" : "asset"); + GameAsset? asset = null; + bool existsInDataStore = false; + bool isPSP = parameters.GameToUseIn == TokenGame.LittleBigPlanetPSP; + Action? onNewAssetRefCallback = parameters.OnNewAssetRefCallback; + + if (parameters.AssetRef.IsBlankHash()) + { + if (!parameters.MayBeBlank) return new(BadRequest, "0", $"The {assetTypeStr} must be set.", onNewAssetRefCallback); + else return new(OK, "0", null, onNewAssetRefCallback); + } + + else if (parameters.AssetRef.StartsWith('g')) + { + if (!parameters.MayBeGuid) return new(BadRequest, null, $"The {assetTypeStr} may not be an in-game asset.", onNewAssetRefCallback); + if (parameters.AssetRef.Length < 2) return new(BadRequest, null, $"The used in-game {assetTypeStr} is invalid (empty GUID).", onNewAssetRefCallback); + + // This should only happen if the user is messing with mods/the API/beta builds, so give them a more detailed response + bool canParseGuid = long.TryParse(parameters.AssetRef[1..], out long guid); + if (!canParseGuid) + return new(BadRequest, null, $"The used in-game {assetTypeStr} is invalid (badly formatted GUID).", onNewAssetRefCallback); + + if (parameters.MustBeTexture && !parameters.GuidChecker.IsTextureGuid(parameters.GameToUseIn, guid)) + return new(BadRequest, null, $"The used in-game {assetTypeStr} was not a valid image (unknown GUID).", onNewAssetRefCallback); + } + + // At this point the reference is a hash + else if (!parameters.MayBeHash) + { + return new(BadRequest, null, $"The {assetTypeStr} may not be a custom asset.", onNewAssetRefCallback); + } + + else if (!CommonPatterns.Sha1Regex().IsMatch(parameters.AssetRef)) + { + // This should only happen if a player is messing with mods/the API, so give them a more detailed response + return new(BadRequest, null, $"The used {assetTypeStr} had an invalid hash.", onNewAssetRefCallback); + } + + else + { + DisallowedAsset? disallowed = parameters.Database.GetDisallowedAssetInfo(parameters.AssetRef); + if (disallowed != null) + { + logger.LogWarning(BunkumCategory.UserContent, $"{parameters.User} tried to use a manually disallowed {assetTypeStr}."); + return new(Unauthorized, disallowanceInfo: disallowed, onNewAssetRefCallback: onNewAssetRefCallback); + } + + string filename = isPSP ? $"psp/{parameters.AssetRef}" : parameters.AssetRef; + existsInDataStore = parameters.DataStore.ExistsInStore(filename); + + if (!existsInDataStore) + { + logger.LogDebug(BunkumCategory.UserContent, $"Referenced asset '{filename}' could not be found in data store."); + + if (parameters.MustBeInDataStoreIfHash) + return new(NotFound, null, $"The used {assetTypeStr} did not exist on the server.", onNewAssetRefCallback); + } + + asset = parameters.Cache.GetAssetInfo(parameters.AssetRef, parameters.Database); + + // Only try to import if the asset exists in the data store + if (existsInDataStore && asset == null) + { + logger.LogInfo(BunkumCategory.UserContent, $"Referenced asset '{filename}' exists in data store but not in database, attempting to import automatically..."); + Stopwatch sw = new(); + sw.Start(); + + if (!parameters.DataStore.TryGetDataFromStore(filename, out byte[]? assetData) || assetData == null) + { + sw.Stop(); + logger.LogError(BunkumCategory.UserContent, $"Failed to read '{filename}' from data store!"); + logger.LogDebug(BunkumCategory.UserContent, $"Failed to get '{filename}' after {sw.ElapsedMilliseconds}ms."); + return new(InternalServerError, null, $"Failed to read {assetTypeStr} internally. Please report this to the server owner.", onNewAssetRefCallback, existsInDataStore: existsInDataStore); + } + + asset = parameters.AssetImporter.ReadAndVerifyAsset(parameters.AssetRef, assetData, parameters.PlatformToUseIn, parameters.Database); + if (asset == null) + { + sw.Stop(); + logger.LogDebug(BunkumCategory.UserContent, $"Failed to get '{filename}' after {sw.ElapsedMilliseconds}ms."); + return new(BadRequest, null, $"The used {assetTypeStr} was invalid or corrupt.", onNewAssetRefCallback, existsInDataStore: existsInDataStore); + } + + sw.Stop(); + logger.LogInfo(BunkumCategory.UserContent, $"Successfully imported '{filename}' in {sw.ElapsedMilliseconds}ms."); + } + + // FIXME: for some reason, PSP texture detection/conversion broke so we can no longer tell if a PSP texture is actually a texture, so skip this for PSP + if (asset != null && !isPSP) + { + bool isHashedTexture = (asset.AssetFlags & AssetFlags.Imagery) != 0; + + if (parameters.MustBeTexture && !isHashedTexture) + return new(BadRequest, null, $"The used {assetTypeStr} was not a valid custom image.", onNewAssetRefCallback, assetInfo: asset, existsInDataStore: existsInDataStore); + + // TODO: actually use AIPI to scan image if not null + } + } + + return new(OK, parameters.AssetRef, null, onNewAssetRefCallback, assetInfo: asset, existsInDataStore: existsInDataStore); + } +} \ No newline at end of file diff --git a/Refresh.Core/Importing/Importer.cs b/Refresh.Core/Importing/Importer.cs index d38a968f..a105a269 100644 --- a/Refresh.Core/Importing/Importer.cs +++ b/Refresh.Core/Importing/Importer.cs @@ -107,32 +107,40 @@ private static bool MatchesMagic(ReadOnlySpan data, ulong magic) /// Whether the file is likely of TGA format private static bool IsPspTga(ReadOnlySpan data) { - byte imageIdLength = data[0]; - byte colorMapType = data[1]; - byte imageType = data[2]; - ReadOnlySpan colorMapSpecification = data[3..8]; - ReadOnlySpan imageSpecification = data[8..18]; - short xOrigin = BinaryPrimitives.ReadInt16LittleEndian(imageSpecification[..2]); - short yOrigin = BinaryPrimitives.ReadInt16LittleEndian(imageSpecification[2..4]); - ushort width = BinaryPrimitives.ReadUInt16LittleEndian(imageSpecification[4..6]); - ushort height = BinaryPrimitives.ReadUInt16LittleEndian(imageSpecification[6..8]); - byte depth = imageSpecification[8]; - byte descriptor = imageSpecification[9]; - - //PSP does not seem to fill out this information - if (imageIdLength != 0) return false; - if (xOrigin != 0) return false; - if (yOrigin != 0) return false; - //These are the fields set by PSP, that shouldn't change from image to image - if (colorMapType != 1) return false; - if (descriptor != 0) return false; - if (imageType != 1) return false; - if (depth != 8) return false; - //Reasonable validation checks (PSP seems to only send images of max size 480x272) - if (width > 500) return false; - if (height > 300) return false; - - return true; + try + { + byte imageIdLength = data[0]; + byte colorMapType = data[1]; + byte imageType = data[2]; + ReadOnlySpan colorMapSpecification = data[3..8]; + ReadOnlySpan imageSpecification = data[8..18]; + short xOrigin = BinaryPrimitives.ReadInt16LittleEndian(imageSpecification[..2]); + short yOrigin = BinaryPrimitives.ReadInt16LittleEndian(imageSpecification[2..4]); + ushort width = BinaryPrimitives.ReadUInt16LittleEndian(imageSpecification[4..6]); + ushort height = BinaryPrimitives.ReadUInt16LittleEndian(imageSpecification[6..8]); + byte depth = imageSpecification[8]; + byte descriptor = imageSpecification[9]; + + //PSP does not seem to fill out this information + if (imageIdLength != 0) return false; + if (xOrigin != 0) return false; + if (yOrigin != 0) return false; + //These are the fields set by PSP, that shouldn't change from image to image + if (colorMapType != 1) return false; + if (descriptor != 0) return false; + if (imageType != 1) return false; + if (depth != 8) return false; + //Reasonable validation checks (PSP seems to only send images of max size 480x272) + if (width > 500) return false; + if (height > 300) return false; + + return true; + } + catch + { + //If the data couldn't be read, it's not a TGA + return false; + } } private bool IsMip(Span rawData) diff --git a/Refresh.Core/Types/Assets/Validation/AssetValidationParameters.cs b/Refresh.Core/Types/Assets/Validation/AssetValidationParameters.cs new file mode 100644 index 00000000..b83f4cae --- /dev/null +++ b/Refresh.Core/Types/Assets/Validation/AssetValidationParameters.cs @@ -0,0 +1,65 @@ +using Bunkum.Core.Storage; +using Refresh.Core.Importing; +using Refresh.Core.Services; +using Refresh.Core.Types.Data; +using Refresh.Database; +using Refresh.Database.Models.Authentication; +using Refresh.Database.Models.Users; + +namespace Refresh.Core.Types.Assets.Validation; + +public struct AssetValidationParameters +{ + /// + /// The reference (hash/guid/blank) to validate + /// + public string AssetRef { get; set; } = "0"; + public GameUser? User { get; set; } + public TokenGame GameToUseIn { get; set; } + public TokenPlatform PlatformToUseIn { get; set; } + public GameDatabaseContext Database { get; set; } = null!; + public IDataStore DataStore { get; set; } = null!; + public CacheService Cache { get; set; } = null!; + public GuidCheckerService GuidChecker { get; set; } = null!; + public AssetImporter AssetImporter { get; set; } = null!; + public AipiService? Aipi { get; set; } + + public bool MayBeBlank { get; set; } = true; + public bool MayBeGuid { get; set; } = true; + public bool MayBeHash { get; set; } = true; + public bool MustBeInDataStoreIfHash { get; set; } = true; + public bool MustBeTexture { get; set; } = false; + + /// + /// What the asset should be referred as in user-faced error messages and in logs, e.g. "planet asset" or "icon". + /// If null, we will default to calling it "asset" or "image" depending on MustBeTexture. + /// + public string? AssetContextTypeStr { get; set; } + + /// + /// Callback which is called with the new asset reference as parameter when constructing ; + /// useful to update asset references of entities during validation without requiring the caller to manually reassign them after validation; + /// this way similar attributes like photo images or face icons can simply be iterated. + /// If null, this will be skipped. + /// + public Action? OnNewAssetRefCallback { get; set; } + + public AssetValidationParameters(string assetKey, DataContext dataContext, AssetImporter assetImporter, AipiService? aipi = null) + { + this.AssetRef = assetKey; + this.User = dataContext.User; + this.GameToUseIn = dataContext.Game; + this.PlatformToUseIn = dataContext.Platform; + this.Database = dataContext.Database; + this.DataStore = dataContext.DataStore; + this.Cache = dataContext.Cache; + this.GuidChecker = dataContext.GuidChecker; + this.AssetImporter = assetImporter; + this.Aipi = aipi; + } + + public AssetValidationParameters() + { + + } +} \ No newline at end of file diff --git a/Refresh.Core/Types/Assets/Validation/ValidatedAssetResult.cs b/Refresh.Core/Types/Assets/Validation/ValidatedAssetResult.cs new file mode 100644 index 00000000..a44eb9c6 --- /dev/null +++ b/Refresh.Core/Types/Assets/Validation/ValidatedAssetResult.cs @@ -0,0 +1,43 @@ +using System.Net; +using Refresh.Database.Models.Assets; + +namespace Refresh.Core.Types.Assets.Validation; + +// key = blank/guid/hash, whatever is used to identify the asset +public struct ValidatedAssetResult +{ + /// + /// HTTP code to return if validation failed. + /// OK: don't cancel request and proceed. + /// + public HttpStatusCode Status { get; set; } + + /// + /// new reference (hash/guid/blank) to use + /// + public string NewAssetRef { get; set; } + + /// + /// message to show to the user. + /// null: don't show anything. + /// + public string? ErrorMessage { get; set; } + + public GameAsset? AssetInfo { get; set; } + public DisallowedAsset? DisallowanceInfo { get; set; } + public bool ExistsInDataStore { get; set; } + + public ValidatedAssetResult(HttpStatusCode status, string? newAssetRef = null, string? errorMessage = null, Action? onNewAssetRefCallback = null, + GameAsset? assetInfo = null, DisallowedAsset? disallowanceInfo = null, bool existsInDataStore = false) + { + this.Status = status; + this.NewAssetRef = newAssetRef ?? "0"; + this.ErrorMessage = errorMessage; + this.AssetInfo = assetInfo; + this.DisallowanceInfo = disallowanceInfo; + this.ExistsInDataStore = existsInDataStore; + + if (onNewAssetRefCallback != null) + onNewAssetRefCallback(this.NewAssetRef); + } +} \ No newline at end of file diff --git a/Refresh.Database/Models/Assets/AssetFlags.cs b/Refresh.Database/Models/Assets/AssetFlags.cs index fa4b1a9b..107f92a2 100644 --- a/Refresh.Database/Models/Assets/AssetFlags.cs +++ b/Refresh.Database/Models/Assets/AssetFlags.cs @@ -20,6 +20,10 @@ public enum AssetFlags /// A planet is considered modded if it depends on this asset, or if the asset already has the Modded flag above. /// ModdedOnPlanets = 1 << 3, + /// + /// This asset is a texture or contains imagery, for now only given to asset types handled by image conversion. + /// + Imagery = 1 << 4, } public static class AssetSafetyLevelExtensions @@ -30,33 +34,33 @@ public static AssetFlags FromAssetType(GameAssetType type, GameAssetFormat? meth { // Common asset types created by the game GameAssetType.Level => AssetFlags.None, - GameAssetType.StreamingLevelChunk => AssetFlags.None | AssetFlags.ModdedOnPlanets, + GameAssetType.StreamingLevelChunk => AssetFlags.ModdedOnPlanets, GameAssetType.Plan => AssetFlags.None, - GameAssetType.ThingRecording => AssetFlags.None | AssetFlags.ModdedOnPlanets, - GameAssetType.SyncedProfile => AssetFlags.None | AssetFlags.ModdedOnPlanets, - GameAssetType.GriefSongState => AssetFlags.None | AssetFlags.ModdedOnPlanets, - GameAssetType.Quest => AssetFlags.None | AssetFlags.ModdedOnPlanets, - GameAssetType.AdventureSharedData => AssetFlags.None | AssetFlags.ModdedOnPlanets, - GameAssetType.AdventureCreateProfile => AssetFlags.None | AssetFlags.ModdedOnPlanets, - GameAssetType.ChallengeGhost => AssetFlags.None | AssetFlags.ModdedOnPlanets, + GameAssetType.ThingRecording => AssetFlags.ModdedOnPlanets, + GameAssetType.SyncedProfile => AssetFlags.ModdedOnPlanets, + GameAssetType.GriefSongState => AssetFlags.ModdedOnPlanets, + GameAssetType.Quest => AssetFlags.ModdedOnPlanets, + GameAssetType.AdventureSharedData => AssetFlags.ModdedOnPlanets, + GameAssetType.AdventureCreateProfile => AssetFlags.ModdedOnPlanets, + GameAssetType.ChallengeGhost => AssetFlags.ModdedOnPlanets, // Common media types created by the game GameAssetType.VoiceRecording => AssetFlags.Media | AssetFlags.ModdedOnPlanets, GameAssetType.Painting => AssetFlags.Media, - GameAssetType.Texture => AssetFlags.Media, - GameAssetType.Jpeg => AssetFlags.Media, - GameAssetType.Png => AssetFlags.Media, - GameAssetType.Tga => AssetFlags.Media | AssetFlags.ModdedOnPlanets, - GameAssetType.Mip => AssetFlags.Media | AssetFlags.ModdedOnPlanets, + GameAssetType.Texture => AssetFlags.Media | AssetFlags.Imagery, + GameAssetType.Jpeg => AssetFlags.Media | AssetFlags.Imagery, + GameAssetType.Png => AssetFlags.Media | AssetFlags.Imagery, + GameAssetType.Tga => AssetFlags.Media | AssetFlags.Imagery | AssetFlags.ModdedOnPlanets, + GameAssetType.Mip => AssetFlags.Media | AssetFlags.Imagery | AssetFlags.ModdedOnPlanets, // Uncommon, but still vanilla assets created by the game in niche scenarios. // While not image/audio data like the other media types, GfxMaterial is marked as media because this file can contain full PS3 shaders. GameAssetType.GfxMaterial => AssetFlags.Media | AssetFlags.ModdedOnPlanets, - GameAssetType.Material => AssetFlags.None | AssetFlags.ModdedOnPlanets, - GameAssetType.Bevel => AssetFlags.None | AssetFlags.ModdedOnPlanets, + GameAssetType.Material => AssetFlags.ModdedOnPlanets, + GameAssetType.Bevel => AssetFlags.ModdedOnPlanets, // Modded media types - GameAssetType.GameDataTexture => AssetFlags.Media | AssetFlags.Modded, + GameAssetType.GameDataTexture => AssetFlags.Media | AssetFlags.Modded | AssetFlags.Imagery, GameAssetType.AnimatedTexture => AssetFlags.Media | AssetFlags.Modded, // Normal modded assets diff --git a/RefreshTests.GameServer/TestContext.cs b/RefreshTests.GameServer/TestContext.cs index fc126a12..aa6a8ea3 100644 --- a/RefreshTests.GameServer/TestContext.cs +++ b/RefreshTests.GameServer/TestContext.cs @@ -56,7 +56,7 @@ public HttpClient GetAuthenticatedClient(TokenType type, TokenGame game, TokenPl int tokenExpirySeconds = GameDatabaseContext.DefaultTokenExpirySeconds, string? ipAddress = null) { - return this.GetAuthenticatedClient(type, game, platform, out _, user, tokenExpirySeconds, ipAddress); + return this.GetAuthenticatedClient(type, game, platform, out string _, user, tokenExpirySeconds, ipAddress); } public HttpClient GetAuthenticatedClient(TokenType type, out string tokenData, @@ -86,25 +86,41 @@ public HttpClient GetAuthenticatedClient(TokenType type, TokenGame game, TokenPl int tokenExpirySeconds = GameDatabaseContext.DefaultTokenExpirySeconds, string? ipAddress = null) { - user ??= this.CreateUser(); - - Token token = this.Database.GenerateTokenForUser(user, type, game, platform, ipAddress ?? "0.0.0.0", tokenExpirySeconds); + Token token = this.GenerateToken(user, type, game, platform, ipAddress, tokenExpirySeconds); tokenData = token.TokenData; - + return this.GetAuthenticatedClient(token.TokenData, type); + } + + public HttpClient GetAuthenticatedClient(TokenType type, TokenGame game, TokenPlatform platform, out Token token, + GameUser? user = null, + int tokenExpirySeconds = GameDatabaseContext.DefaultTokenExpirySeconds, + string? ipAddress = null) + { + token = this.GenerateToken(user, type, game, platform, ipAddress, tokenExpirySeconds); + return this.GetAuthenticatedClient(token.TokenData, type); + } + + public HttpClient GetAuthenticatedClient(string tokenData, TokenType type) + { HttpClient client = this.Listener.GetClient(); if (type == TokenType.Game) { - client.DefaultRequestHeaders.Add("Cookie", "MM_AUTH=" + token.TokenData); + client.DefaultRequestHeaders.Add("Cookie", "MM_AUTH=" + tokenData); } else { - client.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", token.TokenData); + client.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", tokenData); } return client; } + public Token GenerateToken(GameUser? user, TokenType type, TokenGame game, TokenPlatform platform, string? ipAddress, int tokenExpirySeconds) + { + return this.Database.GenerateTokenForUser(user ?? this.CreateUser(), type, game, platform, ipAddress ?? "0.0.0.0", tokenExpirySeconds); + } + public GameUser CreateUser(string? username = null, GameUserRole role = GameUserRole.User, bool verifyEmail = true) { username ??= this.UserIncrement.ToString(); @@ -218,13 +234,13 @@ public GamePlaylist CreatePlaylist(GameUser author, string? title = null) [Pure] public TService GetService() where TService : Service => this.Server.Value.GetService(); - public DataContext GetDataContext(Token? token = null) + public DataContext GetDataContext(Token? token = null, IDataStore? dataStore = null) { return new DataContext { Database = this.Database, Logger = this.Server.Value.Logger, - DataStore = (IDataStore)this.GetService() + DataStore = dataStore ?? (IDataStore)this.GetService() .AddParameterToEndpoint(null!, new BunkumParameterInfo(typeof(IDataStore), ""), null!)!, Match = this.GetService(), Token = token, diff --git a/RefreshTests.GameServer/Tests/Assets/AssetReferenceValidationTests.cs b/RefreshTests.GameServer/Tests/Assets/AssetReferenceValidationTests.cs new file mode 100644 index 00000000..9d986af3 --- /dev/null +++ b/RefreshTests.GameServer/Tests/Assets/AssetReferenceValidationTests.cs @@ -0,0 +1,527 @@ +using System.Security.Cryptography; +using Refresh.Core.Helpers; +using Refresh.Core.Importing; +using Refresh.Core.Types.Assets.Validation; +using Refresh.Core.Types.Data; +using Refresh.Database.Models.Assets; +using Refresh.Database.Models.Authentication; +using Refresh.Database.Models.Users; + +namespace RefreshTests.GameServer.Tests.Assets; + +public class AssetReferenceValidationTests : GameServerTest +{ + private const string ValidImageGuid = "g18451"; // star sticker texture + private const string InvalidImageGuid = "g1087"; // sackboy model + + [Test] + [TestCase("")] + [TestCase(" ")] + [TestCase(" ")] + [TestCase("0")] + public void AcceptBlankHash(string blankHashVariation) + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + HttpClient client = context.GetAuthenticatedClient(TokenType.Game, TokenGame.LittleBigPlanet1, TokenPlatform.PS3, out Token token, user); + DataContext dataContext = context.GetDataContext(token); + AssetImporter importer = new(dataContext.Logger, context.Time); + + string newRefSetByCallback = "unset lol"; + ValidatedAssetResult result = ResourceValidationHelper.ValidateReference(new(blankHashVariation, dataContext, importer) + { + OnNewAssetRefCallback = delegate(string NewAssetRef) { newRefSetByCallback = NewAssetRef; }, + }, dataContext.Logger); + Assert.That(result.Status, Is.EqualTo(OK)); + Assert.That(result.NewAssetRef, Is.EqualTo("0")); + Assert.That(newRefSetByCallback, Is.EqualTo("0")); + Assert.That(result.AssetInfo, Is.Null); + } + + [Test] + [TestCase("")] + [TestCase(" ")] + [TestCase(" ")] + [TestCase("0")] + public void RejectBlankHashIfDisallowed(string blankHashVariation) + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + HttpClient client = context.GetAuthenticatedClient(TokenType.Game, TokenGame.LittleBigPlanet1, TokenPlatform.PS3, out Token token, user); + DataContext dataContext = context.GetDataContext(token); + AssetImporter importer = new(dataContext.Logger, context.Time); + + string newRefSetByCallback = "unset lol"; + ValidatedAssetResult result = ResourceValidationHelper.ValidateReference(new(blankHashVariation, dataContext, importer) + { + MayBeBlank = false, + OnNewAssetRefCallback = delegate(string NewAssetRef) { newRefSetByCallback = NewAssetRef; }, + }, dataContext.Logger); + Assert.That(result.Status, Is.EqualTo(BadRequest)); + Assert.That(result.ErrorMessage, Is.Not.Null); + Assert.That(result.NewAssetRef, Is.EqualTo("0")); + Assert.That(newRefSetByCallback, Is.EqualTo("0")); + } + + [Test] + [TestCase(ValidImageGuid)] + [TestCase(InvalidImageGuid)] + public void AcceptGuidsIfNotRestricted(string guid) + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + HttpClient client = context.GetAuthenticatedClient(TokenType.Game, TokenGame.LittleBigPlanet1, TokenPlatform.PS3, out Token token, user); + DataContext dataContext = context.GetDataContext(token); + AssetImporter importer = new(dataContext.Logger, context.Time); + + string newRefSetByCallback = "unset lol"; + ValidatedAssetResult result = ResourceValidationHelper.ValidateReference(new(guid, dataContext, importer) + { + OnNewAssetRefCallback = delegate(string NewAssetRef) { newRefSetByCallback = NewAssetRef; }, + }, dataContext.Logger); + Assert.That(result.Status, Is.EqualTo(OK)); + Assert.That(result.NewAssetRef, Is.EqualTo(guid)); + Assert.That(newRefSetByCallback, Is.EqualTo(guid)); + Assert.That(result.ErrorMessage, Is.Null); + } + + [Test] + [TestCase(InvalidImageGuid)] + [TestCase(ValidImageGuid)] + public void RejectGuidIfDisallowed(string guid) + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + HttpClient client = context.GetAuthenticatedClient(TokenType.Game, TokenGame.LittleBigPlanet1, TokenPlatform.PS3, out Token token, user); + DataContext dataContext = context.GetDataContext(token); + AssetImporter importer = new(dataContext.Logger, context.Time); + + string newRefSetByCallback = "unset lol"; + ValidatedAssetResult result = ResourceValidationHelper.ValidateReference(new(guid, dataContext, importer) + { + MayBeGuid = false, + OnNewAssetRefCallback = delegate(string NewAssetRef) { newRefSetByCallback = NewAssetRef; }, + }, dataContext.Logger); + Assert.That(result.Status, Is.EqualTo(BadRequest)); + Assert.That(result.ErrorMessage, Is.Not.Null); + Assert.That(result.NewAssetRef, Is.EqualTo("0")); + Assert.That(newRefSetByCallback, Is.EqualTo("0")); + } + + [Test] + [TestCase("g")] + [TestCase("greg")] + [TestCase("g67676767676767676767676767676776767676767")] + public void RejectGuidIfBadlyFormatted(string guid) + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + HttpClient client = context.GetAuthenticatedClient(TokenType.Game, TokenGame.LittleBigPlanet1, TokenPlatform.PS3, out Token token, user); + DataContext dataContext = context.GetDataContext(token); + AssetImporter importer = new(dataContext.Logger, context.Time); + + string newRefSetByCallback = "unset lol"; + ValidatedAssetResult result = ResourceValidationHelper.ValidateReference(new(guid, dataContext, importer) + { + OnNewAssetRefCallback = delegate(string NewAssetRef) { newRefSetByCallback = NewAssetRef; }, + }, dataContext.Logger); + Assert.That(result.Status, Is.EqualTo(BadRequest)); + Assert.That(result.ErrorMessage, Is.Not.Null); + Assert.That(result.NewAssetRef, Is.EqualTo("0")); + Assert.That(newRefSetByCallback, Is.EqualTo("0")); + } + + [Test] + public void RejectNonTextureGuidIfOnlyTexturesAllowed() + { + string guid = InvalidImageGuid; + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + HttpClient client = context.GetAuthenticatedClient(TokenType.Game, TokenGame.LittleBigPlanet1, TokenPlatform.PS3, out Token token, user); + DataContext dataContext = context.GetDataContext(token); + AssetImporter importer = new(dataContext.Logger, context.Time); + + string newRefSetByCallback = "unset lol"; + ValidatedAssetResult result = ResourceValidationHelper.ValidateReference(new(guid, dataContext, importer) + { + MustBeTexture = true, + OnNewAssetRefCallback = delegate(string NewAssetRef) { newRefSetByCallback = NewAssetRef; }, + }, dataContext.Logger); + Assert.That(result.Status, Is.EqualTo(BadRequest)); + Assert.That(result.ErrorMessage, Is.Not.Null); + Assert.That(result.NewAssetRef, Is.EqualTo("0")); + Assert.That(newRefSetByCallback, Is.EqualTo("0")); + } + + [Test] + public void AcceptTextureGuidIfOnlyTexturesAllowed() + { + string guid = ValidImageGuid; + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + HttpClient client = context.GetAuthenticatedClient(TokenType.Game, TokenGame.LittleBigPlanet1, TokenPlatform.PS3, out Token token, user); + DataContext dataContext = context.GetDataContext(token); + AssetImporter importer = new(dataContext.Logger, context.Time); + + string newRefSetByCallback = "unset lol"; + ValidatedAssetResult result = ResourceValidationHelper.ValidateReference(new(guid, dataContext, importer) + { + MustBeTexture = true, + OnNewAssetRefCallback = delegate(string NewAssetRef) { newRefSetByCallback = NewAssetRef; }, + }, dataContext.Logger); + Assert.That(result.Status, Is.EqualTo(OK)); + Assert.That(result.AssetInfo, Is.Null); + Assert.That(result.NewAssetRef, Is.EqualTo(guid)); + Assert.That(newRefSetByCallback, Is.EqualTo(guid)); + } + + [Test] + [TestCase(true, false)] + [TestCase(false, false)] + [TestCase(true, true)] + [TestCase(false, true)] + public void AcceptOrRejectHashDependingOnExistenceInDataStore(bool addToDataStore, bool mustBeInDataStore) + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + HttpClient client = context.GetAuthenticatedClient(TokenType.Game, TokenGame.LittleBigPlanet1, TokenPlatform.PS3, out Token token, user); + DataContext dataContext = context.GetDataContext(token); + AssetImporter importer = new(dataContext.Logger, context.Time); + bool expectDataStoreFailure = !addToDataStore && mustBeInDataStore; + + ReadOnlySpan data = "LVLb"u8; + string hash = BitConverter.ToString(SHA1.HashData(data)).Replace("-", "").ToLower(); + + // add to store but not database, this way we can also test auto-import + if (addToDataStore) + { + dataContext.DataStore.WriteToStore(hash, data); + } + + string newRefSetByCallback = "unset lol"; + ValidatedAssetResult result = ResourceValidationHelper.ValidateReference(new(hash, dataContext, importer) + { + MustBeInDataStoreIfHash = mustBeInDataStore, + OnNewAssetRefCallback = delegate(string NewAssetRef) { newRefSetByCallback = NewAssetRef; }, + }, dataContext.Logger); + + if (expectDataStoreFailure) + { + Assert.That(result.Status, Is.EqualTo(NotFound)); + Assert.That(result.AssetInfo, Is.Null); + Assert.That(result.NewAssetRef, Is.EqualTo("0")); + Assert.That(newRefSetByCallback, Is.EqualTo("0")); + } + else if (addToDataStore) + { + Assert.That(result.Status, Is.EqualTo(OK)); + Assert.That(result.AssetInfo, Is.Not.Null); // ensure it was auto-imported + Assert.That(result.AssetInfo!.AssetHash, Is.EqualTo(hash)); + Assert.That(result.AssetInfo!.AssetType, Is.EqualTo(GameAssetType.Level)); + Assert.That(result.NewAssetRef, Is.EqualTo(hash)); + Assert.That(newRefSetByCallback, Is.EqualTo(hash)); + } + else + { + Assert.That(result.Status, Is.EqualTo(OK)); + Assert.That(result.AssetInfo, Is.Null); // not auto-imported and also not in DB before + Assert.That(result.NewAssetRef, Is.EqualTo(hash)); + Assert.That(newRefSetByCallback, Is.EqualTo(hash)); + } + + Assert.That(result.DisallowanceInfo, Is.Null); + Assert.That(result.ExistsInDataStore, Is.EqualTo(addToDataStore)); + } + + [Test] + public void RejectHashIfReadingFromDataStoreFails() + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + HttpClient client = context.GetAuthenticatedClient(TokenType.Game, TokenGame.LittleBigPlanet1, TokenPlatform.PS3, out Token token, user); + + // read-failing will unconditionally return true on key lookup, but will fail trying to read (null bytes/false return), which is what we want + DataContext dataContext = context.GetDataContext(token, new ReadFailingDataStore()); + AssetImporter importer = new(dataContext.Logger, context.Time); + + ReadOnlySpan data = "LVLb"u8; + string hash = BitConverter.ToString(SHA1.HashData(data)).Replace("-", "").ToLower(); + + string newRefSetByCallback = "unset lol"; + ValidatedAssetResult result = ResourceValidationHelper.ValidateReference(new(hash, dataContext, importer) + { + MustBeInDataStoreIfHash = true, + OnNewAssetRefCallback = delegate(string NewAssetRef) { newRefSetByCallback = NewAssetRef; }, + }, dataContext.Logger); + + Assert.That(result.Status, Is.EqualTo(InternalServerError)); + Assert.That(result.AssetInfo, Is.Null); + Assert.That(result.DisallowanceInfo, Is.Null); + Assert.That(result.ExistsInDataStore, Is.True); + Assert.That(result.NewAssetRef, Is.EqualTo("0")); + Assert.That(newRefSetByCallback, Is.EqualTo("0")); + } + + [Test] + public void RejectHashIfImportingFromDataStoreFails() + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + HttpClient client = context.GetAuthenticatedClient(TokenType.Game, TokenGame.LittleBigPlanet1, TokenPlatform.PS3, out Token token, user); + DataContext dataContext = context.GetDataContext(token); + AssetImporter importer = new(dataContext.Logger, context.Time); + + ReadOnlySpan data = "totallyalevel"u8; + // importing, for now, only really fails if the given hash doesn't match the actual hash + string fakeHash = BitConverter.ToString(SHA1.HashData("veryreallevel"u8)).Replace("-", "").ToLower(); + dataContext.DataStore.WriteToStore(fakeHash, data); + + string newRefSetByCallback = "unset lol"; + ValidatedAssetResult result = ResourceValidationHelper.ValidateReference(new(fakeHash, dataContext, importer) + { + MustBeInDataStoreIfHash = true, + OnNewAssetRefCallback = delegate(string NewAssetRef) { newRefSetByCallback = NewAssetRef; }, + }, dataContext.Logger); + + Assert.That(result.Status, Is.EqualTo(BadRequest)); + Assert.That(result.AssetInfo, Is.Null); + Assert.That(result.DisallowanceInfo, Is.Null); + Assert.That(result.ExistsInDataStore, Is.True); + Assert.That(result.ErrorMessage, Is.Not.Null); + Assert.That(result.NewAssetRef, Is.EqualTo("0")); + Assert.That(newRefSetByCallback, Is.EqualTo("0")); + } + + [Test] + public void RejectHashIfInvalidHash() + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + HttpClient client = context.GetAuthenticatedClient(TokenType.Game, TokenGame.LittleBigPlanet1, TokenPlatform.PS3, out Token token, user); + DataContext dataContext = context.GetDataContext(token); + AssetImporter importer = new(dataContext.Logger, context.Time); + + string newRefSetByCallback = "unset lol"; + ValidatedAssetResult result = ResourceValidationHelper.ValidateReference(new("lololol", dataContext, importer) + { + MustBeInDataStoreIfHash = true, + OnNewAssetRefCallback = delegate(string NewAssetRef) { newRefSetByCallback = NewAssetRef; }, + }, dataContext.Logger); + + Assert.That(result.Status, Is.EqualTo(BadRequest)); + Assert.That(result.AssetInfo, Is.Null); + Assert.That(result.DisallowanceInfo, Is.Null); + Assert.That(result.ExistsInDataStore, Is.False); // Cancelled before actually wasting time looking it up + Assert.That(result.NewAssetRef, Is.EqualTo("0")); + Assert.That(newRefSetByCallback, Is.EqualTo("0")); + } + + [Test] + public void RejectHashIfAssetDisallowed() + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + HttpClient client = context.GetAuthenticatedClient(TokenType.Game, TokenGame.LittleBigPlanet1, TokenPlatform.PS3, out Token token, user); + DataContext dataContext = context.GetDataContext(token); + AssetImporter importer = new(dataContext.Logger, context.Time); + + ReadOnlySpan data = "LVLb"u8; + string hash = BitConverter.ToString(SHA1.HashData(data)).Replace("-", "").ToLower(); + context.Database.DisallowAsset(hash, GameAssetType.Level, "youre evel was so shit that we had to ban it"); + + context.Database.Refresh(); + + string newRefSetByCallback = "unset lol"; + ValidatedAssetResult result = ResourceValidationHelper.ValidateReference(new(hash, dataContext, importer) + { + MustBeInDataStoreIfHash = true, // Ensure disallowance is checked before the data store check + OnNewAssetRefCallback = delegate(string NewAssetRef) { newRefSetByCallback = NewAssetRef; }, + }, dataContext.Logger); + + Assert.That(result.Status, Is.EqualTo(Unauthorized)); + Assert.That(result.ErrorMessage, Is.Null); + Assert.That(result.AssetInfo, Is.Null); + Assert.That(result.DisallowanceInfo, Is.Not.Null); + Assert.That(result.NewAssetRef, Is.EqualTo("0")); + Assert.That(newRefSetByCallback, Is.EqualTo("0")); + } + + [Test] + public void RejectHashIfHashesDisallowed() + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + HttpClient client = context.GetAuthenticatedClient(TokenType.Game, TokenGame.LittleBigPlanet1, TokenPlatform.PS3, out Token token, user); + DataContext dataContext = context.GetDataContext(token); + AssetImporter importer = new(dataContext.Logger, context.Time); + + ReadOnlySpan data = "LVLb"u8; + string hash = BitConverter.ToString(SHA1.HashData(data)).Replace("-", "").ToLower(); + + string newRefSetByCallback = "unset lol"; + ValidatedAssetResult result = ResourceValidationHelper.ValidateReference(new(hash, dataContext, importer) + { + MayBeHash = false, + OnNewAssetRefCallback = delegate(string NewAssetRef) { newRefSetByCallback = NewAssetRef; }, + }, dataContext.Logger); + + Assert.That(result.Status, Is.EqualTo(BadRequest)); + Assert.That(result.ErrorMessage, Is.Not.Null); + Assert.That(result.AssetInfo, Is.Null); + Assert.That(result.DisallowanceInfo, Is.Null); + Assert.That(result.NewAssetRef, Is.EqualTo("0")); + Assert.That(newRefSetByCallback, Is.EqualTo("0")); + } + + [Test] + [TestCase(true)] + [TestCase(false)] + public void AcceptTextureHashIfOnlyTexturesAllowed(bool addToDataStore) + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + HttpClient client = context.GetAuthenticatedClient(TokenType.Game, TokenGame.LittleBigPlanet1, TokenPlatform.PS3, out Token token, user); + DataContext dataContext = context.GetDataContext(token); + AssetImporter importer = new(dataContext.Logger, context.Time); + + ReadOnlySpan data = "TEX "u8; + string hash = BitConverter.ToString(SHA1.HashData(data)).Replace("-", "").ToLower(); + + if (addToDataStore) dataContext.DataStore.WriteToStore(hash, data); + else context.Database.AddAssetToDatabase(new() // not adding to store: test getting from just DB instead + { + AssetHash = hash, + AssetType = GameAssetType.Texture, + }); + + context.Database.Refresh(); + + string newRefSetByCallback = "unset lol"; + ValidatedAssetResult result = ResourceValidationHelper.ValidateReference(new(hash, dataContext, importer) + { + MustBeTexture = true, + MustBeInDataStoreIfHash = false, // not tested in this one + OnNewAssetRefCallback = delegate(string NewAssetRef) { newRefSetByCallback = NewAssetRef; }, + }, dataContext.Logger); + + Assert.That(result.Status, Is.EqualTo(OK)); + Assert.That(result.NewAssetRef, Is.EqualTo(hash)); + Assert.That(newRefSetByCallback, Is.EqualTo(hash)); + Assert.That(result.AssetInfo, Is.Not.Null); + Assert.That(result.AssetInfo!.AssetHash, Is.EqualTo(hash)); + Assert.That(result.AssetInfo!.AssetType, Is.EqualTo(GameAssetType.Texture)); + Assert.That(result.DisallowanceInfo, Is.Null); + Assert.That(result.ExistsInDataStore, Is.EqualTo(addToDataStore)); + } + + [Test] + [TestCase(true)] + [TestCase(false)] + public void RejectNonTextureHashIfOnlyTexturesAllowed(bool addToDataStore) + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + HttpClient client = context.GetAuthenticatedClient(TokenType.Game, TokenGame.LittleBigPlanet1, TokenPlatform.PS3, out Token token, user); + DataContext dataContext = context.GetDataContext(token); + AssetImporter importer = new(dataContext.Logger, context.Time); + + ReadOnlySpan data = "LVLb"u8; + string hash = BitConverter.ToString(SHA1.HashData(data)).Replace("-", "").ToLower(); + + if (addToDataStore) dataContext.DataStore.WriteToStore(hash, data); + else context.Database.AddAssetToDatabase(new() // not adding to store: test getting from just DB instead + { + AssetHash = hash, + AssetType = GameAssetType.Level, + }); + + context.Database.Refresh(); + + string newRefSetByCallback = "unset lol"; + ValidatedAssetResult result = ResourceValidationHelper.ValidateReference(new(hash, dataContext, importer) + { + MustBeTexture = true, + MustBeInDataStoreIfHash = false, // not tested in this one + OnNewAssetRefCallback = delegate(string NewAssetRef) { newRefSetByCallback = NewAssetRef; }, + }, dataContext.Logger); + + Assert.That(result.Status, Is.EqualTo(BadRequest)); + Assert.That(result.NewAssetRef, Is.EqualTo("0")); + Assert.That(newRefSetByCallback, Is.EqualTo("0")); + Assert.That(result.AssetInfo, Is.Not.Null); + Assert.That(result.AssetInfo!.AssetHash, Is.EqualTo(hash)); + Assert.That(result.AssetInfo!.AssetType, Is.EqualTo(GameAssetType.Level)); + Assert.That(result.DisallowanceInfo, Is.Null); + Assert.That(result.ExistsInDataStore, Is.EqualTo(addToDataStore)); + } + + // TODO: test AIPI using a fake test server + + [Test] + public void AcceptsIfMustBeTextureButUnreadablePSPAsset() + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + HttpClient client = context.GetAuthenticatedClient(TokenType.Game, TokenGame.LittleBigPlanetPSP, TokenPlatform.PSP, out Token token, user); + DataContext dataContext = context.GetDataContext(token); + AssetImporter importer = new(dataContext.Logger, context.Time); + + ReadOnlySpan data = "bbbbbbbbbbb"u8; + string hash = BitConverter.ToString(SHA1.HashData(data)).Replace("-", "").ToLower(); + dataContext.DataStore.WriteToStore($"psp/{hash}", data); + + context.Database.Refresh(); + + string newRefSetByCallback = "unset lol"; + ValidatedAssetResult result = ResourceValidationHelper.ValidateReference(new(hash, dataContext, importer) + { + MustBeTexture = true, + MustBeInDataStoreIfHash = true, + OnNewAssetRefCallback = delegate(string NewAssetRef) { newRefSetByCallback = NewAssetRef; }, + }, dataContext.Logger); + + Assert.That(result.Status, Is.EqualTo(OK)); + Assert.That(result.NewAssetRef, Is.EqualTo(hash)); + Assert.That(newRefSetByCallback, Is.EqualTo(hash)); + Assert.That(result.AssetInfo, Is.Not.Null); + Assert.That(result.AssetInfo!.AssetHash, Is.EqualTo(hash)); + Assert.That(result.AssetInfo!.AssetType, Is.EqualTo(GameAssetType.Unknown)); + Assert.That(result.AssetInfo!.IsPSP, Is.True); + Assert.That(result.DisallowanceInfo, Is.Null); + Assert.That(result.ExistsInDataStore, Is.True); + } + + [Test] + public void RejectsIfMustBeTextureAndUnreadableNonPSPAsset() + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + HttpClient client = context.GetAuthenticatedClient(TokenType.Game, TokenGame.LittleBigPlanet1, TokenPlatform.PS3, out Token token, user); + DataContext dataContext = context.GetDataContext(token); + AssetImporter importer = new(dataContext.Logger, context.Time); + + ReadOnlySpan data = "bbbbbbbbbbb"u8; + string hash = BitConverter.ToString(SHA1.HashData(data)).Replace("-", "").ToLower(); + dataContext.DataStore.WriteToStore(hash, data); + + context.Database.Refresh(); + + string newRefSetByCallback = "unset lol"; + ValidatedAssetResult result = ResourceValidationHelper.ValidateReference(new(hash, dataContext, importer) + { + MustBeTexture = true, + MustBeInDataStoreIfHash = true, + OnNewAssetRefCallback = delegate(string NewAssetRef) { newRefSetByCallback = NewAssetRef; }, + }, dataContext.Logger); + + Assert.That(result.Status, Is.EqualTo(BadRequest)); + Assert.That(result.NewAssetRef, Is.EqualTo("0")); + Assert.That(newRefSetByCallback, Is.EqualTo("0")); + Assert.That(result.AssetInfo, Is.Not.Null); + Assert.That(result.AssetInfo!.AssetHash, Is.EqualTo(hash)); + Assert.That(result.AssetInfo!.AssetType, Is.EqualTo(GameAssetType.Unknown)); + Assert.That(result.AssetInfo!.IsPSP, Is.False); + Assert.That(result.DisallowanceInfo, Is.Null); + Assert.That(result.ExistsInDataStore, Is.True); + } +} \ No newline at end of file