using LiteDB; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Formats.Jpeg; using SixLabors.ImageSharp.Formats.Png; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; namespace Campus_live_rewrite.api.dbs { /// /// Website support tickets. A signed-in player opens a ticket (the Support page's "talk to a /// real person" form); staff (dev / mod / owner / support flag) see EVERY ticket and can reply, /// add internal notes, claim, close and reopen. Stored in its own LiteDB file; checkpointed by /// DbMaintenance. Screenshots live in Data/SupportUploads (see the Attachment collection). /// /// Older tickets (before Category / read markers / claiming existed) load with those fields at /// their defaults and work unchanged - the category is read back out of the opening message. /// public static class SupportDB { private static readonly LiteDatabase DB = new LiteDatabase(Environment.CurrentDirectory + "/Data/DBs/Support.db"); public static void Checkpoint() => DB.Checkpoint(); private static ILiteCollection Tickets => DB.GetCollection("tickets"); private static ILiteCollection Attachments => DB.GetCollection("attachments"); private static readonly object Sync = new(); public static readonly string UploadDir = Path.Combine(Environment.CurrentDirectory, "Data", "SupportUploads"); // Limits (anti-spam) and housekeeping. public const int MaxOpenTicketsPerPlayer = 3; public static readonly TimeSpan NewTicketCooldown = TimeSpan.FromMinutes(1); public static readonly TimeSpan AutoCloseAfter = TimeSpan.FromDays(7); // waiting on the player this long public const int MaxAttachmentsPerMessage = 4; public const int MaxAttachmentBytes = 6 * 1024 * 1024; // Screenshot anti-spam. The upload endpoint writes files to disk, so every one of these // is a hard server-side check - the page's own limits are only a convenience. public const int MaxPendingUploads = 8; // uploaded but not yet sent with a message public const int MaxUploadsPerDay = 30; // per player, rolling 24 h (persisted, survives restarts) public const long MaxUploadBytesPerDay = 40L * 1024 * 1024; // per player, rolling 24 h, after re-encoding public const int MaxPlayerAttachmentsPerTicket = 20; // the player's screenshots on one ticket (staff exempt) public const long UploadDirCapBytes = 2L * 1024 * 1024 * 1024; // whole folder; uploads stop (and it logs) past this public static readonly TimeSpan UnusedUploadLifetime = TimeSpan.FromHours(2); private const int MaxImageSide = 2560; // stored images are downscaled to fit this private const long MaxDecodePixels = 40_000_000; // refuse "decompression bombs" before decoding static SupportDB() { Tickets.EnsureIndex(x => x.AccountId); Tickets.EnsureIndex(x => x.Status); Attachments.EnsureIndex(x => x.UploaderId); Directory.CreateDirectory(UploadDir); } public sealed class TicketMessage { public ulong FromAccountId { get; set; } public string FromName { get; set; } = ""; public bool Staff { get; set; } public string Body { get; set; } = ""; public DateTime At { get; set; } = DateTime.UtcNow; public bool Internal { get; set; } // staff-only note, never shown to the player public bool System { get; set; } // "Closed by @x", "Claimed by @y" ... public List? Attachments { get; set; } } public sealed class Ticket { public int Id { get; set; } // LiteDB auto-increment PK public ulong AccountId { get; set; } public string Username { get; set; } = ""; public string Subject { get; set; } = ""; public string Status { get; set; } = "open"; // "open" | "closed" public string? Category { get; set; } // account / join / econ / report_player / report_room / bug / appeal / other public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public List Messages { get; set; } = new(); public DateTime? PlayerReadAt { get; set; } // last time the player opened it public DateTime? StaffReadAt { get; set; } // last time any staff member opened it public ulong AssignedTo { get; set; } // claiming staff member (0 = unclaimed) public string? AssignedName { get; set; } public DateTime? ClosedAt { get; set; } } public sealed class Attachment { [BsonId] public string Name { get; set; } = ""; // random file name, e.g. "3f9c...png" public ulong UploaderId { get; set; } public int TicketId { get; set; } // 0 until a message claims it public string ContentType { get; set; } = "image/png"; public long Bytes { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; } // ---- derived state ------------------------------------------------------------------ private static IEnumerable Public(Ticket t) => (t.Messages ?? new()).Where(m => !m.Internal); /// Whose turn it is on an open ticket: "staff" (the player spoke last) or "player". public static string AwaitingOf(Ticket t) { if (t.Status != "open") return "none"; var last = Public(t).LastOrDefault(m => !m.System); return last == null || !last.Staff ? "staff" : "player"; } /// Staff said something the player hasn't opened the ticket to see yet. public static bool UnreadForPlayer(Ticket t) => Public(t).Any(m => m.Staff && m.FromAccountId != t.AccountId && m.At > (t.PlayerReadAt ?? DateTime.MinValue)); /// The player said something no staff member has opened the ticket to see yet. public static bool UnreadForStaff(Ticket t) => (t.Messages ?? new()).Any(m => !m.Staff && !m.System && m.At > (t.StaffReadAt ?? DateTime.MinValue)); // ---- tickets ------------------------------------------------------------------------ public static Ticket Create(ulong accountId, string username, string subject, string body, string? category, List? attachments) { var t = new Ticket { AccountId = accountId, Username = username, Subject = string.IsNullOrWhiteSpace(subject) ? "Support request" : subject, Status = "open", Category = category, PlayerReadAt = DateTime.UtcNow, }; t.Messages.Add(new TicketMessage { FromAccountId = accountId, FromName = username, Staff = false, Body = body, Attachments = attachments }); lock (Sync) { Tickets.Insert(t); // Id auto-assigned ClaimAttachments(attachments, t.Id); } return t; } public static Ticket? Get(int id) => Tickets.FindById(id); public static List ForAccount(ulong accountId) { AutoCloseStale(); return Tickets.Find(x => x.AccountId == accountId).OrderByDescending(x => x.UpdatedAt).ToList(); } public static List All(string? status) { AutoCloseStale(); var rows = status == "open" ? Tickets.Find(x => x.Status == "open") : status == "closed" ? Tickets.Find(x => x.Status == "closed") : Tickets.FindAll(); return rows.OrderByDescending(x => x.UpdatedAt).ToList(); } /// Add a reply (or, for staff, an internal note). A public reply reopens a closed ticket. public static Ticket? AddMessage(int id, ulong fromId, string fromName, bool staff, string body, bool isInternal, List? attachments) { lock (Sync) { var t = Tickets.FindById(id); if (t == null) return null; var now = DateTime.UtcNow; t.Messages.Add(new TicketMessage { FromAccountId = fromId, FromName = fromName, Staff = staff, Body = body, At = now, Internal = isInternal && staff, Attachments = attachments }); if (!(isInternal && staff)) { t.UpdatedAt = now; // A reply on a closed ticket reopens it, so nobody's follow-up is lost in a closed thread. if (t.Status == "closed") { t.Status = "open"; t.ClosedAt = null; t.Messages.Add(new TicketMessage { FromAccountId = fromId, FromName = fromName, Staff = staff, System = true, Body = $"Reopened by the reply from @{fromName}", At = now.AddMilliseconds(1) }); } } // Writing in a ticket means you've read it. if (staff && fromId != t.AccountId) t.StaffReadAt = now; else t.PlayerReadAt = now; Tickets.Update(t); ClaimAttachments(attachments, id); return t; } } public static Ticket? SetStatus(int id, string status, ulong byId, string byName, bool byStaff) { lock (Sync) { var t = Tickets.FindById(id); if (t == null || t.Status == status) return t; var now = DateTime.UtcNow; t.Status = status; t.UpdatedAt = now; t.ClosedAt = status == "closed" ? now : null; string who = byId == t.AccountId ? "the player" : "@" + byName; t.Messages.Add(new TicketMessage { FromAccountId = byId, FromName = byName, Staff = byStaff, System = true, At = now, Body = status == "closed" ? $"Ticket closed by {who}" : $"Ticket reopened by {who}" }); if (byStaff && byId != t.AccountId) t.StaffReadAt = now; else t.PlayerReadAt = now; Tickets.Update(t); return t; } } /// Claim (staffId != 0) or release (0) a ticket. Recorded in the thread for staff only. public static Ticket? Assign(int id, ulong staffId, string staffName, ulong byId, string byName) { lock (Sync) { var t = Tickets.FindById(id); if (t == null) return null; t.AssignedTo = staffId; t.AssignedName = staffId == 0 ? null : staffName; t.Messages.Add(new TicketMessage { FromAccountId = byId, FromName = byName, Staff = true, System = true, Internal = true, At = DateTime.UtcNow, Body = staffId == 0 ? $"Released by @{byName}" : (staffId == byId ? $"Claimed by @{byName}" : $"Assigned to @{staffName} by @{byName}") }); t.StaffReadAt = DateTime.UtcNow; Tickets.Update(t); return t; } } public static void MarkRead(int id, bool asStaff) { lock (Sync) { var t = Tickets.FindById(id); if (t == null) return; if (asStaff) t.StaffReadAt = DateTime.UtcNow; else t.PlayerReadAt = DateTime.UtcNow; Tickets.Update(t); } } public static int OpenCount() => Tickets.Count(x => x.Status == "open"); public static int AwaitingStaffCount() => Tickets.Find(x => x.Status == "open").Count(t => AwaitingOf(t) == "staff"); public static int OpenCountFor(ulong accountId) => Tickets.Count(x => x.AccountId == accountId && x.Status == "open"); public static DateTime? LastCreatedBy(ulong accountId) => Tickets.Find(x => x.AccountId == accountId).Select(x => (DateTime?)x.CreatedAt).DefaultIfEmpty(null).Max(); // Tickets waiting on the PLAYER for AutoCloseAfter are closed with a note (they can reopen by // replying). Run lazily from the list reads, at most every 10 minutes. private static DateTime _lastSweep = DateTime.MinValue; private static void AutoCloseStale() { if ((DateTime.UtcNow - _lastSweep).TotalMinutes < 10) return; _lastSweep = DateTime.UtcNow; try { var cutoff = DateTime.UtcNow - AutoCloseAfter; foreach (var t in Tickets.Find(x => x.Status == "open").ToList()) { if (AwaitingOf(t) != "player") continue; var last = Public(t).LastOrDefault(m => !m.System); if (last == null || last.At > cutoff) continue; lock (Sync) { t.Status = "closed"; t.ClosedAt = DateTime.UtcNow; t.UpdatedAt = DateTime.UtcNow; t.Messages.Add(new TicketMessage { System = true, Staff = true, FromName = "Campus", At = DateTime.UtcNow, Body = $"Closed automatically after {AutoCloseAfter.TotalDays:0} days without a reply. Reply to reopen it." }); Tickets.Update(t); } Console.WriteLine($"[support] ticket #{t.Id} auto-closed (no reply from @{t.Username} in {AutoCloseAfter.TotalDays:0} days)"); } } catch (Exception ex) { Console.WriteLine($"[support] auto-close sweep failed: {ex.Message}"); } } // ---- screenshots -------------------------------------------------------------------- /// /// Why this player may not upload another screenshot right now, or null when they may. /// Checked BEFORE the image is read or decoded, so a refused caller costs nothing. /// public static string? UploadRefusal(ulong uploaderId) { SweepUnusedUploads(); var since = DateTime.UtcNow.AddDays(-1); List mine; lock (Sync) mine = Attachments.Find(x => x.UploaderId == uploaderId).ToList(); if (mine.Count(a => a.TicketId == 0) >= MaxPendingUploads) return $"You have {MaxPendingUploads} screenshots waiting to be sent. Send your message (or remove some) before adding more."; var today = mine.Where(a => a.CreatedAt > since).ToList(); if (today.Count >= MaxUploadsPerDay || today.Sum(a => a.Bytes) >= MaxUploadBytesPerDay) return "You've attached a lot of screenshots today. Please try again tomorrow, or describe the problem in words."; if (UploadDirBytes() >= UploadDirCapBytes) { Console.WriteLine($"[support] screenshot upload refused: {UploadDir} is over its {UploadDirCapBytes / 1024 / 1024} MB cap"); return "Screenshots are unavailable right now. Please describe the problem in words."; } return null; } /// /// Store an uploaded screenshot. The bytes are never written as uploaded: the image is /// decoded and re-encoded (PNG stays PNG, everything else becomes JPEG), which means a /// renamed file, a polyglot or a malformed image never lands on disk, EXIF (phone GPS /// location) is stripped, and huge images are downscaled. Returns the stored name, or /// null when it isn't a readable PNG/JPEG/GIF/WebP. /// public static string? SaveAttachment(ulong uploaderId, byte[] data) { string? ext = SniffImage(data, out _); if (ext == null || data.Length == 0 || data.Length > MaxAttachmentBytes) return null; byte[] stored; string contentType; try { var info = Image.Identify(data); // header only - no pixel decode if (info == null || info.Width < 1 || info.Height < 1 || (long)info.Width * info.Height > MaxDecodePixels) return null; using var img = Image.Load(data); if (img.Width > MaxImageSide || img.Height > MaxImageSide) img.Mutate(x => x.Resize(new ResizeOptions { Mode = ResizeMode.Max, Size = new Size(MaxImageSide, MaxImageSide) })); img.Metadata.ExifProfile = null; img.Metadata.XmpProfile = null; img.Metadata.IccProfile = null; using var outMs = new MemoryStream(); // SkipMetadata: no text/EXIF/XMP chunks survive - only pixels are written back. if (ext == ".png") { img.SaveAsPng(outMs, new PngEncoder { SkipMetadata = true }); contentType = "image/png"; } else { img.SaveAsJpeg(outMs, new JpegEncoder { Quality = 88, SkipMetadata = true }); contentType = "image/jpeg"; ext = ".jpg"; } stored = outMs.ToArray(); } catch { return null; } // not actually an image string name = Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(16)).ToLowerInvariant() + ext; File.WriteAllBytes(Path.Combine(UploadDir, name), stored); lock (Sync) Attachments.Insert(new Attachment { Name = name, UploaderId = uploaderId, ContentType = contentType, Bytes = stored.Length }); UploadDirBytes(); Interlocked.Add(ref _dirBytes, stored.Length); return name; } /// The uploader removed a screenshot before sending it: delete it now. public static bool DeleteUnsent(ulong uploaderId, string name) { Attachment? a; lock (Sync) { a = Attachments.FindById(name); if (a == null || a.UploaderId != uploaderId || a.TicketId != 0) return false; Attachments.Delete(name); } try { File.Delete(Path.Combine(UploadDir, a.Name)); } catch { } UploadDirBytes(); Interlocked.Add(ref _dirBytes, -a.Bytes); return true; } /// How many screenshots this player has already put on the ticket. public static int PlayerAttachmentCount(Ticket t, ulong playerId) => (t.Messages ?? new()).Where(m => m.FromAccountId == playerId && !m.Staff).Sum(m => m.Attachments?.Count ?? 0); // Total size of the upload folder: counted from disk once, then kept up to date. private static long _dirBytes = -1; private static long UploadDirBytes() { if (Interlocked.Read(ref _dirBytes) < 0) { long total = 0; try { foreach (var f in new DirectoryInfo(UploadDir).EnumerateFiles()) total += f.Length; } catch { } Interlocked.CompareExchange(ref _dirBytes, total, -1); } return Interlocked.Read(ref _dirBytes); } // Screenshots that were uploaded but never sent with a message (the player closed the // form, or a script uploaded and walked away) are deleted after UnusedUploadLifetime. private static DateTime _lastUploadSweep = DateTime.MinValue; private static void SweepUnusedUploads() { if ((DateTime.UtcNow - _lastUploadSweep).TotalMinutes < 10) return; _lastUploadSweep = DateTime.UtcNow; try { var cutoff = DateTime.UtcNow - UnusedUploadLifetime; List stale; lock (Sync) stale = Attachments.Find(x => x.TicketId == 0 && x.CreatedAt < cutoff).ToList(); UploadDirBytes(); foreach (var a in stale) { try { File.Delete(Path.Combine(UploadDir, a.Name)); } catch { } lock (Sync) Attachments.Delete(a.Name); Interlocked.Add(ref _dirBytes, -a.Bytes); } if (stale.Count > 0) Console.WriteLine($"[support] deleted {stale.Count} unused screenshot upload(s)"); } catch (Exception ex) { Console.WriteLine($"[support] upload sweep failed: {ex.Message}"); } } /// The uploader's own not-yet-used uploads among these names (anything else is dropped). public static List OwnUnclaimed(ulong uploaderId, IEnumerable? names) { var list = new List(); foreach (var n in (names ?? Enumerable.Empty()).Distinct().Take(MaxAttachmentsPerMessage)) { var a = Attachments.FindById(n); if (a != null && a.UploaderId == uploaderId && a.TicketId == 0) list.Add(n); } return list; } private static void ClaimAttachments(List? names, int ticketId) { foreach (var n in names ?? new()) { var a = Attachments.FindById(n); if (a == null) continue; a.TicketId = ticketId; Attachments.Update(a); } } public static Attachment? GetAttachment(string name) => Attachments.FindById(name); private static string? SniffImage(byte[] d, out string contentType) { contentType = ""; if (d.Length > 8 && d[0] == 0x89 && d[1] == 0x50 && d[2] == 0x4E && d[3] == 0x47) { contentType = "image/png"; return ".png"; } if (d.Length > 3 && d[0] == 0xFF && d[1] == 0xD8 && d[2] == 0xFF) { contentType = "image/jpeg"; return ".jpg"; } if (d.Length > 6 && d[0] == 'G' && d[1] == 'I' && d[2] == 'F' && d[3] == '8') { contentType = "image/gif"; return ".gif"; } if (d.Length > 12 && d[0] == 'R' && d[1] == 'I' && d[2] == 'F' && d[3] == 'F' && d[8] == 'W' && d[9] == 'E' && d[10] == 'B' && d[11] == 'P') { contentType = "image/webp"; return ".webp"; } return null; } } }