diff --git a/DeepDrftCms/Components/DeleteTrackDialog.razor b/DeepDrftCms/Components/DeleteTrackDialog.razor new file mode 100644 index 0000000..09d272d --- /dev/null +++ b/DeepDrftCms/Components/DeleteTrackDialog.razor @@ -0,0 +1,86 @@ +@using System.Net.Http +@using Microsoft.AspNetCore.Components +@inject IHttpClientFactory HttpClientFactory + + + + + Are you sure you want to delete '@TrackName'? This cannot be undone. + + @if (!string.IsNullOrEmpty(_errorMessage)) + { + @_errorMessage + } + + + Cancel + + @if (_isDeleting) + { + + Deleting... + } + else + { + Delete + } + + + + +@code { + [CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] public int 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 + { + // "DeepDrft.API" is the named client pointing at DeepDrftWeb's own host. + // The Admin role gate on the endpoint is enforced server-side by AuthBlocks; + // the registered HTTP client/auth handler stack attaches the JWT. + var client = HttpClientFactory.CreateClient("DeepDrft.API"); + var response = await client.DeleteAsync($"api/cms/track/{TrackId}"); + + if (response.IsSuccessStatusCode) + { + if (OnDeleted.HasDelegate) + { + await OnDeleted.InvokeAsync(); + } + MudDialog.Close(DialogResult.Ok(true)); + return; + } + + _errorMessage = response.StatusCode switch + { + System.Net.HttpStatusCode.NotFound => "Track not found. It may have already been deleted.", + System.Net.HttpStatusCode.Unauthorized => "You are not authorized to delete this track.", + System.Net.HttpStatusCode.Forbidden => "You are not authorized to delete this track.", + _ => $"Delete failed ({(int)response.StatusCode})." + }; + } + catch (Exception ex) + { + _errorMessage = $"Delete failed: {ex.Message}"; + } + finally + { + _isDeleting = false; + } + } + + private void Cancel() => MudDialog.Cancel(); +} diff --git a/DeepDrftContent.Services/FileDatabase/Models/IIndex.cs b/DeepDrftContent.Services/FileDatabase/Models/IIndex.cs index 2deddec..c8df40e 100644 --- a/DeepDrftContent.Services/FileDatabase/Models/IIndex.cs +++ b/DeepDrftContent.Services/FileDatabase/Models/IIndex.cs @@ -57,4 +57,10 @@ public interface IVaultIndex : IEntryQueryable /// Adds an entry with metadata to the vault index /// void PutEntry(string entryId, MetaData metaData); + + /// + /// Removes an entry (and its metadata) from the vault index. + /// Returns true if an entry was removed, false if it was not present. + /// + bool RemoveEntry(string entryId); } diff --git a/DeepDrftContent.Services/FileDatabase/Models/IndexData.cs b/DeepDrftContent.Services/FileDatabase/Models/IndexData.cs index 8183c1e..92acb48 100644 --- a/DeepDrftContent.Services/FileDatabase/Models/IndexData.cs +++ b/DeepDrftContent.Services/FileDatabase/Models/IndexData.cs @@ -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); } diff --git a/DeepDrftContent.Services/FileDatabase/Services/FileDatabase.cs b/DeepDrftContent.Services/FileDatabase/Services/FileDatabase.cs index 87dd442..d50ae1b 100644 --- a/DeepDrftContent.Services/FileDatabase/Services/FileDatabase.cs +++ b/DeepDrftContent.Services/FileDatabase/Services/FileDatabase.cs @@ -171,10 +171,34 @@ public class FileDatabase : DirectoryIndexDirectory, IDisposable { // Swallow exceptions and return false, matching TypeScript behavior } - + return false; } + /// + /// 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. + /// + public async Task RemoveResourceAsync(string vaultId, string entryId) + { + try + { + var directoryVault = _vaults.Get(vaultId); + if (directoryVault == null) + return null; + + return await directoryVault.RemoveEntryAsync(entryId); + } + catch + { + // Swallow exceptions and return null, matching the FileDatabase error contract. + return null; + } + } + /// /// Gets all vault IDs managed by this database /// diff --git a/DeepDrftContent.Services/FileDatabase/Services/IndexSystem.cs b/DeepDrftContent.Services/FileDatabase/Services/IndexSystem.cs index 7f79375..96ba880 100644 --- a/DeepDrftContent.Services/FileDatabase/Services/IndexSystem.cs +++ b/DeepDrftContent.Services/FileDatabase/Services/IndexSystem.cs @@ -122,6 +122,32 @@ public class VaultIndexDirectory : IndexDirectory } } + /// + /// 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. + /// + protected async Task 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(); + } + } + /// /// Reloads the index from disk. Called when the index file is modified externally. /// diff --git a/DeepDrftContent.Services/FileDatabase/Services/MediaVault.cs b/DeepDrftContent.Services/FileDatabase/Services/MediaVault.cs index 137ac21..f6a4671 100644 --- a/DeepDrftContent.Services/FileDatabase/Services/MediaVault.cs +++ b/DeepDrftContent.Services/FileDatabase/Services/MediaVault.cs @@ -132,6 +132,33 @@ public abstract class MediaVault : VaultIndexDirectory } } + /// + /// 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. + /// + public async Task 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; + } + /// /// Extracts buffer and extension from a media binary /// diff --git a/DeepDrftContent/CLAUDE.md b/DeepDrftContent/CLAUDE.md index da51d45..b89fa0f 100644 --- a/DeepDrftContent/CLAUDE.md +++ b/DeepDrftContent/CLAUDE.md @@ -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 diff --git a/DeepDrftContent/Controllers/TrackController.cs b/DeepDrftContent/Controllers/TrackController.cs index da65828..4443923 100644 --- a/DeepDrftContent/Controllers/TrackController.cs +++ b/DeepDrftContent/Controllers/TrackController.cs @@ -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 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(); + } } diff --git a/DeepDrftWeb/Controllers/CmsDeleteController.cs b/DeepDrftWeb/Controllers/CmsDeleteController.cs new file mode 100644 index 0000000..2d3eabb --- /dev/null +++ b/DeepDrftWeb/Controllers/CmsDeleteController.cs @@ -0,0 +1,85 @@ +using DeepDrftWeb.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace DeepDrftWeb.Controllers; + +/// +/// CMS delete endpoint. Owned by W3-T3 — separate controller from upload/edit to +/// avoid merge contention with parallel CMS tracks. +/// +/// Delete order (CMS-PLAN W1.5): SQL first, then vault. If the SQL row is gone we +/// return success to the user even when the subsequent vault delete fails — SQL is +/// the source of truth for "exists from the user's view". A vault failure is logged +/// as an orphan for maintenance to reap (see PLAN.md §4.3 dead-letter). +/// +[ApiController] +[Route("api/cms/track")] +[Authorize(Roles = "Admin")] +public class CmsDeleteController : ControllerBase +{ + private readonly ITrackService _trackService; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + + public CmsDeleteController( + ITrackService trackService, + IHttpClientFactory httpClientFactory, + ILogger logger) + { + _trackService = trackService; + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + [HttpDelete("{id:long}")] + public async Task DeleteTrack(long id) + { + // 1. Resolve the EntryKey before we delete the SQL row — afterwards the join is gone. + var lookup = await _trackService.GetById(id); + if (!lookup.Success) + { + _logger.LogError("CMS delete: lookup failed for track {TrackId}: {Error}", id, lookup.Messages.FirstOrDefault()?.Message); + return StatusCode(500, "Failed to load track"); + } + + var track = lookup.Value; + if (track == null) + { + return NotFound(); + } + + var entryKey = track.EntryKey; + + // 2. SQL delete. On failure, do NOT touch the vault — nothing to clean up. + var sqlDelete = await _trackService.Delete(id); + if (!sqlDelete.Success) + { + _logger.LogError("CMS delete: SQL delete failed for track {TrackId}: {Error}", id, sqlDelete.Messages.FirstOrDefault()?.Message); + return StatusCode(500, "Failed to delete track"); + } + + // 3. Vault delete. Failure is logged as an orphan but does not fail the request: + // SQL is the source of truth for the user's view; the orphan is a maintenance concern. + var client = _httpClientFactory.CreateClient(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} {Reason} {StatusCode}", + id, entryKey, "vault delete failed after SQL delete", (int)response.StatusCode); + } + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Vault delete threw after SQL delete. {TrackId} {EntryKey} {Reason}", + id, entryKey, "vault delete failed after SQL delete"); + } + + return Ok(); + } +} diff --git a/DeepDrftWeb/Program.cs b/DeepDrftWeb/Program.cs index 9308969..1bba1df 100644 --- a/DeepDrftWeb/Program.cs +++ b/DeepDrftWeb/Program.cs @@ -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"); diff --git a/DeepDrftWeb/Startup.cs b/DeepDrftWeb/Startup.cs index c72c28c..2e03d45 100644 --- a/DeepDrftWeb/Startup.cs +++ b/DeepDrftWeb/Startup.cs @@ -7,6 +7,13 @@ namespace DeepDrftWeb; public static class Startup { + /// + /// 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. + /// + 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(); - + // Add Track services builder.Services .AddScoped() .AddScoped(); + + // 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)