From 266086906e27f2dbb4313cb941d9cef8f0f2f6ec Mon Sep 17 00:00:00 2001 From: Daniel Harvey Date: Mon, 18 May 2026 15:18:28 -0400 Subject: [PATCH 1/2] =?UTF-8?q?Wave=203=20T2:=20WAV=20upload=20flow=20?= =?UTF-8?q?=E2=80=94=20POST=20api/track/upload,=20POST=20api/cms/track,=20?= =?UTF-8?q?/cms/tracks/new?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DeepDrftCms/Pages/Tracks/TrackNew.razor | 182 ++++++++++++++++++ DeepDrftContent/CLAUDE.md | 21 +- .../Controllers/TrackController.cs | 105 ++++++++++ .../Controllers/CmsUploadController.cs | 160 +++++++++++++++ DeepDrftWeb/appsettings.json | 3 + 5 files changed, 469 insertions(+), 2 deletions(-) create mode 100644 DeepDrftCms/Pages/Tracks/TrackNew.razor create mode 100644 DeepDrftWeb/Controllers/CmsUploadController.cs diff --git a/DeepDrftCms/Pages/Tracks/TrackNew.razor b/DeepDrftCms/Pages/Tracks/TrackNew.razor new file mode 100644 index 0000000..1f5c864 --- /dev/null +++ b/DeepDrftCms/Pages/Tracks/TrackNew.razor @@ -0,0 +1,182 @@ +@page "/cms/tracks/new" +@rendermode InteractiveServer +@using System.Net.Http.Headers +@using AuthBlocksWeb.HierarchicalAuthorize +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.Extensions.Logging +@attribute [HierarchicalRoleAuthorize("Admin")] + +@inject IHttpClientFactory HttpClientFactory +@inject NavigationManager Navigation +@inject ISnackbar Snackbar +@inject ILogger Logger + +Add Track — DeepDrft CMS + + + Add Track + + + + WAV file + + @if (_selectedFile is not null) + { + + Selected: @_selectedFile.Name (@FormatBytes(_selectedFile.Size)) + + } + + + + + + + + @if (!string.IsNullOrEmpty(_errorMessage)) + { + @_errorMessage + } + + + + Cancel + + + @if (_isUploading) + { + + Uploading… + } + else + { + Upload + } + + + + + + +@code { + // 1 GB ceiling matches the proxy controller's RequestSizeLimit; the actual streaming + // path means the limit caps the request, not in-memory buffering. + private const long MaxUploadBytes = 1_073_741_824L; + + private IBrowserFile? _selectedFile; + private string _trackName = string.Empty; + private string _artist = string.Empty; + private string _album = string.Empty; + private string _genre = string.Empty; + private string _releaseDate = string.Empty; + private string? _errorMessage; + private bool _isUploading; + + private void OnFileSelected(InputFileChangeEventArgs e) + { + _selectedFile = e.File; + _errorMessage = null; + } + + private async Task SubmitAsync() + { + _errorMessage = null; + + if (_selectedFile is null) + { + _errorMessage = "Please select a WAV file."; + return; + } + + if (!_selectedFile.Name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase)) + { + _errorMessage = "Selected file must be a .wav file."; + return; + } + + if (string.IsNullOrWhiteSpace(_trackName)) + { + _errorMessage = "Track Name is required."; + return; + } + + if (string.IsNullOrWhiteSpace(_artist)) + { + _errorMessage = "Artist is required."; + return; + } + + if (!string.IsNullOrWhiteSpace(_releaseDate) + && !DateOnly.TryParseExact(_releaseDate, "yyyy-MM-dd", out _)) + { + _errorMessage = "Release Date must be in YYYY-MM-DD format."; + 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. + 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"); + using var response = await client.PostAsync("api/cms/track", multipart); + + if (response.IsSuccessStatusCode) + { + 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); + } + catch (Exception ex) + { + Logger.LogError(ex, "CMS upload failed"); + _errorMessage = $"Upload failed: {ex.Message}"; + } + finally + { + _isUploading = false; + } + } + + private void Cancel() + { + Navigation.NavigateTo("/cms/tracks"); + } + + private static string FormatBytes(long bytes) + { + const long KB = 1024; + const long MB = KB * 1024; + const long GB = MB * 1024; + if (bytes >= GB) return $"{bytes / (double)GB:F2} GB"; + if (bytes >= MB) return $"{bytes / (double)MB:F2} MB"; + if (bytes >= KB) return $"{bytes / (double)KB:F2} KB"; + return $"{bytes} bytes"; + } +} diff --git a/DeepDrftContent/CLAUDE.md b/DeepDrftContent/CLAUDE.md index da51d45..894d84c 100644 --- a/DeepDrftContent/CLAUDE.md +++ b/DeepDrftContent/CLAUDE.md @@ -24,7 +24,7 @@ The binary content API host. ApiKey middleware, CORS, forwarded headers. Returns - `WavOffsetService` — in `DeepDrftContent.Services`. - Don't add new domain code to this project. -## The endpoint surface (exactly two endpoints) +## The endpoint surface (three endpoints) ### GET api/track/{trackId}?offset=0 (unauthenticated) @@ -45,7 +45,24 @@ Returns the WAV bytes from the `tracks` vault. - Actually: the endpoint is rarely used in production (the CLI calls `FileDatabase.RegisterResourceAsync` directly). But the endpoint exists for potential web-side uploads in future. - Returns 200 on success, 401 if ApiKey invalid, 400 if body invalid. -**Do not add a third endpoint without product approval.** The surface is intentionally minimal. +### POST api/track/upload ([ApiKeyAuthorize]) + +**Authenticated endpoint.** Accepts a raw WAV upload + metadata as `multipart/form-data`, processes the WAV via `DeepDrftContent.Services.TrackService.AddTrackFromWavAsync`, and returns an unpersisted `TrackEntity` (no `Id` assigned). The caller (the CMS controller on `DeepDrftWeb`) is responsible for then saving that entity to SQL. + +- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`. +- **Form fields**: + - `wav` (`IFormFile`, required): the WAV bytes. File name must end in `.wav`. + - `trackName` (string, required) + - `artist` (string, required) + - `album` (string, optional) + - `genre` (string, optional) + - `releaseDate` (string, optional, format `YYYY-MM-DD`) +- The upload stream is copied to a `.wav`-suffixed temp file under `Path.GetTempPath()` (the audio processor requires that extension and reads from disk). The temp file is always deleted in a `finally` block — success or failure. +- `[RequestSizeLimit(1 GB)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 1 GB)]` lift the per-request ceiling above the framework default (~28 MB) so production-sized WAVs are accepted. The body is streamed to the temp file, not buffered in memory. +- Returns 200 with the unpersisted `TrackEntity` JSON on success. Returns 400 for missing/invalid form fields (no WAV, missing required strings, wrong extension, bad date). Returns 500 if `AddTrackFromWavAsync` returns null or throws. +- **Wave 3 product approval covers this addition** (CMS-PLAN §5, Option B): `DeepDrftWeb` proxies CMS uploads here so it never touches the vault disk path. Do not extend the endpoint surface further without a fresh approval. + +**The surface is intentionally minimal.** Do not add a fourth endpoint without product approval. ## ApiKey middleware behaviour diff --git a/DeepDrftContent/Controllers/TrackController.cs b/DeepDrftContent/Controllers/TrackController.cs index da65828..767af0e 100644 --- a/DeepDrftContent/Controllers/TrackController.cs +++ b/DeepDrftContent/Controllers/TrackController.cs @@ -120,6 +120,111 @@ public class TrackController : ControllerBase } } + // POST api/track/upload: raw WAV in (multipart/form-data) + metadata → unpersisted TrackEntity out. + // Used by the CMS upload flow on DeepDrftWeb; that host proxies the upload here so it never + // touches the vault disk path directly (Option B in CMS-PLAN §5). + // + // RequestSizeLimit/MultipartBodyLengthLimit set to 1 GB: WAV uploads can be tens to hundreds + // of MB and the framework defaults (~28 MB) reject them outright. The IFormFile path streams + // the body to a temp file once Kestrel surfaces it, so the limit is the per-request ceiling, + // not a buffered allocation. + [ApiKeyAuthorize] + [HttpPost("upload")] + [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) + { + _logger.LogInformation("UploadTrack called: trackName={TrackName}, artist={Artist}, size={Size}", + trackName, artist, wav?.Length); + + 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"); + } + + if (!string.Equals(Path.GetExtension(wav.FileName), ".wav", StringComparison.OrdinalIgnoreCase)) + { + return BadRequest("Uploaded file must have a .wav extension"); + } + + DateOnly? parsedReleaseDate = null; + if (!string.IsNullOrWhiteSpace(releaseDate)) + { + if (!DateOnly.TryParseExact(releaseDate, "yyyy-MM-dd", out var parsed)) + { + return BadRequest("releaseDate must be in YYYY-MM-DD format"); + } + parsedReleaseDate = parsed; + } + + // AudioProcessor.ProcessWavFileAsync requires a path ending in .wav and reads from disk. + // Path.GetTempFileName() yields .tmp, which fails that check — generate our own .wav path. + var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".wav"); + + try + { + await using (var tempStream = new FileStream( + tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, + bufferSize: 81920, useAsync: true)) + await using (var uploadStream = wav.OpenReadStream()) + { + await uploadStream.CopyToAsync(tempStream); + } + + var entity = await _trackService.AddTrackFromWavAsync( + tempPath, + trackName, + artist, + string.IsNullOrWhiteSpace(album) ? null : album, + string.IsNullOrWhiteSpace(genre) ? null : genre, + parsedReleaseDate); + + if (entity is null) + { + _logger.LogWarning("UploadTrack: TrackService returned null for {TrackName}", trackName); + return StatusCode(500, "Failed to process and store WAV"); + } + + _logger.LogInformation("UploadTrack succeeded: entryKey={EntryKey}", entity.EntryKey); + return Ok(entity); + } + catch (Exception ex) + { + _logger.LogError(ex, "UploadTrack failed for {TrackName}", trackName); + return StatusCode(500, "Internal server error"); + } + finally + { + try + { + if (System.IO.File.Exists(tempPath)) + { + System.IO.File.Delete(tempPath); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "UploadTrack: failed to delete temp file {TempPath}", tempPath); + } + } + } + [ApiKeyAuthorize] [HttpPut("{trackId}")] public async Task PutTrack(string trackId, [FromBody] AudioBinaryDto track) diff --git a/DeepDrftWeb/Controllers/CmsUploadController.cs b/DeepDrftWeb/Controllers/CmsUploadController.cs new file mode 100644 index 0000000..20fb571 --- /dev/null +++ b/DeepDrftWeb/Controllers/CmsUploadController.cs @@ -0,0 +1,160 @@ +using System.Net.Http.Headers; +using System.Security.Claims; +using DeepDrftModels.Entities; +using DeepDrftWeb.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace DeepDrftWeb.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. DeepDrftWeb intentionally does not reference DeepDrftContent.Services +/// (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["ContentApi:ApiKey"]; + if (string.IsNullOrWhiteSpace(apiKey)) + { + _logger.LogError("ContentApi: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); + _logger.LogWarning("Content API rejected upload: {Status} {Body}", (int)response.StatusCode, body); + return StatusCode((int)response.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/DeepDrftWeb/appsettings.json b/DeepDrftWeb/appsettings.json index 05224fc..113f28f 100644 --- a/DeepDrftWeb/appsettings.json +++ b/DeepDrftWeb/appsettings.json @@ -12,6 +12,9 @@ "ApiUrls": { "ContentApi": "http://localhost:12777/" }, + "ContentApi": { + "ApiKey": "REPLACE_IN_ENV" + }, "ForwardedHeaders": { "DisableHttpsRedirection": "true" } From 7fd8376f96ef46a87bce69807093ce52a52f411e Mon Sep 17 00:00:00 2001 From: Daniel Harvey Date: Mon, 18 May 2026 15:38:56 -0400 Subject: [PATCH 2/2] Fix review findings: scrub ex.Message from UI, gate 5xx body relay, harmonise ContentApi config key, add CancellationToken to UploadTrack --- DeepDrftCms/Pages/Tracks/TrackNew.razor | 4 ++-- DeepDrftContent/Controllers/TrackController.cs | 5 +++-- DeepDrftWeb/Controllers/CmsUploadController.cs | 16 ++++++++++++---- DeepDrftWeb/appsettings.json | 2 +- 4 files changed, 18 insertions(+), 9 deletions(-) diff --git a/DeepDrftCms/Pages/Tracks/TrackNew.razor b/DeepDrftCms/Pages/Tracks/TrackNew.razor index 1f5c864..11f38a4 100644 --- a/DeepDrftCms/Pages/Tracks/TrackNew.razor +++ b/DeepDrftCms/Pages/Tracks/TrackNew.razor @@ -155,8 +155,8 @@ } catch (Exception ex) { - Logger.LogError(ex, "CMS upload failed"); - _errorMessage = $"Upload failed: {ex.Message}"; + Logger.LogError(ex, "Upload failed in TrackNew"); + _errorMessage = "Upload failed. Please try again."; } finally { diff --git a/DeepDrftContent/Controllers/TrackController.cs b/DeepDrftContent/Controllers/TrackController.cs index 767af0e..2bb7d59 100644 --- a/DeepDrftContent/Controllers/TrackController.cs +++ b/DeepDrftContent/Controllers/TrackController.cs @@ -138,7 +138,8 @@ public class TrackController : ControllerBase [FromForm] string? artist, [FromForm] string? album, [FromForm] string? genre, - [FromForm] string? releaseDate) + [FromForm] string? releaseDate, + CancellationToken cancellationToken) { _logger.LogInformation("UploadTrack called: trackName={TrackName}, artist={Artist}, size={Size}", trackName, artist, wav?.Length); @@ -184,7 +185,7 @@ public class TrackController : ControllerBase bufferSize: 81920, useAsync: true)) await using (var uploadStream = wav.OpenReadStream()) { - await uploadStream.CopyToAsync(tempStream); + await uploadStream.CopyToAsync(tempStream, cancellationToken); } var entity = await _trackService.AddTrackFromWavAsync( diff --git a/DeepDrftWeb/Controllers/CmsUploadController.cs b/DeepDrftWeb/Controllers/CmsUploadController.cs index 20fb571..2bc617f 100644 --- a/DeepDrftWeb/Controllers/CmsUploadController.cs +++ b/DeepDrftWeb/Controllers/CmsUploadController.cs @@ -67,10 +67,10 @@ public class CmsUploadController : ControllerBase return BadRequest("artist is required"); } - var apiKey = _configuration["ContentApi:ApiKey"]; + var apiKey = _configuration["DeepDrftContent:ApiKey"]; if (string.IsNullOrWhiteSpace(apiKey)) { - _logger.LogError("ContentApi:ApiKey is not configured"); + _logger.LogError("DeepDrftContent:ApiKey is not configured"); return StatusCode(500, "Content API key is not configured"); } @@ -118,8 +118,16 @@ public class CmsUploadController : ControllerBase if (!response.IsSuccessStatusCode) { var body = await response.Content.ReadAsStringAsync(cancellationToken); - _logger.LogWarning("Content API rejected upload: {Status} {Body}", (int)response.StatusCode, body); - return StatusCode((int)response.StatusCode, body); + 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; diff --git a/DeepDrftWeb/appsettings.json b/DeepDrftWeb/appsettings.json index 113f28f..6b30219 100644 --- a/DeepDrftWeb/appsettings.json +++ b/DeepDrftWeb/appsettings.json @@ -12,7 +12,7 @@ "ApiUrls": { "ContentApi": "http://localhost:12777/" }, - "ContentApi": { + "DeepDrftContent": { "ApiKey": "REPLACE_IN_ENV" }, "ForwardedHeaders": {