Files
Radicade/Server/BugReporting/BugReportingAPI.cs
T
2026-10-16 23:33:13 -07:00

479 lines
14 KiB
C#

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<IActionResult> ReportBug()
{
var fields = new List<KeyValuePair<string, string>>();
var logs = new List<string>();
var images = new List<ReportDiscord.DiscordAttachment>();
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<DiscordField>();
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<IActionResult> 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<DiscordField>(),
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<string>();
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<KeyValuePair<string, string>> 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<string, string>(key, value));
}
private static bool TryParseJsonObject(string value, out List<KeyValuePair<string, string>> obj)
{
obj = new List<KeyValuePair<string, string>>();
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<string, string>(prop.Name,
prop.Value.ValueKind == JsonValueKind.String ? prop.Value.GetString() ?? "" : prop.Value.ToString()));
}
return true;
}
catch
{
return false;
}
}
private static string? Get(List<KeyValuePair<string, string>> 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<FileClass?> 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<IActionResult> 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<DiscordField>(),
TimestampUtc = DateTime.UtcNow.ToString("O")
}, new List<ReportDiscord.DiscordAttachment>
{
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<UploadedFile?> 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<byte[]> 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<string> 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);
}