mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-26 15:39:01 -07:00
Radicade Public Code
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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><name>.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace RadicadeServer.RecNet.CDN
|
||||
{
|
||||
public class ImageNameDTO
|
||||
{
|
||||
public string ImageName { get; set; } = null!;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace RadicadeServer.RecNet.CDN
|
||||
{
|
||||
public class UploadResponse
|
||||
{
|
||||
public string filename { get; set; }
|
||||
public UploadResponse(string filename)
|
||||
{
|
||||
this.filename = filename;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user