using System.Collections.Concurrent;
using System.Net;
using System.Text.RegularExpressions;
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api.server;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
namespace Campus_live_rewrite.Controllers
{
///
/// Admin panel "RecFlare CDN" tab: what cdn.recflare.net (the RecFlare reference server's public
/// CDN) holds, and pulling files from it into this server.
///
/// The CDN has NO listing - it only serves a file you name (/room/, /avatar/, /invention/, /data/,
/// /config/, /sigs/). So this tab works from names: look one up, or scan every room save this
/// server references but doesn't have (the rooms that load empty - Run The Block, My Little
/// Monsters...) and restore the ones RecFlare has. A lookup is a one-byte Range request, answered
/// 206 with the whole size in Content-Range. Restored room saves are servable at once
/// (RoomBlobFilter checks the file on every read).
///
public class AdminRecflareCdnController : ControllerBase
{
private const string Base = "https://cdn.recflare.net";
private static readonly string[] Kinds = { "room", "avatar", "invention", "data", "config", "sigs" };
// A file name (avatar bundles may sit under quest/). No "..", no leading slash.
private static readonly Regex SafeName = new(@"^(quest/|\d{4}-\d{2}-\d{2}/)?[A-Za-z0-9][A-Za-z0-9._-]{0,200}$", RegexOptions.Compiled);
private static readonly HttpClient Http = new(new HttpClientHandler { AutomaticDecompression = DecompressionMethods.None })
{
Timeout = TimeSpan.FromSeconds(60),
DefaultRequestHeaders = { { "User-Agent", "CampusServer (admin RecFlare CDN tab)" } }
};
private static ContentResult J(object o, int status = 200) => new()
{ Content = JsonConvert.SerializeObject(o), ContentType = "application/json", StatusCode = status };
private static string? LocalDir(string kind) => kind switch
{
"room" => RoomBlobFilter.BlobDirectory,
"avatar" => Path.Combine(Environment.CurrentDirectory, "Data", "CDN", "AvatarBlobs"),
_ => null
};
private static bool HaveLocally(string kind, string name)
{
var dir = LocalDir(kind);
if (dir == null) return false;
try { var fi = new FileInfo(Path.Combine(dir, name)); return fi.Exists && fi.Length > 0; } catch { return false; }
}
public sealed record Probe(bool Found, long Size, string? ContentType, int Status, string? Error);
/// Does RecFlare have it? One byte is fetched; the size comes from Content-Range.
public static async Task ProbeAsync(string kind, string name, CancellationToken ct = default)
{
try
{
using var req = new HttpRequestMessage(HttpMethod.Get, $"{Base}/{kind}/{name}");
req.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(0, 0);
using var resp = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct);
string? type = resp.Content.Headers.ContentType?.MediaType;
// a miss is a small JSON {"error":...}; a real file is binary (or text for config)
bool found = (resp.StatusCode == HttpStatusCode.PartialContent || resp.StatusCode == HttpStatusCode.OK)
&& !(kind != "config" && type != null && type.Contains("json"));
long size = resp.Content.Headers.ContentRange?.Length ?? resp.Content.Headers.ContentLength ?? -1;
return new Probe(found, found ? size : 0, type, (int)resp.StatusCode, null);
}
catch (Exception ex) { return new Probe(false, 0, null, 0, ex.Message); }
}
private static bool Valid(string? kind, string? name, out string why)
{
why = "";
if (kind == null || !Kinds.Contains(kind)) { why = "Kind must be one of: " + string.Join(", ", Kinds) + "."; return false; }
if (string.IsNullOrWhiteSpace(name) || !SafeName.IsMatch(name.Trim())) { why = "That isn't a file name (letters, digits, . _ - only; avatar bundles may start with quest/, RecFlare room files with a date folder)."; return false; }
return true;
}
// ---- look one up ------------------------------------------------------------------------
[HttpGet("/admin/api/recflarecdn/lookup")]
public async Task Lookup(string kind, string name)
{
var (_, error) = AuthHelper.ValidateTokenDev(HttpContext);
if (error != null) return error;
if (!Valid(kind, name, out var why)) return J(new { success = false, message = why });
name = name.Trim();
var p = await ProbeAsync(kind, name, HttpContext.RequestAborted);
return J(new
{
success = true, kind, name, url = $"{Base}/{kind}/{name}",
found = p.Found, size = p.Size, contentType = p.ContentType, status = p.Status, error = p.Error,
local = HaveLocally(kind, name), canRestore = p.Found && LocalDir(kind) != null
});
}
// ---- every room save we reference but don't have -----------------------------------------
private static readonly SemaphoreSlim _scanGate = new(1, 1);
private static object? _lastScan;
private static DateTime _lastScanAt;
[HttpGet("/admin/api/recflarecdn/scan")]
public async Task Scan(bool refresh = false)
{
var (_, error) = AuthHelper.ValidateTokenDev(HttpContext);
if (error != null) return error;
if (!refresh && _lastScan != null && DateTime.UtcNow - _lastScanAt < TimeSpan.FromMinutes(10)) return J(_lastScan);
if (!await _scanGate.WaitAsync(0)) return J(new { success = false, message = "A scan is already running - try again in a moment." });
try
{
var rows = new List<(ulong roomId, string room, long subId, string sub, string what, string blob)>();
foreach (var r in RoomDB.GetAllRoomsIncludingDorms())
{
if (!string.IsNullOrWhiteSpace(r.DataBlob) && r.DataBlob != RoomBlobFilter.DefaultRoomMetaBlob && !RoomBlobFilter.BlobHasContent(r.DataBlob))
rows.Add((r.RoomId, r.Name ?? "", 0, "", "room data", r.DataBlob!));
foreach (var s in r.SubRooms ?? new())
{
string? b = s.CurrentSave?.DataBlob;
if (!string.IsNullOrWhiteSpace(b) && !RoomBlobFilter.BlobHasContent(b))
rows.Add((r.RoomId, r.Name ?? "", (long)s.SubRoomId, s.Name ?? "", "save", b!));
}
}
// probe them, 8 at a time
var results = new ConcurrentDictionary();
using var gate = new SemaphoreSlim(8);
await Task.WhenAll(rows.Select(x => x.blob).Distinct().Select(async b =>
{
await gate.WaitAsync();
try { results[b] = SafeName.IsMatch(b) ? await ProbeAsync("room", b) : new Probe(false, 0, null, 0, "unusual name"); }
finally { gate.Release(); }
}));
var list = rows.Select(x => new
{
roomId = x.roomId, room = x.room, subRoomId = x.subId, subRoom = x.sub, what = x.what, blob = x.blob,
found = results[x.blob].Found, size = results[x.blob].Size, error = results[x.blob].Error
}).OrderByDescending(x => x.found).ThenBy(x => x.room).ThenBy(x => x.subRoomId).ToList();
_lastScan = new
{
success = true, scannedAt = DateTime.UtcNow, missing = list.Count, onRecflare = list.Count(x => x.found),
rooms = list.Select(x => x.roomId).Distinct().Count(), rows = list
};
_lastScanAt = DateTime.UtcNow;
Console.WriteLine($"[recflare-cdn] scan: {list.Count} missing room file(s), {list.Count(x => x.found)} on RecFlare");
return J(_lastScan);
}
finally { _scanGate.Release(); }
}
// ---- pull one (or several) into this server ------------------------------------------------
public sealed class RestoreBody { public string? Kind { get; set; } public List? Names { get; set; } }
[HttpPost("/admin/api/recflarecdn/restore")]
public async Task Restore([FromBody] RestoreBody body)
{
var (admin, error) = AuthHelper.ValidateTokenDev(HttpContext);
if (error != null) return error;
string kind = body?.Kind ?? "room";
var dir = LocalDir(kind);
if (dir == null) return J(new { success = false, message = "Only room and avatar files can be restored into the server." });
var done = new List