166 lines
8.8 KiB
C#
166 lines
8.8 KiB
C#
using Microsoft.AspNetCore.Mvc;
|
|
using Newtonsoft.Json;
|
|
using Campus_live_rewrite.api.server;
|
|
using Campus_live_rewrite.api.server.Classes.db_class;
|
|
|
|
namespace Campus_live_rewrite.Controllers
|
|
{
|
|
/// <summary>
|
|
/// The college portfolio's "finished beat" (the owner's hip-hop track, 98 BPM): anyone can play
|
|
/// the latest upload, only the portfolio OWNER can replace it.
|
|
///
|
|
/// Owner = the website session (campus_site cookie, a website-surface token - the same login as
|
|
/// the Campus website) of ConfigCampus.PortfolioOwnerAccountId. The page lives on the same origin
|
|
/// (/collegeportfolio), so the browser sends that cookie with the upload. Anyone else - signed out
|
|
/// or signed in as any other account - gets 403 and nothing is written. Files go to
|
|
/// Data/CollegePortfolio/beats (every upload is kept; the newest is the one played), which the
|
|
/// DLL-only deploy never touches, so an upload survives updates.
|
|
/// </summary>
|
|
[ApiController]
|
|
public class PortfolioBeatController : ControllerBase
|
|
{
|
|
private const string SiteCookie = "campus_site";
|
|
private const long MaxBytes = 100L * 1024 * 1024;
|
|
private static readonly string Dir = Path.Combine(Environment.CurrentDirectory, "Data", "CollegePortfolio", "beats");
|
|
private static readonly string MetaPath = Path.Combine(Dir, "beat.json");
|
|
private static readonly object _lock = new();
|
|
|
|
private static readonly Dictionary<string, string> Types = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
[".wav"] = "audio/wav", [".mp3"] = "audio/mpeg", [".m4a"] = "audio/mp4", [".aac"] = "audio/aac",
|
|
[".flac"] = "audio/flac", [".ogg"] = "audio/ogg", [".aif"] = "audio/aiff", [".aiff"] = "audio/aiff",
|
|
};
|
|
|
|
private class BeatMeta
|
|
{
|
|
public string File { get; set; } = "";
|
|
public string OriginalName { get; set; } = "";
|
|
public long Size { get; set; }
|
|
public DateTime UploadedAt { get; set; }
|
|
}
|
|
|
|
private player_info? SiteUser()
|
|
{
|
|
string token = Request.Cookies[SiteCookie] ?? "";
|
|
if (string.IsNullOrEmpty(token)) return null;
|
|
string data = ClientSecurity.DecodeToken(token, requireSurface: "website");
|
|
if (string.IsNullOrEmpty(data) || data.StartsWith("error_")) return null;
|
|
try { return JsonConvert.DeserializeObject<player_info>(data); } catch { return null; }
|
|
}
|
|
|
|
private static BeatMeta? Latest()
|
|
{
|
|
try
|
|
{
|
|
if (!System.IO.File.Exists(MetaPath)) return null;
|
|
var m = JsonConvert.DeserializeObject<BeatMeta>(System.IO.File.ReadAllText(MetaPath));
|
|
return m != null && System.IO.File.Exists(Path.Combine(Dir, m.File)) ? m : null;
|
|
}
|
|
catch { return null; }
|
|
}
|
|
|
|
/// <summary>Who is looking: drives the page (upload box enabled only for the owner).</summary>
|
|
[HttpGet("/collegeportfolio/api/me")]
|
|
public IActionResult Me()
|
|
{
|
|
var u = SiteUser();
|
|
bool owner = u != null && u.Id == ConfigCampus.PortfolioOwnerAccountId;
|
|
Response.Headers["Cache-Control"] = "no-store";
|
|
return Ok(new { signedIn = u != null, owner, username = u?.Username });
|
|
}
|
|
|
|
/// <summary>The current beat's details (null when nothing has been uploaded yet).</summary>
|
|
[HttpGet("/collegeportfolio/api/beat")]
|
|
public IActionResult Info()
|
|
{
|
|
var m = Latest();
|
|
Response.Headers["Cache-Control"] = "no-store";
|
|
if (m == null) return Ok(new { exists = false });
|
|
return Ok(new { exists = true, name = m.OriginalName, size = m.Size, uploadedAt = m.UploadedAt,
|
|
url = "/collegeportfolio/api/beat/file?v=" + m.UploadedAt.Ticks });
|
|
}
|
|
|
|
/// <summary>The audio itself - range requests on, so the player can seek.</summary>
|
|
[HttpGet("/collegeportfolio/api/beat/file")]
|
|
public IActionResult File()
|
|
{
|
|
var m = Latest();
|
|
if (m == null) return NotFound();
|
|
string ext = Path.GetExtension(m.File);
|
|
return PhysicalFile(Path.Combine(Dir, m.File), Types.TryGetValue(ext, out var ct) ? ct : "application/octet-stream", enableRangeProcessing: true);
|
|
}
|
|
|
|
/// <summary>Replace the beat. Owner's website session only.</summary>
|
|
[HttpPost("/collegeportfolio/api/beat")]
|
|
[RequestSizeLimit(MaxBytes + 1024 * 1024)]
|
|
[RequestFormLimits(MultipartBodyLengthLimit = MaxBytes + 1024 * 1024)]
|
|
public async Task<IActionResult> Upload()
|
|
{
|
|
var u = SiteUser();
|
|
if (u == null)
|
|
return StatusCode(401, new { ok = false, error = "Sign in to your Campus account on the website first." });
|
|
if (u.Id != ConfigCampus.PortfolioOwnerAccountId)
|
|
{
|
|
Console.WriteLine($"[portfolio] upload refused: @{u.Username} ({u.Id}) is not the portfolio owner");
|
|
return StatusCode(403, new { ok = false, error = "Only the portfolio owner can upload the beat." });
|
|
}
|
|
// Same-origin only: a page on another site can't post with the owner's cookie.
|
|
string origin = Request.Headers["Origin"].ToString();
|
|
if (origin.Length > 0)
|
|
{
|
|
bool sameSite = Uri.TryCreate(origin, UriKind.Absolute, out var o)
|
|
&& string.Equals(o.Host, Request.Host.Host, StringComparison.OrdinalIgnoreCase);
|
|
if (!sameSite) return StatusCode(403, new { ok = false, error = "Cross-site upload refused." });
|
|
}
|
|
if (!Request.HasFormContentType)
|
|
return BadRequest(new { ok = false, error = "Send the file as multipart/form-data." });
|
|
|
|
var form = await Request.ReadFormAsync();
|
|
var file = form.Files.GetFile("file") ?? form.Files.FirstOrDefault();
|
|
if (file == null || file.Length == 0) return BadRequest(new { ok = false, error = "No file received." });
|
|
if (file.Length > MaxBytes) return BadRequest(new { ok = false, error = "That file is over 100 MB." });
|
|
string ext = Path.GetExtension(file.FileName ?? "");
|
|
if (!Types.ContainsKey(ext))
|
|
return BadRequest(new { ok = false, error = "Audio files only: .wav, .mp3, .m4a, .aac, .flac, .ogg, .aif(f)." });
|
|
|
|
// The bytes must really be audio of that kind, not a renamed file.
|
|
byte[] head = new byte[12];
|
|
using (var s = file.OpenReadStream()) { int n = 0, r; while (n < 12 && (r = await s.ReadAsync(head, n, 12 - n)) > 0) n += r; }
|
|
if (!LooksLikeAudio(ext, head))
|
|
return BadRequest(new { ok = false, error = "That file isn't a valid " + ext.TrimStart('.').ToUpperInvariant() + " audio file." });
|
|
|
|
Directory.CreateDirectory(Dir);
|
|
var now = DateTime.UtcNow;
|
|
string stored = $"beat-{now:yyyyMMdd-HHmmss}{ext.ToLowerInvariant()}";
|
|
string tmp = Path.Combine(Dir, stored + ".part");
|
|
using (var outS = System.IO.File.Create(tmp)) await file.CopyToAsync(outS);
|
|
string safeName = new string(Path.GetFileName(file.FileName ?? "beat").Where(c => c >= 32 && c < 127).ToArray());
|
|
lock (_lock)
|
|
{
|
|
System.IO.File.Move(tmp, Path.Combine(Dir, stored), overwrite: true);
|
|
var meta = new BeatMeta { File = stored, OriginalName = safeName.Length > 0 ? safeName : stored, Size = file.Length, UploadedAt = now };
|
|
System.IO.File.WriteAllText(MetaPath, JsonConvert.SerializeObject(meta, Formatting.Indented));
|
|
}
|
|
Console.WriteLine($"[portfolio] @{u.Username} uploaded the beat: '{safeName}' ({file.Length / 1024} KB) -> {stored}");
|
|
return Ok(new { ok = true, name = safeName, size = file.Length, uploadedAt = now,
|
|
url = "/collegeportfolio/api/beat/file?v=" + now.Ticks });
|
|
}
|
|
|
|
private static bool LooksLikeAudio(string ext, byte[] h)
|
|
{
|
|
string a(int i, int n) => System.Text.Encoding.ASCII.GetString(h, i, n);
|
|
switch (ext.ToLowerInvariant())
|
|
{
|
|
case ".wav": return a(0, 4) == "RIFF" && a(8, 4) == "WAVE";
|
|
case ".mp3": return a(0, 3) == "ID3" || (h[0] == 0xFF && (h[1] & 0xE0) == 0xE0);
|
|
case ".m4a": return a(4, 4) == "ftyp";
|
|
case ".aac": return (h[0] == 0xFF && (h[1] & 0xF6) == 0xF0) || a(4, 4) == "ftyp";
|
|
case ".flac": return a(0, 4) == "fLaC";
|
|
case ".ogg": return a(0, 4) == "OggS";
|
|
case ".aif": case ".aiff": return a(0, 4) == "FORM" && (a(8, 4) == "AIFF" || a(8, 4) == "AIFC");
|
|
default: return false;
|
|
}
|
|
}
|
|
}
|
|
}
|