using System.IO.Compression;
using System.Text.Json;
using System.Text.Json.Serialization;
using Google.Protobuf;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Admin;
///
/// admin.rec.net/api/admin/v1/rooms/zip-bulk-import — preferred room
/// import path going forward. The legacy multi-file endpoint
/// (POST api/admin/v1/rooms/import) stays alongside this for
/// tooling that already targets it.
///
/// The archive format mirrors RecNet's own room export shape so a
/// user can download a room from rec.net, drop the zip into the admin
/// page, and we re-create it with full fidelity — including the
/// .htr AudioSampler / Holotar assets already extracted into
/// each subroom folder so we never have to hit Rec Room's CDN:
///
/// root.zip/
/// Rooms/Room_<id>_<name>_<timestamp>/
/// RoomDetails.json (full RecNet v4 details schema)
/// RoomImage.jpg | <hash>.jpg
/// Promo/Image/PromoImage_*.jpg
/// SubRooms/<SceneName>/
/// Subroom.json (per-subroom manifest)
/// <hash>.room (referenced by Subroom.CurrentSave.DataBlob)
/// AudioSampler/<hash>.htr
/// Holotar/<hash>.htr
/// Image/PVImage_<hash>.jpg
/// Inventions/Invention_<id>_<name>_<timestamp>/
/// Invention.json
/// InventionDetails.json
/// InventionVersion.json
/// InventionImage.jpg
/// <hash>.inv
///
/// Entry-scene resolution: the order in RoomDetails.SubRooms[]
/// is the entry order (RecNet API convention). The first entry is the
/// entry scene. The admin can override with an explicit
/// RoomDetails.EntryScene field.
///
[ApiController]
[Route("api/admin/v1/rooms")]
[Authorize]
[AdminOnly]
public class RoomZipImportController(
DorkNetDbContext db,
RoomBlobNormalizerService roomBlobNormalizer,
IObjectStorage storage,
ILogger logger) : ControllerBase
{
private long CurrentAdminId => this.RequireCurrentPlayerId();
// ── Room-blob normaliser toggle ─────────────────────────────────────
// The normaliser re-encodes .room bytes through the 2020 schema to
// strip non-canonical encodings. It's been tripping the watch into
// "Error attempting to parse room data stream" — the generated 2020
// protobuf bindings appear to be missing fields or to emit them in
// a wire-order the watch's parser disagrees with.
//
// Per-import flag (`normalizeBlobs` in the form / finalize body)
// defaults OFF. The SPA's importer surfaces it as a checkbox so an
// admin can opt back in once we're confident a particular zip's
// contents survive the re-encode. The diagnostic parse still runs
// either way (its parse-OK / parse-FAIL log line is useful), but
// when the flag is false the original bytes are what reach S3.
/// Write a blob to S3 at its canonical (bucket, key) AND
/// record the metadata row in .
/// Bytes are NOT persisted on the DB row — S3 is the only place
/// they live. Previously the importer wrote bytes to the
/// Bytes column and skipped S3 entirely, so freshly
/// imported rooms were only reachable through the read-path's
/// DB-fallback branch. Centralised here so every importer call
/// site (scene saves, .htr/PV/polaroid assets, .meta sentinels,
/// invention saves, room thumbnails, promo images) ends up in
/// S3 the same way.
private async Task WriteBlobAsync(
long roomId, string blobName, byte[] bytes, long uploaderPlayerId,
string? referencesCsv = null, string? contentTypeOverride = null)
{
var (bucket, key) = BlobRouter.Route(blobName);
var contentType = contentTypeOverride ?? MimeFromName(blobName);
try
{
await storage.PutAsync(bucket, key, bytes, contentType);
}
catch (Exception ex)
{
logger.LogError(ex,
"[zip-import] S3 PUT failed bucket={Bucket} key={Key} blob={Blob} bytes={Bytes} — row will still be recorded so the import isn't lost",
bucket, key, blobName, bytes.Length);
}
var entity = new RoomDataBlobEntity
{
RoomId = roomId,
BlobName = blobName,
UploadedByPlayerId = uploaderPlayerId,
UploadedAt = DateTime.UtcNow,
ReferencedFilenamesCsv = referencesCsv ?? string.Empty,
};
db.RoomDataBlobs.Add(entity);
return entity;
}
private static string MimeFromName(string name) => Path.GetExtension(name).ToLowerInvariant() switch
{
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".gif" => "image/gif",
".webp" => "image/webp",
".mp4" => "video/mp4",
".htr" => "application/octet-stream",
".room" => "application/octet-stream",
".meta" => "application/octet-stream",
".inv" => "application/octet-stream",
".dat" => "application/octet-stream",
_ => "application/octet-stream",
};
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
// ── RoomDetails.json DTOs ──────────────────────────────────────────
public sealed class RoomDetailsDto
{
public long? RoomId { get; set; }
public string? Name { get; set; }
public string? FriendlyName { get; set; }
public string? Description { get; set; }
public string? ImageName { get; set; }
public int? WarningMask { get; set; }
public string? CustomWarning { get; set; }
public long? CreatorAccountId { get; set; }
public int? State { get; set; }
public int? Accessibility { get; set; }
public bool? SupportsLevelVoting { get; set; }
public bool? IsRRO { get; set; }
public bool? SupportsScreens { get; set; }
public bool? SupportsWalkVR { get; set; }
public bool? SupportsTeleportVR { get; set; }
public bool? SupportsVRLow { get; set; }
public bool? SupportsMobile { get; set; }
public bool? SupportsJuniors { get; set; }
public DateTime? CreatedAt { get; set; }
public DateTime? PublishedAt { get; set; }
public bool? IsDorm { get; set; }
public int? MaxPlayers { get; set; }
public bool? CloningAllowed { get; set; }
public bool? DisableMicAutoMute { get; set; }
public StatsDto? Stats { get; set; }
public List? Tags { get; set; }
public List? SubRooms { get; set; }
public string? EntryScene { get; set; } // admin override; not in real exports
}
public sealed class StatsDto
{
public int? CheerCount { get; set; }
public int? FavoriteCount { get; set; }
public int? VisitorCount { get; set; }
public int? VisitCount { get; set; }
}
public sealed class TagDto
{
public string? Tag { get; set; }
public int? Type { get; set; }
public bool? IsPrimaryGenre { get; set; }
}
public sealed class SubRoomDto
{
public long? SubRoomId { get; set; }
public string? UnitySceneId { get; set; }
public string? Name { get; set; }
public bool? IsSandbox { get; set; }
public int? MaxPlayers { get; set; }
public int? Accessibility { get; set; }
public CurrentSaveDto? CurrentSave { get; set; }
}
public sealed class CurrentSaveDto
{
public long? SubRoomDataSaveId { get; set; }
public string? DataBlob { get; set; }
}
// ── Invention DTOs ────────────────────────────────────────────────
public sealed class InventionJsonDto
{
public long? InventionId { get; set; }
public string? ReplicationId { get; set; }
public long? CreatorPlayerId { get; set; }
public string? Name { get; set; }
public string? Description { get; set; }
public string? LongDescription { get; set; }
public string? ImageName { get; set; }
public int? UgcVersion { get; set; }
public int? CurrentVersionNumber { get; set; }
public int? LatestVersionNumber { get; set; }
public int? Accessibility { get; set; }
public DateTime? CreatedAt { get; set; }
public DateTime? ModifiedAt { get; set; }
public DateTime? FirstPublishedAt { get; set; }
public long? CreationRoomId { get; set; }
public int? NumPlayersHaveUsedInRoom { get; set; }
public int? CheerCount { get; set; }
public int? CreatorPermission { get; set; }
public int? GeneralPermission { get; set; }
public bool? IsAgInvention { get; set; }
public bool? IsRecRoomApproved { get; set; }
}
public sealed class InventionVersionDto
{
public int? VersionNumber { get; set; }
public string? BlobName { get; set; }
}
public sealed class InventionDetailsDto
{
public List? Tags { get; set; }
}
// ── Endpoint ──────────────────────────────────────────────────────
[HttpPost("zip-bulk-import")]
[DisableRequestSizeLimit]
[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)] // 2 GB
public async Task ZipImport(
[FromForm(Name = "archive")] IFormFile? archive,
[FromForm(Name = "creatorPlayerId")] long? creatorPlayerId,
[FromForm(Name = "normalizeBlobs")] bool? normalizeBlobs)
{
if (archive is null || archive.Length == 0)
return BadRequest(new { error = "missing_archive" });
await using var stream = archive.OpenReadStream();
return await RunImportAsync(
stream, archive.Length, creatorPlayerId,
normalizeBlobs: normalizeBlobs ?? false);
}
// ── Chunked upload (Cloudflare 100MB workaround) ─────────────────
//
// The whole multipart-zip path 413s through Cloudflare's edge for
// anything over 100 MB on free/pro plans. Real RecNet exports
// routinely run 150-300 MB. Splitting the upload into ≤90 MB
// chunks keeps each request safely under the cap; the server
// appends chunks to a temp file and only invokes the import logic
// once the finalize call comes in.
//
// Sessions are in-memory and short-lived (admins use the importer
// synchronously). If the process restarts before finalize, the
// upload is lost — acceptable for an admin tool used by 1-2 people.
private static readonly System.Collections.Concurrent.ConcurrentDictionary Sessions = new();
public sealed class ZipUploadSession
{
public Guid Id { get; init; }
public string TempPath { get; init; } = string.Empty;
public long TotalBytes { get; set; }
public long ReceivedBytes { get; set; }
public string FileName { get; init; } = string.Empty;
public long AdminPlayerId { get; init; }
public DateTime CreatedAt { get; init; } = DateTime.UtcNow;
}
public sealed record ChunkInitRequest(string FileName, long TotalBytes);
/// POST zip-upload-init — allocates a temp file and
/// returns an uploadId the SPA threads through subsequent
/// chunk POSTs. Capped at 2 GB total; opens a sparse temp file so
/// disk usage tracks actual bytes received.
[HttpPost("zip-upload-init")]
public ActionResult ChunkInit([FromBody] ChunkInitRequest body)
{
if (body.TotalBytes <= 0 || body.TotalBytes > 2_000_000_000)
return BadRequest(new { error = "invalid_size" });
// Prune stale sessions before allocating a new one. Anything
// older than 2 hours with no activity is abandoned.
foreach (var (sid, sess) in Sessions)
{
if (DateTime.UtcNow - sess.CreatedAt > TimeSpan.FromHours(2))
{
Sessions.TryRemove(sid, out _);
try { if (System.IO.File.Exists(sess.TempPath)) System.IO.File.Delete(sess.TempPath); } catch { /* best effort */ }
}
}
var id = Guid.NewGuid();
var safeName = Path.GetFileName(body.FileName);
var tempPath = Path.Combine(Path.GetTempPath(), $"dorknet-ziplupload-{id:N}.zip");
// Pre-allocate the file at full length so chunk writes at
// arbitrary offsets work without the OS having to grow it
// page-by-page during the random writes.
using (var fs = new FileStream(tempPath, FileMode.Create, FileAccess.Write))
{
fs.SetLength(body.TotalBytes);
}
Sessions[id] = new ZipUploadSession
{
Id = id,
TempPath = tempPath,
TotalBytes = body.TotalBytes,
FileName = safeName,
AdminPlayerId = CurrentAdminId,
};
return Ok(new { uploadId = id });
}
/// POST zip-upload-chunk/{id}?offset=N — writes the
/// posted body at offset in the session's temp file.
/// Per-call size capped at 100 MB (Kestrel-side, well below the
/// Cloudflare 100 MB edge cap so we never see a 413). Body is the
/// raw chunk bytes; the SPA's uploadZipInChunks helper sends
/// the right slice.
[HttpPost("zip-upload-chunk/{id:guid}")]
[DisableRequestSizeLimit]
[RequestSizeLimit(100_000_000)]
public async Task ChunkAppend(Guid id, [FromQuery] long offset)
{
if (!Sessions.TryGetValue(id, out var session)) return NotFound(new { error = "unknown_session" });
if (session.AdminPlayerId != CurrentAdminId) return Forbid();
if (offset < 0 || offset >= session.TotalBytes)
return BadRequest(new { error = "bad_offset", offset, total = session.TotalBytes });
await using var fs = new FileStream(session.TempPath, FileMode.Open, FileAccess.Write, FileShare.Read);
fs.Position = offset;
var written = 0L;
await Request.Body.CopyToAsync(fs);
written = fs.Position - offset;
session.ReceivedBytes = Math.Max(session.ReceivedBytes, fs.Position);
return Ok(new
{
uploadId = id,
offset,
written,
received = session.ReceivedBytes,
total = session.TotalBytes,
});
}
/// Body for zip-upload-finalize.
/// stamps every imported room/invention with that player as the
/// owner. and
/// , when non-null and
/// non-empty, restrict the import to only those top-level folders
/// inside the archive — the SPA's preview offers a checkbox per
/// room/invention so admins can skip a bad export's broken entry
/// without rebuilding the zip. Null/empty means "import
/// everything" (back-compat with older SPA builds).
public sealed record ChunkFinalizeRequest(
long? CreatorPlayerId,
List? SelectedRoomFolders,
List? SelectedInventionFolders,
bool? NormalizeBlobs);
/// POST zip-upload-finalize/{id} — runs the existing
/// zip import on the assembled temp file then deletes it. Same
/// response shape as zip-bulk-import.
[HttpPost("zip-upload-finalize/{id:guid}")]
public async Task ChunkFinalize(Guid id, [FromBody] ChunkFinalizeRequest? body)
{
if (!Sessions.TryGetValue(id, out var session)) return NotFound(new { error = "unknown_session" });
if (session.AdminPlayerId != CurrentAdminId) return Forbid();
try
{
await using var stream = System.IO.File.OpenRead(session.TempPath);
return await RunImportAsync(
stream,
session.TotalBytes,
body?.CreatorPlayerId,
body?.SelectedRoomFolders,
body?.SelectedInventionFolders,
normalizeBlobs: body?.NormalizeBlobs ?? false);
}
finally
{
Sessions.TryRemove(id, out _);
try { if (System.IO.File.Exists(session.TempPath)) System.IO.File.Delete(session.TempPath); }
catch (Exception ex) { logger.LogWarning(ex, "[zip-import] failed to clean up {Path}", session.TempPath); }
}
}
/// DELETE zip-upload-abort/{id} — drop an in-flight
/// upload session (e.g. user navigated away). No-op if the id is
/// already gone.
[HttpDelete("zip-upload-abort/{id:guid}")]
public IActionResult ChunkAbort(Guid id)
{
if (Sessions.TryRemove(id, out var session))
{
try { if (System.IO.File.Exists(session.TempPath)) System.IO.File.Delete(session.TempPath); }
catch (Exception ex) { logger.LogWarning(ex, "[zip-import] failed to clean up {Path}", session.TempPath); }
}
return Ok();
}
// ── Shared import core (called by both single-shot and chunked) ──
private async Task RunImportAsync(
Stream archiveStream,
long archiveBytes,
long? creatorPlayerId,
List? selectedRoomFolders = null,
List? selectedInventionFolders = null,
bool normalizeBlobs = false)
{
var creator = creatorPlayerId ?? CurrentAdminId;
if (!await db.Players.AnyAsync(p => p.Id == creator))
return BadRequest(new { error = "creator_not_found", creator });
using var zip = new ZipArchive(archiveStream, ZipArchiveMode.Read, leaveOpen: false);
// Index entries by normalized path. We do path-based lookups
// throughout (per-room folder, per-subroom folder, per-invention
// folder), so a flat dictionary keyed by full path is simplest.
var entryByPath = new Dictionary(StringComparer.OrdinalIgnoreCase);
foreach (var e in zip.Entries)
{
if (e.FullName.EndsWith("/", StringComparison.Ordinal)) continue;
entryByPath[Normalize(e.FullName)] = e;
}
// Find every room manifest (`Rooms//RoomDetails.json`).
// Folder names are like "Room_3163127_YarrHarrHeist_20260509_180319"
// but we don't parse anything out of them — RoomDetails.json is
// the source of truth.
var roomFolders = entryByPath.Keys
.Where(k => k.StartsWith("Rooms/", StringComparison.OrdinalIgnoreCase)
&& k.EndsWith("/RoomDetails.json", StringComparison.OrdinalIgnoreCase))
.Select(k => k.Substring(0, k.Length - "/RoomDetails.json".Length))
.ToList();
// Inventions can live either at the zip root (Inventions/Invention___/)
// OR inside a room folder (Rooms/Room___/Inventions/Invention_…).
// Both layouts show up in the wild — handle either.
var inventionFolders = entryByPath.Keys
.Where(k => k.EndsWith("/Invention.json", StringComparison.OrdinalIgnoreCase)
&& (k.StartsWith("Inventions/", StringComparison.OrdinalIgnoreCase)
|| k.Contains("/Inventions/", StringComparison.OrdinalIgnoreCase)))
.Select(k => k[..^"/Invention.json".Length])
.ToList();
if (roomFolders.Count == 0 && inventionFolders.Count == 0)
return BadRequest(new { error = "empty_archive_no_manifests" });
// If the admin supplied an explicit selection (non-null,
// non-empty), filter to just those folders. Comparison is
// case-insensitive and tolerates leading/trailing slashes that
// the client might have included. Anything in the selection
// that we can't match against the archive is just ignored —
// the per-room report will simply not include it.
if (selectedRoomFolders is { Count: > 0 })
{
var allowed = selectedRoomFolders
.Select(s => Normalize(s).TrimEnd('/'))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
roomFolders = roomFolders.Where(f => allowed.Contains(f)).ToList();
}
if (selectedInventionFolders is { Count: > 0 })
{
var allowed = selectedInventionFolders
.Select(s => Normalize(s).TrimEnd('/'))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
inventionFolders = inventionFolders.Where(f => allowed.Contains(f)).ToList();
}
var roomReports = new List