Merge cms-w3-t3-delete: DELETE endpoints, FileDatabase remove, DeleteTrackDialog
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user