using System.Collections.Concurrent; using System.Security.Claims; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using RadicadeServer.DB; using RadicadeServer.Extensions; using RadicadeServer.RecNet.Enums.Rooms; using RadicadeServer.Utils; namespace RadicadeServer.API.Studio { [Route("studio")] public class StudioAPI : Controller { private sealed record StoredCloudBuild(long RoomId, long SubRoomId, StudioCloudBuild Build); private static readonly ConcurrentDictionary CloudBuilds = new(StringComparer.Ordinal); private readonly ILogger logger; private readonly StudioBuildWorker worker; private readonly IDbContextFactory dbFactory; public StudioAPI(ILogger logger, StudioBuildWorker worker, IDbContextFactory dbFactory) { this.logger = logger; this.worker = worker; this.dbFactory = dbFactory; } [HttpGet("cloud-builds/for-room"), Authorize] public async Task CloudBuildsForRoom( [FromQuery] long roomId, [FromQuery] long subRoomId, [FromQuery] int skip = 0, [FromQuery] int take = 30) { if (roomId <= 0 || subRoomId <= 0) return BadRequest(); var accountId = CurrentAccountId(); if (accountId == null) return Unauthorized(); if (!await CanBuildInRoomAsync(roomId, accountId.Value, HttpContext.RequestAborted)) return Forbid(); logger.LogInformation("Studio cloud builds requested: room={RoomId}, subRoom={SubRoomId}, skip={Skip}, take={Take}.", roomId, subRoomId, skip, take); return Ok(BuildPage(roomId, subRoomId, skip, take)); } private static object BuildPage(long roomId, long subRoomId, int skip, int take) { var builds = CloudBuilds.Values .Where(x => (roomId == 0 || x.RoomId == roomId) && (subRoomId == 0 || x.SubRoomId == subRoomId)) .OrderByDescending(x => x.Build.StartedAt) .ToList(); var page = builds.Skip(Math.Max(0, skip)).Take(Math.Clamp(take, 0, 100)).Select(x => x.Build).ToList(); return new { Results = page, TotalResults = builds.Count, TotalCount = builds.Count }; } [HttpPost("cloud-builds/create"), Authorize] public async Task CreateCloudBuild( [FromQuery] long? roomId = null, [FromQuery] long? subRoomId = null, [FromQuery] long? subRoomDataSaveId = null, [FromQuery] bool? autoPublish = null, [FromQuery] string? buildBranch = null) { var form = Request.HasFormContentType ? await Request.ReadFormAsync(HttpContext.RequestAborted) : null; long? FormLong(string key) => long.TryParse(form?[key].ToString(), System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var value) ? value : null; bool? FormBool(string key) => bool.TryParse(form?[key].ToString(), out var value) ? value : null; roomId ??= FormLong("roomId"); subRoomId ??= FormLong("subRoomId"); subRoomDataSaveId ??= FormLong("subRoomDataSaveId"); autoPublish ??= FormBool("autoPublish"); buildBranch ??= form?["buildBranch"].ToString(); if (roomId is not > 0 || subRoomId is not > 0 || subRoomDataSaveId is not > 0) return BadRequest(new { Success = false, Error = "roomId, subRoomId and subRoomDataSaveId are required", Value = (object?)null }); var resolvedRoomId = roomId.Value; var resolvedSubRoomId = subRoomId.Value; var resolvedSaveId = subRoomDataSaveId.Value; var resolvedAutoPublish = autoPublish ?? false; var resolvedBuildBranch = buildBranch; var accountId = CurrentAccountId(); if (accountId == null || accountId > int.MaxValue) return Unauthorized(new { Success = false, Error = "The Studio account could not be resolved.", Value = (object?)null }); await using (var db = await dbFactory.CreateDbContextAsync(HttpContext.RequestAborted)) { var room = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(x => x.RoomId == resolvedRoomId, HttpContext.RequestAborted); var canBuild = room != null && (room.CreatorAccountId == accountId.Value || await db.RoomRoles.AsNoTracking().AnyAsync(x => x.RoomId == resolvedRoomId && x.AccountId == accountId.Value && x.Role >= RoleType.CoOwner, HttpContext.RequestAborted)); if (!canBuild) return Forbid(); } var now = DateTime.UtcNow; var build = new StudioCloudBuild( Guid.NewGuid().ToString("N"), now, null, null, string.Empty, (int)accountId); CloudBuilds[build.CloudBuildId] = new StoredCloudBuild(resolvedRoomId, resolvedSubRoomId, build); var job = new StudioBuildWorker.BuildJob(build.CloudBuildId, resolvedRoomId, resolvedSubRoomId, resolvedSaveId, accountId.Value, dbFactory, build, updated => CloudBuilds.AddOrUpdate(build.CloudBuildId, _ => new StoredCloudBuild(resolvedRoomId, resolvedSubRoomId, updated), (_, existing) => existing with { Build = updated })); worker.Enqueue(job); logger.LogInformation("Queued local Studio Windows build: room={RoomId}, subRoom={SubRoomId}, save={SaveId}, autoPublish={AutoPublish}, branch={BuildBranch}, cloudBuild={CloudBuildId}.", resolvedRoomId, resolvedSubRoomId, resolvedSaveId, resolvedAutoPublish, resolvedBuildBranch, build.CloudBuildId); return Ok(new { Success = true, Error = (string?)null, Value = build }); } [HttpGet("cloud-builds"), Authorize] public async Task CloudBuild([FromQuery] string cloudBuildId) { if (!CloudBuilds.TryGetValue(cloudBuildId, out var stored)) return NotFound(); var accountId = CurrentAccountId(); if (accountId == null) return Unauthorized(); if (!await CanBuildInRoomAsync(stored.RoomId, accountId.Value, HttpContext.RequestAborted)) return Forbid(); return Ok(stored.Build); } private long? CurrentAccountId() { if (long.TryParse(User.FindFirstValue("userId"), out var userId) && userId > 0) return userId; if (long.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var identifier) && identifier > 0) return identifier; return null; } private async Task CanBuildInRoomAsync(long roomId, long accountId, CancellationToken cancellationToken) { await using var db = await dbFactory.CreateDbContextAsync(cancellationToken); var room = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(x => x.RoomId == roomId, cancellationToken); return room != null && (room.CreatorAccountId == accountId || await db.RoomRoles.AsNoTracking().AnyAsync(x => x.RoomId == roomId && x.AccountId == accountId && x.Role >= RoleType.CoOwner, cancellationToken)); } [HttpGet("collaboration/owners-in-room")] public IActionResult OwnersInRoom([FromQuery] long roomId) { logger.LogInformation("Studio owners-in-room requested: room={RoomId}; answering an empty owner list.", roomId); return Ok(new { Success = true, Error = (string?)null, Value = Array.Empty() }); } [HttpGet("analytics/rudderstack-api-key")] public IActionResult RudderstackApiKey() { return Ok(new { key = string.Empty, value = string.Empty }); } } } public sealed record StudioCloudBuild( string CloudBuildId, DateTime StartedAt, DateTime? CompletedAt, string? Error, string UnityAssetId, int CreatedByAccountId);