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;
///
/// Catch-all binary blob server. Owns every GET /{*path} 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 :
///
/// • img.{apex} → image-transform pipeline. Reads from S3
/// (BlobRouter routes by filename suffix), supports on-the-fly
/// ?cropSquare=1 / ?width=N via
/// , falls back to disk
/// (data/images/) 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
/// [Host(...)] 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 GET /{*path}.
///
/// Every response is signed via
/// before being returned — the 2020 watch's image-verify path rejects
/// any image lacking the Content-Signature header.
///
[ApiController]
public class CdnController(
RoomDataBlobService roomDataBlob,
IObjectStorage storage,
ImageSignatureService signatures,
DorkNetDbContext db,
ServerSettingsService serverSettings,
ILogger logger) : ControllerBase
{
/// 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.
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> 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/.dat — RoomDataBlob downloads
// • cdn.{apex}/data/.htr — room thumbnails / .htr files
// • cdn.{apex}/config/ — LoadingScreenTipData and similar
// • img.{apex}/.{png,jpg,jpeg,webp,gif} — image fetches
// • img.{apex}/img/ — 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/` — 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/` — 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/` 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_.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 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 ───────────────────────
/// GET unity_assets/{assetId}/{target}/{version} — the
/// 2023 client's asset-bundle download for baked Studio scenes. The
/// client builds this URL itself in RecNet.Runtime:
/// String.Format("unity_assets/{0}/{1}/{2}",
/// WebUtility.UrlEncode(unityAssetId), (byte)target, (int)version)
/// after reading UnityAssetId / Target / Version
/// from rooms/{id}/subrooms/{sid}/saves/{saveId}. It does NOT
/// fetch the bare {filename}.assetbundle the save metadata names
/// — so the bundle bytes only resolve through this id-addressed route.
///
/// Resolution: the importer stores each baked bundle in S3 under
/// its original filename ({saveId}__bundle_t{target}_v{version}.assetbundle)
/// and stamps the owning scene's
/// + .
/// We look the scene up by StudioUnityAssetId == assetId, pick the
/// bundle whose embedded target+version match, and stream its bytes.
///
/// 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.
[Route("/unity_assets/{assetId}/{target:int}/{version:int}")]
[AcceptVerbs("GET", "HEAD")]
public async Task 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");
}
/// Parse a Studio baked-bundle filename
/// ({saveId}__bundle_t{target}_v{version}.assetbundle, or older
/// {saveId}__bundle_t{target}.assetbundle) 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.
private static (int Target, int Version, string Filename)? ParseBundleName(string fileName)
{
var name = Path.GetFileName(fileName);
var match = System.Text.RegularExpressions.Regex.Match(
name,
@"^(?\d+)__bundle_t(?\d+)(?:_v(?\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 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 "$.jpg" — but the bytes are stored at
// ".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
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 "$.jpg". The importer stores everything from
// SubRooms//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 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();
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()
: 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");
}
/// 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.
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 ShouldOverlayRroRoomRoleDataAsync(string fileName) =>
await ResolveRroRoomAsync(fileName) is not null;
/// The AG/RRO room a blob name belongs to, or null when the name
/// is not an RRO room's (current blob, synthetic room_{id}_* form,
/// or a sub-room scene blob). Drives both the role overlay on S3 hits and
/// the per-room stub on misses.
private async Task 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();
}
///
/// Apply the watch's ?cropSquare=1 + ?width=N transforms
/// per-request on the way out and sign the *resulting* bytes — the
/// originals stay untouched in S3/disk/DB so a later
/// ?width=512 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.
///
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",
};
}
/// Blob names that carry PersistedRoomData: subroom scene
/// saves (.room), room-level role/meta blobs (.meta)
/// and our synthetic room_<id>_*.dat defaults.
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 LoadRoomImageAliases()
{
var result = new Dictionary(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>(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;
}
}