Radicade Public Code

This commit is contained in:
JogmansonReal
2026-10-16 23:33:13 -07:00
commit 55175acdf6
832 changed files with 363261 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
using Microsoft.AspNetCore.Mvc;
namespace RadicadeServer.CDN
{
internal static class CdnCaching
{
public const string Immutable = "public, max-age=31536000, immutable";
public const string Replaceable = "public, max-age=86400";
public static string ForBlob(string name) =>
name.EndsWith(".assetbundle", StringComparison.OrdinalIgnoreCase) ? Immutable : Replaceable;
public static void Apply(Controller controller, string cacheControl)
{
if (controller.ControllerContext?.HttpContext == null) return;
controller.Response.Headers["Cache-Control"] = cacheControl;
}
}
}
+47
View File
@@ -0,0 +1,47 @@
using RadicadeServer.Utils;
namespace RadicadeServer.CDN
{
/// <summary>
/// What may be read out of the CDN storage directory and handed to a client, and which file
/// a requested name actually is.
///
/// Two rules live here because every route that serves a stored blob needs both, and a route
/// that re-implements them gets one of them wrong:
///
/// - A request is a single bare file name in that one directory. A name carrying a separator
/// or <c>..</c> would let a URL read outside it, and a name ending in the admin upload's
/// <c>.uploading</c> suffix is a half-imported bundle: it must answer as if it does not
/// exist rather than serve a truncated file, which the client would then fail to load with
/// no way to tell why.
/// - A bundle is asked for under two spellings. The subroom's <c>UnityAssetId</c> is stored
/// both bare and with the <c>.assetbundle</c> suffix an import appends, so a name resolves
/// to the exact file first and to <c>&lt;name&gt;.assetbundle</c> second.
/// </summary>
internal static class CdnStorageNames
{
internal static bool IsServable(string? name) =>
!string.IsNullOrEmpty(name)
&& name != "."
&& !AdminUtils.IsUploadPart(name)
&& !name.Contains('/')
&& !name.Contains('\\')
&& !name.Contains("..")
&& name.IndexOf('\0') < 0;
/// <summary>
/// The stored file a requested name is, or null when nothing by either spelling exists.
/// A name this server must never serve answers null too, so a route that only checks the
/// result is still safe.
/// </summary>
internal static string? Resolve(string? name)
{
if (!IsServable(name)) return null;
if (System.IO.File.Exists(Path.Join(DBConfig.CDNStorage, name))) return name;
var withExtension = name + ".assetbundle";
return System.IO.File.Exists(Path.Join(DBConfig.CDNStorage, withExtension)) ? withExtension : null;
}
}
}
+61
View File
@@ -0,0 +1,61 @@
using Microsoft.AspNetCore.Mvc;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet;
using RadicadeServer.Utils;
namespace RadicadeServer.CDN.Config
{
[Route("cdn/config")]
public class CDNConfigAPI : Controller
{
RecDBContext db;
public CDNConfigAPI(RecDBContext context)
{
this.db = context;
}
[HttpGet("LoadingScreenTipData")]
public List<LoadingScreenTip> GetLoadingScreenTipData()
{
var official = LoadingScreenImages.Tips;
var officialNames = new HashSet<string>(LoadingScreenImages.Names, StringComparer.OrdinalIgnoreCase);
var loadingScreens = db.ServerConfig.FirstOrDefault(x => x.Key == "LoadingScreenTipData");
var stored = string.IsNullOrWhiteSpace(loadingScreens?.Value) || loadingScreens.Value == "[]"
? new List<LoadingScreenTip>()
: loadingScreens.Value.FromJson<List<LoadingScreenTip>>() ?? new List<LoadingScreenTip>();
var byImage = new Dictionary<string, LoadingScreenTip>(StringComparer.OrdinalIgnoreCase);
foreach (var tip in stored)
{
if (!string.IsNullOrWhiteSpace(tip.ImageName))
byImage.TryAdd(tip.ImageName, tip);
}
var adopted = stored.FirstOrDefault(tip =>
!officialNames.Contains(tip.ImageName ?? string.Empty)
&& !(tip.Title == "radicade" && tip.Message == "yum"));
var tips = new List<LoadingScreenTip>(official.Count);
for (var i = 0; i < official.Count; i++)
{
var image = official[i].ImageName;
var text = byImage.TryGetValue(image, out var storedTip) ? storedTip
: i == 0 && adopted != null ? adopted
: official[i];
tips.Add(new LoadingScreenTip
{
ImageName = image,
Title = string.IsNullOrWhiteSpace(text.Title) ? official[i].Title : text.Title,
Message = string.IsNullOrWhiteSpace(text.Message) ? official[i].Message : text.Message,
RoomNames = text.RoomNames ?? new List<string>(),
PlatformMask = text.PlatformMask
});
}
return tips;
}
}
}
+44
View File
@@ -0,0 +1,44 @@
using Microsoft.AspNetCore.Mvc;
using SixLabors.ImageSharp;
namespace RadicadeServer.CDN
{
[Route("cdn/data/")]
[Route("data/")]
public class DataCDN : Controller
{
[HttpGet]
public IActionResult Index()
{
try
{
using var entries = Directory.EnumerateFileSystemEntries(DBConfig.CDNStorage).GetEnumerator();
_ = entries.MoveNext();
return Ok(new { service = "data", status = "ok" });
}
catch
{
return StatusCode(StatusCodes.Status503ServiceUnavailable,
new { service = "data", status = "DATA_STORAGE_UNAVAILABLE" });
}
}
[HttpGet("{datablob}")]
public IActionResult GetDataBlob(string datablob)
{
if (!CdnStorageNames.IsServable(datablob)) return BadRequest();
// This is the URL the client's bundle downloader ranges over, so the name has to be
// one that may be served at all and it may arrive with or without the .assetbundle
// suffix the import appends.
var name = CdnStorageNames.Resolve(datablob);
if (name == null) return NotFound();
CdnCaching.Apply(this, CdnCaching.ForBlob(name));
return PhysicalFile(Path.Join(DBConfig.CDNStorage, name), "application/octet-stream", enableRangeProcessing: true);
}
}
}
+25
View File
@@ -0,0 +1,25 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace RadicadeServer.RecNet.CDN
{
public class ImageDB
{
[Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public long Id { get; set; }
public string Name { get; set; } = null!;
public long OwnerId { get; set; }
public SavedImageType ImageType { get; set; }
public SavedImageAccessibility Accessibility { get; set; }
public long? RoomId { get; set; }
public long? EventId { get; set; }
public string PlayerIds { get; set; } = "[]";
public DateTime CreatedAt { get; set; }
}
}
+7
View File
@@ -0,0 +1,7 @@
namespace RadicadeServer.RecNet.CDN
{
public class ImageNameDTO
{
public string ImageName { get; set; } = null!;
}
}
+34
View File
@@ -0,0 +1,34 @@
namespace RadicadeServer.RecNet.CDN
{
public enum SavedImageAccessibility
{
Private = 0,
Public = 1,
FriendsOnly = 2
}
public enum SavedImageType
{
None,
ShareCamera,
OutfitThumbnail,
RoomThumbnail,
ProfileThumbnail,
InventionThumbnail,
PlayerEventThumbnail,
RoomLoadScreen
}
public class SavedImageMetaDTO
{
public List<long> playerIds { get; set; } = new();
public SavedImageType savedImageType { get; set; } = SavedImageType.ShareCamera;
public long? roomId { get; set; } = null;
public long? playerEventId { get; set; } = null;
public SavedImageAccessibility accessibility { get; set; } = SavedImageAccessibility.Public;
}
}
+63
View File
@@ -0,0 +1,63 @@
using System.Text;
using Microsoft.AspNetCore.Mvc;
namespace RadicadeServer.CDN
{
[Route("cdn/sigs")]
public class SigsCDN : Controller
{
private static readonly byte[] PlaceholderSignature = Encoding.ASCII.GetBytes(
"RecRoom.exe:AAAAAAAAAAAAAAAAAAAAAA==:AAAAAAAAAAAAAAAAAAAAAA==\n" +
"RecRoom_Data/globalgamemanagers:AAAAAAAAAAAAAAAAAAAAAA==:AAAAAAAAAAAAAAAAAAAAAA==\n" +
"GameAssembly.dll:AAAAAAAAAAAAAAAAAAAAAA==:AAAAAAAAAAAAAAAAAAAAAA==\n");
[HttpGet("{signatureId}")]
public IActionResult GetSignature(string signatureId)
{
if (string.IsNullOrWhiteSpace(signatureId)) return BadRequest();
var rangeHeader = ControllerContext.HttpContext?.Request.Headers.Range.ToString();
if (string.IsNullOrWhiteSpace(rangeHeader) || !rangeHeader.StartsWith("bytes=", StringComparison.OrdinalIgnoreCase))
return File(PlaceholderSignature, "text/plain");
var (start, end) = ParseByteRange(rangeHeader, PlaceholderSignature.Length);
var count = end - start + 1;
var body = new byte[count];
Array.Copy(PlaceholderSignature, start, body, 0, count);
Response.Headers.AcceptRanges = "bytes";
Response.Headers.ContentRange = $"bytes {start}-{end}/{PlaceholderSignature.Length}";
Response.StatusCode = StatusCodes.Status206PartialContent;
return File(body, "text/plain", enableRangeProcessing: false);
}
private static (int Start, int End) ParseByteRange(string rangeHeader, int length)
{
var spec = rangeHeader["bytes=".Length..].Trim();
if (spec.Contains(',')) return (0, length - 1);
var parts = spec.Split('-', 2);
if (parts.Length != 2) return (0, length - 1);
if (parts[0].Length == 0)
{
if (!int.TryParse(parts[1], out var suffixLength) || suffixLength <= 0)
return (0, length - 1);
var start = Math.Max(0, length - suffixLength);
return (start, length - 1);
}
if (!int.TryParse(parts[0], out var parsedStart))
return (0, length - 1);
var startOffset = parsedStart >= length ? 0 : Math.Max(0, parsedStart);
var endOffset = length - 1;
if (parts[1].Length > 0 && int.TryParse(parts[1], out var parsedEnd))
endOffset = Math.Clamp(parsedEnd, startOffset, length - 1);
return (startOffset, endOffset);
}
}
}
+81
View File
@@ -0,0 +1,81 @@
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using RadicadeServer.API.Studio;
namespace RadicadeServer.CDN
{
/// <summary>
/// Step one of the client's two-step bundle download. The client asks
/// <c>/rooms/unity_assets/{assetId}/{target}/{version}</c> and is handed a file name, then
/// ranges over that file at <c>/data/{Filename}</c> (and, for a subroom's bundle, under the
/// CDN's own <c>/unity/{Filename}</c> alias).
///
/// The id arrives in two shapes and both have to be answered. A Studio manifest id is 40
/// alphanumerics and its baked bundles are named in the manifest's own metadata. A legacy
/// bundle is stored under its file name (or that name minus <c>.assetbundle</c>), and that
/// name is what the subroom's <c>UnityAssetId</c> carries - every imported room, the built-in
/// rooms and every room bound from the admin panel. Answering only the 40-character shape
/// leaves every legacy bundle unresolvable, and the client reports one 400 as
/// "Error downloading room assets" for the whole room.
/// </summary>
[Route("rooms/")]
public class UnityAssetsCDN : Controller
{
[HttpGet("unity_assets/{assetId}/{target}/{version}")]
public async Task<IActionResult> GetUnityAsset(string assetId, int target, int version)
{
return await ServeMetadataAsync(assetId, target, version);
}
[HttpGet("unity_assets/{assetId}")]
public async Task<IActionResult> GetUnityAsset(string assetId)
{
return await ServeMetadataAsync(assetId, 0, 1);
}
private async Task<IActionResult> ServeMetadataAsync(string assetId, int target, int version)
{
if (!CdnStorageNames.IsServable(assetId) || target < 0 || target > byte.MaxValue || version < 0)
return BadRequest();
string? filename = null;
string? rejectedDependencies = null;
if (StudioUnityAssetRegistry.IsValidAssetId(assetId))
{
var asset = await StudioUnityAssetRegistry.ReadAsync(assetId, HttpContext.RequestAborted);
var baked = asset?.BakedUnityAssets.FirstOrDefault(x => x.Target == target && x.Version == version);
if (baked != null)
{
filename = baked.Filename;
rejectedDependencies = baked.RejectedDependenciesFilename;
}
else if (asset != null)
{
// The manifest exists, so the file on disk under this id is that JSON
// manifest and not a bundle. A target/version it does not name is a 404:
// handing the manifest over as the bundle would have the client parse
// metadata as an asset bundle.
return NotFound();
}
}
// Reached for every legacy id, and for a 40-character id with no manifest behind it:
// the bundle is the file stored under the name the subroom points at, with or without
// the .assetbundle suffix an import appends.
filename ??= CdnStorageNames.Resolve(assetId);
if (filename == null) return NotFound();
var body = JsonSerializer.Serialize(new
{
UnityAssetId = assetId,
Target = target,
Version = version,
Filename = filename,
RejectedDependenciesFilename = rejectedDependencies
});
return Content(body, "application/json");
}
}
}
+11
View File
@@ -0,0 +1,11 @@
namespace RadicadeServer.RecNet.CDN
{
public class UploadResponse
{
public string filename { get; set; }
public UploadResponse(string filename)
{
this.filename = filename;
}
}
}
+71
View File
@@ -0,0 +1,71 @@
using Microsoft.AspNetCore.Mvc;
namespace RadicadeServer.CDN
{
[Route("cdn/video/")]
public class VideosCDN : Controller
{
private static readonly StringComparison PathComparison =
OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
[HttpGet("{*datablob}")]
public IActionResult GetDataBlob(string datablob)
{
if (string.IsNullOrEmpty(datablob)) return NotFound();
if (datablob.Contains("..", StringComparison.Ordinal) ||
datablob.Contains('\\') ||
datablob.Contains(':') ||
datablob.StartsWith('/'))
return NotFound();
var fullPath = Path.GetFullPath(Path.Join(DBConfig.CDNVideos, datablob));
var root = DBConfig.CDNVideos.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
+ Path.DirectorySeparatorChar;
if (!fullPath.StartsWith(root, PathComparison)) return NotFound();
var candidates = System.IO.File.Exists(fullPath)
? new[] { fullPath }
: new[]
{
fullPath + ".mp4",
fullPath + ".m4v",
fullPath + ".webm",
fullPath + ".mov",
fullPath + ".ogv"
};
var existing = candidates.FirstOrDefault(p => System.IO.File.Exists(p));
if (existing == null) return NotFound();
var ext = Path.GetExtension(existing).ToLowerInvariant();
var contentType = ext switch
{
".mp4" or ".m4v" => "video/mp4",
".webm" => "video/webm",
".mov" => "video/quicktime",
".ogv" => "video/ogg",
_ => "application/octet-stream"
};
Response.Headers["Accept-Ranges"] = "bytes";
return PhysicalFile(existing, contentType, enableRangeProcessing: true);
}
}
}