using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Mvc; using RadicadeServer.Radicade.Discord; using RadicadeServer.Discord.Webhooks; namespace RadicadeServer.API.BugReporting; [Route("api/bugreporting/")] [Route("bugreporting/")] public class BugReportingAPI : Controller { private const int MaxLogChars = 3000; private const long MaxAttachmentBytes = 20 * 1024 * 1024; private const long MaxImageBytes = 8 * 1024 * 1024; private const int MaxImageAttachments = 10; [HttpPost("v2/reportbug")] [HttpPost("v1/reportbug")] public async Task ReportBug() { var fields = new List>(); var logs = new List(); var images = new List(); if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); foreach (var kv in form) AddField(fields, kv.Key, kv.Value.ToString()); foreach (var file in form.Files) { var parsed = await ClassifyFileAsync(file); if (parsed == null) continue; if (parsed.IsImage) images.Add(new ReportDiscord.DiscordAttachment(parsed.Filename!, parsed.Data!, parsed.MimeType!)); else logs.Add(parsed.Text ?? ""); } } else { try { using var reader = new StreamReader(Request.Body); var body = await reader.ReadToEndAsync(); if (!string.IsNullOrWhiteSpace(body)) { var trimmed = body.TrimStart(); if (trimmed.StartsWith("{", StringComparison.Ordinal)) { using var doc = JsonDocument.Parse(body); if (doc.RootElement.ValueKind == JsonValueKind.Object) { foreach (var prop in doc.RootElement.EnumerateObject()) { AddField(fields, prop.Name, prop.Value.ValueKind == JsonValueKind.String ? prop.Value.GetString() ?? "" : prop.Value.ToString()); } } } else if (trimmed.Contains('=')) { foreach (var pair in body.Split('&', StringSplitOptions.RemoveEmptyEntries)) { var eq = pair.IndexOf('='); if (eq < 0) { AddField(fields, Uri.UnescapeDataString(pair), ""); continue; } AddField(fields, Uri.UnescapeDataString(pair.Substring(0, eq)), Uri.UnescapeDataString(pair.Substring(eq + 1))); } } } } catch { } } var description = Get(fields, "Description", "description", "Summary", "summary") ?? ""; if (string.IsNullOrWhiteSpace(description) && logs.Count > 0) description = logs[0]; var buildTimestamp = Get(fields, "BuildTimestamp", "buildTimestamp"); if (long.TryParse(buildTimestamp, out var ticks) && ticks > 0) { try { var dt = new DateTime(ticks, DateTimeKind.Utc); if (dt.Year is > 2000 and < 2100) AddField(fields, "Build Time", dt.ToString("yyyy-MM-dd HH:mm:ss 'UTC'")); } catch { } } var embedFields = new List(); foreach (var kv in fields) { if (string.IsNullOrWhiteSpace(kv.Value)) continue; embedFields.Add(new DiscordField { Name = kv.Key, Value = kv.Value, Inline = true }); } for (int i = 0; i < logs.Count; i++) embedFields.Add(new DiscordField { Name = $"Log {i + 1}", Value = logs[i], Inline = false }); var payload = new DiscordPayload { Title = "Bug Report", Description = Truncate(description, 3900), ThumbnailUrl = WebhookImages.BugReport, Color = 0xED4245, Fields = embedFields.Take(24).ToList(), TimestampUtc = DateTime.UtcNow.ToString("O") }; var imagesToSend = images.Take(MaxImageAttachments).ToList(); var embedImageUrl = imagesToSend.Count > 0 ? $"attachment://{imagesToSend[0].Filename}" : null; ReportDiscord.SendAsync(WebhookConfig.ReportsUrl, "🐞 New Bug Report", payload, imagesToSend, embedImageUrl); return Ok(new { Success = true }); } [HttpPost("ado/bug")] public async Task ReportAdoBug() { string body; using (var reader = new StreamReader(Request.Body)) body = await reader.ReadToEndAsync(); var summary = body.Length > 0 && body.TrimStart().StartsWith("{", StringComparison.Ordinal) ? SummarizeJson(body) : Truncate(body.Trim(), 500); ReportDiscord.SendAsync(WebhookConfig.ReportsUrl, "\ud83d\udc1e Crash / bug report", new DiscordPayload { Title = "Bug Report (ADO)", Description = Truncate(string.IsNullOrWhiteSpace(summary) ? "(empty report)" : summary, 3900), ThumbnailUrl = WebhookImages.BugReport, Color = 0xED4245, Fields = new List(), TimestampUtc = DateTime.UtcNow.ToString("O") }); return Ok(new { Id = 0, Url = string.Empty, Success = true }); } private static string SummarizeJson(string body) { try { using var doc = JsonDocument.Parse(body); if (doc.RootElement.ValueKind != JsonValueKind.Object) return Truncate(body, 500); var parts = new List(); foreach (var prop in doc.RootElement.EnumerateObject()) { if (prop.Value.ValueKind == JsonValueKind.Object || prop.Value.ValueKind == JsonValueKind.Array) continue; var value = prop.Value.ValueKind == JsonValueKind.String ? prop.Value.GetString() ?? "" : prop.Value.ToString(); if (string.IsNullOrWhiteSpace(value)) continue; parts.Add($"{prop.Name}={Truncate(value, 200)}"); if (parts.Count >= 12) break; } return string.Join(", ", parts); } catch (JsonException) { return Truncate(body, 500); } } private static void AddField(List> fields, string key, string value) { if (TryParseJsonObject(value, out var nested)) { foreach (var kv in nested) AddField(fields, kv.Key, kv.Value); return; } if (fields.Any(kv => string.Equals(kv.Key, key, StringComparison.OrdinalIgnoreCase))) return; fields.Add(new KeyValuePair(key, value)); } private static bool TryParseJsonObject(string value, out List> obj) { obj = new List>(); if (string.IsNullOrWhiteSpace(value) || !value.TrimStart().StartsWith("{", StringComparison.Ordinal)) return false; try { using var doc = JsonDocument.Parse(value); if (doc.RootElement.ValueKind != JsonValueKind.Object) return false; foreach (var prop in doc.RootElement.EnumerateObject()) { obj.Add(new KeyValuePair(prop.Name, prop.Value.ValueKind == JsonValueKind.String ? prop.Value.GetString() ?? "" : prop.Value.ToString())); } return true; } catch { return false; } } private static string? Get(List> fields, params string[] names) { foreach (var name in names) { var hit = fields.FirstOrDefault(kv => string.Equals(kv.Key, name, StringComparison.OrdinalIgnoreCase)); if (!string.IsNullOrWhiteSpace(hit.Value)) return hit.Value; } return null; } private static async Task ClassifyFileAsync(IFormFile file) { if (file.Length == 0 || file.Length > MaxAttachmentBytes) return null; try { var data = new byte[file.Length]; await using var stream = file.OpenReadStream(); var read = 0; while (read < data.Length) { var n = await stream.ReadAsync(data, read, data.Length - read); if (n <= 0) break; read += n; } var mime = DetectImageMime(data, read); if (mime != null) { if (file.Length > MaxImageBytes) return null; var ext = MimeToExt(mime); var name = SanitizeFileName(file.FileName); if (string.IsNullOrWhiteSpace(name) || !name.Contains('.')) name = $"screenshot.{ext}"; return new FileClass(name, data[..read], mime, null, true); } return new FileClass(null, null, null, Truncate(Encoding.UTF8.GetString(data, 0, read), MaxLogChars), false); } catch { return null; } } private static string? DetectImageMime(byte[] data, int length) { if (length < 8) return null; if (data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47) return "image/png"; if (data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF) return "image/jpeg"; if (data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x38) return "image/gif"; if (data[0] == 0x42 && data[1] == 0x4D) return "image/bmp"; if (data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 && data[8] == 0x57 && data[9] == 0x45 && data[10] == 0x42 && data[11] == 0x50) return "image/webp"; return null; } private static string MimeToExt(string mime) => mime switch { "image/png" => "png", "image/jpeg" => "jpg", "image/gif" => "gif", "image/bmp" => "bmp", "image/webp" => "webp", _ => "png" }; private static string SanitizeFileName(string fileName) { if (string.IsNullOrWhiteSpace(fileName)) return ""; var name = Path.GetFileName(fileName).Trim(); return name.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 ? new string(name.Where(c => !Path.GetInvalidFileNameChars().Contains(c)).ToArray()) : name; } private static string Truncate(string value, int max) { if (string.IsNullOrEmpty(value)) return ""; if (value.Length <= max) return value; var cut = max; if (char.IsHighSurrogate(value[cut - 1]) && cut < value.Length && char.IsLowSurrogate(value[cut])) cut--; return value[..cut]; } [HttpPost("ado/file")] public async Task UploadAdoFile() { var upload = await ReadUploadAsync(); if (upload == null) { return Ok(new { Id = 0, Url = string.Empty, Success = true }); } var path = await StoreUploadAsync(upload); if (upload.Data.Length <= MaxImageBytes) { ReportDiscord.SendAsync(WebhookConfig.ReportsUrl, "Crash report attachment", new DiscordPayload { Title = "Bug Report Attachment", Description = $"{upload.Filename} ({upload.Data.Length} byte(s))", ThumbnailUrl = WebhookImages.BugReport, Color = 0xED4245, Fields = new List(), TimestampUtc = DateTime.UtcNow.ToString("O") }, new List { new(upload.Filename, upload.Data, upload.MimeType) }); } return Ok(new { Id = 0, Url = string.Empty, Success = true, FileName = upload.Filename, Size = upload.Data.Length }); } private sealed record UploadedFile(string Filename, byte[] Data, string MimeType); private static string AdoFileFolder => Path.Combine(DBConfig.PrivateData, "BugReports"); private async Task ReadUploadAsync() { if (Request.HasFormContentType) { try { var form = await Request.ReadFormAsync(); foreach (var file in form.Files) { if (file.Length == 0 || file.Length > MaxAttachmentBytes) continue; var data = await ReadAllAsync(file.OpenReadStream()); if (data.Length == 0) continue; return new UploadedFile(UploadName(file.FileName, file.ContentType), data, UploadMime(file.ContentType, file.FileName)); } } catch (Exception) { } } var body = await ReadAllAsync(Request.Body); if (body.Length == 0) return null; var name = FirstQuery("fileName", "filename", "name", "file") ?? DispositionFileName() ?? "ado-upload"; return new UploadedFile(UploadName(name, Request.ContentType), body, UploadMime(Request.ContentType, name)); } private static async Task ReadAllAsync(Stream stream) { using var buffer = new MemoryStream(); var chunk = new byte[81920]; int read; while ((read = await stream.ReadAsync(chunk)) > 0) { buffer.Write(chunk, 0, read); if (buffer.Length >= MaxAttachmentBytes) break; } return buffer.ToArray(); } private string? FirstQuery(params string[] names) { foreach (var name in names) { var value = Request.Query[name].FirstOrDefault(); if (!string.IsNullOrWhiteSpace(value)) return value!; } return null; } private string? DispositionFileName() { var header = Request.Headers.ContentDisposition.FirstOrDefault(); if (string.IsNullOrWhiteSpace(header)) return null; const string marker = "filename="; var at = header!.IndexOf(marker, StringComparison.OrdinalIgnoreCase); if (at < 0) return null; return header[(at + marker.Length)..].Trim().Trim('"'); } private static string UploadName(string? name, string? contentType) { var clean = SanitizeFileName(name ?? ""); if (string.IsNullOrWhiteSpace(clean)) clean = "ado-upload"; if (!clean.Contains('.')) clean += ExtensionFor(contentType); return clean; } private static string UploadMime(string? contentType, string? name) { if (!string.IsNullOrWhiteSpace(contentType) && contentType!.Contains('/')) return contentType; var extension = Path.GetExtension(name ?? "").ToLowerInvariant(); return extension switch { ".png" => "image/png", ".jpg" or ".jpeg" => "image/jpeg", ".gif" => "image/gif", ".webp" => "image/webp", ".txt" or ".log" => "text/plain", ".json" => "application/json", ".zip" => "application/zip", _ => "application/octet-stream" }; } private static string ExtensionFor(string? contentType) => (contentType ?? "").Split(';')[0].Trim().ToLowerInvariant() switch { "image/png" => ".png", "image/jpeg" => ".jpg", "image/gif" => ".gif", "image/webp" => ".webp", "text/plain" => ".log", "application/json" => ".json", "application/zip" => ".zip", _ => ".bin" }; private static async Task StoreUploadAsync(UploadedFile upload) { Directory.CreateDirectory(AdoFileFolder); var stamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss"); var path = Path.Combine(AdoFileFolder, $"{stamp}-{upload.Filename}"); for (int n = 1; System.IO.File.Exists(path); n++) path = Path.Combine(AdoFileFolder, $"{stamp}-{n}-{upload.Filename}"); await System.IO.File.WriteAllBytesAsync(path, upload.Data); return path; } private sealed record FileClass(string? Filename, byte[]? Data, string? MimeType, string? Text, bool IsImage); }