mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
3edeaa5484
Enable + co-owner still gave no Maker Pen. The 2023 client grants Maker Pen / save / spawn rights off the player's GameRole (ICreatorRole.CreatorRoleCanUseMakerPen on RecRoom.Systems.PlayerRoles .GameRole), and game roles are persisted OBJECTS in the room blob: every real save inspected (Rec Center room_100_v1.dat, Stunt Runner room_125_v1.dat) carries three GameRoleNode chips — a default role named after the room plus the built-in "In-Game" and "Eliminated" roles, with well-known GUIDs shared by every room. A blob with none leaves a co-owner with no role to hold, so MakerPen.CheckMakerPenPermissionAndUpdateMode fails regardless of the room-role collection. BuildRroEditableBlob now emits Rec Royale's own three role objects, laid out field-for-field like the client's writer (including the always-present empty Overridable submessages), with view ids derived from the activity id so repeated downloads describe the same objects. The default role sets can_use_maker_pen, matching the permissive room-role overlay. Test pins three persistence views and the three role GUIDs.
776 lines
38 KiB
C#
776 lines
38 KiB
C#
using Microsoft.AspNetCore.Mvc;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using System.Text;
|
||
using System.Text.Json;
|
||
using DorkNet.Server.Data;
|
||
using DorkNet.Server.Data.Entities;
|
||
using DorkNet.Server.Services;
|
||
|
||
namespace DorkNet.Server.Controllers.Cdn;
|
||
|
||
/// <summary>
|
||
/// Catch-all binary blob server. Owns every <c>GET /{*path}</c> request
|
||
/// that hits the API host pool — cdn.* / storage.* / data.* / img.* /
|
||
/// the bare apex all funnel into this one controller and we branch on
|
||
/// the subdomain prefix inside <see cref="Serve"/>:
|
||
///
|
||
/// • <c>img.{apex}</c> → image-transform pipeline. Reads from S3
|
||
/// (BlobRouter routes by filename suffix), supports on-the-fly
|
||
/// <c>?cropSquare=1</c> / <c>?width=N</c> via
|
||
/// <see cref="ImageTransformService"/>, falls back to disk
|
||
/// (<c>data/images/</c>) and finally a 1×1 transparent PNG so the
|
||
/// 2020 watch's image cache absorbs misses without 404 retry storms.
|
||
/// • everything else (cdn.* / storage.* / data.* / unknown sub) →
|
||
/// CDN serve path. S3 first, default RoomDataBlob fallback, image
|
||
/// misses still fall back to the transparent PNG (same shape the
|
||
/// old ImgController surfaced) so the watch's room-thumbnail
|
||
/// pipeline behaves identically post-merge.
|
||
///
|
||
/// Pre-refactor the two paths lived in separate controllers
|
||
/// (ImgController + CdnController) each with their own
|
||
/// <c>[Host(...)]</c> filter; HostFilteringMiddleware now controls
|
||
/// which hosts the app accepts at all (via DomainConfig) and routing
|
||
/// is purely path-based, so the two handlers had to merge to avoid
|
||
/// AmbiguousMatchException on <c>GET /{*path}</c>.
|
||
///
|
||
/// Every response is signed via <see cref="ImageSignatureService.AddContentSignature"/>
|
||
/// before being returned — the 2020 watch's image-verify path rejects
|
||
/// any image lacking the Content-Signature header.
|
||
/// </summary>
|
||
[ApiController]
|
||
public class CdnController(
|
||
RoomDataBlobService roomDataBlob,
|
||
IObjectStorage storage,
|
||
ImageSignatureService signatures,
|
||
DorkNetDbContext db,
|
||
ServerSettingsService serverSettings,
|
||
ILogger<CdnController> logger) : ControllerBase
|
||
{
|
||
/// <summary>1x1 transparent PNG used for missing image files.
|
||
/// The 2020 watch treats image HTTP failures harshly (retry-storms
|
||
/// on missing room thumbnails), so image paths fail soft — return
|
||
/// a valid PNG body with a short cache TTL instead of 404.</summary>
|
||
private static readonly byte[] TransparentPng = new byte[]
|
||
{
|
||
0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A,0x00,0x00,0x00,0x0D,
|
||
0x49,0x48,0x44,0x52,0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x01,
|
||
0x08,0x06,0x00,0x00,0x00,0x1F,0x15,0xC4,0x89,0x00,0x00,0x00,
|
||
0x0D,0x49,0x44,0x41,0x54,0x78,0x9C,0x63,0x00,0x01,0x00,0x00,
|
||
0x05,0x00,0x01,0x0D,0x0A,0x2D,0xB4,0x00,0x00,0x00,0x00,0x49,
|
||
0x45,0x4E,0x44,0xAE,0x42,0x60,0x82,
|
||
};
|
||
|
||
private static readonly string ImageDir =
|
||
Path.Combine(AppContext.BaseDirectory, "data", "images");
|
||
private static readonly Lazy<Dictionary<string, string>> RoomImageAliases =
|
||
new(LoadRoomImageAliases);
|
||
|
||
// Specific route prefixes only — NO global catch-all wildcard.
|
||
// A previous version had [Route("/{*path:minlength(1)}")] which
|
||
// matched every GET on every host, then used an in-handler host
|
||
// gate to NotFound for non-cdn/img subdomains. That broke admin
|
||
// API endpoints on admin.localhost: ASP.NET routing matched the
|
||
// wildcard endpoint at UseRouting time, StaticFileMiddleware then
|
||
// refused to serve admin SPA assets (its ValidateNoEndpoint guard
|
||
// bails when an endpoint is already matched), and MapControllers
|
||
// ran CdnController.Serve which 404'd — same dynamic broke every
|
||
// admin/site API route and asset request.
|
||
//
|
||
// The route templates below cover the watch's actual CDN URL
|
||
// shapes (confirmed via Coolify access logs):
|
||
// • cdn.{apex}/room/<blob>.dat — RoomDataBlob downloads
|
||
// • cdn.{apex}/data/<hash>.htr — room thumbnails / .htr files
|
||
// • cdn.{apex}/config/<name> — LoadingScreenTipData and similar
|
||
// • img.{apex}/<file>.{png,jpg,jpeg,webp,gif} — image fetches
|
||
// • img.{apex}/img/<file> — legacy path-prefixed images
|
||
//
|
||
// The in-handler host gate stays as defence-in-depth so that, if
|
||
// a stray request somehow reaches one of these routes on the wrong
|
||
// host (admin.*, api.*, etc.), we still 404 cleanly instead of
|
||
// serving binary CDN bytes.
|
||
[Route("/room/{*path:minlength(1)}")]
|
||
[Route("/data/{*path:minlength(1)}")]
|
||
[Route("/config/{*path:minlength(1)}")]
|
||
[Route("/img/{*path:minlength(1)}")]
|
||
// `cdn.{apex}/video/<BlobName>` — the watch's CommunityBoard
|
||
// builds this URL via `String.Concat("/video/", blobName)` at
|
||
// RecNet/CommunityBoard.txt:1068, so the prefix path needs an
|
||
// explicit route. Without this, the catch-all regex below only
|
||
// matches single-segment names and `/video/cb_video_*.mp4` 404s.
|
||
[Route("/video/{*path:minlength(1)}")]
|
||
// `cdn.{apex}/invention/<BlobName>` — the watch's invention
|
||
// download fetcher (BBHENFCNLAB.GetInventionData at
|
||
// BBHENFCNLAB_NestedType_NBDMFFEGNGJ.txt:207) builds the URL via
|
||
// String.Concat("/invention/", filename). Same byte-fetch flow as
|
||
// /room/, routed to S3 via BlobRouter on the filename.
|
||
[Route("/invention/{*path:minlength(1)}")]
|
||
// 2023 builds a CDN URL as `/sigs/<signature-set-id>` during boot
|
||
// file-hash verification. The original Rec Room CDN no longer
|
||
// serves the embedded 2023 id, so keep this on a dedicated path and
|
||
// fail soft instead of letting the request 404.
|
||
[Route("/sigs/{*path:minlength(1)}")]
|
||
// ASP.NET route templates treat `[` / `]` as token markers
|
||
// (e.g. `[controller]`). To use them as literals inside a regex
|
||
// constraint they must be doubled — so `[^/]` becomes `[[^/]]`.
|
||
//
|
||
// Extension list: image formats + room/.htr/.inv blobs that the
|
||
// watch fetches directly, Studio .assetbundle files, plus video
|
||
// formats (mp4/webm/mov/m4v) so
|
||
// community-board video blobs uploaded via /admin/v1/communityboard/
|
||
// video/upload (which writes `cb_video_<hash>.mp4` etc.) resolve
|
||
// through the same bare-filename catch-all.
|
||
[Route("/{path:regex(^[[^/]]+\\.(png|jpg|jpeg|webp|gif|dat|bin|holotar|inv|room|htr|assetbundle|mp4|webm|mov|m4v)$)}")]
|
||
[AcceptVerbs("GET", "HEAD")]
|
||
public async Task<IActionResult> Serve(string? path)
|
||
{
|
||
// Defence-in-depth host check. Without the prior global wildcard
|
||
// these routes are narrow enough that stray hits on admin/api/etc.
|
||
// should be rare, but if they do land here we 404 cleanly instead
|
||
// of serving binary CDN bytes that would confuse the caller.
|
||
var host = Request.Host.Host;
|
||
var isImg = DomainConfig.MatchesSubdomain(host, "img");
|
||
var isCdn = DomainConfig.MatchesSubdomain(host, "cdn")
|
||
|| DomainConfig.MatchesSubdomain(host, "storage")
|
||
|| DomainConfig.MatchesSubdomain(host, "data");
|
||
if (!isImg && !isCdn) return NotFound();
|
||
|
||
// Use the full request path (e.g. "config/LoadingScreenTipData",
|
||
// "room/foo.dat") rather than the route-captured suffix so the
|
||
// downstream switch logic — which still keys on the literal
|
||
// "config/LoadingScreenTipData" — keeps working regardless of
|
||
// which of the multiple [Route] templates above matched.
|
||
var fullPath = (Request.Path.Value ?? string.Empty).TrimStart('/');
|
||
|
||
if (isImg) return await ServeImage(fullPath);
|
||
return await ServeCdn(fullPath);
|
||
}
|
||
|
||
// ── Studio baked Unity asset-bundle download ───────────────────────
|
||
/// <summary>GET <c>unity_assets/{assetId}/{target}/{version}</c> — the
|
||
/// 2023 client's asset-bundle download for baked Studio scenes. The
|
||
/// client builds this URL itself in RecNet.Runtime:
|
||
/// <c>String.Format("unity_assets/{0}/{1}/{2}",
|
||
/// WebUtility.UrlEncode(unityAssetId), (byte)target, (int)version)</c>
|
||
/// after reading <c>UnityAssetId</c> / <c>Target</c> / <c>Version</c>
|
||
/// from <c>rooms/{id}/subrooms/{sid}/saves/{saveId}</c>. It does NOT
|
||
/// fetch the bare <c>{filename}.assetbundle</c> the save metadata names
|
||
/// — so the bundle bytes only resolve through this id-addressed route.
|
||
///
|
||
/// <para>Resolution: the importer stores each baked bundle in S3 under
|
||
/// its original filename (<c>{saveId}__bundle_t{target}_v{version}.assetbundle</c>)
|
||
/// and stamps the owning scene's <see cref="Data.Entities.RoomSceneEntity.StudioUnityAssetId"/>
|
||
/// + <see cref="Data.Entities.RoomSceneEntity.StudioAssetBundleNamesCsv"/>.
|
||
/// We look the scene up by <c>StudioUnityAssetId == assetId</c>, pick the
|
||
/// bundle whose embedded target+version match, and stream its bytes.</para>
|
||
///
|
||
/// <para>A genuine miss 404s — handing back a default room blob here
|
||
/// (as the room-blob path does) would feed the Unity AssetBundle loader
|
||
/// garbage and fail the scene load far less legibly than an honest
|
||
/// 404.</para></summary>
|
||
[Route("/unity_assets/{assetId}/{target:int}/{version:int}")]
|
||
[AcceptVerbs("GET", "HEAD")]
|
||
public async Task<IActionResult> ServeUnityAsset(string assetId, int target, int version)
|
||
{
|
||
assetId = (assetId ?? string.Empty).Trim();
|
||
if (string.IsNullOrEmpty(assetId)) return NotFound();
|
||
|
||
// The Studio UnityAssetId is the per-save asset id from the dump
|
||
// sidecar, unique per baked save, so this maps to one scene in
|
||
// practice; tolerate >1 by scanning each candidate's bundle list.
|
||
var scenes = await db.RoomScenes
|
||
.Where(s => s.StudioUnityAssetId == assetId)
|
||
.ToListAsync();
|
||
|
||
var candidates = scenes
|
||
.SelectMany(s => (s.StudioAssetBundleNamesCsv ?? string.Empty)
|
||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||
.Select(ParseBundleName)
|
||
.Where(b => b is not null)
|
||
.Cast<(int Target, int Version, string Filename)>()
|
||
.Where(b => b.Target == target)
|
||
.ToList();
|
||
|
||
// Exact match first; otherwise serve the highest available version
|
||
// for the requested target (a Studio re-bake bumps the version, but
|
||
// a cached client manifest may still ask for a superseded one).
|
||
var pick = candidates.FirstOrDefault(b => b.Version == version);
|
||
if (pick.Filename is null && candidates.Count > 0)
|
||
pick = candidates.OrderByDescending(b => b.Version).First();
|
||
|
||
if (pick.Filename is null)
|
||
{
|
||
logger.LogWarning("[unity_assets] no bundle assetId={AssetId} target={Target} version={Version} scenes={Scenes}",
|
||
assetId, target, version, scenes.Count);
|
||
return NotFound();
|
||
}
|
||
|
||
var (bucket, key) = BlobRouter.Route(pick.Filename);
|
||
byte[]? bytes = null;
|
||
try
|
||
{
|
||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||
bytes = await storage.GetAsync(bucket, key, cts.Token);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
logger.LogWarning(ex, "[unity_assets] S3 GetAsync threw {Bucket}/{Key}", bucket, key);
|
||
}
|
||
|
||
if (bytes is not { Length: > 0 })
|
||
{
|
||
logger.LogWarning("[unity_assets] MISS assetId={AssetId} file={File} bucket={Bucket} key={Key}",
|
||
assetId, pick.Filename, bucket, key);
|
||
return NotFound();
|
||
}
|
||
|
||
logger.LogInformation("[unity_assets] hit assetId={AssetId} target={Target} version={Version} file={File} bytes={Bytes}",
|
||
assetId, target, version, pick.Filename, bytes.Length);
|
||
Response.Headers.CacheControl = "public, max-age=3600";
|
||
signatures.AddContentSignature(Response, bytes);
|
||
return new FileContentResult(bytes, "application/octet-stream");
|
||
}
|
||
|
||
/// <summary>Parse a Studio baked-bundle filename
|
||
/// (<c>{saveId}__bundle_t{target}_v{version}.assetbundle</c>, or older
|
||
/// <c>{saveId}__bundle_t{target}.assetbundle</c>) into its
|
||
/// target/version/filename. Mirrors the parser in RoomsController so the
|
||
/// id-addressed CDN route and the save-metadata endpoint agree on the
|
||
/// same naming contract.</summary>
|
||
private static (int Target, int Version, string Filename)? ParseBundleName(string fileName)
|
||
{
|
||
var name = Path.GetFileName(fileName);
|
||
var match = System.Text.RegularExpressions.Regex.Match(
|
||
name,
|
||
@"^(?<save>\d+)__bundle_t(?<target>\d+)(?:_v(?<version>\d+))?\.assetbundle$",
|
||
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.CultureInvariant);
|
||
if (!match.Success) return null;
|
||
if (!int.TryParse(match.Groups["target"].Value, out var target)) return null;
|
||
var version = 0;
|
||
if (match.Groups["version"].Success &&
|
||
!int.TryParse(match.Groups["version"].Value, out version))
|
||
return null;
|
||
return (target, version, name);
|
||
}
|
||
|
||
// ── Image pipeline (img.{apex}) ────────────────────────────────────
|
||
private async Task<IActionResult> ServeImage(string? path)
|
||
{
|
||
var safe = Path.GetFileName(path ?? string.Empty);
|
||
// Strip the leading '$' sigil that ImageData.image_name carries
|
||
// for hash-addressed assets. The watch URL-escapes it (%24) and
|
||
// the route binder decodes it back, so what arrives here is
|
||
// literally "$<hash>.jpg" — but the bytes are stored at
|
||
// "<hash>.jpg" (the bare BlobName the importer writes). Without
|
||
// this strip, every placed in-world polaroid 404s into the
|
||
// transparent-PNG fallback and renders as "?" in-game.
|
||
if (safe.StartsWith('$')) safe = safe[1..];
|
||
if (string.IsNullOrWhiteSpace(safe))
|
||
{
|
||
signatures.AddContentSignature(Response, TransparentPng);
|
||
return File(TransparentPng, "image/png");
|
||
}
|
||
|
||
var ext = Path.GetExtension(safe).ToLowerInvariant();
|
||
var contentType = ext switch
|
||
{
|
||
".png" => "image/png",
|
||
".jpg" or ".jpeg" => "image/jpeg",
|
||
".webp" => "image/webp",
|
||
".gif" => "image/gif",
|
||
_ => "image/png", // default to PNG for unknown — better than octet-stream for browser <img> tags
|
||
};
|
||
|
||
// Three-tier resolution: S3 first (canonical post-migration),
|
||
// then on-disk legacy folder (room thumbnails shipped with the
|
||
// server), then RoomDataBlobs.Bytes column (uploads that
|
||
// pre-date the S3 migration). Once the migrator has uploaded
|
||
// everything to S3 and PR 4 drops the Bytes column, the disk
|
||
// and DB fallbacks become rare cases.
|
||
byte[]? s3Bytes = null;
|
||
var (s3Bucket, s3Key) = BlobRouter.Route(safe);
|
||
try
|
||
{
|
||
using var s3Timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
||
s3Bytes = await storage.GetAsync(s3Bucket, s3Key, s3Timeout.Token);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
logger.LogWarning(ex,
|
||
"[img] S3 lookup threw host={Host} path='{Path}' file='{Safe}' bucket={Bucket} key={Key}; falling back to disk / DB / placeholder",
|
||
Request.Host.Host, path, safe, s3Bucket, s3Key);
|
||
}
|
||
|
||
// Extension-less hash fallback. The 2020 watch references
|
||
// some persistence-view image slots as a bare 32-char hex hash
|
||
// (no .jpg, no .png) — distinct from ImageData.image_name which
|
||
// carries "$<hash>.jpg". The importer stores everything from
|
||
// SubRooms/<scene>/Image/ with an extension, so a bare-hash
|
||
// request misses on first lookup. Retry with the two common
|
||
// image extensions before giving up.
|
||
if ((s3Bytes is null || s3Bytes.Length == 0) && string.IsNullOrEmpty(ext) && LooksLikeHash(safe))
|
||
{
|
||
foreach (var altExt in new[] { ".jpg", ".png" })
|
||
{
|
||
var altName = safe + altExt;
|
||
var (altBucket, altKey) = BlobRouter.Route(altName);
|
||
try
|
||
{
|
||
using var altTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
||
var altBytes = await storage.GetAsync(altBucket, altKey, altTimeout.Token);
|
||
if (altBytes is { Length: > 0 })
|
||
{
|
||
logger.LogInformation(
|
||
"[img] s3 hit (ext-fallback) host={Host} path={Path} resolvedAs={Alt} bytes={Bytes}",
|
||
Request.Host.Host, safe, altName, altBytes.Length);
|
||
s3Bytes = altBytes;
|
||
contentType = altExt == ".png" ? "image/png" : "image/jpeg";
|
||
break;
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
logger.LogWarning(ex,
|
||
"[img] ext-fallback S3 lookup threw for {Name} bucket={Bucket} key={Key}",
|
||
altName, altBucket, altKey);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (s3Bytes is { Length: > 0 })
|
||
{
|
||
logger.LogInformation("[img] s3 hit host={Host} path={Path} bytes={Bytes}",
|
||
Request.Host.Host, safe, s3Bytes.Length);
|
||
Response.Headers.CacheControl = "public, max-age=300";
|
||
return RespondWithTransforms(s3Bytes, contentType);
|
||
}
|
||
|
||
var full = ResolveLocalImagePath(safe);
|
||
if (full is not null)
|
||
{
|
||
logger.LogInformation("[img] disk hit host={Host} path={Path} → {Full}",
|
||
Request.Host.Host, path, full);
|
||
// Short TTL on the public CF edge so swapping a thumbnail
|
||
// (e.g. switching from full-res to a resized cached PNG)
|
||
// propagates within minutes instead of being pinned for
|
||
// hours by CF's default image-cache TTL. The watch keeps
|
||
// its own per-session local cache regardless.
|
||
var diskBytes = await System.IO.File.ReadAllBytesAsync(full);
|
||
Response.Headers.CacheControl = "public, max-age=300";
|
||
return RespondWithTransforms(diskBytes, contentType);
|
||
}
|
||
|
||
// S3 is the only canonical store — DB carries text-only
|
||
// metadata, never bytes. If S3 misses, the bytes don't exist
|
||
// on this server; fall through to the transparent-PNG miss.
|
||
|
||
// Stop the 404 storm — the watch retries hard on missing room
|
||
// thumbnails. Return a 1x1 transparent PNG so it caches a
|
||
// valid (empty) image and stops asking. Log loudly so the
|
||
// user can see what filename the watch tried but disk lookup
|
||
// failed at: tells us at a glance whether the client is even
|
||
// hitting this controller (no log = wrong host / DNS issue),
|
||
// and if so, what filename it expects vs what's on disk.
|
||
logger.LogWarning(
|
||
"[img] MISS host={Host} path='{Path}' file='{Safe}' lookedAt='{Full}' query='{Query}'",
|
||
Request.Host.Host, path, safe, full, Request.QueryString);
|
||
// Don't let CF pin the empty-fallback for hours — once the real
|
||
// image lands on disk the next request should pick it up.
|
||
Response.Headers.CacheControl = "public, max-age=60";
|
||
signatures.AddContentSignature(Response, TransparentPng);
|
||
return File(TransparentPng, "image/png");
|
||
}
|
||
|
||
// ── CDN serve path (cdn.*, storage.*, data.*, apex) ────────────────
|
||
private async Task<IActionResult> ServeCdn(string? path)
|
||
{
|
||
if (!string.IsNullOrEmpty(path) &&
|
||
path.StartsWith("sigs/", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
logger.LogWarning("[cdn] signature set MISS host={Host} path={Path} -> empty signature payload",
|
||
Request.Host.Host, path);
|
||
var payload = Array.Empty<byte>();
|
||
Response.Headers.CacheControl = "public, max-age=60";
|
||
signatures.AddContentSignature(Response, payload);
|
||
return new FileContentResult(payload, "application/octet-stream");
|
||
}
|
||
|
||
if (string.Equals(path, "config/LoadingScreenTipData", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
// Tips live in the LoadingScreenTips DB table; admins
|
||
// edit them via the SPA. Wire shape per
|
||
// LoadingScreenTip.cs: List of {Title, Message,
|
||
// ImageName, Context, PlatformMask, HasImage, RoomNames}.
|
||
// HasImage MUST be set explicitly — without it the watch
|
||
// waits forever on the image promise and never renders.
|
||
var rows = await db.LoadingScreenTips
|
||
.Where(t => t.IsActive)
|
||
.OrderBy(t => t.SortOrder).ThenBy(t => t.Id)
|
||
.AsNoTracking()
|
||
.ToListAsync();
|
||
var tips = rows.Select(t => new
|
||
{
|
||
Title = t.Title,
|
||
Message = t.Message,
|
||
ImageName = t.ImageName,
|
||
Context = t.Context,
|
||
PlatformMask = t.PlatformMask,
|
||
HasImage = !string.IsNullOrEmpty(t.ImageName),
|
||
RoomNames = string.IsNullOrEmpty(t.RoomNamesCsv)
|
||
? Array.Empty<string>()
|
||
: t.RoomNamesCsv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
|
||
}).ToArray();
|
||
var payload = JsonSerializer.SerializeToUtf8Bytes(tips);
|
||
signatures.AddContentSignature(Response, payload);
|
||
return new FileContentResult(payload, "application/json");
|
||
}
|
||
|
||
var fileName = string.IsNullOrEmpty(path)
|
||
? string.Empty
|
||
: path.Split('/', StringSplitOptions.RemoveEmptyEntries).LastOrDefault() ?? string.Empty;
|
||
// Strip the leading '$' sigil that ImageData.image_name uses
|
||
// for hash-addressed assets. The img subdomain branch above
|
||
// already handles this; defense-in-depth for any client that
|
||
// fetches the same name via cdn.* / storage.* / data.* hosts.
|
||
if (!string.IsNullOrEmpty(fileName) && fileName[0] == '$') fileName = fileName[1..];
|
||
|
||
if (string.IsNullOrEmpty(fileName))
|
||
{
|
||
logger.LogWarning("[cdn] empty filename host={Host} path={Path} — default blob",
|
||
Request.Host.Host, path);
|
||
var defaultBlob = roomDataBlob.GetDefaultBlob();
|
||
signatures.AddContentSignature(Response, defaultBlob);
|
||
return new FileContentResult(defaultBlob, "application/octet-stream");
|
||
}
|
||
|
||
// Two-tier read: S3 first (the canonical store post-migration),
|
||
// then RoomDataBlobs.Bytes as a backstop for any blob that was
|
||
// uploaded before the storage migration ran. Once the migrator
|
||
// has uploaded all existing bytes and the Bytes column is
|
||
// dropped, this fallback becomes a no-op.
|
||
//
|
||
// Defensive: ObjectStorageService.GetAsync only catches 404; an
|
||
// S3 misconfig (wrong endpoint, dead creds, bucket missing) or
|
||
// a network blip throws → we'd otherwise return 500 and the
|
||
// watch's RecNet.Rooms.GetRoomData logs "Failed to download
|
||
// room data" + Photon disconnects the player. Catching here
|
||
// and falling through to the DB / default-blob path keeps the
|
||
// dorm load resilient to S3 outages — players boot into the
|
||
// stock dorm scene instead of getting kicked.
|
||
byte[]? s3Bytes = null;
|
||
var (s3Bucket, s3Key) = BlobRouter.Route(fileName);
|
||
try
|
||
{
|
||
// 15 seconds for room/data blobs (vs 2 seconds on the image
|
||
// path further up): the watch's room-load flow downloads
|
||
// PersistedRoomData and DESERIALISES it as the player enters
|
||
// the room — a default-empty blob means the saved dorm
|
||
// appears completely reset. Better to wait for the real
|
||
// bytes than hand back zeros and silently lose the player's
|
||
// build. Garage on the same host typically responds in
|
||
// 50-300ms; the larger budget only burns on cold-start /
|
||
// network blips.
|
||
using var s3Timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||
s3Bytes = await storage.GetAsync(s3Bucket, s3Key, s3Timeout.Token);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
logger.LogWarning(ex, "[cdn] S3 GetAsync threw for {Bucket}/{Key}; serving default blob",
|
||
s3Bucket, s3Key);
|
||
}
|
||
if (s3Bytes is { Length: > 0 })
|
||
{
|
||
var overlaidRoomRoleData = false;
|
||
if (await ShouldOverlayRroRoomRoleDataAsync(fileName))
|
||
{
|
||
try
|
||
{
|
||
s3Bytes = roomDataBlob.OverlayRroEditableRoleData(s3Bytes);
|
||
overlaidRoomRoleData = true;
|
||
logger.LogInformation("[cdn] overlaid RRO room-role permissions host={Host} file={File} bytes={Bytes}",
|
||
Request.Host.Host, fileName, s3Bytes.Length);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
logger.LogWarning(ex, "[cdn] failed to overlay RRO room-role permissions for {File}; serving original blob",
|
||
fileName);
|
||
}
|
||
}
|
||
|
||
// Modern-authored saves (RecNet zip exports, 2024+ clients)
|
||
// stamp PersistedRoomVersion values past what the March-2023
|
||
// client knows, and it rejects the whole room with the
|
||
// "update Rec Room to visit this room" gate before spawning
|
||
// anything. Clamp the two top-level version varints down to
|
||
// the 2023 maxima on the way out; blobs already at or below
|
||
// them pass through byte-identical. Admins can bypass the
|
||
// clamp from the SPA's server-settings page.
|
||
var clampedRoomVersions = false;
|
||
if (IsRoomDataName(fileName) &&
|
||
!await serverSettings.IsRoomBlobVersionClampDisabledAsync())
|
||
{
|
||
var (clamped, clampChanged) = RoomDataBlobService.ClampVersionsFor2023(s3Bytes);
|
||
if (clampChanged)
|
||
{
|
||
logger.LogInformation(
|
||
"[cdn] clamped persisted-room versions to 2023 maxima host={Host} file={File} bytes={Bytes}",
|
||
Request.Host.Host, fileName, clamped.Length);
|
||
s3Bytes = clamped;
|
||
clampedRoomVersions = true;
|
||
}
|
||
}
|
||
|
||
logger.LogInformation("[cdn] s3 hit host={Host} file={File} bytes={Bytes}",
|
||
Request.Host.Host, fileName, s3Bytes.Length);
|
||
// Allow CF edge to cache for an hour. RoomDataBlobs are
|
||
// content-addressed by hash so the same BlobName never
|
||
// serves different bytes — safe to cache aggressively.
|
||
// Overlaid/clamped responses depend on runtime settings, so
|
||
// keep their edge TTL short enough that admin toggles land
|
||
// within a minute.
|
||
Response.Headers.CacheControl = overlaidRoomRoleData || clampedRoomVersions
|
||
? "public, max-age=60"
|
||
: "public, max-age=3600";
|
||
signatures.AddContentSignature(Response, s3Bytes);
|
||
return new FileContentResult(s3Bytes, MimeFromName(fileName));
|
||
}
|
||
|
||
// S3 is the only canonical store — DB carries text-only
|
||
// metadata (BlobName, owner, timestamps) and never holds
|
||
// bytes. RRO thumbnail images are the exception for local dev:
|
||
// tools/fetch-room-images.py stores them under data/images/, and
|
||
// the 2023 client requests them through cdn.* rather than img.*.
|
||
if (IsImageName(fileName))
|
||
{
|
||
var imagePath = ResolveLocalImagePath(fileName);
|
||
if (imagePath is not null)
|
||
{
|
||
var imageBytes = await System.IO.File.ReadAllBytesAsync(imagePath);
|
||
logger.LogInformation("[cdn] disk image hit host={Host} file={File} -> {Path} bytes={Bytes}",
|
||
Request.Host.Host, fileName, imagePath, imageBytes.Length);
|
||
Response.Headers.CacheControl = "public, max-age=300";
|
||
return RespondWithTransforms(imageBytes, MimeFromName(fileName));
|
||
}
|
||
|
||
logger.LogWarning("[cdn] image MISS host={Host} file={File} query='{Query}' -> transparent png",
|
||
Request.Host.Host, fileName, Request.QueryString);
|
||
Response.Headers.CacheControl = "public, max-age=60";
|
||
signatures.AddContentSignature(Response, TransparentPng);
|
||
return new FileContentResult(TransparentPng, "image/png");
|
||
}
|
||
|
||
var rroRoom = await ResolveRroRoomAsync(fileName);
|
||
if (rroRoom is not null)
|
||
{
|
||
// Per-room stub: a real save's skeleton at the 2023 version
|
||
// ceiling with the room's own activity_id. The old generic stub
|
||
// (version 19, roles only) was fetched and silently rejected by
|
||
// the client, which is why an enabled baked room still had no
|
||
// Maker Pen. See RoomDataBlobService.BuildRroEditableBlob.
|
||
logger.LogInformation("[cdn] MISS host={Host} file={File} -> RRO editable blob (room {Room}, activity {Activity})",
|
||
Request.Host.Host, fileName, rroRoom.Id, rroRoom.LocationReplicationId);
|
||
var rroBlob = roomDataBlob.GetRroEditableBlob(rroRoom.LocationReplicationId, rroRoom.Name);
|
||
rroBlob = ClampForClientUnlessDisabled(rroBlob, fileName);
|
||
Response.Headers.CacheControl = "public, max-age=60";
|
||
signatures.AddContentSignature(Response, rroBlob);
|
||
return new FileContentResult(rroBlob, "application/octet-stream");
|
||
}
|
||
|
||
logger.LogInformation("[cdn] MISS host={Host} file={File} -> default blob",
|
||
Request.Host.Host, fileName);
|
||
var fallbackBlob = ClampForClientUnlessDisabled(roomDataBlob.GetDefaultBlob(), fileName);
|
||
signatures.AddContentSignature(Response, fallbackBlob);
|
||
return new FileContentResult(fallbackBlob, "application/octet-stream");
|
||
}
|
||
|
||
/// <summary>Same 2023 version clamp the S3-hit path applies, for the
|
||
/// synthesised blobs on the miss path — a stub past the client's ceiling
|
||
/// is rejected exactly like a modern export would be.</summary>
|
||
private byte[] ClampForClientUnlessDisabled(byte[] bytes, string fileName)
|
||
{
|
||
if (!IsRoomDataName(fileName)) return bytes;
|
||
if (serverSettings.IsRoomBlobVersionClampDisabledAsync().GetAwaiter().GetResult()) return bytes;
|
||
var (clamped, changed) = RoomDataBlobService.ClampVersionsFor2023(bytes);
|
||
if (changed)
|
||
logger.LogInformation("[cdn] clamped synthesised blob versions to 2023 maxima file={File}", fileName);
|
||
return clamped;
|
||
}
|
||
|
||
private async Task<bool> ShouldOverlayRroRoomRoleDataAsync(string fileName) =>
|
||
await ResolveRroRoomAsync(fileName) is not null;
|
||
|
||
/// <summary>The AG/RRO room a blob name belongs to, or null when the name
|
||
/// is not an RRO room's (current blob, synthetic <c>room_{id}_*</c> form,
|
||
/// or a sub-room scene blob). Drives both the role overlay on S3 hits and
|
||
/// the per-room stub on misses.</summary>
|
||
private async Task<RoomEntity?> ResolveRroRoomAsync(string fileName)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(fileName)) return null;
|
||
|
||
// MakerPen across ALL Rec Room Originals: overlay the permissive
|
||
// room-role data (which grants CanUseMakerPen etc.) onto any AG/RRO
|
||
// room's data blob. This used to also require an accepted RoomRole,
|
||
// which limited MakerPen to hand-role-granted rooms (Stunt Runner,
|
||
// Rec Center). Dropping that requirement gives every RRO editing
|
||
// rights. Chips are preserved because the overlay only REPLACES the
|
||
// blob's RoomRoleData field (RoomDataBlobService.OverlayRroEditable-
|
||
// RoleData) — the game-rule circuit data and GameRoleData are left
|
||
// intact — so quest rooms keep working, exactly as Stunt Runner does.
|
||
var byCurrentBlob = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(r =>
|
||
r.IsAGRoom && r.CurrentDataBlobName == fileName);
|
||
if (byCurrentBlob is not null) return byCurrentBlob;
|
||
|
||
// Synthetic/default room-data blob names for rooms with no saved blob:
|
||
// the CURRENT form is room_{id}_dorknet_v8.dat
|
||
// (RoomService.SyntheticDefaultRoomDataBlobName) and the legacy form is
|
||
// room_{id}_v1.dat. Baked RRO rooms (empty CurrentDataBlobName) load
|
||
// their permissions from this synthetic blob, so it MUST overlay too —
|
||
// otherwise MakerPen only appears in RRO rooms that happen to have a
|
||
// real saved blob (which is why it worked for Rec Center / Stunt Runner
|
||
// but not the rest). Parse the id from any room_{id}_* form.
|
||
if (fileName.StartsWith("room_", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
var rest = fileName["room_".Length..];
|
||
var underscore = rest.IndexOf('_');
|
||
if (underscore > 0 && long.TryParse(rest[..underscore], out var roomId))
|
||
{
|
||
var byId = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(r => r.Id == roomId && r.IsAGRoom);
|
||
if (byId is not null) return byId;
|
||
}
|
||
}
|
||
|
||
return await (
|
||
from scene in db.RoomScenes.AsNoTracking()
|
||
join room in db.Rooms.AsNoTracking() on scene.RoomId equals room.Id
|
||
where room.IsAGRoom && scene.DataBlobName == fileName
|
||
select room
|
||
).FirstOrDefaultAsync();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Apply the watch's <c>?cropSquare=1</c> + <c>?width=N</c> transforms
|
||
/// per-request on the way out and sign the *resulting* bytes — the
|
||
/// originals stay untouched in S3/disk/DB so a later
|
||
/// <c>?width=512</c> request can re-render from full resolution. If
|
||
/// the transform pipeline can't decode the bytes (corrupt file, GIF
|
||
/// that Skia doesn't support, etc.) we fall back to serving the raw
|
||
/// bytes rather than 5xxing the whole image request.
|
||
/// </summary>
|
||
private IActionResult RespondWithTransforms(byte[] sourceBytes, string sourceContentType)
|
||
{
|
||
var cropSquare = ParseFlag(Request.Query["cropSquare"]);
|
||
var width = ParseInt(Request.Query["width"]);
|
||
var transformed = (cropSquare || width is not null)
|
||
? ImageTransformService.TryTransform(sourceBytes, sourceContentType, cropSquare, width)
|
||
: null;
|
||
|
||
if (transformed is { } t)
|
||
{
|
||
signatures.AddContentSignature(Response, t.Bytes);
|
||
return File(t.Bytes, t.ContentType);
|
||
}
|
||
|
||
signatures.AddContentSignature(Response, sourceBytes);
|
||
return File(sourceBytes, sourceContentType);
|
||
}
|
||
|
||
private static bool ParseFlag(Microsoft.Extensions.Primitives.StringValues v)
|
||
{
|
||
var s = v.ToString();
|
||
return !string.IsNullOrEmpty(s) && s != "0" && !s.Equals("false", StringComparison.OrdinalIgnoreCase);
|
||
}
|
||
|
||
private static int? ParseInt(Microsoft.Extensions.Primitives.StringValues v)
|
||
=> int.TryParse(v.ToString(), out var n) && n > 0 ? n : null;
|
||
|
||
private static bool LooksLikeHash(string s)
|
||
{
|
||
if (s.Length is not (32 or 40)) return false;
|
||
foreach (var c in s)
|
||
{
|
||
var isHex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
|
||
if (!isHex) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
private static string MimeFromName(string name)
|
||
{
|
||
var ext = Path.GetExtension(name).ToLowerInvariant();
|
||
return ext switch
|
||
{
|
||
".png" => "image/png",
|
||
".jpg" or ".jpeg" => "image/jpeg",
|
||
".gif" => "image/gif",
|
||
".webp" => "image/webp",
|
||
".mp4" or ".m4v" => "video/mp4",
|
||
".webm" => "video/webm",
|
||
".mov" => "video/quicktime",
|
||
".assetbundle" => "application/octet-stream",
|
||
_ => "application/octet-stream",
|
||
};
|
||
}
|
||
|
||
/// <summary>Blob names that carry PersistedRoomData: subroom scene
|
||
/// saves (<c>.room</c>), room-level role/meta blobs (<c>.meta</c>)
|
||
/// and our synthetic <c>room_<id>_*.dat</c> defaults.</summary>
|
||
private static bool IsRoomDataName(string name)
|
||
{
|
||
var ext = Path.GetExtension(name).ToLowerInvariant();
|
||
return ext is ".room" or ".meta" or ".dat";
|
||
}
|
||
|
||
private static bool IsImageName(string name)
|
||
{
|
||
var ext = Path.GetExtension(name).ToLowerInvariant();
|
||
return ext is ".png" or ".jpg" or ".jpeg" or ".gif" or ".webp";
|
||
}
|
||
|
||
private static string? ResolveLocalImagePath(string fileName)
|
||
{
|
||
var direct = Path.Combine(ImageDir, fileName);
|
||
if (System.IO.File.Exists(direct)) return direct;
|
||
|
||
var baseName = Path.GetFileNameWithoutExtension(fileName);
|
||
if (!baseName.StartsWith("image_", StringComparison.OrdinalIgnoreCase))
|
||
return null;
|
||
|
||
var roomName = baseName["image_".Length..];
|
||
if (!RoomImageAliases.Value.TryGetValue(roomName, out var mappedName))
|
||
return null;
|
||
|
||
var mapped = Path.Combine(ImageDir, mappedName);
|
||
return System.IO.File.Exists(mapped) ? mapped : null;
|
||
}
|
||
|
||
private static Dictionary<string, string> LoadRoomImageAliases()
|
||
{
|
||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||
var candidates = new[]
|
||
{
|
||
Path.Combine(AppContext.BaseDirectory, "data", "room_images.json"),
|
||
Path.Combine(Directory.GetCurrentDirectory(), "data", "room_images.json"),
|
||
};
|
||
|
||
foreach (var path in candidates)
|
||
{
|
||
if (!System.IO.File.Exists(path)) continue;
|
||
try
|
||
{
|
||
using var fs = System.IO.File.OpenRead(path);
|
||
var map = JsonSerializer.Deserialize<Dictionary<string, string>>(fs);
|
||
if (map is null) continue;
|
||
foreach (var (roomName, imageName) in map)
|
||
if (!string.IsNullOrWhiteSpace(roomName) && !string.IsNullOrWhiteSpace(imageName))
|
||
result[roomName] = imageName;
|
||
break;
|
||
}
|
||
catch
|
||
{
|
||
// Fall back to direct filename lookup.
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
}
|