mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-26 07:29:00 -07:00
479 lines
14 KiB
C#
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);
|
|
}
|