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. The 2020
/// client sends camera share state inside an imgMeta JSON form field
/// (accessibility=1 for public ShareCamera uploads), while newer callers
/// can still send top-level IsPublic.
///
[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")]
[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 imageMeta = ParseImageMeta(form);
var caption = form?["Caption"].ToString() ?? form?["Description"].ToString() ?? string.Empty;
var taggedIds = form?["TaggedPlayerIds"].ToString() ?? string.Empty;
if (string.IsNullOrWhiteSpace(taggedIds) && imageMeta?.PlayerIds.Count > 0)
taggedIds = string.Join(",", imageMeta.PlayerIds);
long roomId = 0;
if (form is not null && long.TryParse(form["RoomId"].ToString(), out var rid)) roomId = rid;
if (roomId == 0 && imageMeta?.RoomId is > 0) roomId = imageMeta.RoomId.Value;
if (roomId == 0) roomId = presence.GetRoom(pid)?.RoomId ?? 0;
var explicitPublic = form is null ? null : TryReadBool(form["IsPublic"].ToString());
var isPublic = explicitPublic ?? (imageMeta?.Accessibility == 1 || imageMeta?.SavedImageType == 1);
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)));
}
public sealed class ImageBulkRequest
{
public List? ImageIds { get; set; }
public List? SavedImageIds { get; set; }
}
[HttpGet("api/images/v5/bulk")]
[HttpPost("api/images/v5/bulk")]
[Authorize]
public async Task BulkImages([FromBody] ImageBulkRequest? body)
{
var pid = this.RequireCurrentPlayerId();
var ids = (body?.ImageIds ?? body?.SavedImageIds ?? new List())
.Concat(Request.Query.SelectMany(q => q.Value)
.SelectMany(v => (v ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Select(v => long.TryParse(v, out var id) ? id : 0))
.Where(id => id > 0)
.Distinct()
.Take(200)
.ToList();
if (ids.Count == 0) return Ok(Array.Empty());
var rows = await db.Photos
.Where(p => ids.Contains(p.Id) && p.DeletedAt == null
&& (p.IsPublic || p.UploaderPlayerId == pid))
.ToListAsync();
return Ok(rows.Select(p => BuildImageInfo(p, p.UploaderPlayerId)));
}
/// GET api/images/v6?name={blobName} — single-image
/// lookup by name. Client contract (RecNet.Runtime
/// KLJOGJHBONK.INHMKKAJJKO(string) ) returns ONE
/// ICOFKEGOGOD ImageDTO, not a list; a bare array crashes its
/// reader. Not found (or private + not the owner) → 404 so the promise
/// rejects cleanly rather than delivering a wrong-shaped body.
[HttpGet("api/images/v6")]
[AllowAnonymous]
public async Task ImagesV6([FromQuery] string? name)
{
if (string.IsNullOrWhiteSpace(name)) return NotFound();
var pid = this.CurrentPlayerId();
var photo = await db.Photos.FirstOrDefaultAsync(p =>
p.BlobName == name && p.DeletedAt == null &&
(p.IsPublic || (pid != null && p.UploaderPlayerId == pid.Value)));
if (photo is null) return NotFound();
return Ok(BuildImageInfo(photo, photo.UploaderPlayerId));
}
/// GET api/images/v6/{id} — single photo by numeric id.
/// The 2023-03-21 client builds the path with
/// String.Format("{0}v6/{1}", "api/images/", id)
/// (KLJOGJHBONK.txt:2694-2698 ) and passes verb 0 = GET into the
/// request-builder ctor (:2702 Move rdx, 0 → :2708 Call
/// 0x1830036A0 ); no query fields are attached.
///
/// The issuing method KLJOGJHBONK.HLANOFILAEO(System.Int64) returns
/// FGLDKEJLAKB<IReadOnlyList<Int32>> , but that is a
/// CLIENT-SIDE projection: a
/// Func<LGLCPNPJCEC, IReadOnlyList<Int32>>
/// (:2756 ) reduces the wire object to its tagged-player ids. So the
/// body must be ONE LGLCPNPJCEC object — an array breaks the reader —
/// with the 12 keys its generated serializer registers
/// (IBILPLGNAJE.txt:819-1078 : Id, ImageName, PlayerId, RoomId,
/// PlayerEventId, Accessibility, AccessibilityLocked, Type, CreatedAt,
/// TaggedPlayerIds, CheerCount, CommentCount), all of which
/// already emits.
///
/// Without this route the request fell through to
/// api/images/{*path} , which rejects "123" as a non-image filename →
/// 404 on every photo-detail open. Visibility matches the by-name lookup:
/// public photos to anyone, private ones only to their uploader.
[HttpGet("api/images/v6/{id:long}")]
[AllowAnonymous]
public async Task ImageByIdV6(long id)
{
var pid = this.CurrentPlayerId();
var photo = await db.Photos.FirstOrDefaultAsync(p =>
p.Id == id && p.DeletedAt == null &&
(p.IsPublic || (pid != null && p.UploaderPlayerId == pid.Value)));
if (photo is null) return NotFound();
return Ok(BuildImageInfo(photo, photo.UploaderPlayerId));
}
/// 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)} .
/// A short ValidTill lets the Rec Center board pick up newly
/// shared camera photos without needing a server restart.
[HttpGet("api/images/v1/slideshow")]
public async Task SlideshowInfo()
{
var rows = await db.Photos
.Where(p => p.IsPublic && p.DeletedAt == null)
.OrderByDescending(p => p.CreatedAt)
.Take(50)
.ToListAsync();
return Ok(new
{
ValidTill = DateTime.UtcNow.AddMinutes(5),
Images = rows.Select(p => BuildImageInfo(p, p.UploaderPlayerId)),
});
}
/// 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}")]
[HttpGet("api/images/v2/player/{accountId:long}")]
[HttpGet("api/images/v3/player/{accountId:long}")]
[HttpGet("api/images/v4/player/{accountId:long}")]
[HttpGet("api/images/v5/player/{accountId:long}")]
public async Task GetByAccount(long accountId,
[FromQuery] int take = 50, [FromQuery] int skip = 0, [FromQuery] string? sort = null)
{
take = Math.Clamp(take, 1, 200);
skip = Math.Max(0, skip);
var query = db.Photos
.Where(p => p.UploaderPlayerId == accountId && p.IsPublic && p.DeletedAt == null)
.AsQueryable();
query = ParseSort(sort) switch
{
1 => query.OrderByDescending(p => p.CheerCount).ThenByDescending(p => p.CreatedAt),
2 => query.OrderByDescending(p => p.ViewCount).ThenByDescending(p => p.CreatedAt),
_ => query.OrderByDescending(p => p.CreatedAt),
};
var rows = await query
.Skip(skip)
.Take(take)
.ToListAsync();
return Ok(rows.Select(p => BuildImageInfo(p, accountId)));
}
/// GET api/images/v{2-5}/room/{roomId} — a room's public
/// photo gallery. The 2023 client sends sort /filter as
/// ENUM NAMES (?sort=CheerCount_Desc&filter=PublicOnly ),
/// not ints — binding them as int makes [ApiController]
/// auto-400 and the watch toasts "Could not show images for room".
/// filter is accepted but unused: we only store public,
/// non-deleted photos for this view anyway (PublicOnly semantics).
[HttpGet("api/images/v2/room/{roomId:long}")]
[HttpGet("api/images/v3/room/{roomId:long}")]
[HttpGet("api/images/v4/room/{roomId:long}")]
[HttpGet("api/images/v5/room/{roomId:long}")]
public async Task GetByRoom(
long roomId,
[FromQuery] int take = 50,
[FromQuery] int skip = 0,
[FromQuery] string? sort = null,
[FromQuery] string? filter = null)
{
take = Math.Clamp(take, 1, 200);
skip = Math.Max(0, skip);
_ = filter;
var query = db.Photos
.Where(p => p.RoomId == roomId && p.IsPublic && p.DeletedAt == null)
.AsQueryable();
query = ParseSort(sort) switch
{
1 => query.OrderByDescending(p => p.CheerCount).ThenByDescending(p => p.CreatedAt),
2 => query.OrderByDescending(p => p.ViewCount).ThenByDescending(p => p.CreatedAt),
_ => query.OrderByDescending(p => p.CreatedAt),
};
var rows = await query
.Skip(skip)
.Take(take)
.ToListAsync();
return Ok(rows.Select(p => BuildImageInfo(p, p.UploaderPlayerId)));
}
/// Sort selector shared by the album views. Accepts the
/// 2020 client's ints (0/1/2) and the 2023 client's enum names —
/// the names contain the metric ("CheerCount_Desc", "ViewCount_…");
/// anything unrecognised falls back to newest-first.
private static int ParseSort(string? sort)
{
if (string.IsNullOrWhiteSpace(sort)) return 0;
if (int.TryParse(sort, out var n)) return n;
if (sort.Contains("Cheer", StringComparison.OrdinalIgnoreCase)) return 1;
if (sort.Contains("View", StringComparison.OrdinalIgnoreCase)) return 2;
return 0;
}
/// 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 });
}
/// POST api/images/v1/{id}/report — file a moderation
/// report against a photo from the in-game photo viewer.
/// KLJOGJHBONK.IDAJEMBKAGF(System.Int64) formats the path as
/// String.Format("{0}v1/{1}/report", "api/images/", id)
/// (KLJOGJHBONK.txt:4010-4014 ) and moves verb 2 = POST into the
/// builder ctor (:4021 Move rdx, 2 , host 1 at :4020 ).
///
/// The request carries NO fields and NO body — no category, no free text —
/// and the reply is dispatched through BNDIAONDFFF.KDOPJCNKOOK
/// (:4031 ), which never deserialises it: the call is
/// fire-and-forget, so the client cannot tell a 404 from a 200 and every
/// report was being dropped silently. Everything actionable therefore has
/// to be derived server-side from the photo id, which is why the reported
/// photo's uploader becomes the report target and the photo's room the
/// context. Category 5 = "Other", matching the screenshare reporter's
/// default for reports that arrive without one.
[HttpPost("api/images/v1/{id:long}/report")]
[Authorize]
public async Task ReportPhoto(long id)
{
var reporter = this.RequireCurrentPlayerId();
var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == id && p.DeletedAt == null);
if (photo is null) return NotFound();
// ReportEntity has no TargetPhotoId column, so the photo id + blob name
// ride in the message text the same way club reports carry "[club {id}]"
// — a moderator needs the actual image to act on the report.
var message = $"[photo {photo.Id} {photo.BlobName}] reported from the in-game photo viewer";
db.Reports.Add(new ReportEntity
{
ReporterPlayerId = reporter,
TargetPlayerId = photo.UploaderPlayerId,
RoomId = photo.RoomId,
Category = 5,
Message = message[..Math.Min(1000, message.Length)],
});
await db.SaveChangesAsync();
logger.LogInformation(
"[images] photo {PhotoId} reported by {Reporter} (uploader {Uploader}, room {RoomId})",
photo.Id, reporter, photo.UploaderPlayerId, photo.RoomId);
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
// ── Legacy v1 ops (real persistence, was Ack-only stubs) ─────────
public sealed class CheerImageRequest
{
public long ImageId { get; set; }
public long PhotoId { get; set; }
}
/// Field names the cheer-state batch accepts. The 2023-03-21
/// client sends the singular "id"
/// (KLJOGJHBONK.txt:3763 Move rdx, "id" ); the older spellings are
/// kept so the 2020.12 watch and our own tooling keep working.
private static readonly string[] CheerBulkIdKeys =
{ "id", "ids", "SavedImageId", "SavedImageIds", "ImageId", "ImageIds" };
/// GET — and POST once the id list reaches 100 — for
/// api/images/v{4,5}/cheered/bulk : the caller's cheer state for a
/// batch of photos (the hearts on the photo feed).
///
/// VERB: the client picks it at runtime.
/// KLJOGJHBONK.JCLGICHPPGB(IEnumerable<Int64>) calls
/// ALHIJCJOLCB.JIECAFGCODK(count, 100) and moves the result straight
/// into the request-builder's verb register
/// (KLJOGJHBONK.txt:3747 Call ALHIJCJOLCB.JIECAFGCODK →
/// :3755 Move rdx, rdi → :3753 Move r9,
/// "api/images/v5/cheered/bulk" ) — GET (0) for short lists, POST (2) for
/// long ones. Registering POST only meant the common case fell into the
/// api/images/{*path} byte catch-all and 404'd, so cheer state never
/// loaded.
///
/// REQUEST: one field, "id" , added via
/// BNDIAONDFFF.AFGEDDANEKP("id", ids) (KLJOGJHBONK.txt:3763 ).
/// The builder emits fields as query values on GET and form values on POST —
/// never as a JSON body — so the ids are read straight off the request
/// instead of through [FromBody] , which would 415 the form POST
/// before the action ran.
///
/// RESPONSE: List<LAKGLIDCEDE> , i.e.
/// [{"SavedImageId":Int64,"IsCheered":Boolean}] — key literals in the
/// generated serializer at KJEIGGBIIHP.txt:215,242 . One entry per
/// requested id so the client's cache has no holes.
[HttpGet("api/images/v4/cheered/bulk")]
[HttpGet("api/images/v5/cheered/bulk")]
[HttpPost("api/images/v4/cheered/bulk")]
[HttpPost("api/images/v5/cheered/bulk")]
[Authorize]
public async Task CheeredBulk()
{
var pid = this.RequireCurrentPlayerId();
var ids = await ReadCheerBulkIdsAsync();
if (ids.Count == 0) return Ok(Array.Empty());
var cheered = await db.Cheers
.Where(c => c.FromPlayerId == pid && ids.Contains(c.TargetPhotoId))
.Select(c => c.TargetPhotoId)
.ToListAsync();
var set = cheered.ToHashSet();
return Ok(ids.Select(id => new
{
SavedImageId = id,
IsCheered = set.Contains(id),
}));
}
/// Collect the cheer-state batch ids from wherever this client
/// put them: query values on the GET path, form values on the POST path
/// (both produced by BNDIAONDFFF 's field list), or a JSON body for
/// non-game callers. Repeated fields and comma-joined values are both
/// accepted because the builder's query assembler emits one
/// &id= per element while our own admin tooling sends a single
/// comma-separated value.
private async Task> ReadCheerBulkIdsAsync()
{
var ids = new List();
void Collect(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return;
foreach (var part in raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
if (long.TryParse(part, out var id) && id > 0) ids.Add(id);
}
foreach (var key in CheerBulkIdKeys)
foreach (var value in Request.Query[key])
Collect(value);
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var key in CheerBulkIdKeys)
foreach (var value in form[key])
Collect(value);
}
else if (Request.ContentLength is > 0)
{
try
{
Request.EnableBuffering();
Request.Body.Position = 0;
using var doc = await JsonDocument.ParseAsync(Request.Body, cancellationToken: HttpContext.RequestAborted);
Request.Body.Position = 0;
CollectJsonIds(doc.RootElement, Collect);
}
catch (JsonException)
{
// Not JSON — the query/form pass above is authoritative.
}
}
return ids.Distinct().Take(500).ToList();
}
/// Pull ids out of a JSON body that is either a bare array of
/// numbers or an object keyed by one of .
private static void CollectJsonIds(JsonElement root, Action collect)
{
switch (root.ValueKind)
{
case JsonValueKind.Array:
foreach (var item in root.EnumerateArray()) CollectJsonIds(item, collect);
break;
case JsonValueKind.Object:
foreach (var key in CheerBulkIdKeys)
{
foreach (var prop in root.EnumerateObject())
{
if (!string.Equals(prop.Name, key, StringComparison.OrdinalIgnoreCase)) continue;
CollectJsonIds(prop.Value, collect);
}
}
break;
case JsonValueKind.Number:
collect(root.GetRawText());
break;
case JsonValueKind.String:
collect(root.GetString());
break;
}
}
/// 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; }
/// What the 2023-03-21 client actually sends:
/// 0 = Private, 1 = Public, 2 = FriendsOnly. It never sends
/// , so reading only that flag left every photo
/// private no matter what the player picked.
public int? Accessibility { get; set; }
/// Resolved visibility: the enum when present, else the
/// legacy boolean.
public bool ResolvedIsPublic => Accessibility is int a ? a == 1 : IsPublic;
}
/// 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,
[FromForm(Name = "Accessibility")] int? accessibilityForm)
{
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" });
// Accessibility (0=Private, 1=Public, 2=FriendsOnly) is what the 2023
// client sends; IsPublic is the older boolean.
var accessibility = body?.Accessibility ?? accessibilityForm;
var isPublic = accessibility is int a
? a == 1
: 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;
}
private sealed class ParsedImageMeta
{
public int? SavedImageType { get; init; }
public int? Accessibility { get; init; }
public long? RoomId { get; init; }
public IReadOnlyList PlayerIds { get; init; } = Array.Empty();
}
private static ParsedImageMeta? ParseImageMeta(IFormCollection? form)
{
if (form is null) return null;
var meta = form["imgMeta"].ToString();
if (string.IsNullOrWhiteSpace(meta))
meta = form["imageMeta"].ToString();
if (string.IsNullOrWhiteSpace(meta))
return null;
try
{
using var doc = JsonDocument.Parse(meta);
if (doc.RootElement.ValueKind != JsonValueKind.Object)
return null;
return new ParsedImageMeta
{
SavedImageType = TryGetInt(doc.RootElement, "savedImageType", "SavedImageType", "imageType", "ImageType"),
Accessibility = TryGetInt(doc.RootElement, "accessibility", "Accessibility"),
RoomId = TryGetLong(doc.RootElement, "roomId", "RoomId"),
PlayerIds = TryGetLongArray(doc.RootElement, "playerIds", "PlayerIds"),
};
}
catch (JsonException)
{
return null;
}
}
private static bool? TryReadBool(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
if (bool.TryParse(value, out var parsed)) return parsed;
if (value == "1") return true;
if (value == "0") return false;
return null;
}
private static int? TryGetInt(JsonElement root, params string[] names)
{
foreach (var name in names)
{
if (!root.TryGetProperty(name, out var value))
continue;
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var n))
return n;
if (value.ValueKind == JsonValueKind.String && int.TryParse(value.GetString(), out n))
return n;
}
return null;
}
private static long? TryGetLong(JsonElement root, params string[] names)
{
foreach (var name in names)
{
if (!root.TryGetProperty(name, out var value))
continue;
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out var n))
return n;
if (value.ValueKind == JsonValueKind.String && long.TryParse(value.GetString(), out n))
return n;
}
return null;
}
private static IReadOnlyList TryGetLongArray(JsonElement root, params string[] names)
{
foreach (var name in names)
{
if (!root.TryGetProperty(name, out var value) || value.ValueKind != JsonValueKind.Array)
continue;
return value.EnumerateArray()
.Select(x => x.ValueKind == JsonValueKind.Number && x.TryGetInt64(out var n)
? n
: x.ValueKind == JsonValueKind.String && long.TryParse(x.GetString(), out n)
? n
: 0L)
.Where(x => x > 0)
.Distinct()
.ToArray();
}
return Array.Empty();
}
/// 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");
// Accessibility maps from IsPublic so the 2020.12 watch's IBANLCLBGLM
// deserialiser reads a non-default value (0=Private, 1=Public).
var accessibility = p.IsPublic ? 1 : 0;
return new
{
// 2020.12 watch's IBANLCLBGLM strict keys (per
// docs/recroom-2020-client-response-contracts.md:1048).
// Missing any of these throws KeyNotFoundException in LitJson
// and the gallery empties out.
Id = p.Id,
ImageName = p.BlobName,
AccountId = (int)accountId,
// Server-side mirror of AccountId as PlayerId — some
// call-sites read PlayerId (lowercase id) instead of
// AccountId. Including both is harmless on the watch.
PlayerId = (int)accountId,
RoomId = p.RoomId,
// PlayerEventId is always 0 here (photo-event linkage isn't
// tracked yet); the field still has to be present so the
// strict deserialiser doesn't bail.
PlayerEventId = 0L,
Accessibility = accessibility,
// AccessibilityLocked = whether the watch can flip the
// privacy toggle. We don't gate on this server-side so the
// value is always false (UI is free to toggle).
AccessibilityLocked = false,
// ImageType: 0=ScreenShot, 1=ProfileImage, 2=ImageOfMe, …
// we don't differentiate yet — surface 0 unconditionally.
Type = 0,
ImageType = 0,
CreatedAt = p.CreatedAt,
TaggedPlayerIds = ParseTagged(p.TaggedPlayerIdsCsv).ToArray(),
CheerCount = p.CheerCount,
// We don't track per-photo comments yet — surface 0 for
// the strict-key requirement.
CommentCount = 0,
// Bonus fields — useful for other clients (and our own feed UI).
Filename = p.BlobName,
Url = $"https://{cdnHost}/{p.BlobName}",
Caption = p.Caption,
Description = p.Caption,
IsPublic = p.IsPublic,
ViewCount = p.ViewCount,
};
}
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);
}
}