diff --git a/DeepDrftManager/Components/Pages/Tracks/TrackEdit.razor b/DeepDrftManager/Components/Pages/Tracks/TrackEdit.razor index 3884fb5..b5d3c1a 100644 --- a/DeepDrftManager/Components/Pages/Tracks/TrackEdit.razor +++ b/DeepDrftManager/Components/Pages/Tracks/TrackEdit.razor @@ -1,10 +1,8 @@ -@page "/cms/tracks/{Id:int}" -@using System.Net.Http.Headers -@using System.Net.Http.Json +@page "/cms/tracks/{Id:long}" +@using DeepDrftManager.Services @attribute [HierarchicalRoleAuthorize([SystemRoleConstants.Admin])] @inject ITrackService TrackService -@inject IHttpClientFactory HttpClientFactory -@inject IAuthSession AuthSession +@inject ICmsTrackService CmsTrackService @inject ISnackbar Snackbar @inject IDialogService DialogService @inject NavigationManager Nav @@ -91,7 +89,7 @@ @code { - [Parameter] public int Id { get; set; } + [Parameter] public long Id { get; set; } private TrackEntity? _track; private TrackEditForm _form = new(); @@ -126,27 +124,32 @@ _busy = true; try { - var http = HttpClientFactory.CreateClient("DeepDrft.API"); - await AttachBearerAsync(http); - - var payload = new + // Re-fetch under the current scope so we mutate the DB-authoritative entity, not + // the copy loaded at OnInitialized. Metadata-only update — EntryKey is immutable. + var lookup = await TrackService.GetById(Id); + if (!lookup.Success || lookup.Value is null) { - TrackName = _form.TrackName, - Artist = _form.Artist, - Album = string.IsNullOrWhiteSpace(_form.Album) ? null : _form.Album, - Genre = string.IsNullOrWhiteSpace(_form.Genre) ? null : _form.Genre, - ReleaseDate = _form.ReleaseDate is { } d ? DateOnly.FromDateTime(d) : (DateOnly?)null - }; + Snackbar.Add("Save failed — track could not be loaded.", Severity.Error); + return; + } - var response = await http.PutAsJsonAsync($"api/cms/track/{Id}", payload); - if (response.IsSuccessStatusCode) + var track = lookup.Value; + track.TrackName = _form.TrackName; + track.Artist = _form.Artist; + track.Album = string.IsNullOrWhiteSpace(_form.Album) ? null : _form.Album; + track.Genre = string.IsNullOrWhiteSpace(_form.Genre) ? null : _form.Genre; + track.ReleaseDate = _form.ReleaseDate is { } d ? DateOnly.FromDateTime(d) : null; + + var updated = await TrackService.Update(track); + if (updated.Success) { Snackbar.Add("Track updated.", Severity.Success); await LoadAsync(); } else { - Snackbar.Add($"Save failed: {(int)response.StatusCode} {response.ReasonPhrase}", Severity.Error); + var error = updated.Messages.FirstOrDefault()?.Message ?? "Unknown error"; + Snackbar.Add($"Save failed: {error}", Severity.Error); } } catch (Exception ex) @@ -160,7 +163,6 @@ } } - // DELETE api/cms/track/{Id} is handled by CmsDeleteController (T3 branch). private async Task ConfirmDelete() { if (_track is null) return; @@ -176,18 +178,16 @@ _busy = true; try { - var http = HttpClientFactory.CreateClient("DeepDrft.API"); - await AttachBearerAsync(http); - - var response = await http.DeleteAsync($"api/cms/track/{Id}"); - if (response.IsSuccessStatusCode) + var result = await CmsTrackService.DeleteTrackAsync(Id); + if (result.Success) { Snackbar.Add("Track deleted.", Severity.Success); Nav.NavigateTo("/cms/tracks"); } else { - Snackbar.Add($"Delete failed: {(int)response.StatusCode} {response.ReasonPhrase}", Severity.Error); + var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error"; + Snackbar.Add($"Delete failed: {error}", Severity.Error); _busy = false; } } @@ -199,15 +199,6 @@ } } - private async Task AttachBearerAsync(HttpClient http) - { - var token = await AuthSession.GetValidTokenAsync(); - if (!string.IsNullOrEmpty(token)) - { - http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); - } - } - private sealed class TrackEditForm { public string TrackName { get; set; } = string.Empty; diff --git a/DeepDrftManager/Components/Pages/Tracks/TrackList.razor b/DeepDrftManager/Components/Pages/Tracks/TrackList.razor index 1762f2d..e6153dd 100644 --- a/DeepDrftManager/Components/Pages/Tracks/TrackList.razor +++ b/DeepDrftManager/Components/Pages/Tracks/TrackList.razor @@ -1,10 +1,9 @@ @page "/cms/tracks" @using System.Net -@using System.Net.Http.Headers +@using DeepDrftManager.Services @attribute [HierarchicalRoleAuthorize([SystemRoleConstants.Admin])] @inject ITrackService TrackService -@inject IHttpClientFactory HttpClientFactory -@inject IAuthSession AuthSession +@inject ICmsTrackService CmsTrackService @inject IDialogService DialogService @inject ISnackbar Snackbar @inject ILogger Logger @@ -113,20 +112,16 @@ try { - var client = HttpClientFactory.CreateClient("DeepDrft.API"); - var token = await AuthSession.GetValidTokenAsync(); - if (!string.IsNullOrEmpty(token)) - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); - var response = await client.DeleteAsync($"api/cms/track/{track.Id}"); - - if (response.IsSuccessStatusCode) + var result = await CmsTrackService.DeleteTrackAsync(track.Id); + if (result.Success) { Snackbar.Add($"Deleted '{track.TrackName}'.", Severity.Success); if (_table is not null) await _table.ReloadServerData(); } else { - Snackbar.Add($"Delete failed ({(int)response.StatusCode} {response.ReasonPhrase}).", Severity.Error); + var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error"; + Snackbar.Add($"Delete failed: {error}", Severity.Error); } } catch (Exception ex) diff --git a/DeepDrftManager/Components/Pages/Tracks/TrackNew.razor b/DeepDrftManager/Components/Pages/Tracks/TrackNew.razor index 10b787b..60b80ec 100644 --- a/DeepDrftManager/Components/Pages/Tracks/TrackNew.razor +++ b/DeepDrftManager/Components/Pages/Tracks/TrackNew.razor @@ -1,12 +1,13 @@ @page "/cms/tracks/new" -@using System.Net.Http.Headers +@using System.Security.Claims +@using DeepDrftManager.Services @attribute [HierarchicalRoleAuthorize([SystemRoleConstants.Admin])] -@inject IHttpClientFactory HttpClientFactory +@inject ICmsTrackService CmsTrackService +@inject AuthenticationStateProvider AuthStateProvider @inject NavigationManager Navigation @inject ISnackbar Snackbar @inject ILogger Logger -@inject IAuthSession AuthSession Add Track — DeepDrft CMS @@ -61,8 +62,8 @@ @code { - // 1 GB ceiling matches the proxy controller's RequestSizeLimit; the actual streaming - // path means the limit caps the request, not in-memory buffering. + // 1 GB ceiling matches DeepDrftContent's per-request limit on api/track/upload; the + // streaming path means the limit caps the request, not in-memory buffering. private const long MaxUploadBytes = 1_073_741_824L; private IBrowserFile? _selectedFile; @@ -115,41 +116,46 @@ return; } + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + var userIdValue = authState.User.FindFirstValue(ClaimTypes.NameIdentifier); + if (!long.TryParse(userIdValue, out var createdByUserId)) + { + // The page is gated by [HierarchicalRoleAuthorize(Admin)], so a missing or + // unparseable id here is a configuration bug, not normal client state. + Logger.LogError("Authenticated user has no parseable NameIdentifier claim: {Value}", userIdValue); + _errorMessage = "Your session is missing a valid identifier. Please sign in again."; + return; + } + _isUploading = true; try { - using var multipart = new MultipartFormDataContent(); - - // OpenReadStream streams chunks from the browser via the SignalR circuit; - // wrapping in StreamContent avoids materialising the whole file in memory - // before the proxy controller receives it. + // OpenReadStream streams chunks from the browser via the SignalR circuit; the + // service wraps it in StreamContent so the whole file is never materialised in + // memory before DeepDrftContent receives it. await using var fileStream = _selectedFile.OpenReadStream(MaxUploadBytes); - var fileContent = new StreamContent(fileStream); - fileContent.Headers.ContentType = new MediaTypeHeaderValue( - string.IsNullOrWhiteSpace(_selectedFile.ContentType) ? "audio/wav" : _selectedFile.ContentType); - multipart.Add(fileContent, "wav", _selectedFile.Name); - multipart.Add(new StringContent(_trackName), "trackName"); - multipart.Add(new StringContent(_artist), "artist"); - if (!string.IsNullOrWhiteSpace(_album)) multipart.Add(new StringContent(_album), "album"); - if (!string.IsNullOrWhiteSpace(_genre)) multipart.Add(new StringContent(_genre), "genre"); - if (!string.IsNullOrWhiteSpace(_releaseDate)) multipart.Add(new StringContent(_releaseDate), "releaseDate"); - var client = HttpClientFactory.CreateClient("DeepDrft.API"); - await AttachBearerAsync(client); - using var response = await client.PostAsync("api/cms/track", multipart); + var result = await CmsTrackService.UploadTrackAsync( + fileStream, + _selectedFile.Name, + _selectedFile.ContentType, + _trackName, + _artist, + string.IsNullOrWhiteSpace(_album) ? null : _album, + string.IsNullOrWhiteSpace(_genre) ? null : _genre, + string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate, + createdByUserId); - if (response.IsSuccessStatusCode) + if (result.Success) { Snackbar.Add($"Uploaded '{_trackName}'.", Severity.Success); Navigation.NavigateTo("/cms/tracks"); return; } - var body = await response.Content.ReadAsStringAsync(); - _errorMessage = string.IsNullOrWhiteSpace(body) - ? $"Upload failed ({(int)response.StatusCode})." - : $"Upload failed ({(int)response.StatusCode}): {body}"; - Logger.LogWarning("CMS upload rejected: {Status} {Body}", (int)response.StatusCode, body); + var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error"; + _errorMessage = $"Upload failed: {error}"; + Logger.LogWarning("CMS upload rejected: {Error}", error); } catch (Exception ex) { @@ -167,15 +173,6 @@ Navigation.NavigateTo("/cms/tracks"); } - private async Task AttachBearerAsync(HttpClient http) - { - var token = await AuthSession.GetValidTokenAsync(); - if (!string.IsNullOrEmpty(token)) - { - http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); - } - } - private static string FormatBytes(long bytes) { const long KB = 1024; diff --git a/DeepDrftManager/Components/Shared/DeleteTrackDialog.razor b/DeepDrftManager/Components/Shared/DeleteTrackDialog.razor deleted file mode 100644 index 65bae87..0000000 --- a/DeepDrftManager/Components/Shared/DeleteTrackDialog.razor +++ /dev/null @@ -1,88 +0,0 @@ -@using System.Net.Http -@using System.Net.Http.Headers -@using Microsoft.AspNetCore.Components -@inject IHttpClientFactory HttpClientFactory -@inject IAuthSession AuthSession - - - - - Are you sure you want to delete '@TrackName'? This cannot be undone. - - @if (!string.IsNullOrEmpty(_errorMessage)) - { - @_errorMessage - } - - - Cancel - - @if (_isDeleting) - { - - Deleting... - } - else - { - Delete - } - - - - -@code { - [CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = default!; - - [Parameter] public long TrackId { get; set; } - [Parameter] public string TrackName { get; set; } = ""; - [Parameter] public EventCallback OnDeleted { get; set; } - - private bool _isDeleting; - private string? _errorMessage; - - private async Task ConfirmAsync() - { - _isDeleting = true; - _errorMessage = null; - - try - { - var client = HttpClientFactory.CreateClient("DeepDrft.API"); - var token = await AuthSession.GetValidTokenAsync(); - if (!string.IsNullOrEmpty(token)) - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); - var response = await client.DeleteAsync($"api/cms/track/{TrackId}"); - - if (response.IsSuccessStatusCode) - { - if (OnDeleted.HasDelegate) - { - await OnDeleted.InvokeAsync(); - } - MudDialog.Close(DialogResult.Ok(true)); - return; - } - - _errorMessage = response.StatusCode switch - { - System.Net.HttpStatusCode.NotFound => "Track not found. It may have already been deleted.", - System.Net.HttpStatusCode.Unauthorized => "You are not authorized to delete this track.", - System.Net.HttpStatusCode.Forbidden => "You are not authorized to delete this track.", - _ => $"Delete failed ({(int)response.StatusCode})." - }; - } - catch (Exception ex) - { - _errorMessage = $"Delete failed: {ex.Message}"; - } - finally - { - _isDeleting = false; - } - } - - private void Cancel() => MudDialog.Cancel(); -} diff --git a/DeepDrftManager/Components/_Imports.razor b/DeepDrftManager/Components/_Imports.razor index cd6df25..8772000 100644 --- a/DeepDrftManager/Components/_Imports.razor +++ b/DeepDrftManager/Components/_Imports.razor @@ -16,4 +16,3 @@ @using Models.Common @using AuthBlocksModels.SystemDefinitions @using AuthBlocksWeb.HierarchicalAuthorize -@using AuthBlocksWeb.Services diff --git a/DeepDrftManager/Controllers/CmsDeleteController.cs b/DeepDrftManager/Controllers/CmsDeleteController.cs deleted file mode 100644 index 804c04d..0000000 --- a/DeepDrftManager/Controllers/CmsDeleteController.cs +++ /dev/null @@ -1,90 +0,0 @@ -using DeepDrftData; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; - -namespace DeepDrftManager.Controllers; - -/// -/// CMS delete endpoint. Owned by W3-T3 — separate controller from upload/edit to -/// avoid merge contention with parallel CMS tracks. -/// -/// Delete order (CMS-PLAN W1.5): SQL first, then vault. If the SQL row is gone we -/// return success to the user even when the subsequent vault delete fails — SQL is -/// the source of truth for "exists from the user's view". A vault failure is logged -/// as an orphan for maintenance to reap (see PLAN.md §4.3 dead-letter). -/// -[ApiController] -[Route("api/cms/track")] -[Authorize(Roles = "Admin")] -public class CmsDeleteController : ControllerBase -{ - // Named HttpClient used to call DeepDrftContent's ApiKey-protected endpoints. - // The Manager owns this name now that the CMS lives here; the client is registered - // in Program.cs alongside the public "DeepDrft.API" client. - private const string ContentCmsHttpClientName = "DeepDrft.Content.Cms"; - - private readonly ITrackService _trackService; - private readonly IHttpClientFactory _httpClientFactory; - private readonly ILogger _logger; - - public CmsDeleteController( - ITrackService trackService, - IHttpClientFactory httpClientFactory, - ILogger logger) - { - _trackService = trackService; - _httpClientFactory = httpClientFactory; - _logger = logger; - } - - [HttpDelete("{id:long}")] - public async Task DeleteTrack(long id) - { - // 1. Resolve the EntryKey before we delete the SQL row — afterwards the join is gone. - var lookup = await _trackService.GetById(id); - if (!lookup.Success) - { - _logger.LogError("CMS delete: lookup failed for track {TrackId}: {Error}", id, lookup.Messages.FirstOrDefault()?.Message); - return StatusCode(500, "Failed to load track"); - } - - var track = lookup.Value; - if (track == null) - { - return NotFound(); - } - - var entryKey = track.EntryKey; - - // 2. SQL delete. On failure, do NOT touch the vault — nothing to clean up. - var sqlDelete = await _trackService.Delete(id); - if (!sqlDelete.Success) - { - _logger.LogError("CMS delete: SQL delete failed for track {TrackId}: {Error}", id, sqlDelete.Messages.FirstOrDefault()?.Message); - return StatusCode(500, "Failed to delete track"); - } - - // 3. Vault delete. Failure is logged as an orphan but does not fail the request: - // SQL is the source of truth for the user's view; the orphan is a maintenance concern. - var client = _httpClientFactory.CreateClient(ContentCmsHttpClientName); - try - { - var response = await client.DeleteAsync($"api/track/{Uri.EscapeDataString(entryKey)}"); - if (!response.IsSuccessStatusCode) - { - _logger.LogWarning( - "Vault delete failed after SQL delete. {TrackId} {EntryKey} {StatusCode}", - id, entryKey, (int)response.StatusCode); - } - } - catch (Exception ex) - { - _logger.LogWarning( - ex, - "Vault delete threw after SQL delete. {TrackId} {EntryKey}", - id, entryKey); - } - - return Ok(); - } -} diff --git a/DeepDrftManager/Controllers/CmsEditController.cs b/DeepDrftManager/Controllers/CmsEditController.cs deleted file mode 100644 index dee7215..0000000 --- a/DeepDrftManager/Controllers/CmsEditController.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using DeepDrftData; -using DeepDrftModels.Entities; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using NetBlocks.Models; - -namespace DeepDrftManager.Controllers; - -[ApiController] -[Authorize(Roles = "Admin")] -[Route("api/cms/track")] -public class CmsEditController : ControllerBase -{ - private readonly ITrackService _trackService; - - public CmsEditController(ITrackService trackService) - { - _trackService = trackService; - } - - // Metadata-only update. EntryKey is immutable in Wave 1 — audio replacement - // is a separate Wave 2 operation that touches the vault. - [HttpPut("{id:long}")] - public async Task>> Update(long id, [FromBody] CmsTrackUpdateRequest request) - { - var existing = await _trackService.GetById(id); - if (!existing.Success) - { - var failure = ApiResult.CreateFailResult(existing.GetMessage()); - return StatusCode(500, new ApiResultDto(failure)); - } - - if (existing.Value is null) - { - return NotFound(); - } - - var track = existing.Value; - track.TrackName = request.TrackName; - track.Artist = request.Artist; - track.Album = request.Album; - track.Genre = request.Genre; - track.ReleaseDate = request.ReleaseDate; - - var updated = await _trackService.Update(track); - var apiResult = ApiResult.From(updated); - var dto = new ApiResultDto(apiResult); - - return updated.Success ? Ok(dto) : StatusCode(500, dto); - } -} - -public record CmsTrackUpdateRequest( - [Required, MaxLength(200)] string TrackName, - [Required, MaxLength(200)] string Artist, - [MaxLength(200)] string? Album, - [MaxLength(100)] string? Genre, - DateOnly? ReleaseDate); diff --git a/DeepDrftManager/Controllers/CmsUploadController.cs b/DeepDrftManager/Controllers/CmsUploadController.cs deleted file mode 100644 index 3cf86dd..0000000 --- a/DeepDrftManager/Controllers/CmsUploadController.cs +++ /dev/null @@ -1,168 +0,0 @@ -using System.Net.Http.Headers; -using System.Security.Claims; -using DeepDrftData; -using DeepDrftModels.Entities; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; - -namespace DeepDrftManager.Controllers; - -/// -/// CMS upload surface. Proxies a WAV + metadata multipart form to DeepDrftContent's -/// POST api/track/upload, then persists the returned unpersisted TrackEntity to SQL via -/// ITrackService.Create. DeepDrftManager intentionally does not reference DeepDrftContent.Data -/// (CMS-PLAN §5, Option B) — all vault access is over HTTP. -/// -[ApiController] -[Authorize(Roles = "Admin")] -[Route("api/cms")] -public class CmsUploadController : ControllerBase -{ - private const string ContentClientName = "DeepDrft.Content"; - private const string UploadPath = "api/track/upload"; - - private readonly IHttpClientFactory _httpClientFactory; - private readonly ITrackService _trackService; - private readonly IConfiguration _configuration; - private readonly ILogger _logger; - - public CmsUploadController( - IHttpClientFactory httpClientFactory, - ITrackService trackService, - IConfiguration configuration, - ILogger logger) - { - _httpClientFactory = httpClientFactory; - _trackService = trackService; - _configuration = configuration; - _logger = logger; - } - - // Match DeepDrftContent's per-request ceiling so the proxy itself does not reject - // a payload the downstream endpoint would accept. - [HttpPost("track")] - [RequestSizeLimit(1_073_741_824)] - [RequestFormLimits(MultipartBodyLengthLimit = 1_073_741_824)] - public async Task> UploadTrack( - [FromForm] IFormFile? wav, - [FromForm] string? trackName, - [FromForm] string? artist, - [FromForm] string? album, - [FromForm] string? genre, - [FromForm] string? releaseDate, - CancellationToken cancellationToken) - { - if (wav is null || wav.Length == 0) - { - return BadRequest("WAV file is required"); - } - - if (string.IsNullOrWhiteSpace(trackName)) - { - return BadRequest("trackName is required"); - } - - if (string.IsNullOrWhiteSpace(artist)) - { - return BadRequest("artist is required"); - } - - var apiKey = _configuration["DeepDrftContent:ApiKey"]; - if (string.IsNullOrWhiteSpace(apiKey)) - { - _logger.LogError("DeepDrftContent:ApiKey is not configured"); - return StatusCode(500, "Content API key is not configured"); - } - - var userIdValue = User.FindFirstValue(ClaimTypes.NameIdentifier); - if (!long.TryParse(userIdValue, out var userId)) - { - // [Authorize(Roles = "Admin")] gates upstream, so a missing/unparseable - // user id here is a configuration bug, not a normal client state. - _logger.LogError("Authenticated user has no parseable NameIdentifier claim: {Value}", userIdValue); - return StatusCode(500, "Authenticated user is missing a valid identifier"); - } - - // Forward the upload to DeepDrftContent. We rebuild the multipart container rather - // than relaying Request.Body so the boundary is owned by HttpClient and IFormFile's - // already-buffered stream (memory + temp-file backed by Kestrel) is the source. - using var multipart = new MultipartFormDataContent(); - await using var wavStream = wav.OpenReadStream(); - var wavContent = new StreamContent(wavStream); - wavContent.Headers.ContentType = new MediaTypeHeaderValue( - string.IsNullOrWhiteSpace(wav.ContentType) ? "audio/wav" : wav.ContentType); - multipart.Add(wavContent, "wav", wav.FileName); - multipart.Add(new StringContent(trackName), "trackName"); - multipart.Add(new StringContent(artist), "artist"); - if (!string.IsNullOrWhiteSpace(album)) multipart.Add(new StringContent(album), "album"); - if (!string.IsNullOrWhiteSpace(genre)) multipart.Add(new StringContent(genre), "genre"); - if (!string.IsNullOrWhiteSpace(releaseDate)) multipart.Add(new StringContent(releaseDate), "releaseDate"); - - var client = _httpClientFactory.CreateClient(ContentClientName); - using var request = new HttpRequestMessage(HttpMethod.Post, UploadPath) { Content = multipart }; - request.Headers.Add("ApiKey", apiKey); - - HttpResponseMessage response; - try - { - response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Content API call failed for upload of {TrackName}", trackName); - return StatusCode(502, "Content API is unreachable"); - } - - using (response) - { - if (!response.IsSuccessStatusCode) - { - var body = await response.Content.ReadAsStringAsync(cancellationToken); - var statusCode = (int)response.StatusCode; - if (statusCode >= 500) - { - _logger.LogError("Content API returned {Status} for upload of {TrackName}: {Body}", statusCode, trackName, body); - return StatusCode(statusCode, "Upload failed on the content server. Please try again."); - } - - // 4xx: body is user-friendly validation text from DeepDrftContent — relay as-is. - _logger.LogWarning("Content API rejected upload: {Status} {Body}", statusCode, body); - return StatusCode(statusCode, body); - } - - TrackEntity? unpersisted; - try - { - unpersisted = await response.Content.ReadFromJsonAsync(cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to deserialize TrackEntity from Content API response"); - return StatusCode(502, "Content API returned an unexpected response"); - } - - if (unpersisted is null) - { - _logger.LogError("Content API returned a null TrackEntity"); - return StatusCode(502, "Content API returned an empty response"); - } - - unpersisted.CreatedByUserId = userId; - - var saveResult = await _trackService.Create(unpersisted); - if (!saveResult.Success || saveResult.Value is null) - { - // The vault write succeeded but the SQL persist failed — audio is now orphaned - // in the tracks vault under EntryKey. CMS-PLAN W2.4 covers the dead-letter - // mechanism; until then we log loudly so the orphan is recoverable manually. - var error = saveResult.Messages.FirstOrDefault()?.Message ?? "Unknown error"; - _logger.LogError( - "Track persisted to vault but SQL save failed. Orphaned entry: {EntryKey}. Error: {Error}", - unpersisted.EntryKey, error); - return StatusCode(500, $"Track was uploaded but could not be saved: {error}"); - } - - return Ok(saveResult.Value); - } - } -} diff --git a/DeepDrftManager/Program.cs b/DeepDrftManager/Program.cs index 4edf02e..f737902 100644 --- a/DeepDrftManager/Program.cs +++ b/DeepDrftManager/Program.cs @@ -4,6 +4,7 @@ using DeepDrftData; using DeepDrftData.Data; using DeepDrftData.Repositories; using DeepDrftManager.Components; +using DeepDrftManager.Services; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.EntityFrameworkCore; using MudBlazor.Services; @@ -16,8 +17,7 @@ var builder = WebApplication.CreateBuilder(args); // - environment/apikey.json: { "DeepDrftContent": { "ApiKey": "..." } } // - environment/connections.json: { "ConnectionStrings": { "DefaultConnection": "...", "Auth": "..." } } // - environment/authblocks.json: { "AuthBlocks": { "Jwt": {...}, "Email": {...}, "Admin": {...} } } -// Content API key — not consumed by this host in Phase 1. Required by the CredentialTools -// pattern (the file must exist); will be used by CmsUploadController when it migrates here. +// Content API key — consumed by CmsTrackService for the upload proxy and the vault-delete client. var apiKeyPath = CredentialTools.ResolvePathOrThrow("apikey", "environment/apikey.json"); builder.Configuration.AddJsonFile(apiKeyPath, optional: false, reloadOnChange: false); @@ -40,6 +40,11 @@ builder.Services .AddScoped() .AddScoped(sp => sp.GetRequiredService()); +// CMS track mutations (upload proxy + delete). Called directly by the InteractiveServer +// Blazor components — no in-process HTTP roundtrip. Vault access still goes over HTTP to +// DeepDrftContent via the named clients below. +builder.Services.AddScoped(); + // AuthBlocks: JWT Bearer auth, Identity, EF schema, admin seeding. // Auth schema runs in its own database (separate from DefaultConnection by design). builder.Services.AddAuthBlocks(options => @@ -77,17 +82,8 @@ builder.Services.AddAuthBlocks(options => var baseUrl = GetKestrelUrl(builder); AuthBlocksWeb.Startup.ConfigureAuthServices(builder.Services, baseUrl); -// Named HttpClient used by CMS pages for in-process CMS endpoints (CmsUploadController, -// CmsEditController, CmsDeleteController) and the AuthBlocks surface — both live on this host. -// Base-addressed to the Manager's own Kestrel URL so callers using relative paths -// (e.g. "api/cms/track") hit our own controllers, not the public host. -builder.Services.AddHttpClient("DeepDrft.API", client => -{ - client.BaseAddress = new Uri(baseUrl); -}); - -// Named HttpClient for unauthenticated Content API calls (e.g. CmsUploadController proxying WAV -// data to DeepDrftContent's POST api/track/upload). API key added per-request by the controller. +// Named HttpClient for unauthenticated Content API calls (CmsTrackService proxying WAV data +// to DeepDrftContent's POST api/track/upload). API key added per-request by the service. var contentApiUrl = builder.Configuration["ApiUrls:ContentApi"] ?? throw new InvalidOperationException("ApiUrls:ContentApi is required"); builder.Services.AddHttpClient("DeepDrft.Content", client => @@ -95,8 +91,8 @@ builder.Services.AddHttpClient("DeepDrft.Content", client => client.BaseAddress = new Uri(contentApiUrl); }); -// Named HttpClient for ApiKey-protected Content API calls (e.g. CmsDeleteController's vault -// delete). API key baked into the default request headers so callers need not add it manually. +// Named HttpClient for ApiKey-protected Content API calls (CmsTrackService's vault delete). +// API key baked into the default request headers so callers need not add it manually. var contentApiKey = builder.Configuration["DeepDrftContent:ApiKey"] ?? throw new InvalidOperationException("DeepDrftContent:ApiKey is required"); builder.Services.AddHttpClient("DeepDrft.Content.Cms", client => @@ -116,10 +112,6 @@ builder.Services.Configure(options => options.KnownProxies.Clear(); }); -// Controllers: discovers CMS mutation controllers (CmsUploadController, CmsEditController, -// CmsDeleteController) and the AuthBlocks surface. Matches DeepDrftPublic precedent. -builder.Services.AddControllers(); - // InteractiveServer only — no WASM render mode on the CMS host. builder.Services.AddRazorComponents() .AddInteractiveServerComponents(); @@ -165,16 +157,13 @@ app.MapStaticAssets(); // Razor pages (/account/login, /account/logout). app.MapAuthBlocks(); -// Mounts CMS mutation controllers (CmsUploadController, CmsEditController, CmsDeleteController). -app.MapControllers(); - // Blazor page authorization is owned by AuthorizeRouteView in Routes.razor, not // ASP.NET Core endpoint authorization. AuthBlocks tokens live in browser localStorage // (read via JS interop by JwtAuthenticationStateProvider), so the JWT never reaches // the server on a navigation request. Without AllowAnonymous here, the JwtBearer // challenge for an unauthenticated nav returns 401 before the Blazor router runs, // short-circuiting the NotAuthorized -> RedirectToLogin path. JWT enforcement -// remains in force for the API surfaces (MapAuthBlocks, MapControllers). +// remains in force for the AuthBlocks API surface (MapAuthBlocks). app.MapRazorComponents() .AddInteractiveServerRenderMode() .AddAdditionalAssemblies(typeof(AuthBlocksWeb._Imports).Assembly) diff --git a/DeepDrftManager/Services/CmsTrackService.cs b/DeepDrftManager/Services/CmsTrackService.cs new file mode 100644 index 0000000..4013f24 --- /dev/null +++ b/DeepDrftManager/Services/CmsTrackService.cs @@ -0,0 +1,188 @@ +using System.Net.Http.Headers; +using DeepDrftData; +using DeepDrftModels.Entities; +using NetBlocks.Models; + +namespace DeepDrftManager.Services; + +/// +/// Direct-call CMS track service. Replaces the former in-process HTTP roundtrip through +/// CmsUploadController / CmsDeleteController: the Manager is InteractiveServer-only, so its +/// Blazor components inject this service and call it directly rather than POSTing to their +/// own loopback controllers. Vault access remains over HTTP to DeepDrftContent (a separate +/// host); SQL metadata is reached directly via . +/// +public class CmsTrackService : ICmsTrackService +{ + private const string ContentClientName = "DeepDrft.Content"; + private const string ContentCmsClientName = "DeepDrft.Content.Cms"; + private const string UploadPath = "api/track/upload"; + + private readonly IHttpClientFactory _httpClientFactory; + private readonly ITrackService _trackService; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + public CmsTrackService( + IHttpClientFactory httpClientFactory, + ITrackService trackService, + IConfiguration configuration, + ILogger logger) + { + _httpClientFactory = httpClientFactory; + _trackService = trackService; + _configuration = configuration; + _logger = logger; + } + + public async Task> UploadTrackAsync( + Stream wavStream, + string fileName, + string contentType, + string trackName, + string artist, + string? album, + string? genre, + string? releaseDate, + long createdByUserId, + CancellationToken ct = default) + { + var apiKey = _configuration["DeepDrftContent:ApiKey"]; + if (string.IsNullOrWhiteSpace(apiKey)) + { + _logger.LogError("DeepDrftContent:ApiKey is not configured"); + return ResultContainer.CreateFailResult("Content API key is not configured."); + } + + // Rebuild the multipart container so the boundary is owned by HttpClient and the + // caller-supplied stream (already buffered by the SignalR upload) is the source. + using var multipart = new MultipartFormDataContent(); + var wavContent = new StreamContent(wavStream); + wavContent.Headers.ContentType = new MediaTypeHeaderValue( + string.IsNullOrWhiteSpace(contentType) ? "audio/wav" : contentType); + multipart.Add(wavContent, "wav", fileName); + multipart.Add(new StringContent(trackName), "trackName"); + multipart.Add(new StringContent(artist), "artist"); + if (!string.IsNullOrWhiteSpace(album)) multipart.Add(new StringContent(album), "album"); + if (!string.IsNullOrWhiteSpace(genre)) multipart.Add(new StringContent(genre), "genre"); + if (!string.IsNullOrWhiteSpace(releaseDate)) multipart.Add(new StringContent(releaseDate), "releaseDate"); + + var client = _httpClientFactory.CreateClient(ContentClientName); + using var request = new HttpRequestMessage(HttpMethod.Post, UploadPath) { Content = multipart }; + request.Headers.Add("ApiKey", apiKey); + + HttpResponseMessage response; + try + { + response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct); + } + catch (Exception ex) + { + _logger.LogError(ex, "Content API call failed for upload of {TrackName}", trackName); + return ResultContainer.CreateFailResult("Content API is unreachable."); + } + + using (response) + { + if (!response.IsSuccessStatusCode) + { + var body = await response.Content.ReadAsStringAsync(ct); + var statusCode = (int)response.StatusCode; + if (statusCode >= 500) + { + _logger.LogError("Content API returned {Status} for upload of {TrackName}: {Body}", statusCode, trackName, body); + return ResultContainer.CreateFailResult("Upload failed on the content server. Please try again."); + } + + // 4xx: body is user-friendly validation text from DeepDrftContent — relay as-is. + _logger.LogWarning("Content API rejected upload: {Status} {Body}", statusCode, body); + return ResultContainer.CreateFailResult( + string.IsNullOrWhiteSpace(body) ? $"Upload rejected ({statusCode})." : body); + } + + TrackEntity? unpersisted; + try + { + unpersisted = await response.Content.ReadFromJsonAsync(ct); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to deserialize TrackEntity from Content API response"); + return ResultContainer.CreateFailResult("Content API returned an unexpected response."); + } + + if (unpersisted is null) + { + _logger.LogError("Content API returned a null TrackEntity"); + return ResultContainer.CreateFailResult("Content API returned an empty response."); + } + + unpersisted.CreatedByUserId = createdByUserId; + + var saveResult = await _trackService.Create(unpersisted); + if (!saveResult.Success || saveResult.Value is null) + { + // The vault write succeeded but the SQL persist failed — audio is now orphaned + // in the tracks vault under EntryKey. CMS-PLAN W2.4 covers the dead-letter + // mechanism; until then we log loudly so the orphan is recoverable manually. + var error = saveResult.Messages.FirstOrDefault()?.Message ?? "Unknown error"; + _logger.LogError( + "Track persisted to vault but SQL save failed. Orphaned entry: {EntryKey}. Error: {Error}", + unpersisted.EntryKey, error); + return ResultContainer.CreateFailResult($"Track was uploaded but could not be saved: {error}"); + } + + return saveResult; + } + } + + public async Task DeleteTrackAsync(long id, CancellationToken ct = default) + { + // 1. Resolve the EntryKey before we delete the SQL row — afterwards the join is gone. + var lookup = await _trackService.GetById(id); + if (!lookup.Success) + { + var error = lookup.Messages.FirstOrDefault()?.Message ?? "unknown error"; + _logger.LogError("CMS delete: GetById threw for track {TrackId}: {Error}", id, error); + return Result.CreateFailResult("Failed to load track."); + } + + if (lookup.Value is null) + { + return Result.CreateFailResult("Track not found."); + } + + var track = lookup.Value; + + var entryKey = track.EntryKey; + + // 2. SQL delete. On failure, do NOT touch the vault — nothing to clean up. + var sqlDelete = await _trackService.Delete(id); + if (!sqlDelete.Success) + { + var error = sqlDelete.Messages.FirstOrDefault()?.Message; + _logger.LogError("CMS delete: SQL delete failed for track {TrackId}: {Error}", id, error); + return Result.CreateFailResult("Failed to delete track."); + } + + // 3. Vault delete. Failure is logged as an orphan but does not fail the operation: + // SQL is the source of truth for the user's view; the orphan is a maintenance concern. + var client = _httpClientFactory.CreateClient(ContentCmsClientName); + try + { + using var response = await client.DeleteAsync($"api/track/{Uri.EscapeDataString(entryKey)}", ct); + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning( + "Vault delete failed after SQL delete. {TrackId} {EntryKey} {StatusCode}", + id, entryKey, (int)response.StatusCode); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Vault delete threw after SQL delete. {TrackId} {EntryKey}", id, entryKey); + } + + return Result.CreatePassResult(); + } +} diff --git a/DeepDrftManager/Services/ICmsTrackService.cs b/DeepDrftManager/Services/ICmsTrackService.cs new file mode 100644 index 0000000..5424965 --- /dev/null +++ b/DeepDrftManager/Services/ICmsTrackService.cs @@ -0,0 +1,38 @@ +using DeepDrftModels.Entities; +using NetBlocks.Models; + +namespace DeepDrftManager.Services; + +/// +/// CMS-side track mutations for the Manager host. Coordinates the dual-database write: +/// SQL metadata via ITrackService and binary audio in DeepDrftContent's vault over HTTP. +/// DeepDrftManager intentionally does not reference DeepDrftContent.Services (CMS-PLAN §5, +/// Option B) — all vault access is over the network to DeepDrftContent. +/// +public interface ICmsTrackService +{ + /// + /// Proxy a WAV upload to DeepDrftContent, then persist the returned metadata to SQL. + /// On success the returned entity carries the SQL-assigned Id. If the vault write + /// succeeds but the SQL persist fails, the audio is orphaned under EntryKey — the + /// failure is logged loudly and surfaced as a failed result. + /// + Task> UploadTrackAsync( + Stream wavStream, + string fileName, + string contentType, + string trackName, + string artist, + string? album, + string? genre, + string? releaseDate, + long createdByUserId, + CancellationToken ct = default); + + /// + /// Delete a track's SQL row, then its vault entry. SQL is the source of truth: a SQL + /// delete failure fails the operation, but a subsequent vault delete failure is logged + /// and swallowed (the orphan is a maintenance concern, not a user-facing error). + /// + Task DeleteTrackAsync(long id, CancellationToken ct = default); +}