using System.Text; using System.Text.RegularExpressions; using Campus_live_rewrite.api.server; using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json; namespace Campus_live_rewrite.Controllers { /// /// Admin panel "Videos" tab: upload / list / delete the .mp4 files a Remote Video Player can play. /// They live in Data/Videos and the server hands them out at /cdn/video/<name> (Program.cs, video/mp4 only), so the /// address to type into the player's Video Url is <CdnBaseUrl>/video/<name>. Deploys never touch Data/Videos. /// public class AdminVideosController : ControllerBase { private const long MaxBytes = 500L * 1024 * 1024; private static readonly Regex SafeName = new(@"^[A-Za-z0-9][A-Za-z0-9._-]{0,100}\.mp4$", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static ContentResult J(object o, int status = 200) => new() { Content = JsonConvert.SerializeObject(o), ContentType = "application/json", StatusCode = status }; private static string Dir => Path.Combine(Environment.CurrentDirectory, "Data", "Videos"); private static string UrlOf(string name) => $"{ConfigCampus.CdnBaseUrl.TrimEnd('/')}/video/{name}"; /// Which video / audio codecs the file declares (looks at the first and last 4 MB, where the mp4 "moov" box sits). private static string Codecs(string path) { try { using var fs = System.IO.File.OpenRead(path); var buf = new byte[(int)Math.Min(fs.Length, 4 * 1024 * 1024)]; var found = new List(); void Scan() { string s = Encoding.Latin1.GetString(buf); foreach (var (tag, label) in new[] { ("avc1", "H.264"), ("hvc1", "H.265"), ("hev1", "H.265"), ("av01", "AV1"), ("vp09", "VP9"), ("mp4a", "AAC"), ("Opus", "Opus") }) if (s.Contains(tag) && !found.Contains(label)) found.Add(label); } int n = fs.Read(buf, 0, buf.Length); if (n < buf.Length) Array.Resize(ref buf, n); Scan(); if (fs.Length > buf.Length) { buf = new byte[(int)Math.Min(fs.Length - n, 4 * 1024 * 1024)]; fs.Seek(-buf.Length, SeekOrigin.End); fs.ReadExactly(buf, 0, buf.Length); Scan(); } return string.Join(" + ", found); } catch { return ""; } } private static object Row(FileInfo f) { var yt = YoutubeFetch.Find(f.Name); return new { name = f.Name, size = f.Length, modified = f.LastWriteTimeUtc, url = UrlOf(f.Name), codecs = Codecs(f.FullName), youtube = yt == null ? null : new { id = yt.VideoId, title = yt.Title, channel = yt.Channel, expires = yt.ExpiresAtUtc }, }; } [HttpGet("/admin/api/videos")] public ContentResult List() { var (_, error) = AuthHelper.ValidateTokenDev(HttpContext); if (error != null) return error; Directory.CreateDirectory(Dir); YoutubeFetch.Sweep(); // expired YouTube fetches go now, not just on the hourly timer var files = new DirectoryInfo(Dir).GetFiles("*.mp4").OrderByDescending(f => f.LastWriteTimeUtc).Select(Row).ToList(); return J(new { success = true, baseUrl = UrlOf(""), videos = files }); } [HttpPost("/admin/api/videos/upload")] [DisableRequestSizeLimit] [RequestFormLimits(MultipartBodyLengthLimit = MaxBytes + 1024 * 1024)] public async Task Upload() { var (admin, error) = AuthHelper.ValidateTokenDev(HttpContext); if (error != null) return error; if (!Request.HasFormContentType) return J(new { success = false, message = "Send the video as a form upload." }, 400); var form = await Request.ReadFormAsync(); var file = form.Files.GetFile("file") ?? form.Files.FirstOrDefault(); if (file == null || file.Length == 0) return J(new { success = false, message = "Pick an .mp4 file." }, 400); if (file.Length > MaxBytes) return J(new { success = false, message = $"That file is over {MaxBytes / (1024 * 1024)} MB." }, 400); // name: what the admin typed, else the file's own name; spaces become '_', anything else odd is dropped string name = form["name"].ToString().Trim(); if (name.Length == 0) name = Path.GetFileName(file.FileName ?? ""); name = Regex.Replace(name.Replace(' ', '_'), @"[^A-Za-z0-9._-]", ""); if (name.Length > 0 && !name.EndsWith(".mp4", StringComparison.OrdinalIgnoreCase)) name += ".mp4"; if (!SafeName.IsMatch(name)) return J(new { success = false, message = "Give it a simple name (letters, numbers, . _ -) ending in .mp4." }, 400); name = name[..^4] + ".mp4"; // lower-case extension: the server only hands out *.mp4 as video/mp4 Directory.CreateDirectory(Dir); string target = Path.Combine(Dir, name); bool replace = string.Equals(form["replace"].ToString(), "true", StringComparison.OrdinalIgnoreCase); if (System.IO.File.Exists(target) && !replace) return J(new { success = false, message = $"{name} already exists - tick 'replace' or pick another name." }, 409); string tmp = Path.Combine(Dir, ".upload_" + Guid.NewGuid().ToString("N")); try { await using (var fs = System.IO.File.Create(tmp)) await file.CopyToAsync(fs); // a real mp4/mov starts with a size, then the box type "ftyp" var head = new byte[12]; int got; using (var fs = System.IO.File.OpenRead(tmp)) got = fs.Read(head, 0, 12); if (got < 12 || Encoding.ASCII.GetString(head, 4, 4) != "ftyp") { System.IO.File.Delete(tmp); return J(new { success = false, message = "That is not an MP4 file (no 'ftyp' header)." }, 400); } System.IO.File.Move(tmp, target, overwrite: true); } catch (Exception ex) { try { System.IO.File.Delete(tmp); } catch { } return J(new { success = false, message = "Upload failed: " + ex.Message }, 500); } Console.WriteLine($"[admin] {admin!.Username} ({admin.Id}) uploaded video {name} ({new FileInfo(target).Length} bytes)"); var row = Row(new FileInfo(target)); string codecs = Codecs(target); string note = codecs.Contains("H.264") || codecs.Length == 0 ? "" : " Note: this is not H.264 - the game's video player on Windows plays H.264/AAC reliably, other codecs may show a black screen."; return J(new { success = true, video = row, message = $"Uploaded {name}.{note}" }); } public sealed class YoutubeBody { public string Url { get; set; } = ""; } /// Fetch a Creative Commons YouTube video (see YoutubeFetch): licence checked first, default-deny, rate limited, deleted after 3 days. [HttpPost("/admin/api/videos/youtube")] public async Task Youtube([FromBody] YoutubeBody b) { var (admin, error) = AuthHelper.ValidateTokenDev(HttpContext); if (error != null) return error; Directory.CreateDirectory(Dir); var r = await YoutubeFetch.FetchAsync(b?.Url ?? "", admin!.Id); if (!r.Ok) return J(new { success = false, message = r.Message }, r.Status); var e = r.Entry!; Console.WriteLine($"[admin] {admin.Username} ({admin.Id}) {(r.AlreadyHad ? "re-used" : "fetched")} YouTube video {e.VideoId} as {e.File}"); return J(new { success = true, message = r.Message + $" Credit: \"{e.Title}\" by {e.Channel} (CC BY). Deleted automatically on {e.ExpiresAtUtc:yyyy-MM-dd}.", video = Row(new FileInfo(Path.Combine(Dir, e.File))) }); } public sealed class DeleteBody { public string Name { get; set; } = ""; } [HttpPost("/admin/api/videos/delete")] public ContentResult Delete([FromBody] DeleteBody b) { var (admin, error) = AuthHelper.ValidateTokenDev(HttpContext); if (error != null) return error; string name = b?.Name ?? ""; if (!SafeName.IsMatch(name)) return J(new { success = false, message = "Bad name." }, 400); string path = Path.Combine(Dir, name); if (!System.IO.File.Exists(path)) return J(new { success = false, message = "No such video." }, 404); System.IO.File.Delete(path); YoutubeFetch.Forget(name); Console.WriteLine($"[admin] {admin!.Username} ({admin.Id}) deleted video {name}"); return J(new { success = true, message = $"Deleted {name}." }); } } }