using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using Campus_live_rewrite.api.dbs;
using Campus_live_rewrite.api.server;
using Campus_live_rewrite.api.server.Classes.db_class;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Campus_live_rewrite.Controllers
{
///
/// CampusStudio (the desktop recolour app) talks to the server through these routes only.
///
/// AUTH. Sign-in with a Campus username + password mints a token with surface "studio": it
/// is accepted here and NOWHERE else (ClientSecurity.DecodeToken refuses "studio" on every
/// generic path), so a CampusStudio login can't act in the game, the website or the admin
/// panel. Who may use it = owner / dev accounts and accounts with the Studio role (admin
/// panel > player > Studio). The role is re-checked on every call, so revoking it takes
/// effect immediately. Banned accounts are refused.
///
/// PUBLISH. The app sends the bundles it built plus a few fields; the server does NOT take a
/// record from the client. It copies the SOURCE item's record and changes only the name, the
/// ids, the file names and the admin-only tag - price, outfit type, accessibility and the
/// rest always come from the source. Files are checked (names, UnityFS / PNG magic, sizes),
/// never overwrite an existing file, and the item is registered live (CustomItemsCache.AddStudio
/// -> StudioUGC.json) - no restart.
///
/// Updating: an item published from CampusStudio can be republished under the same id (so
/// owners keep it) by the account that made it, or by an owner / dev account. Items made
/// with the first local build of the tool carry the "campusstudio" tag and count as
/// CampusStudio items with no recorded maker (owner / dev only).
///
[ApiController]
public class StudioController : ControllerBase
{
private const string StudioTag = "campusstudio";
private const string CreatedAt = "2025-07-18T12:00:00Z"; // the client drops items created after its 2025-07-19 build
private static readonly TimeSpan TokenTtl = TimeSpan.FromDays(7);
private const long MaxBundle = 40L * 1024 * 1024, MaxImage = 8L * 1024 * 1024, MaxRequest = 300L * 1024 * 1024;
private static readonly Regex BundleName = new("^[a-z0-9]{25}\\.assetbundle$", RegexOptions.Compiled);
private static readonly Regex ImageName = new("^[a-z0-9]{25}\\.png$", RegexOptions.Compiled);
private static readonly object _publishLock = new();
private static string Root => Environment.CurrentDirectory;
private static string BlobDir => Path.Combine(Root, "Data", "CDN", "AvatarBlobs");
private static string QuestDir => Path.Combine(BlobDir, "quest");
private static string ImageDir => Path.Combine(Root, "Data", "Images");
private static string MetaPath => Path.Combine(Root, "Data", "APIS", "items", "StudioMeta.json");
// ---- who ------------------------------------------------------------------------------
public static bool CanUseStudio(ulong id, out string? why)
{
why = null;
var full = PlayerDB.GetFullAccount(id);
if (full == null) { why = "Account not found."; return false; }
if (PlayerDB.HasActiveModerationBlock(id)) { why = "This account is banned."; return false; }
var f = full.dev_Flag;
if ((f & (DevFlag.owner | DevFlag.dev | DevFlag.studio)) != 0) return true;
why = "This account doesn't have the Studio role. Ask an admin to turn on \"Studio\" for you in the admin panel.";
return false;
}
private static bool IsOwnerOrDev(ulong id) =>
((PlayerDB.GetFullAccount(id)?.dev_Flag ?? DevFlag.none) & (DevFlag.owner | DevFlag.dev)) != 0;
private (player_info? user, IActionResult? error) Caller()
{
string auth = Request.Headers["Authorization"].ToString();
string token = auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) ? auth.Substring(7).Trim() : "";
if (token.Length == 0) return (null, StatusCode(401, new { ok = false, error = "Sign in first." }));
string data = ClientSecurity.DecodeToken(token, requireSurface: "studio");
if (string.IsNullOrEmpty(data) || data.StartsWith("error_"))
return (null, StatusCode(401, new { ok = false, error = data == "error_token_expired" ? "Your sign-in has expired - sign in again." : "Sign in again." }));
player_info? u;
try { u = JsonConvert.DeserializeObject(data); } catch { u = null; }
if (u == null) return (null, StatusCode(401, new { ok = false, error = "Sign in again." }));
if (!CanUseStudio(u.Id, out var why)) return (null, StatusCode(403, new { ok = false, error = why }));
return (u, null);
}
// ---- meta (who made which studio item; next save id) ------------------------------------
private class Meta
{
public Dictionary Items { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public int NextSaveId { get; set; }
}
private class MetaItem
{
public ulong Creator { get; set; }
public string? CreatorName { get; set; }
public string? Source { get; set; }
public DateTime Created { get; set; }
public DateTime Updated { get; set; }
}
private static Meta LoadMeta()
{
try
{
if (System.IO.File.Exists(MetaPath))
{
var m = JsonConvert.DeserializeObject(System.IO.File.ReadAllText(MetaPath));
if (m != null) { m.Items = new Dictionary(m.Items, StringComparer.OrdinalIgnoreCase); return m; }
}
}
catch (Exception ex) { Console.WriteLine($"[studio] meta unreadable: {ex.Message}"); }
return new Meta();
}
private static void SaveMeta(Meta m)
{
Directory.CreateDirectory(Path.GetDirectoryName(MetaPath)!);
string tmp = MetaPath + ".tmp";
System.IO.File.WriteAllText(tmp, JsonConvert.SerializeObject(m, Formatting.Indented));
System.IO.File.Move(tmp, MetaPath, overwrite: true);
}
private static bool HasStudioTag(JObject rec) =>
rec["Tags"] is JArray tags && tags.Any(t => string.Equals((string?)t?["Value"], StudioTag, StringComparison.OrdinalIgnoreCase));
private static bool IsStudioItem(string id, JObject? rec, Meta meta) =>
CustomItemsCache.IsStudio(id) || meta.Items.ContainsKey(id) || (rec != null && HasStudioTag(rec));
// ---- routes ---------------------------------------------------------------------------
public class LoginBody { public string? username { get; set; } public string? password { get; set; } }
/// POST /api/studio/v1/login {username, password} -> a studio token (7 days).
[HttpPost("/api/studio/v1/login")]
public IActionResult Login([FromBody] LoginBody? body)
{
if (body == null || string.IsNullOrWhiteSpace(body.username) || string.IsNullOrWhiteSpace(body.password))
return BadRequest(new { ok = false, error = "Enter your username and password." });
var result = PlayerDB.GetAccountPassword(body.username.Trim(), body.password, isAdminPanel: false);
if (!result.success)
return StatusCode(401, new { ok = false, error = result.error ?? "Incorrect username or password." });
if (!CanUseStudio(result.accid, out var why))
{
Console.WriteLine($"[studio] sign-in refused for {result.accid}: {why}");
return StatusCode(403, new { ok = false, error = why });
}
string token = ClientSecurity.RefreshToken_Web(result.accid, TokenTtl, "studio");
var full = PlayerDB.GetFullAccount(result.accid);
Console.WriteLine($"[studio] @{full?.player?.Username} ({result.accid}) signed in to CampusStudio");
return Ok(new { ok = true, token, id = result.accid, username = full?.player?.Username,
expires = DateTimeOffset.UtcNow.Add(TokenTtl).ToUnixTimeSeconds() });
}
[HttpGet("/api/studio/v1/me")]
public IActionResult Me()
{
var (u, err) = Caller();
if (err != null) return err;
return Ok(new { ok = true, id = u!.Id, username = u.Username, admin = IsOwnerOrDev(u.Id) });
}
/// The library: every first-party + CampusStudio modelled item.
[HttpGet("/api/studio/v1/items")]
public IActionResult Items()
{
var (u, err) = Caller();
if (err != null) return err;
var meta = LoadMeta();
bool admin = IsOwnerOrDev(u!.Id);
var list = new List