using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.Http; using System.Text.Json; using DorkNet.Models.Auth; using DorkNet.Server.Auth; using DorkNet.Server.Data; using DorkNet.Server.Data.Entities; using DorkNet.Server.Services; namespace DorkNet.Server.Controllers.API.Images; /// /// api.rec.net/api/images/v* — in-game camera photo endpoints. The 2020 /// watch's camera UI ("save photo to album", "share photo") posts here. /// /// Wire shape (observed from server logs — the 2020 watch hits these /// without a corresponding storage.rec.net/upload, meaning the bytes /// come straight in this request body): /// POST api/images/v4/uploadsaved /// - body: multipart/form-data with the image file plus optional /// fields (Caption, Description, RoomId, IsPublic, TaggedPlayerIds). /// On the 2020 client the field name varies — we accept "File", /// "Image", and any *.png/*.jpg upload for safety. /// - response: an ImageInfo-shaped object (PascalCase keys). The /// watch's success path calls a JSON parser whose strict keys are /// AccountId, ImageName, CreatedAt — so all three are required. /// Returning [] (the previous catch-all) made the watch silently /// drop the photo. /// /// Saved photos are stored as rows with /// IsPublic=false by default. A separate Share button (POST /// api/images/v4/share/{id}) flips IsPublic=true and surfaces them on /// feed.rec.net. /// [ApiController] public class ImagesController( DorkNetDbContext db, PlayerPresenceService presence, LevelService level, IObjectStorage storage, ImageSignatureService signatures, NotificationService notifications, DomainConfig domain, ILogger logger) : ControllerBase { private static readonly string ImageDir = Path.Combine(AppContext.BaseDirectory, "data", "images"); 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, }; /// POST api/images/v{2-5}/uploadsaved — receive an image /// from the in-game camera "save to album" button. Persists the /// bytes in and creates a /// row keyed by the uploader. The photo /// defaults to IsPublic=false (saved-only) so it doesn't surface /// on the public feed unless the user explicitly hits Share. [HttpPost("api/images/v2/uploadsaved")] [HttpPost("api/images/v3/uploadsaved")] [HttpPost("api/images/v4/uploadsaved")] [HttpPost("api/images/v5/uploadsaved")] // 2018 flat-screen (non-VR) camera save posts a single 'image' part to // uploadsavedsingle (RecNet.cs:35080); ReadImageBytesAsync already falls // back to the first uploaded file, so the same handler serves it. [HttpPost("api/images/v2/uploadsavedsingle")] [HttpPost("api/images/v3/uploadsavedsingle")] [HttpPost("api/images/v4/uploadsavedsingle")] [HttpPost("api/images/v5/uploadsavedsingle")] [Authorize] public async Task UploadSaved() { var pid = this.RequireCurrentPlayerId(); var bytes = await ReadImageBytesAsync(); if (bytes is null || bytes.Length == 0) { logger.LogWarning("[images] uploadsaved by {Pid}: no file bytes received", pid); return BadRequest(new { error = "missing_file" }); } // Pull optional metadata from the form (the 2020 watch sometimes // includes a caption / room context, sometimes posts bytes only). var form = Request.HasFormContentType ? await Request.ReadFormAsync() : null; var caption = form?["Caption"].ToString() ?? form?["Description"].ToString() ?? string.Empty; var taggedIds = form?["TaggedPlayerIds"].ToString() ?? string.Empty; long roomId = 0; if (form is not null && long.TryParse(form["RoomId"].ToString(), out var rid)) roomId = rid; if (roomId == 0) roomId = presence.GetRoom(pid)?.RoomId ?? 0; var isPublic = string.Equals(form?["IsPublic"].ToString(), "true", StringComparison.OrdinalIgnoreCase); var isProfileThumbnail = IsProfileThumbnail(form); LogUploadForm("uploadsaved", pid, form, isProfileThumbnail); // Detect format. The watch uses PNG; if a JPEG slips through we // tag it accordingly so the cdn serves the right MIME. var extension = SniffExtension(bytes); var blobName = $"img_p{pid}_{Guid.NewGuid():N}.{extension}"; // Dual-write during the migration window: S3 is the canonical // store going forward, but we keep the DB Bytes column populated // so reads from older code paths still work. PR 4 drops the // column once every blob has a confirmed S3 copy. try { using var s3Timeout = new CancellationTokenSource(TimeSpan.FromSeconds(4)); var (bucket, key) = BlobRouter.Route(blobName); await storage.PutAsync( bucket, key, bytes, extension == "png" ? "image/png" : "image/jpeg", s3Timeout.Token); } catch (Exception ex) { logger.LogError( ex, "[images] S3 upload failed for {Blob}; keeping DB fallback so the client does not fail", blobName); } db.RoomDataBlobs.Add(new RoomDataBlobEntity { RoomId = 0, BlobName = blobName, UploadedByPlayerId = pid, UploadedAt = DateTime.UtcNow, ReferencedFilenamesCsv = string.Empty, }); var photo = new PhotoEntity { UploaderPlayerId = pid, BlobName = blobName, Caption = caption.Trim(), TaggedPlayerIdsCsv = NormaliseTaggedIds(taggedIds, exceptId: pid), RoomId = roomId, IsPublic = isPublic, }; db.Photos.Add(photo); if (isProfileThumbnail) { var player = await db.Players.FirstOrDefaultAsync(p => p.Id == pid); if (player is not null) { player.ProfileImageName = blobName; logger.LogInformation( "[images] profile thumbnail upload set profile image: player={PlayerId} blob={Blob}", pid, blobName); } } await db.SaveChangesAsync(); if (isProfileThumbnail) { var player = await db.Players.FirstOrDefaultAsync(p => p.Id == pid); if (player is not null) await NotifyAccountImageChangedAsync(player); } // Small XP bump for taking a photo — encourages camera use. await level.AwardXpAsync(pid, LevelService.RoomVisitXp, $"photo_saved:{photo.Id}"); logger.LogInformation( "[images] uploaded saved photo {Id} by {Pid} ({Bytes} bytes) → {Blob}", photo.Id, pid, bytes.Length, blobName); return Ok(BuildImageInfo(photo, pid)); } /// POST api/images/v{1-5}/profile — direct profile image upload. /// Older clients use this instead of tagging an uploadsaved request as a /// ProfileThumbnail, so this route must always update Account.ProfileImage. [HttpPost("api/images/v1/profile")] [HttpPost("api/images/v2/profile")] [HttpPost("api/images/v3/profile")] [HttpPost("api/images/v4/profile")] [HttpPost("api/images/v5/profile")] [Authorize] public async Task UploadProfile() { var pid = this.RequireCurrentPlayerId(); var bytes = await ReadImageBytesAsync(); if (bytes is null || bytes.Length == 0) { logger.LogWarning("[images] profile upload by {Pid}: no file bytes received", pid); return BadRequest(new { error = "missing_file" }); } var form = Request.HasFormContentType ? await Request.ReadFormAsync() : null; LogUploadForm("profile", pid, form, isProfile: true); var extension = SniffExtension(bytes); var blobName = $"img_p{pid}_{Guid.NewGuid():N}.{extension}"; var contentType = extension == "png" ? "image/png" : "image/jpeg"; try { using var s3Timeout = new CancellationTokenSource(TimeSpan.FromSeconds(4)); var (bucket, key) = BlobRouter.Route(blobName); await storage.PutAsync(bucket, key, bytes, contentType, s3Timeout.Token); } catch (Exception ex) { logger.LogError( ex, "[images] profile S3 upload failed for {Blob}; keeping DB fallback so the client does not fail", blobName); } db.RoomDataBlobs.Add(new RoomDataBlobEntity { RoomId = 0, BlobName = blobName, UploadedByPlayerId = pid, UploadedAt = DateTime.UtcNow, ReferencedFilenamesCsv = string.Empty, }); var photo = new PhotoEntity { UploaderPlayerId = pid, BlobName = blobName, Caption = string.Empty, TaggedPlayerIdsCsv = string.Empty, RoomId = presence.GetRoom(pid)?.RoomId ?? 0, IsPublic = false, }; db.Photos.Add(photo); var player = await db.Players.FirstOrDefaultAsync(p => p.Id == pid); if (player is not null) player.ProfileImageName = blobName; await db.SaveChangesAsync(); if (player is not null) await NotifyAccountImageChangedAsync(player); logger.LogInformation( "[images] uploaded profile photo {Id} by {Pid} ({Bytes} bytes) -> {Blob}", photo.Id, pid, bytes.Length, blobName); return Ok(new { ImageName = blobName, AccountId = (int)pid, CreatedAt = photo.CreatedAt, }); } /// GET api/images/v{2-5}/saved — list the caller's saved /// photos. The watch's "My Photos" tab calls this to populate the /// album view. [HttpGet("api/images/v2/saved")] [HttpGet("api/images/v3/saved")] [HttpGet("api/images/v4/saved")] [HttpGet("api/images/v5/saved")] [Authorize] public async Task GetSaved() { var pid = this.RequireCurrentPlayerId(); var rows = await db.Photos .Where(p => p.UploaderPlayerId == pid && p.DeletedAt == null) .OrderByDescending(p => p.CreatedAt) .Take(200) .ToListAsync(); return Ok(rows.Select(p => BuildImageInfo(p, pid))); } /// GET api/images/v2/named — the watch's /// Images.DownloadNamedImageMappings endpoint. Returns a /// list of NamedImageDTO entries; an empty list is a valid /// response (the watch's ExpectListResponse rejects an /// object body but tolerates an empty array). [HttpGet("api/images/v2/named")] public IActionResult NamedImages() => Ok(Array.Empty()); /// GET api/images/v1/slideshow — the watch's lobby /// slideshow info. Response shape per SlideshowInfoDTO.Deserialize: /// {ValidTill (DateTime, required), Images (list, required)}. /// Far-future ValidTill stops the watch from re-polling. [HttpGet("api/images/v1/slideshow")] public IActionResult SlideshowInfo() => Ok(new { // WireDates.FarFuture (2099), NOT year 9999 — a 9999 ValidTill // overflows the watch's local-timezone date parse in some DST // timezones → "malformed RecNet response". See WireDates. ValidTill = WireDates.FarFuture, Images = Array.Empty(), }); /// Binary image fallback for clients that request image bytes /// through api/images/{name} instead of img.rec.net/{name}. Specific JSON /// endpoints above still win route matching; this only handles CDN-shaped /// image paths. [HttpGet("api/images/{*path}", Order = 100)] public async Task ServeImageFromApiHost(string? path) { var fileName = Path.GetFileName(path ?? string.Empty); // Strip the leading '$' sigil that ImageData.image_name uses for // hash-addressed assets. Watch sends "%24.jpg"; route // decode gives us "$.jpg"; the bytes are stored at // ".jpg". Without the strip, placed in-world polaroids // miss in S3 and the watch renders the "?" fallback. if (!string.IsNullOrEmpty(fileName) && fileName[0] == '$') fileName = fileName[1..]; if (string.IsNullOrWhiteSpace(fileName) || !IsImageName(fileName)) return NotFound(); var result = await LoadImageBytesAsync(fileName); if (result.Bytes is { Length: > 0 }) { logger.LogInformation( "[images] api image hit host={Host} path={Path} file={File} source={Source} bytes={Bytes} query='{Query}'", Request.Host.Host, path, fileName, result.Source, result.Bytes.Length, Request.QueryString); Response.Headers.CacheControl = "public, max-age=300"; signatures.AddContentSignature(Response, result.Bytes); return File(result.Bytes, MimeFromName(fileName)); } logger.LogWarning( "[images] api image MISS host={Host} path={Path} file={File} query='{Query}' -> transparent png", Request.Host.Host, path, fileName, Request.QueryString); Response.Headers.CacheControl = "public, max-age=60"; signatures.AddContentSignature(Response, TransparentPng); return File(TransparentPng, "image/png"); } /// GET api/images/v{2-5}/byaccount/{accountId} — list a /// player's PUBLIC photos. Drives the "their album" tab in /// the watch's player profile. [HttpGet("api/images/v2/byaccount/{accountId:long}")] [HttpGet("api/images/v3/byaccount/{accountId:long}")] [HttpGet("api/images/v4/byaccount/{accountId:long}")] [HttpGet("api/images/v5/byaccount/{accountId:long}")] public async Task GetByAccount(long accountId, [FromQuery] int take = 50, [FromQuery] int skip = 0) { take = Math.Clamp(take, 1, 200); skip = Math.Max(0, skip); var rows = await db.Photos .Where(p => p.UploaderPlayerId == accountId && p.IsPublic && p.DeletedAt == null) .OrderByDescending(p => p.CreatedAt) .Skip(skip).Take(take) .ToListAsync(); return Ok(rows.Select(p => BuildImageInfo(p, accountId))); } /// POST api/images/v{2-5}/share/{id} — flip a saved photo /// to public so it shows up on feed.rec.net. The watch's "Share" /// button hits this; if the watch posts to a different URL we'll /// add it when we see it in logs. [HttpPost("api/images/v2/share/{id:long}")] [HttpPost("api/images/v3/share/{id:long}")] [HttpPost("api/images/v4/share/{id:long}")] [HttpPost("api/images/v5/share/{id:long}")] [Authorize] public async Task Share(long id) { var pid = this.RequireCurrentPlayerId(); var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == id); if (photo is null) return NotFound(); if (photo.UploaderPlayerId != pid) return Forbid(); photo.IsPublic = true; await db.SaveChangesAsync(); return Ok(BuildImageInfo(photo, pid)); } /// DELETE api/images/v{2-5}/{id} — uploader (or admin) /// soft-deletes a saved photo. [HttpDelete("api/images/v2/{id:long}")] [HttpDelete("api/images/v3/{id:long}")] [HttpDelete("api/images/v4/{id:long}")] [HttpDelete("api/images/v5/{id:long}")] [Authorize] public async Task Delete(long id) { var pid = this.RequireCurrentPlayerId(); var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == id); if (photo is null) return NotFound(); if (photo.UploaderPlayerId != pid) { var isAdmin = await db.Players.Where(x => x.Id == pid).Select(x => x.IsAdmin).FirstOrDefaultAsync(); if (!isAdmin) return Forbid(); } photo.DeletedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return Ok(new { deleted = id }); } // ── Legacy v1 ops (real persistence, was Ack-only stubs) ───────── public sealed class CheerImageRequest { public long ImageId { get; set; } public long PhotoId { get; set; } } /// POST api/images/v1/cheer — cheer a photo. /// Was previously Ack-only; now persists a CheerEntity row /// keyed on TargetPhotoId + bumps Photos.CheerCount. /// Idempotent per (player, photo). [HttpPost("api/images/v1/cheer")] [Authorize] public async Task CheerV1([FromBody] CheerImageRequest? body, [FromForm(Name = "ImageId")] long? imageIdForm, [FromForm(Name = "PhotoId")] long? photoIdForm) { var pid = this.RequireCurrentPlayerId(); var photoId = body?.PhotoId ?? body?.ImageId ?? photoIdForm ?? imageIdForm ?? 0; if (photoId <= 0) return BadRequest(new { error = "missing photoId" }); var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == photoId && p.DeletedAt == null); if (photo is null) return NotFound(); var existing = await db.Cheers.FirstOrDefaultAsync(c => c.FromPlayerId == pid && c.TargetPhotoId == photoId); if (existing is null) { db.Cheers.Add(new CheerEntity { FromPlayerId = pid, TargetPhotoId = photoId, CheeredAt = DateTime.UtcNow, }); photo.CheerCount += 1; await db.SaveChangesAsync(); } return Ok(new { photo.Id, photo.CheerCount }); } /// GET api/images/v1/listsaved — the 2020 client /// deserializes this as SavedImagesListDTO { Images: string[] }. /// Do not return the richer v2/v4 photo-object list here. [HttpGet("api/images/v1/listsaved")] [Authorize] public async Task ListSavedV1() { var pid = this.RequireCurrentPlayerId(); var names = await db.Photos .Where(p => p.UploaderPlayerId == pid && p.DeletedAt == null) .OrderByDescending(p => p.CreatedAt) .Select(p => p.BlobName) .Take(200) .ToListAsync(); return Ok(new { Images = names }); } public sealed class ModifyAccessibilityRequest { public long PhotoId { get; set; } public long ImageId { get; set; } public string? ImageName { get; set; } public bool IsPublic { get; set; } } /// POST api/images/v1/modifyaccessibility — flip /// the IsPublic flag on a saved photo. Was Ack-only; now writes /// the column. [HttpPost("api/images/v1/modifyaccessibility")] [Authorize] public async Task ModifyAccessibility( [FromBody] ModifyAccessibilityRequest? body, [FromForm(Name = "PhotoId")] long? photoIdForm, [FromForm(Name = "ImageId")] long? imageIdForm, [FromForm(Name = "ImageName")] string? imageNameForm, [FromForm(Name = "IsPublic")] bool? isPublicForm) { var pid = this.RequireCurrentPlayerId(); var photoId = body?.PhotoId ?? body?.ImageId ?? photoIdForm ?? imageIdForm ?? 0; var imageName = body?.ImageName ?? imageNameForm; if (photoId <= 0 && string.IsNullOrWhiteSpace(imageName)) return BadRequest(new { error = "missing photoId" }); var isPublic = body?.IsPublic ?? isPublicForm ?? true; var photo = photoId > 0 ? await db.Photos.FirstOrDefaultAsync(p => p.Id == photoId) : await db.Photos.FirstOrDefaultAsync(p => p.BlobName == imageName); if (photo is null) return NotFound(); if (photo.UploaderPlayerId != pid) return Forbid(); photo.IsPublic = isPublic; await db.SaveChangesAsync(); return Ok(new { photo.Id, photo.IsPublic }); } public sealed class DeleteSavedRequest { public long PhotoId { get; set; } public long ImageId { get; set; } public string? ImageName { get; set; } } /// POST api/images/v1/deletesaved + DELETE alias — /// soft-delete a saved photo. Was Ack-only; now flips /// DeletedAt. [HttpPost("api/images/v1/deletesaved")] [HttpDelete("api/images/v1/deletesaved/{id:long}")] [Authorize] public async Task DeleteSavedV1( long? id, [FromBody] DeleteSavedRequest? body, [FromForm(Name = "PhotoId")] long? photoIdForm, [FromForm(Name = "ImageId")] long? imageIdForm, [FromForm(Name = "ImageName")] string? imageNameForm) { var photoId = id ?? body?.PhotoId ?? body?.ImageId ?? photoIdForm ?? imageIdForm ?? 0; var imageName = body?.ImageName ?? imageNameForm; if (photoId <= 0 && string.IsNullOrWhiteSpace(imageName)) return BadRequest(new { error = "missing photoId" }); if (photoId <= 0) { var pid = this.RequireCurrentPlayerId(); var photo = await db.Photos.FirstOrDefaultAsync(p => p.UploaderPlayerId == pid && p.BlobName == imageName && p.DeletedAt == null); if (photo is null) return NotFound(); photo.DeletedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return Ok(new RecNetResult { Success = true, Error = string.Empty }); } return await Delete(photoId); } public sealed class SendLinkRequest { public long PhotoId { get; set; } public long ImageId { get; set; } public List? RecipientIds { get; set; } } /// POST api/images/v1/sendlink — send a link to a /// photo as a DM to one or more recipients. Was Ack-only; now /// inserts one per recipient. [HttpPost("api/images/v1/sendlink")] [Authorize] public async Task SendLink( [FromBody] SendLinkRequest? body, [FromForm(Name = "PhotoId")] long? photoIdForm, [FromForm(Name = "ImageId")] long? imageIdForm, [FromForm(Name = "RecipientIds")] string? recipientIdsForm) { var pid = this.RequireCurrentPlayerId(); var photoId = body?.PhotoId ?? body?.ImageId ?? photoIdForm ?? imageIdForm ?? 0; if (photoId <= 0) return BadRequest(new { error = "missing photoId" }); var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == photoId); if (photo is null) return NotFound(); var recipients = body?.RecipientIds ?? ParseIdList(recipientIdsForm) ?? new(); if (recipients.Count == 0) return Ok(new { Sent = 0 }); var preview = $"shared a photo (id {photoId})"; foreach (var rid in recipients.Distinct()) { db.Messages.Add(new MessageEntity { SenderPlayerId = pid, RecipientPlayerId = rid, Body = $"{preview} → /img/{photo.BlobName}", }); } await db.SaveChangesAsync(); return Ok(new { Sent = recipients.Count }); } private static List? ParseIdList(string? csv) { if (string.IsNullOrWhiteSpace(csv)) return null; return csv.Split(',', StringSplitOptions.RemoveEmptyEntries) .Select(s => long.TryParse(s.Trim(), out var v) ? v : 0L) .Where(v => v > 0) .ToList(); } // ── Helpers ────────────────────────────────────────────────────── /// Read raw image bytes from the request — supports both /// multipart/form-data (the typical case, with field "File" or /// "Image") and a raw octet-stream body (some 2020 client paths). private async Task ReadImageBytesAsync() { if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); // Try common field names; fall back to the first uploaded file. var file = form.Files["File"] ?? form.Files["Image"] ?? form.Files["image"] ?? form.Files["file"] ?? form.Files.FirstOrDefault(); if (file is null || file.Length == 0) return null; using var ms = new MemoryStream(); await file.CopyToAsync(ms); return ms.ToArray(); } // Raw body fallback — when the watch posts the bytes directly. if (Request.ContentLength is > 0) { using var ms = new MemoryStream(); await Request.Body.CopyToAsync(ms); return ms.ToArray(); } return null; } /// Detect PNG vs JPEG by magic bytes — anything else /// defaults to .png since the 2020 client's camera always saves /// PNG. private static string SniffExtension(byte[] bytes) { if (bytes.Length >= 4 && bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47) return "png"; if (bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF) return "jpg"; return "png"; } private async Task<(byte[]? Bytes, string Source)> LoadImageBytesAsync(string fileName) { var (bucket, key) = BlobRouter.Route(fileName); try { using var s3Timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2)); var s3Bytes = await storage.GetAsync(bucket, key, s3Timeout.Token); if (s3Bytes is { Length: > 0 }) return (s3Bytes, $"s3:{bucket}/{key}"); } catch (Exception ex) { logger.LogWarning(ex, "[images] API image S3 lookup threw for {File} bucket={Bucket} key={Key}", fileName, bucket, key); } // S3 is the only canonical store. Disk-on-server is a legacy // surface used by tools/fetch-room-images.py'd thumbnails that // ship with the server — keep that as a last resort, but DB // bytes are gone. var full = Path.Combine(ImageDir, fileName); if (System.IO.File.Exists(full)) return (await System.IO.File.ReadAllBytesAsync(full), "disk"); return (null, "miss"); } private static bool TryGetPlayerId(string fileName, string prefix, out long playerId) { playerId = 0; if (!fileName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return false; var start = prefix.Length; var end = fileName.IndexOf('_', start); return end > start && long.TryParse(fileName[start..end], out playerId); } private static bool IsImageName(string name) { var ext = Path.GetExtension(name).ToLowerInvariant(); return ext is ".png" or ".jpg" or ".jpeg" or ".gif" or ".webp" or ".avif"; } 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", ".avif" => "image/avif", _ => "image/png", }; } private void LogUploadForm(string endpoint, long playerId, IFormCollection? form, bool isProfile) { if (form is null) { logger.LogInformation( "[images] {Endpoint} metadata player={PlayerId} form=false profile={Profile}", endpoint, playerId, isProfile); return; } var fileNames = string.Join( ",", form.Files.Select(f => $"{f.Name}:{f.FileName}:{f.Length}")); var fieldNames = string.Join(",", form.Keys); var meta = form["imgMeta"].ToString(); if (string.IsNullOrWhiteSpace(meta)) meta = form["imageMeta"].ToString(); if (meta.Length > 512) meta = meta[..512]; logger.LogInformation( "[images] {Endpoint} metadata player={PlayerId} files={Files} fields={Fields} profile={Profile} imgMeta={ImgMeta}", endpoint, playerId, fileNames, fieldNames, isProfile, meta); } private static bool IsProfileThumbnail(IFormCollection? form) { if (form is null) return false; static bool IsProfileValue(string? value) { if (string.IsNullOrWhiteSpace(value)) return false; return value.Trim().Equals("4", StringComparison.OrdinalIgnoreCase) || value.Contains("ProfileThumbnail", StringComparison.OrdinalIgnoreCase); } if (IsProfileValue(form["SavedImageType"].ToString()) || IsProfileValue(form["savedImageType"].ToString()) || IsProfileValue(form["ImageType"].ToString()) || IsProfileValue(form["imageType"].ToString())) return true; var meta = form["imgMeta"].ToString(); if (string.IsNullOrWhiteSpace(meta)) meta = form["imageMeta"].ToString(); if (string.IsNullOrWhiteSpace(meta)) return false; if (IsProfileValue(meta)) return true; try { using var doc = JsonDocument.Parse(meta); if (doc.RootElement.ValueKind != JsonValueKind.Object) return false; foreach (var name in new[] { "savedImageType", "SavedImageType", "imageType", "ImageType" }) { if (!doc.RootElement.TryGetProperty(name, out var value)) continue; if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var n) && n == 4) return true; if (value.ValueKind == JsonValueKind.String && IsProfileValue(value.GetString())) return true; } } catch (JsonException) { } return false; } /// Build the response shape every images/v* endpoint /// returns. PascalCase keys + redundant aliases so whichever JSON /// parser the 2020 watch uses, it finds the field it wants. The /// most important keys are AccountId, ImageName, CreatedAt — those /// are what the watch's strict deserialiser reads. Url /// is built against the configured deployment apex (DORKNET_DOMAIN) /// so every callsite for the URL string flows through one source /// of truth. private object BuildImageInfo(PhotoEntity p, long accountId) { var cdnHost = domain.Sub("cdn"); return new { // Primary keys the 2020 watch's ImageInfo deserialiser reads. ImageName = p.BlobName, AccountId = (int)accountId, ImageType = 0, CreatedAt = p.CreatedAt, // Bonus fields — useful for other clients (and our own feed UI). Id = p.Id, Filename = p.BlobName, Url = $"https://{cdnHost}/{p.BlobName}", Caption = p.Caption, Description = p.Caption, RoomId = p.RoomId, IsPublic = p.IsPublic, CheerCount = p.CheerCount, ViewCount = p.ViewCount, TaggedPlayerIds = ParseTagged(p.TaggedPlayerIdsCsv).ToArray(), }; } private static IEnumerable ParseTagged(string? csv) => string.IsNullOrWhiteSpace(csv) ? Enumerable.Empty() : csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(s => long.TryParse(s, out var v) ? v : 0L) .Where(v => v > 0); private Task NotifyAccountImageChangedAsync(PlayerEntity player) { var account = new DorkNet.Models.Auth.RecNetAccount { AccountId = (int)player.Id, RawUsername = player.Username, Username = player.Username, DisplayName = player.DisplayName ?? player.Username, ProfileImage = player.ProfileImageName ?? string.Empty, TreatAsJunior = player.IsJunior, HasBirthday = true, Platforms = 1, }; var selfAccount = new DorkNet.Models.Auth.RecNetSelfAccount { AccountId = account.AccountId, RawUsername = account.RawUsername, Username = account.Username, DisplayName = account.DisplayName, ProfileImage = account.ProfileImage, TreatAsJunior = account.TreatAsJunior, HasBirthday = account.HasBirthday, Platforms = account.Platforms, Email = player.Email ?? string.Empty, Phone = player.Phone ?? string.Empty, Birthday = player.Birthday, JuniorState = player.IsJunior ? 1 : 0, ParentAccountId = null, }; return Task.WhenAll( notifications.NotifyTypedAsync(player.Id, "AccountUpdate", account), notifications.NotifyTypedAsync(player.Id, "SelfAccountUpdate", selfAccount)); } private static string NormaliseTaggedIds(string? csv, long exceptId) { if (string.IsNullOrWhiteSpace(csv)) return string.Empty; var ids = csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(s => long.TryParse(s, out var v) ? v : 0L) .Where(v => v > 0 && v != exceptId) .Distinct() .ToList(); return string.Join(",", ids); } }