Wave 3 T2: WAV upload flow — POST api/track/upload, POST api/cms/track, /cms/tracks/new

This commit is contained in:
Daniel Harvey
2026-05-18 15:18:28 -04:00
parent f46c2557c8
commit 266086906e
5 changed files with 469 additions and 2 deletions
+182
View File
@@ -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<TrackNew> Logger
<PageTitle>Add Track — DeepDrft CMS</PageTitle>
<MudContainer MaxWidth="MaxWidth.Medium" Class="mt-8">
<MudText Typo="Typo.h4" GutterBottom="true">Add Track</MudText>
<MudPaper Class="pa-6" Elevation="2">
<MudStack Spacing="4">
<MudText Typo="Typo.subtitle1">WAV file</MudText>
<InputFile OnChange="OnFileSelected" accept=".wav,audio/wav,audio/x-wav" />
@if (_selectedFile is not null)
{
<MudText Typo="Typo.body2">
Selected: @_selectedFile.Name (@FormatBytes(_selectedFile.Size))
</MudText>
}
<MudTextField @bind-Value="_trackName" Label="Track Name" Required="true" RequiredError="Track Name is required" Variant="Variant.Outlined" />
<MudTextField @bind-Value="_artist" Label="Artist" Required="true" RequiredError="Artist is required" Variant="Variant.Outlined" />
<MudTextField @bind-Value="_album" Label="Album" Variant="Variant.Outlined" />
<MudTextField @bind-Value="_genre" Label="Genre" Variant="Variant.Outlined" />
<MudTextField @bind-Value="_releaseDate" Label="Release Date (YYYY-MM-DD)" Placeholder="2024-01-15" Variant="Variant.Outlined" />
@if (!string.IsNullOrEmpty(_errorMessage))
{
<MudAlert Severity="Severity.Error">@_errorMessage</MudAlert>
}
<MudStack Row="true" Spacing="2" Justify="Justify.FlexEnd">
<MudButton Variant="Variant.Text"
OnClick="Cancel"
Disabled="_isUploading">
Cancel
</MudButton>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
OnClick="SubmitAsync"
Disabled="_isUploading">
@if (_isUploading)
{
<MudProgressCircular Indeterminate="true" Size="Size.Small" Class="mr-2" />
<text>Uploading…</text>
}
else
{
<text>Upload</text>
}
</MudButton>
</MudStack>
</MudStack>
</MudPaper>
</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.
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";
}
}
+19 -2
View File
@@ -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
@@ -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<ActionResult<DeepDrftModels.Entities.TrackEntity>> 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<ActionResult> PutTrack(string trackId, [FromBody] AudioBinaryDto track)
@@ -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;
/// <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. DeepDrftWeb intentionally does not reference DeepDrftContent.Services
/// (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["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<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);
}
}
}
+3
View File
@@ -12,6 +12,9 @@
"ApiUrls": {
"ContentApi": "http://localhost:12777/"
},
"ContentApi": {
"ApiKey": "REPLACE_IN_ENV"
},
"ForwardedHeaders": {
"DisableHttpsRedirection": "true"
}