Merge branch 'cms-direct-service' into dev

This commit is contained in:
Daniel Harvey
2026-05-24 21:16:58 -04:00
11 changed files with 304 additions and 512 deletions
@@ -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 @@
</MudContainer>
@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;
@@ -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<TrackList> 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)
@@ -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<TrackNew> Logger
@inject IAuthSession AuthSession
<PageTitle>Add Track — DeepDrft CMS</PageTitle>
@@ -61,8 +62,8 @@
</MudContainer>
@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;
@@ -1,88 +0,0 @@
@using System.Net.Http
@using System.Net.Http.Headers
@using Microsoft.AspNetCore.Components
@inject IHttpClientFactory HttpClientFactory
@inject IAuthSession AuthSession
<MudDialog>
<DialogContent>
<MudText Typo="Typo.body1">
Are you sure you want to delete '@TrackName'? This cannot be undone.
</MudText>
@if (!string.IsNullOrEmpty(_errorMessage))
{
<MudAlert Severity="Severity.Error" Class="mt-3" Dense="true">@_errorMessage</MudAlert>
}
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel" Disabled="_isDeleting">Cancel</MudButton>
<MudButton Color="Color.Error"
Variant="Variant.Filled"
OnClick="ConfirmAsync"
Disabled="_isDeleting">
@if (_isDeleting)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="me-2" />
<span>Deleting...</span>
}
else
{
<span>Delete</span>
}
</MudButton>
</DialogActions>
</MudDialog>
@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();
}
@@ -16,4 +16,3 @@
@using Models.Common
@using AuthBlocksModels.SystemDefinitions
@using AuthBlocksWeb.HierarchicalAuthorize
@using AuthBlocksWeb.Services
@@ -1,90 +0,0 @@
using DeepDrftData;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace DeepDrftManager.Controllers;
/// <summary>
/// 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).
/// </summary>
[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<CmsDeleteController> _logger;
public CmsDeleteController(
ITrackService trackService,
IHttpClientFactory httpClientFactory,
ILogger<CmsDeleteController> logger)
{
_trackService = trackService;
_httpClientFactory = httpClientFactory;
_logger = logger;
}
[HttpDelete("{id:long}")]
public async Task<ActionResult> 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();
}
}
@@ -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<ActionResult<ApiResultDto<TrackEntity>>> Update(long id, [FromBody] CmsTrackUpdateRequest request)
{
var existing = await _trackService.GetById(id);
if (!existing.Success)
{
var failure = ApiResult<TrackEntity>.CreateFailResult(existing.GetMessage());
return StatusCode(500, new ApiResultDto<TrackEntity>(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<TrackEntity>.From(updated);
var dto = new ApiResultDto<TrackEntity>(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);
@@ -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;
/// <summary>
/// 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.
/// </summary>
[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<CmsUploadController> _logger;
public CmsUploadController(
IHttpClientFactory httpClientFactory,
ITrackService trackService,
IConfiguration configuration,
ILogger<CmsUploadController> 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<ActionResult<TrackEntity>> 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<TrackEntity>(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);
}
}
}
+12 -23
View File
@@ -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<TrackManager>()
.AddScoped<ITrackService>(sp => sp.GetRequiredService<TrackManager>());
// 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<ICmsTrackService, CmsTrackService>();
// 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<ForwardedHeadersOptions>(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<App>()
.AddInteractiveServerRenderMode()
.AddAdditionalAssemblies(typeof(AuthBlocksWeb._Imports).Assembly)
+188
View File
@@ -0,0 +1,188 @@
using System.Net.Http.Headers;
using DeepDrftData;
using DeepDrftModels.Entities;
using NetBlocks.Models;
namespace DeepDrftManager.Services;
/// <summary>
/// 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 <see cref="ITrackService"/>.
/// </summary>
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<CmsTrackService> _logger;
public CmsTrackService(
IHttpClientFactory httpClientFactory,
ITrackService trackService,
IConfiguration configuration,
ILogger<CmsTrackService> logger)
{
_httpClientFactory = httpClientFactory;
_trackService = trackService;
_configuration = configuration;
_logger = logger;
}
public async Task<ResultContainer<TrackEntity>> 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<TrackEntity>.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<TrackEntity>.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<TrackEntity>.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<TrackEntity>.CreateFailResult(
string.IsNullOrWhiteSpace(body) ? $"Upload rejected ({statusCode})." : body);
}
TrackEntity? unpersisted;
try
{
unpersisted = await response.Content.ReadFromJsonAsync<TrackEntity>(ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to deserialize TrackEntity from Content API response");
return ResultContainer<TrackEntity>.CreateFailResult("Content API returned an unexpected response.");
}
if (unpersisted is null)
{
_logger.LogError("Content API returned a null TrackEntity");
return ResultContainer<TrackEntity>.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<TrackEntity>.CreateFailResult($"Track was uploaded but could not be saved: {error}");
}
return saveResult;
}
}
public async Task<Result> 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();
}
}
@@ -0,0 +1,38 @@
using DeepDrftModels.Entities;
using NetBlocks.Models;
namespace DeepDrftManager.Services;
/// <summary>
/// CMS-side track mutations for the Manager host. Coordinates the dual-database write:
/// SQL metadata via <c>ITrackService</c> 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.
/// </summary>
public interface ICmsTrackService
{
/// <summary>
/// Proxy a WAV upload to DeepDrftContent, then persist the returned metadata to SQL.
/// On success the returned entity carries the SQL-assigned <c>Id</c>. If the vault write
/// succeeds but the SQL persist fails, the audio is orphaned under <c>EntryKey</c> — the
/// failure is logged loudly and surfaced as a failed result.
/// </summary>
Task<ResultContainer<TrackEntity>> UploadTrackAsync(
Stream wavStream,
string fileName,
string contentType,
string trackName,
string artist,
string? album,
string? genre,
string? releaseDate,
long createdByUserId,
CancellationToken ct = default);
/// <summary>
/// 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).
/// </summary>
Task<Result> DeleteTrackAsync(long id, CancellationToken ct = default);
}