diff --git a/DeepDrftData/Data/Configurations/ReleaseConfiguration.cs b/DeepDrftData/Data/Configurations/ReleaseConfiguration.cs index d0260aa..b14893c 100644 --- a/DeepDrftData/Data/Configurations/ReleaseConfiguration.cs +++ b/DeepDrftData/Data/Configurations/ReleaseConfiguration.cs @@ -59,9 +59,12 @@ public class ReleaseConfiguration : BaseEntityConfiguration // Unique constraint on the natural key (title + artist). Prevents duplicate release rows // from concurrent uploads of the same album. The FindOrCreateRelease path catches the - // resulting ClassifiedDbException (UniqueViolation) and re-queries for the winning row. + // resulting UniqueViolation and re-queries for the winning row. + // Partial filter excludes soft-deleted rows so re-uploading a deleted release does not + // hit a uniqueness conflict when FindOrCreateRelease creates a fresh row. builder.HasIndex(e => new { e.Title, e.Artist }) .IsUnique() - .HasDatabaseName("IX_release_title_artist"); + .HasDatabaseName("IX_release_title_artist") + .HasFilter("\"is_deleted\" = false"); } } diff --git a/DeepDrftData/Migrations/20260612102604_MakeReleaseTitleArtistUniquePartial.Designer.cs b/DeepDrftData/Migrations/20260612102604_MakeReleaseTitleArtistUniquePartial.Designer.cs new file mode 100644 index 0000000..55839d7 --- /dev/null +++ b/DeepDrftData/Migrations/20260612102604_MakeReleaseTitleArtistUniquePartial.Designer.cs @@ -0,0 +1,179 @@ +// +using System; +using DeepDrftData.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace DeepDrftData.Migrations +{ + [DbContext(typeof(DeepDrftContext))] + [Migration("20260612102604_MakeReleaseTitleArtistUniquePartial")] + partial class MakeReleaseTitleArtistUniquePartial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Artist") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("artist"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedByUserId") + .HasColumnType("bigint") + .HasColumnName("created_by_user_id"); + + b.Property("Genre") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("genre"); + + b.Property("ImagePath") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("image_path"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("ReleaseDate") + .HasColumnType("date") + .HasColumnName("release_date"); + + b.Property("ReleaseType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Single") + .HasColumnName("release_type"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("IsDeleted") + .HasDatabaseName("IX_release_is_deleted"); + + b.HasIndex("Title", "Artist") + .IsUnique() + .HasDatabaseName("IX_release_title_artist") + .HasFilter("\"is_deleted\" = false"); + + b.ToTable("release", (string)null); + }); + + modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EntryKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("entry_key"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("OriginalFileName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("original_file_name"); + + b.Property("ReleaseId") + .HasColumnType("bigint") + .HasColumnName("release_id"); + + b.Property("TrackName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("track_name"); + + b.Property("TrackNumber") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1) + .HasColumnName("track_number"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("IsDeleted") + .HasDatabaseName("IX_track_is_deleted"); + + b.HasIndex("ReleaseId"); + + b.ToTable("track", (string)null); + }); + + modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b => + { + b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release") + .WithMany("Tracks") + .HasForeignKey("ReleaseId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Release"); + }); + + modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b => + { + b.Navigation("Tracks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DeepDrftData/Migrations/20260612102604_MakeReleaseTitleArtistUniquePartial.cs b/DeepDrftData/Migrations/20260612102604_MakeReleaseTitleArtistUniquePartial.cs new file mode 100644 index 0000000..3efb9ec --- /dev/null +++ b/DeepDrftData/Migrations/20260612102604_MakeReleaseTitleArtistUniquePartial.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DeepDrftData.Migrations +{ + /// + public partial class MakeReleaseTitleArtistUniquePartial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_release_title_artist", + table: "release"); + + migrationBuilder.CreateIndex( + name: "IX_release_title_artist", + table: "release", + columns: new[] { "title", "artist" }, + unique: true, + filter: "\"is_deleted\" = false"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_release_title_artist", + table: "release"); + + migrationBuilder.CreateIndex( + name: "IX_release_title_artist", + table: "release", + columns: new[] { "title", "artist" }, + unique: true); + } + } +} diff --git a/DeepDrftData/Migrations/DeepDrftContextModelSnapshot.cs b/DeepDrftData/Migrations/DeepDrftContextModelSnapshot.cs index 1a1c42e..04133da 100644 --- a/DeepDrftData/Migrations/DeepDrftContextModelSnapshot.cs +++ b/DeepDrftData/Migrations/DeepDrftContextModelSnapshot.cs @@ -90,7 +90,8 @@ namespace DeepDrftData.Migrations b.HasIndex("Title", "Artist") .IsUnique() - .HasDatabaseName("IX_release_title_artist"); + .HasDatabaseName("IX_release_title_artist") + .HasFilter("\"is_deleted\" = false"); b.ToTable("release", (string)null); }); diff --git a/DeepDrftManager/Components/Pages/Tracks/AlbumHeaderFields.razor b/DeepDrftManager/Components/Pages/Tracks/AlbumHeaderFields.razor index 4989bd0..b891655 100644 --- a/DeepDrftManager/Components/Pages/Tracks/AlbumHeaderFields.razor +++ b/DeepDrftManager/Components/Pages/Tracks/AlbumHeaderFields.razor @@ -1,6 +1,5 @@ @using DeepDrftModels.Enums @using Microsoft.AspNetCore.Components.Forms -@inject IHttpClientFactory HttpClientFactory @@ -93,19 +92,11 @@ [Parameter] public bool Disabled { get; set; } - // The image endpoint (GET api/image/{entryKey}) is unauthenticated, so the browser hits - // DeepDrftAPI directly. Base address comes from the same named client the CMS uses. - private string? ExistingImagePreviewUrl - { - get - { - if (string.IsNullOrEmpty(ExistingImagePath)) return null; - var baseAddress = HttpClientFactory.CreateClient("DeepDrft.Content.Cms").BaseAddress; - return baseAddress is null - ? null - : new Uri(baseAddress, $"api/image/{Uri.EscapeDataString(ExistingImagePath)}").ToString(); - } - } + // Relative path — resolves against the Manager's own origin, proxied by ImageProxyController. + private string? ExistingImagePreviewUrl => + string.IsNullOrEmpty(ExistingImagePath) + ? null + : $"/api/image/{Uri.EscapeDataString(ExistingImagePath)}"; private Task HandleImageFileSelected(InputFileChangeEventArgs e) => SelectedImageFileChanged.InvokeAsync(e.File); diff --git a/DeepDrftManager/Components/Pages/Tracks/CmsAlbumBrowser.razor b/DeepDrftManager/Components/Pages/Tracks/CmsAlbumBrowser.razor index df4374c..28f43ec 100644 --- a/DeepDrftManager/Components/Pages/Tracks/CmsAlbumBrowser.razor +++ b/DeepDrftManager/Components/Pages/Tracks/CmsAlbumBrowser.razor @@ -2,7 +2,6 @@ @using DeepDrftManager.Services @using DeepDrftModels.DTOs @inject ICmsTrackService CmsTrackService -@inject IHttpClientFactory HttpClientFactory @inject IDialogService DialogService @inject ISnackbar Snackbar @inject ILogger Logger @@ -126,13 +125,6 @@ else // back. We only re-project when the parent hands us a genuinely new list. private IReadOnlyList? _projectedReleases; - // The cover-art endpoint (GET api/image/{entryKey}) lives on DeepDrftAPI and is unauthenticated, - // so the browser hits it directly. Base address comes from the same named client the CMS uses. - private Uri? _contentApiBase; - - protected override void OnInitialized() => - _contentApiBase = HttpClientFactory.CreateClient("DeepDrft.Content.Cms").BaseAddress; - // Re-project rows only when the parent supplies a genuinely new release list (reference change). // Local edits to _rows (a removed row after delete) must survive re-renders triggered by the // same cached VM.Albums instance. @@ -145,10 +137,9 @@ else } } - private string? ThumbUrl(string imagePath) => - _contentApiBase is null - ? null - : new Uri(_contentApiBase, $"api/image/{Uri.EscapeDataString(imagePath)}").ToString(); + // Relative path — resolves against the Manager's own origin, proxied by ImageProxyController. + private static string ThumbUrl(string imagePath) => + $"/api/image/{Uri.EscapeDataString(imagePath)}"; private async Task ToggleExpand(AlbumRow row) { diff --git a/DeepDrftManager/Components/Pages/Tracks/CmsTrackGrid.razor b/DeepDrftManager/Components/Pages/Tracks/CmsTrackGrid.razor index e2e6946..61c9e28 100644 --- a/DeepDrftManager/Components/Pages/Tracks/CmsTrackGrid.razor +++ b/DeepDrftManager/Components/Pages/Tracks/CmsTrackGrid.razor @@ -2,7 +2,6 @@ @using DeepDrftManager.Services @using DeepDrftModels.DTOs @inject ICmsTrackService CmsTrackService -@inject IHttpClientFactory HttpClientFactory @inject IDialogService DialogService @inject ISnackbar Snackbar @inject ILogger Logger @@ -133,23 +132,17 @@ // The parent owns "Generate All Missing"; while it runs it disables this grid's per-row buttons. private bool _bulkRunning; - // The image endpoint (GET api/image/{entryKey}) lives on DeepDrftAPI and is unauthenticated, so - // the browser hits it directly. Base address comes from the same named client the CMS uses. - private Uri? _contentApiBase; - protected override async Task OnInitializedAsync() { - _contentApiBase = HttpClientFactory.CreateClient("DeepDrft.Content.Cms").BaseAddress; await RefreshWaveformStatusAsync(); } private bool HasProfile(string entryKey) => _waveformStatus.TryGetValue(entryKey, out var hasProfile) && hasProfile; - private string? ThumbUrl(string imagePath) => - _contentApiBase is null - ? null - : new Uri(_contentApiBase, $"api/image/{Uri.EscapeDataString(imagePath)}").ToString(); + // Relative path — resolves against the Manager's own origin, proxied by ImageProxyController. + private static string ThumbUrl(string imagePath) => + $"/api/image/{Uri.EscapeDataString(imagePath)}"; /// Number of tracks with a missing waveform profile — drives the parent's bulk button label. public int GetMissingCount() => _waveformStatus.Count(kv => !kv.Value); diff --git a/DeepDrftManager/Components/Pages/Tracks/TrackEdit.razor b/DeepDrftManager/Components/Pages/Tracks/TrackEdit.razor index 54372ca..c11a294 100644 --- a/DeepDrftManager/Components/Pages/Tracks/TrackEdit.razor +++ b/DeepDrftManager/Components/Pages/Tracks/TrackEdit.razor @@ -5,7 +5,6 @@ @attribute [Authorize] @inject ICmsTrackService CmsTrackService @inject CmsTrackBrowserViewModel VM -@inject IHttpClientFactory HttpClientFactory @inject ISnackbar Snackbar @inject IDialogService DialogService @inject NavigationManager Nav @@ -160,18 +159,11 @@ !string.IsNullOrWhiteSpace(_form.TrackName) && !string.IsNullOrWhiteSpace(_form.Artist); - // The image endpoint (GET api/image/{entryKey}) is unauthenticated, so the browser can hit - // DeepDrftAPI directly. Base address comes from the same named client the CMS uses for writes. - private string? ImagePreviewUrl - { - get - { - if (string.IsNullOrEmpty(_form.ImagePath)) return null; - var baseAddress = HttpClientFactory.CreateClient("DeepDrft.Content.Cms").BaseAddress; - if (baseAddress is null) return null; - return new Uri(baseAddress, $"api/image/{Uri.EscapeDataString(_form.ImagePath)}").ToString(); - } - } + // Relative path — resolves against the Manager's own origin, proxied by ImageProxyController. + private string? ImagePreviewUrl => + string.IsNullOrEmpty(_form.ImagePath) + ? null + : $"/api/image/{Uri.EscapeDataString(_form.ImagePath)}"; protected override async Task OnInitializedAsync() { diff --git a/DeepDrftManager/Controllers/ImageProxyController.cs b/DeepDrftManager/Controllers/ImageProxyController.cs new file mode 100644 index 0000000..21b8cd9 --- /dev/null +++ b/DeepDrftManager/Controllers/ImageProxyController.cs @@ -0,0 +1,63 @@ +using Microsoft.AspNetCore.Mvc; + +namespace DeepDrftManager.Controllers; + +/// +/// Proxies image API calls to DeepDrftAPI so the browser never makes cross-origin requests. +/// The CMS host runs server-side only, so rendered image URLs must resolve against the Manager's +/// own origin, not the internal API address. This controller forwards unauthenticated +/// api/image/{entryKey} requests upstream using the "DeepDrft.Content" named client +/// (no API key — the image endpoint is public). +/// +[ApiController] +[Route("api/image")] +public class ImageProxyController : ControllerBase +{ + private readonly HttpClient _upstream; + private readonly ILogger _logger; + + public ImageProxyController(IHttpClientFactory httpClientFactory, ILogger logger) + { + _upstream = httpClientFactory.CreateClient("DeepDrft.Content"); + _logger = logger; + } + + /// Proxies image binary streaming by vault entry key from DeepDrftAPI. + [HttpGet("{entryKey}")] + public async Task GetImage(string entryKey, CancellationToken ct = default) + { + _logger.LogInformation("Proxying image {EntryKey}", entryKey); + + var path = $"api/image/{Uri.EscapeDataString(entryKey)}"; + + HttpResponseMessage upstream; + try + { + upstream = await _upstream.GetAsync(path, HttpCompletionOption.ResponseHeadersRead, ct); + } + catch (Exception ex) + { + _logger.LogError(ex, "Upstream call to DeepDrftAPI image/{EntryKey} failed", entryKey); + return StatusCode(502, "Upstream unavailable"); + } + + if (!upstream.IsSuccessStatusCode) + { + upstream.Dispose(); + _logger.LogWarning("DeepDrftAPI image/{EntryKey} returned {Status}", entryKey, (int)upstream.StatusCode); + return StatusCode((int)upstream.StatusCode); + } + + // Do NOT dispose upstream here — File() takes ownership of the response stream + // and disposes it after the body is sent. + var contentType = upstream.Content.Headers.ContentType?.ToString() ?? "application/octet-stream"; + var contentLength = upstream.Content.Headers.ContentLength; + + if (contentLength.HasValue) + Response.ContentLength = contentLength.Value; + + var stream = await upstream.Content.ReadAsStreamAsync(ct); + HttpContext.Response.RegisterForDispose(upstream); + return File(stream, contentType, enableRangeProcessing: false); + } +} diff --git a/DeepDrftManager/Program.cs b/DeepDrftManager/Program.cs index 1b6b778..3ccb7e6 100644 --- a/DeepDrftManager/Program.cs +++ b/DeepDrftManager/Program.cs @@ -61,6 +61,11 @@ builder.Services.Configure(options => options.KnownProxies.Clear(); }); +// MVC controllers — required for the ImageProxyController that forwards browser image requests +// to DeepDrftAPI so rendered URLs resolve against the Manager's own origin, not the internal +// API address. +builder.Services.AddControllers(); + // InteractiveServer only — no WASM render mode on the CMS host. builder.Services.AddRazorComponents() .AddInteractiveServerComponents(); @@ -98,6 +103,7 @@ app.UseAntiforgery(); app.UseAuthorization(); app.MapStaticAssets(); +app.MapControllers(); // The AuthBlocks API surface (/api/auth/*, /api/users/*, etc.) now lives on DeepDrftAPI; this host // only renders the AuthBlocksWeb Razor pages (/account/login, /account/logout), which call that API.