Merge cms-w3-t3-delete: DELETE endpoints, FileDatabase remove, DeleteTrackDialog

This commit is contained in:
Daniel Harvey
2026-05-18 15:48:02 -04:00
12 changed files with 346 additions and 12 deletions
@@ -0,0 +1,88 @@
@using System.Net.Http
@using System.Net.Http.Headers
@using Microsoft.AspNetCore.Components
@inject IHttpClientFactory HttpClientFactory
@inject AuthBlocksWeb.Services.ITokenService TokenService
<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 TokenService.GetAccessTokenAsync();
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();
}
@@ -57,4 +57,10 @@ public interface IVaultIndex : IEntryQueryable
/// Adds an entry with metadata to the vault index
/// </summary>
void PutEntry(string entryId, MetaData metaData);
/// <summary>
/// Removes an entry (and its metadata) from the vault index.
/// Returns true if an entry was removed, false if it was not present.
/// </summary>
bool RemoveEntry(string entryId);
}
@@ -131,4 +131,6 @@ public class VaultIndex : IndexData, IVaultIndex
public MetaData? GetEntry(string entryId) => Entries.Get(entryId);
public void PutEntry(string entryId, MetaData metaData) => Entries.Set(entryId, metaData);
public bool RemoveEntry(string entryId) => Entries.Delete(entryId);
}
@@ -1,5 +1,6 @@
using DeepDrftContent.Services.FileDatabase.Models;
using DeepDrftContent.Services.FileDatabase.Utils;
using Microsoft.Extensions.Logging;
namespace DeepDrftContent.Services.FileDatabase.Services;
@@ -12,19 +13,20 @@ public class FileDatabase : DirectoryIndexDirectory, IDisposable
private readonly StructuralMap<string, MediaVault> _vaults;
private readonly IndexWatcher _indexWatcher;
private readonly IndexFactoryService _indexFactory;
private readonly ILogger<FileDatabase> _logger;
private bool _disposed;
/// <summary>
/// Factory method to create a FileDatabase instance
/// </summary>
public static async Task<FileDatabase?> FromAsync(string rootPath)
public static async Task<FileDatabase?> FromAsync(string rootPath, ILogger<FileDatabase>? logger = null)
{
var factoryService = new IndexFactoryService();
var rootIndex = await factoryService.LoadOrCreateDirectoryIndexAsync(rootPath);
if (rootIndex != null)
{
var db = new FileDatabase(rootPath, rootIndex, factoryService);
var db = new FileDatabase(rootPath, rootIndex, factoryService, logger);
await db.InitVaultsAsync();
return db;
}
@@ -32,11 +34,12 @@ public class FileDatabase : DirectoryIndexDirectory, IDisposable
return null;
}
private FileDatabase(string rootPath, IDirectoryIndex index, IndexFactoryService indexFactory) : base(rootPath, index)
private FileDatabase(string rootPath, IDirectoryIndex index, IndexFactoryService indexFactory, ILogger<FileDatabase>? logger = null) : base(rootPath, index)
{
_vaults = new StructuralMap<string, MediaVault>();
_indexWatcher = new IndexWatcher();
_indexFactory = indexFactory;
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<FileDatabase>.Instance;
}
/// <summary>
@@ -171,10 +174,34 @@ public class FileDatabase : DirectoryIndexDirectory, IDisposable
{
// Swallow exceptions and return false, matching TypeScript behavior
}
return false;
}
/// <summary>
/// Removes a resource from a specific vault. Returns null if the vault does not exist,
/// false if the entry was not found, true if the entry was removed. Distinguishing
/// "no such vault" / "no such entry" / "removed" lets the HTTP host map cleanly to
/// 404 vs. 200. Follows the FileDatabase error-swallow contract: any unexpected failure
/// returns null so callers can surface 5xx without try/catch at the controller layer.
/// </summary>
public async Task<bool?> RemoveResourceAsync(string vaultId, string entryId)
{
try
{
var directoryVault = _vaults.Get(vaultId);
if (directoryVault == null)
return null;
return await directoryVault.RemoveEntryAsync(entryId);
}
catch (Exception ex)
{
_logger.LogError(ex, "RemoveResourceAsync failed for vault {VaultName} key {Key}", vaultId, entryId);
return null;
}
}
/// <summary>
/// Gets all vault IDs managed by this database
/// </summary>
@@ -122,6 +122,32 @@ public class VaultIndexDirectory : IndexDirectory
}
}
/// <summary>
/// Removes an entry from the index under the index lock, persisting on success.
/// Returns the removed entry's metadata, or null if the entry did not exist.
/// Caller is responsible for any backing-file cleanup using the returned metadata.
/// </summary>
protected async Task<MetaData?> RemoveFromIndexAsync(string entryId)
{
await _indexLock.WaitAsync();
try
{
var metaData = _vaultIndex.GetEntry(entryId);
if (metaData == null)
return null;
if (!_vaultIndex.RemoveEntry(entryId))
return null;
await SaveIndexAsync(_vaultIndex);
return metaData;
}
finally
{
_indexLock.Release();
}
}
/// <summary>
/// Reloads the index from disk. Called when the index file is modified externally.
/// </summary>
@@ -132,6 +132,33 @@ public abstract class MediaVault : VaultIndexDirectory
}
}
/// <summary>
/// Removes an entry from the vault: drops it from the index (persisting the change)
/// and deletes the backing file from disk. Returns true if an entry was removed,
/// false if the entry was not present. Follows the FileDatabase error-swallow contract
/// for read failures; index/file write failures propagate so the caller can map them
/// to a 5xx.
/// </summary>
public async Task<bool> RemoveEntryAsync(string entryId)
{
var metaData = await RemoveFromIndexAsync(entryId);
if (metaData == null)
return false;
// Index already persisted; if the file is missing or fails to delete, the entry
// is still gone from the catalogue. Treat a missing file as success (callers asked
// for the entry to go away, and it has). A failure deleting an existing file leaves
// an orphan on disk; surface it to the caller via exception so the host can log,
// matching the AddEntryAsync error-propagation shape.
var mediaPath = GetMediaPathFromEntryKey(metaData.MediaKey, metaData.Extension);
if (FileUtils.FileExists(mediaPath))
{
File.Delete(mediaPath);
}
return true;
}
/// <summary>
/// Extracts buffer and extension from a media binary
/// </summary>
+14 -2
View File
@@ -11,7 +11,7 @@ The binary content API host. ApiKey middleware, CORS, forwarded headers. Returns
## What lives here now (only)
- `Program.cs`, `Startup.cs`: HTTP host config, DI wiring, middleware setup, port binding.
- `Controllers/TrackController.cs`: Two endpoints (see below).
- `Controllers/TrackController.cs`: Three endpoints (see below).
- `Middleware/ApiKeyAuthenticationMiddleware.cs`, `Middleware/ApiKeyAuthorizeAttribute.cs`: ApiKey validation logic.
- `Models/`: Settings POCOs only (`ApiKeySettings`, `CorsSettings`, `FileDatabaseSettings`). No domain code.
- `environment/filedatabase.json`: FileDatabase vault path config (required).
@@ -45,7 +45,19 @@ 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.
### DELETE api/track/{entryKey} ([ApiKeyAuthorize])
**Authenticated endpoint.** Removes an entry from the `tracks` vault (drops the index entry and deletes the backing file).
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
- **Route parameter `entryKey`**: the entry id inside the `tracks` vault (i.e. `TrackEntity.EntryKey`).
- Delegates to `FileDatabase.RemoveResourceAsync` which returns a tri-state outcome:
- `null` → vault missing or unexpected error → 500.
- `false` → entry not present → 404.
- `true` → entry removed → 200.
- Added in CMS Wave 3 (W1.5) so the CMS delete endpoint on `DeepDrftWeb` (`DELETE api/cms/track/{id}`) can clean up the vault after the SQL row is gone. Wave 3 product approval covers this — the "do not add a third endpoint without product approval" rule from prior waves is satisfied.
The endpoint surface is now intentionally **three** endpoints. Do not add a fourth without product approval.
## ApiKey middleware behaviour
@@ -142,4 +142,31 @@ public class TrackController : ControllerBase
DeepDrftContent.Services.Constants.VaultConstants.Tracks, trackId, audioBinary);
return success ? Ok() : BadRequest("Failed to store audio track");
}
[ApiKeyAuthorize]
[HttpDelete("{entryKey}")]
public async Task<ActionResult> DeleteTrack(string entryKey)
{
_logger.LogInformation("DeleteTrack called with entryKey: {EntryKey}", entryKey);
// RemoveResourceAsync distinguishes three outcomes per FileDatabase's error-swallow contract:
// null → vault missing or unexpected error → 500
// false → entry not present (already deleted or never existed) → 404
// true → entry removed → 200
var outcome = await _fileDatabase.RemoveResourceAsync(VaultConstants.Tracks, entryKey);
if (outcome == null)
{
_logger.LogError("DeleteTrack failed for entryKey: {EntryKey} (vault missing or remove error)", entryKey);
return StatusCode(500, "Internal server error");
}
if (outcome == false)
{
_logger.LogWarning("DeleteTrack: entry not found: {EntryKey}", entryKey);
return NotFound();
}
_logger.LogInformation("DeleteTrack: removed entry {EntryKey}", entryKey);
return Ok();
}
}
+13 -5
View File
@@ -5,12 +5,13 @@ using DeepDrftContent.Services.FileDatabase.Models;
using DeepDrftContent.Services.FileDatabase.Services;
using DeepDrftContent.Services.Processors;
using DeepDrftContent.Models;
using Microsoft.Extensions.Logging;
namespace DeepDrftContent
{
public static class Startup
{
public static async Task ConfigureDomainServices(WebApplicationBuilder builder)
public static Task ConfigureDomainServices(WebApplicationBuilder builder)
{
// Audio services
builder.Services.AddSingleton<WavOffsetService>();
@@ -22,10 +23,17 @@ namespace DeepDrftContent
var fileDatabaseSettings = builder.Configuration.GetSection(nameof(FileDatabaseSettings)).Get<FileDatabaseSettings>();
if (fileDatabaseSettings is null) { throw new Exception("File database settings are not configured"); }
var fileDatabase = await FileDatabase.FromAsync(fileDatabaseSettings.VaultPath);
if (fileDatabase is null) { throw new Exception("Unable to initialize file database"); }
builder.Services.AddSingleton(fileDatabase);
await InitializeTrackVault(fileDatabase);
var vaultPath = fileDatabaseSettings.VaultPath;
builder.Services.AddSingleton(sp =>
{
var logger = sp.GetRequiredService<ILogger<FileDatabase>>();
var db = FileDatabase.FromAsync(vaultPath, logger).GetAwaiter().GetResult();
if (db is null) throw new Exception("Unable to initialize file database");
InitializeTrackVault(db).GetAwaiter().GetResult();
return db;
});
return Task.CompletedTask;
}
private static async Task InitializeTrackVault(FileDatabase fileDatabase)
@@ -0,0 +1,85 @@
using DeepDrftWeb.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace DeepDrftWeb.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
{
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(Startup.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();
}
}
+6
View File
@@ -13,6 +13,12 @@ builder.Services.AddMudServices();
builder.Services.AddCmsServices();
// CMS → DeepDrftContent calls require the DeepDrftContent ApiKey. Loaded from a
// gitignored environment file, same shape as DeepDrftContent/environment/apikey.json.
// Optional so the file's absence in non-CMS dev does not fail boot; missing key is
// surfaced when Startup.ConfigureDomainServices binds the CMS HttpClient.
builder.Configuration.AddJsonFile("environment/apikey.json", optional: true, reloadOnChange: true);
var baseUrl = builder.GetKestrelUrl();
var contentApiUrl = builder.Configuration["ApiUrls:ContentApi"] ?? throw new Exception("Content API URL is not configured");
+21 -1
View File
@@ -7,6 +7,13 @@ namespace DeepDrftWeb;
public static class Startup
{
/// <summary>
/// Named HttpClient used by CMS controllers to call DeepDrftContent's ApiKey-protected endpoints.
/// Distinct from the public WASM-facing "DeepDrft.Content" client so the API key never reaches
/// the browser. Configured server-side only.
/// </summary>
public const string ContentCmsHttpClientName = "DeepDrft.Content.Cms";
public static void ConfigureDomainServices(WebApplicationBuilder builder)
{
// Add Entity Framework services
@@ -18,11 +25,24 @@ public static class Startup
builder.Services
.AddHttpContextAccessor()
.AddScoped<DarkModeService>();
// Add Track services
builder.Services
.AddScoped<TrackRepository>()
.AddScoped<ITrackService, TrackService>();
// CMS → DeepDrftContent client. The API key is required up front (no lazy resolution)
// so a misconfiguration surfaces at startup instead of on the first delete attempt.
var contentApiUrl = builder.Configuration["ApiUrls:ContentApi"]
?? throw new InvalidOperationException("ApiUrls:ContentApi is required");
var contentApiKey = builder.Configuration["DeepDrftContent:ApiKey"]
?? throw new InvalidOperationException("DeepDrftContent:ApiKey is required (see environment/apikey.json)");
builder.Services.AddHttpClient(ContentCmsHttpClientName, client =>
{
client.BaseAddress = new Uri(contentApiUrl);
client.DefaultRequestHeaders.Add("ApiKey", contentApiKey);
});
}
public static string GetKestrelUrl(this WebApplicationBuilder builder)