using System.Security.Cryptography; using System.Text; using RadicadeServer.DB; namespace RadicadeServer.Utils { public static class AdminUtils { /// /// The ceiling every admin upload is held to, on the request and on the multipart form. /// /// Kestrel's own default is 30 MB, and no room import fits under it: an .assetbundle or /// a .room save routinely runs to tens or hundreds of megabytes. A route that does not /// raise the limit has its connection killed part-way through the body, so the panel sees /// a broken connection instead of an answer - which is what made importing a large room /// look like an authentication failure. Named once here so the next upload route cannot /// quietly forget it. /// public const long MaxUploadBytes = 256L * 1024 * 1024; /// /// A room file being assembled from parts lives beside its final name under this suffix /// until the last part lands. Nothing serves or lists one, so a half-imported bundle can /// never be fetched by the client, and a name ending in this suffix is refused outright. /// public const string PartSuffix = ".uploading"; /// The multipart ceiling this server answers with, in the words the panel shows an admin. public const string MaxUploadLabel = "256 MiB"; public static bool IsUploadPart(string? name) => !string.IsNullOrEmpty(name) && name.EndsWith(PartSuffix, StringComparison.OrdinalIgnoreCase); private static bool SecretMatches(string? given, string? stored) { if (string.IsNullOrEmpty(given) || string.IsNullOrEmpty(stored)) return false; return CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(given), Encoding.UTF8.GetBytes(stored)); } public static bool IsAdminKey(this string? key) { return SecretMatches(key, DBConfig.AdminApiKey); } public static bool IsSupportKey(this string? key) { return SecretMatches(key, DBConfig.AdminApiKey) || SecretMatches(key, DBConfig.SupportApiKey); } public static bool TryResolveUploadName(string? requested, out string? name, out string? error) { name = null; error = null; if (string.IsNullOrWhiteSpace(requested)) return true; var trimmed = requested.Trim(); if (trimmed.Length > 128) { error = "Name is too long."; return false; } if (trimmed != Path.GetFileName(trimmed) || trimmed.Contains("..")) { error = "Name must be a bare file name."; return false; } foreach (var c in trimmed) { if (!char.IsLetterOrDigit(c) && c != '-' && c != '_' && c != '.') { error = "Name may only contain letters, digits, '-', '_' and '.'."; return false; } } name = trimmed; return true; } /// A subroom's Unity bundle is served under the name it is stored as /// (RoomServerAPI's unity_assets route hands the client that file name, and /// the client then fetches it from the CDN by name), so binding accepts any /// uploaded '.assetbundle'. That is deliberately wider than /// StudioUnityAssetRegistry.IsValidAssetId, which only covers the /// 40-character manifest ids the Studio pipeline mints. public static bool TryResolveBundleName(string? requested, out string? name, out string? error) { if (!TryResolveUploadName(requested, out name, out error)) return false; if (name == null) { error = "A bundle file name is required."; return false; } if (!name.EndsWith(".assetbundle", StringComparison.OrdinalIgnoreCase)) { error = $"'{name}' is not a .assetbundle file."; name = null; return false; } if (!System.IO.File.Exists(Path.Join(DBConfig.CDNStorage, name))) { error = $"No uploaded bundle named '{name}'."; name = null; return false; } return true; } } }