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.Storage; /// /// storage.rec.net — file upload endpoint the 2020 client posts to when /// the master client persists a room save. Verified against the IL2CPP /// disassembly of RecNet.Storage.UploadFile: /// /// Method: POST. /// URL: https://storage.rec.net/upload (Service=Storage, /// requestUri="upload"; per Service enum at dump.cs:586534). /// Body: multipart/form-data with fields /// File (binary, content-type from caller or /// application/octet-stream), FileType /// (Storage.FileType: 0=Unknown, 1=RoomSave, 2=Holotar, 3=Image, /// 4=Video, 5=Invention), and optional References /// (comma-joined filenames). /// Response: UploadFileResponseDTO with one key /// Filename — the cdn URL segment the client will request via /// cdn.rec.net/room/{Filename} the next time anyone enters /// the room. /// /// /// Permission rule: only the room's creator (or the dorm owner for a /// dorm save) can persist a new blob. Saves from other players in the /// same room are silently rejected with 403 — they can build with the /// Maker Pen but their changes won't outlive the session. /// [ApiController] [Authorize] public class StorageController( DorkNetDbContext db, PlayerPresenceService presence, IObjectStorage objectStorage, ILogger logger) : ControllerBase { /// /// FileType enum values from the client's RecNet.Storage+FileType /// (dump.cs:586846). Kept as a private enum so callers can pattern- /// match without leaking client-side numbering elsewhere. /// private enum FileType { Unknown = 0, RoomSave = 1, Holotar = 2, Image = 3, Video = 4, Invention = 5, } [HttpPost("/upload", Order = -2000)] [Consumes("multipart/form-data")] public async Task Upload() { // Form parsing — ASP.NET Core deserialises the multipart body // automatically. The two field names are the literal strings the // client uses (capitalised; verified in // RecNet.Storage.UploadFile ISIL state {028, 044}). if (!Request.HasFormContentType) return BadRequest(new { error = "expected multipart/form-data" }); var form = await Request.ReadFormAsync(); var file = form.Files["File"]; if (file is null || file.Length == 0) return BadRequest(new { error = "missing or empty File part" }); if (!int.TryParse(form["FileType"].ToString(), out var fileTypeRaw)) return BadRequest(new { error = "missing or non-numeric FileType" }); var fileType = (FileType)fileTypeRaw; var playerId = this.RequireCurrentPlayerId(); var references = form["References"].ToString() ?? string.Empty; logger.LogInformation( "[storage] upload accepted host={Host} fileType={FileType} player={PlayerId} bytes={Bytes} refs={ReferenceCount}", Request.Host.Host, fileType, playerId, file.Length, string.IsNullOrWhiteSpace(references) ? 0 : references.Split(',', StringSplitOptions.RemoveEmptyEntries).Length); // Read the upload bytes upfront. SQLite BLOB column expects a // byte[]; the upload is small (KB to ~1MB) so memory is fine. byte[] bytes; using (var ms = new MemoryStream()) { await file.CopyToAsync(ms); bytes = ms.ToArray(); } return fileType switch { FileType.RoomSave => await UploadRoomSaveAsync(playerId, bytes, references), FileType.Invention => await UploadInventionAsync(playerId, bytes, references), FileType.Image => await UploadImageAsync(playerId, bytes), FileType.Holotar => await UploadGenericAsync(playerId, bytes, "holotar"), FileType.Video => await UploadGenericAsync(playerId, bytes, "video"), _ => Ok(new { filename = $"stub_{Guid.NewGuid():N}.{Extension(fileType)}", }), }; } private async Task UploadRoomSaveAsync( long playerId, byte[] bytes, string references) { // Determine which room the upload belongs to. The client doesn't // include the room id in the request — it just posts a save and // expects the server to know based on session context. We use // PlayerPresenceService, which is updated by /goto/room/* and // is the canonical "what room is this player currently in" // store. var presenceRoom = presence.GetRoom(playerId); if (presenceRoom is null) { logger.LogWarning( "[storage] room save from player {PlayerId} had no active presence; saving to personal dorm", playerId); var dorm = await db.Rooms.FirstOrDefaultAsync(r => r.IsDormRoom && r.CreatorPlayerId == playerId); if (dorm is not null) return await UploadDormSaveAsync(playerId, bytes, references, dorm.Id); return BadRequest(new { error = "no active room session" }); } var roomId = presenceRoom.RoomId; var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); if (room is null) { var roomName = string.IsNullOrWhiteSpace(presenceRoom.Name) ? $"Room_{roomId}" : presenceRoom.Name; if (await db.Rooms.AnyAsync(r => r.Name == roomName)) roomName = $"Room_{roomId}"; logger.LogWarning( "[storage] room save from player {PlayerId} referenced missing room {RoomId} ({RoomName}); creating owner room row", playerId, roomId, roomName); room = new RoomEntity { Id = roomId, Name = roomName, Description = "Created from an active game session.", CreatorPlayerId = playerId, Accessibility = presenceRoom.IsPrivate ? 0 : 1, IsAGRoom = true, IsDormRoom = false, LocationReplicationId = string.IsNullOrWhiteSpace(presenceRoom.Location) ? "76d98498-60a1-430c-ab76-b54a29b7a163" : presenceRoom.Location, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, }; db.Rooms.Add(room); await db.SaveChangesAsync(); } // Dorm saves go to a per-player DormStateEntity instead of the // shared RoomEntity. Pass the real dorm Room.Id through so the // saved blob is keyed against the player's own dorm row (every // player has their own dorm RoomEntity now, not the legacy // canonical sentinel id=1). if (room.IsDormRoom) return await UploadDormSaveAsync(playerId, bytes, references, room.Id); // Permission: only the room owner can save. (Future Phase 5 // expansion: co-owners + admins. For now, strict owner-only — // matches the 2020 official server's "RoomRolePermissions // .CanUseMakerPen" behaviour, which rejects upload at the wire // for non-owners.) if (room.CreatorPlayerId != playerId) { logger.LogWarning( "[storage] player {PlayerId} tried to save room {RoomId} (creator={CreatorId})", playerId, room.Id, room.CreatorPlayerId); return Forbid(); } // Allocate the next BlobName for this room. We use a monotonic // counter per-room: the (N+1)th save becomes "room__v.dat". // SQLite handles the count() query fast enough for our scale; if // this grows too big, switch to a per-room counter column on // RoomEntity. var versionNumber = await db.RoomDataBlobs .Where(b => b.RoomId == roomId) .CountAsync() + 1; var blobName = $"room_{roomId}_v{versionNumber}.dat"; var (bucket, key) = BlobRouter.Route(blobName); await StoreBlobObjectAsync(bucket, key, bytes, "application/octet-stream"); var entry = new RoomDataBlobEntity { RoomId = roomId, BlobName = blobName, UploadedByPlayerId = playerId, UploadedAt = DateTime.UtcNow, ReferencedFilenamesCsv = references, }; db.RoomDataBlobs.Add(entry); // Update RoomEntity.CurrentDataBlobName so the next visitor's // /api/rooms/v4/details/{id} response carries the new name and // the persistence flow downloads the latest bytes. room.CurrentDataBlobName = blobName; room.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); logger.LogInformation( "[storage] room save: room={RoomId} player={PlayerId} blob={Blob} size={Size}B", roomId, playerId, blobName, bytes.Length); return Ok(new { filename = blobName }); } /// /// Per-player dorm save. Stores bytes in /// keyed under RoomId=1 (matchmaking is unchanged) and /// upserts the row for this player so /// next time they enter their dorm, RoomsController.Details serves /// the right BlobName from their personal state row instead of the /// shared RoomEntity row. /// private async Task UploadDormSaveAsync( long playerId, byte[] bytes, string references, long dormRoomId) { // Migration backfill: pre-personal-dorm saves used the legacy // canonical RoomId=1. Re-key any of those rows for THIS player // to their actual dorm RoomId so the version count below // doesn't collide with existing blob names. Idempotent — runs // a no-op once everything's migrated. var blobNamePrefix = $"dorm_p{playerId}_v"; await db.RoomDataBlobs .Where(b => b.UploadedByPlayerId == playerId && b.BlobName.StartsWith(blobNamePrefix) && b.RoomId != dormRoomId) .ExecuteUpdateAsync(s => s.SetProperty(b => b.RoomId, dormRoomId)); // Per-player blob-name-based versioning. Counting by blob-name // prefix is more robust than RoomId because it survives any // future migrations of the dorm row identity. var versionNumber = await db.RoomDataBlobs .Where(b => b.UploadedByPlayerId == playerId && b.BlobName.StartsWith(blobNamePrefix)) .CountAsync() + 1; var blobName = $"dorm_p{playerId}_v{versionNumber}.dat"; var (bucket, key) = BlobRouter.Route(blobName); await StoreBlobObjectAsync(bucket, key, bytes, "application/octet-stream"); db.RoomDataBlobs.Add(new RoomDataBlobEntity { RoomId = dormRoomId, BlobName = blobName, UploadedByPlayerId = playerId, UploadedAt = DateTime.UtcNow, ReferencedFilenamesCsv = references, }); // Upsert DormStateEntity for this player. var dormState = await db.DormStates .FirstOrDefaultAsync(d => d.PlayerId == playerId); if (dormState is null) { dormState = new DormStateEntity { PlayerId = playerId }; db.DormStates.Add(dormState); } dormState.CurrentDataBlobName = blobName; dormState.UpdatedAt = DateTime.UtcNow; // Bump the dorm room's UpdatedAt timestamp so the watch's // freshness check (DataModifiedAt comparison) sees this as the // latest version and stops showing the "room is not up to // date" warning after a save. await db.Rooms .Where(r => r.Id == dormRoomId) .ExecuteUpdateAsync(s => s .SetProperty(r => r.UpdatedAt, DateTime.UtcNow) .SetProperty(r => r.CurrentDataBlobName, blobName)); await db.SaveChangesAsync(); logger.LogInformation( "[storage] dorm save: player={PlayerId} blob={Blob} size={Size}B", playerId, blobName, bytes.Length); return Ok(new { filename = blobName }); } /// /// Per-creation invention upload. Stores bytes in /// with a recognisable /// invention_p<id>_.dat blob name; the /// metadata row in is created /// separately by the client's POST to /// api/inventions/v3/save referencing the returned /// Filename. We don't enforce a per-invention id at upload time /// (the inventon row may not exist yet) — the unique BlobName /// guards uniqueness on its own. /// private async Task UploadInventionAsync( long playerId, byte[] bytes, string references) { var blobName = $"invention_p{playerId}_{Guid.NewGuid():N}.dat"; var (bucket, key) = BlobRouter.Route(blobName); await StoreBlobObjectAsync(bucket, key, bytes, "application/octet-stream"); db.RoomDataBlobs.Add(new RoomDataBlobEntity { // RoomId=0 is the "no specific room — invention library blob" // sentinel. The cdn lookup keys on BlobName so RoomId is // informational only here. RoomId = 0, BlobName = blobName, UploadedByPlayerId = playerId, UploadedAt = DateTime.UtcNow, ReferencedFilenamesCsv = references, }); await db.SaveChangesAsync(); logger.LogInformation( "[storage] invention upload: player={PlayerId} blob={Blob} size={Size}B", playerId, blobName, bytes.Length); return Ok(new { filename = blobName }); } /// Profile image / screenshot upload. Stored as bytes on /// the same RoomDataBlobs table (RoomId=0 is the /// "no specific room" sentinel). The catch-all serves them via /// cdn.rec.net/{Filename} like every other blob. The /// caller is expected to follow up with /// POST account/v1/profileimage to wire the returned /// filename into . /// private async Task UploadImageAsync(long playerId, byte[] bytes) { var blobName = $"img_p{playerId}_{Guid.NewGuid():N}.png"; var (bucket, key) = BlobRouter.Route(blobName); await StoreBlobObjectAsync(bucket, key, bytes, "image/png"); db.RoomDataBlobs.Add(new RoomDataBlobEntity { RoomId = 0, BlobName = blobName, UploadedByPlayerId = playerId, UploadedAt = DateTime.UtcNow, ReferencedFilenamesCsv = string.Empty, }); await db.SaveChangesAsync(); logger.LogInformation( "[storage] image upload: player={PlayerId} blob={Blob} size={Size}B", playerId, blobName, bytes.Length); return Ok(new { filename = blobName }); } /// Catch-all bytes-on-disk upload used for FileTypes that /// don't have specialised behaviour (Holotar, Video). Stored /// identically to Image; callers reference the returned Filename /// from wherever they need it. private async Task UploadGenericAsync( long playerId, byte[] bytes, string kind) { var ext = kind switch { "video" => "mp4", _ => "bin" }; var blobName = $"{kind}_p{playerId}_{Guid.NewGuid():N}.{ext}"; var contentType = kind switch { "video" => "video/mp4", _ => "application/octet-stream", }; var (bucket, key) = BlobRouter.Route(blobName); await StoreBlobObjectAsync(bucket, key, bytes, contentType); db.RoomDataBlobs.Add(new RoomDataBlobEntity { RoomId = 0, BlobName = blobName, UploadedByPlayerId = playerId, UploadedAt = DateTime.UtcNow, ReferencedFilenamesCsv = string.Empty, }); await db.SaveChangesAsync(); logger.LogInformation( "[storage] {Kind} upload: player={PlayerId} blob={Blob} size={Size}B", kind, playerId, blobName, bytes.Length); return Ok(new { filename = blobName }); } private async Task StoreBlobObjectAsync(string bucket, string key, byte[] bytes, string contentType) { try { using var s3Timeout = new CancellationTokenSource(TimeSpan.FromSeconds(8)); var written = await objectStorage.PutAsync(bucket, key, bytes, contentType, s3Timeout.Token); logger.LogInformation( "[storage] object write: bucket={Bucket} key={Key} bytes={Bytes} mode={Mode}", bucket, key, written, objectStorage.IsS3Configured ? "s3" : "disk-fallback"); } catch (Exception ex) { logger.LogError( ex, "[storage] object write failed: bucket={Bucket} key={Key}; keeping DB fallback so the client request can complete", bucket, key); } } private static string Extension(FileType type) => type switch { FileType.RoomSave => "dat", FileType.Image => "png", FileType.Video => "mp4", FileType.Holotar => "holotar", FileType.Invention => "inv", _ => "bin", }; }