From 12c9e2dc6ccf1d17dfd1a247c50be0de1213fd76 Mon Sep 17 00:00:00 2001 From: ahmed Date: Sun, 7 Jun 2026 17:53:04 +0300 Subject: [PATCH 1/2] feat: factory-based download endpoint (asset/image) with shared routing - Added DownloadType enum (Asset, Image) in CCE.Domain.Content. - Created IDownloadService, AssetDownloadService, MediaDownloadService, and DownloadServiceFactory in CCE.Application.Content.Download. - Created DownloadEndpointExtensions with shared MapDownloadEndpoints() in CCE.Api.Common.Extensions. - Registered download services in DependencyInjection.cs. - Replaced old asset download endpoints in External and Internal AssetEndpoints.cs with shared implementation. - Replaced old media download endpoint in MediaPublicEndpoints.cs with shared implementation. - External: /api/download/{id}?type=asset|image - Internal: /api/admin/download/{id}?type=asset|image --- .../Extensions/DownloadEndpointExtensions.cs | 41 +++++++++++++++ .../Endpoints/AssetEndpoints.cs | 27 ---------- .../Endpoints/MediaPublicEndpoints.cs | 18 ------- backend/src/CCE.Api.External/Program.cs | 2 + .../Endpoints/AssetEndpoints.cs | 26 ---------- backend/src/CCE.Api.Internal/Program.cs | 2 + .../Content/Download/DownloadServices.cs | 52 +++++++++++++++++++ .../src/CCE.Domain/Content/DownloadType.cs | 8 +++ .../CCE.Infrastructure/DependencyInjection.cs | 6 +++ 9 files changed, 111 insertions(+), 71 deletions(-) create mode 100644 backend/src/CCE.Api.Common/Extensions/DownloadEndpointExtensions.cs create mode 100644 backend/src/CCE.Application/Content/Download/DownloadServices.cs create mode 100644 backend/src/CCE.Domain/Content/DownloadType.cs diff --git a/backend/src/CCE.Api.Common/Extensions/DownloadEndpointExtensions.cs b/backend/src/CCE.Api.Common/Extensions/DownloadEndpointExtensions.cs new file mode 100644 index 00000000..8114d0b6 --- /dev/null +++ b/backend/src/CCE.Api.Common/Extensions/DownloadEndpointExtensions.cs @@ -0,0 +1,41 @@ +using CCE.Application.Content.Download; +using CCE.Domain.Content; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace CCE.Api.Common.Extensions; + +public static class DownloadEndpointExtensions +{ + public static IEndpointRouteBuilder MapDownloadEndpoints( + this IEndpointRouteBuilder app, + string prefix) + { + var group = app.MapGroup($"{prefix}/download").WithTags("Download"); + + group.MapGet("{id:guid}", async ( + Guid id, + DownloadType type, + DownloadServiceFactory factory, + HttpContext httpContext, + CancellationToken ct) => + { + var service = factory.Create(type); + var result = await service.DownloadAsync(id, ct).ConfigureAwait(false); + if (result is null) + return Results.NotFound(); + + httpContext.Response.ContentType = result.MimeType; + httpContext.Response.Headers.ContentDisposition = + $"inline; filename=\"{System.Net.WebUtility.UrlEncode(result.FileName)}\""; + + await result.Stream.CopyToAsync(httpContext.Response.Body, ct).ConfigureAwait(false); + + return Results.Empty; + }) + .WithName("Download"); + + return app; + } +} diff --git a/backend/src/CCE.Api.External/Endpoints/AssetEndpoints.cs b/backend/src/CCE.Api.External/Endpoints/AssetEndpoints.cs index defa18ad..f3415edf 100644 --- a/backend/src/CCE.Api.External/Endpoints/AssetEndpoints.cs +++ b/backend/src/CCE.Api.External/Endpoints/AssetEndpoints.cs @@ -4,13 +4,11 @@ using CCE.Application.Content; using CCE.Application.Content.Commands.UploadAsset; using CCE.Application.Content.Queries.GetAssetById; -using CCE.Domain.Content; using CCE.Infrastructure; using MediatR; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; namespace CCE.Api.External.Endpoints; @@ -68,31 +66,6 @@ public static IEndpointRouteBuilder MapAssetEndpoints(this IEndpointRouteBuilder }) .WithName("GetAssetById"); - assets.MapGet("{id:guid}/download", async ( - System.Guid id, - HttpContext httpContext, - ICceDbContext db, - IFileStorage storage, - CancellationToken ct) => - { - var asset = await db.AssetFiles.FirstOrDefaultAsync(a => a.Id == id, ct).ConfigureAwait(false); - if (asset is null) - return Results.NotFound(); - - if (asset.VirusScanStatus != VirusScanStatus.Clean) - return Results.StatusCode(StatusCodes.Status403Forbidden); - - httpContext.Response.ContentType = asset.MimeType; - httpContext.Response.Headers.ContentDisposition = - $"inline; filename=\"{System.Net.WebUtility.UrlEncode(asset.OriginalFileName)}\""; - - await using var stream = await storage.OpenReadAsync(asset.Url, ct).ConfigureAwait(false); - await stream.CopyToAsync(httpContext.Response.Body, ct).ConfigureAwait(false); - - return Results.Empty; - }) - .WithName("DownloadAsset"); - return app; } diff --git a/backend/src/CCE.Api.External/Endpoints/MediaPublicEndpoints.cs b/backend/src/CCE.Api.External/Endpoints/MediaPublicEndpoints.cs index 3d108efe..64e31f04 100644 --- a/backend/src/CCE.Api.External/Endpoints/MediaPublicEndpoints.cs +++ b/backend/src/CCE.Api.External/Endpoints/MediaPublicEndpoints.cs @@ -1,5 +1,4 @@ using CCE.Api.Common.Extensions; -using CCE.Application.Content; using CCE.Application.Media.Commands.DeleteMedia; using CCE.Application.Media.Commands.UploadMedia; using CCE.Application.Media.Commands.UpdateMediaMetadata; @@ -64,23 +63,6 @@ public static IEndpointRouteBuilder MapMediaPublicEndpoints(this IEndpointRouteB .RequireAuthorization() .WithName("UpdateMediaMetadataExternal"); - media.MapGet("{id:guid}/download", async ( - System.Guid id, - IMediator mediator, - HttpContext httpContext, - CancellationToken ct) => - { - var meta = await mediator.Send(new GetMediaByIdQuery(id), ct).ConfigureAwait(false); - if (!meta.Success || meta.Data is null) - return Results.NotFound(); - - var fileStorage = httpContext.RequestServices.GetRequiredKeyedService("media"); - var stream = await fileStorage.OpenReadAsync(meta.Data.StorageKey, ct).ConfigureAwait(false); - return Results.File(stream, meta.Data.MimeType, meta.Data.OriginalFileName); - }) - .RequireAuthorization() - .WithName("DownloadMediaExternal"); - media.MapDelete("{id:guid}", async ( System.Guid id, IMediator mediator, diff --git a/backend/src/CCE.Api.External/Program.cs b/backend/src/CCE.Api.External/Program.cs index da662576..95d9ae1c 100644 --- a/backend/src/CCE.Api.External/Program.cs +++ b/backend/src/CCE.Api.External/Program.cs @@ -1,6 +1,7 @@ using CCE.Api.Common.Auth; using CCE.Api.Common.Authorization; using CCE.Api.Common.Caching; +using CCE.Api.Common.Extensions; using CCE.Api.Common.Health; using CCE.Api.Common.Identity; using CCE.Api.Common.Middleware; @@ -125,6 +126,7 @@ app.MapMediaPublicEndpoints(); app.MapVerificationEndpoints(); app.MapCountryCodesPublicEndpoints(); +app.MapDownloadEndpoints("/api"); app.MapGet("/health", async (IMediator mediator) => { diff --git a/backend/src/CCE.Api.Internal/Endpoints/AssetEndpoints.cs b/backend/src/CCE.Api.Internal/Endpoints/AssetEndpoints.cs index 28ab29ce..8ae498d2 100644 --- a/backend/src/CCE.Api.Internal/Endpoints/AssetEndpoints.cs +++ b/backend/src/CCE.Api.Internal/Endpoints/AssetEndpoints.cs @@ -65,32 +65,6 @@ public static IEndpointRouteBuilder MapAssetEndpoints(this IEndpointRouteBuilder .RequireAuthorization(Permissions.Resource_Center_Upload) .WithName("GetAssetById"); - assets.MapGet("/{id:guid}/download", async ( - System.Guid id, - HttpContext httpContext, - ICceDbContext db, - IFileStorage storage, - CancellationToken ct) => - { - var asset = await db.AssetFiles.FirstOrDefaultAsync(a => a.Id == id, ct).ConfigureAwait(false); - if (asset is null) - return Results.NotFound(); - - if (asset.VirusScanStatus != VirusScanStatus.Clean) - return Results.StatusCode(StatusCodes.Status403Forbidden); - - httpContext.Response.ContentType = asset.MimeType; - httpContext.Response.Headers.ContentDisposition = - $"inline; filename=\"{System.Net.WebUtility.UrlEncode(asset.OriginalFileName)}\""; - - await using var stream = await storage.OpenReadAsync(asset.Url, ct).ConfigureAwait(false); - await stream.CopyToAsync(httpContext.Response.Body, ct).ConfigureAwait(false); - - return Results.Empty; - }) - .RequireAuthorization(Permissions.Resource_Center_Upload) - .WithName("DownloadAsset"); - return app; } diff --git a/backend/src/CCE.Api.Internal/Program.cs b/backend/src/CCE.Api.Internal/Program.cs index 68a916ec..04bc3e4f 100644 --- a/backend/src/CCE.Api.Internal/Program.cs +++ b/backend/src/CCE.Api.Internal/Program.cs @@ -1,5 +1,6 @@ using CCE.Api.Common.Auth; using CCE.Api.Common.Authorization; +using CCE.Api.Common.Extensions; using CCE.Api.Common.Health; using CCE.Api.Common.Identity; using CCE.Api.Common.Middleware; @@ -91,6 +92,7 @@ app.MapMediaEndpoints(); app.MapCountryCodeEndpoints(); app.MapEvaluationEndpoints(); + app.MapDownloadEndpoints("/api/admin"); // Sub-11d follow-up — dev sign-in shim. Mounts /dev/sign-in, // /dev/sign-out, /dev/whoami when Auth:DevMode=true. Production diff --git a/backend/src/CCE.Application/Content/Download/DownloadServices.cs b/backend/src/CCE.Application/Content/Download/DownloadServices.cs new file mode 100644 index 00000000..4bb45f93 --- /dev/null +++ b/backend/src/CCE.Application/Content/Download/DownloadServices.cs @@ -0,0 +1,52 @@ +using CCE.Application.Common.Interfaces; +using CCE.Domain.Content; +using CCE.Domain.Media; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace CCE.Application.Content.Download; + +public sealed record DownloadResult(Stream Stream, string MimeType, string FileName); + +public interface IDownloadService +{ + Task DownloadAsync(Guid id, CancellationToken ct); +} + +public sealed class AssetDownloadService(ICceDbContext db, IFileStorage storage) + : IDownloadService +{ + public async Task DownloadAsync(Guid id, CancellationToken ct) + { + var asset = await db.AssetFiles.FirstOrDefaultAsync(a => a.Id == id, ct).ConfigureAwait(false); + if (asset is null || asset.VirusScanStatus != VirusScanStatus.Clean) + return null; + + var stream = await storage.OpenReadAsync(asset.Url, ct).ConfigureAwait(false); + return new DownloadResult(stream, asset.MimeType, asset.OriginalFileName); + } +} + +public sealed class MediaDownloadService(ICceDbContext db, [FromKeyedServices("media")] IFileStorage storage) + : IDownloadService +{ + public async Task DownloadAsync(Guid id, CancellationToken ct) + { + var media = await db.MediaFiles.FirstOrDefaultAsync(m => m.Id == id, ct).ConfigureAwait(false); + if (media is null) + return null; + + var stream = await storage.OpenReadAsync(media.StorageKey, ct).ConfigureAwait(false); + return new DownloadResult(stream, media.MimeType, media.OriginalFileName); + } +} + +public sealed class DownloadServiceFactory(IServiceProvider sp) +{ + public IDownloadService Create(DownloadType type) => type switch + { + DownloadType.Asset => sp.GetRequiredService(), + DownloadType.Image => sp.GetRequiredService(), + _ => throw new NotSupportedException($"Download type '{type}' is not supported.") + }; +} diff --git a/backend/src/CCE.Domain/Content/DownloadType.cs b/backend/src/CCE.Domain/Content/DownloadType.cs new file mode 100644 index 00000000..0583c58e --- /dev/null +++ b/backend/src/CCE.Domain/Content/DownloadType.cs @@ -0,0 +1,8 @@ +namespace CCE.Domain.Content; + +public enum DownloadType +{ + None = 0, + Asset = 1, + Image = 2, +} diff --git a/backend/src/CCE.Infrastructure/DependencyInjection.cs b/backend/src/CCE.Infrastructure/DependencyInjection.cs index e836b134..56dcf386 100644 --- a/backend/src/CCE.Infrastructure/DependencyInjection.cs +++ b/backend/src/CCE.Infrastructure/DependencyInjection.cs @@ -4,6 +4,7 @@ using CCE.Application.Common.Sanitization; using CCE.Application.Community; using CCE.Application.Content; +using CCE.Application.Content.Download; using CCE.Application.Content.Public; using CCE.Application.Evaluation; using CCE.Application.Media; @@ -177,6 +178,11 @@ public static IServiceCollection AddInfrastructure( services.Configure(configuration.GetSection(MediaUploadOptions.SectionName)); services.AddTransient(); services.AddSingleton(); + + // Download services (factory pattern — asset / image) + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); // ResourceCategory uses IRepository (registered below) services.AddScoped(); From a52ff54ddaf52ad0fd6f12056029c3dfc3f4a4b9 Mon Sep 17 00:00:00 2001 From: ahmed Date: Sun, 7 Jun 2026 18:16:56 +0300 Subject: [PATCH 2/2] fix: use int? for DownloadType query param to avoid enum binding issues --- .../Extensions/DownloadEndpointExtensions.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/backend/src/CCE.Api.Common/Extensions/DownloadEndpointExtensions.cs b/backend/src/CCE.Api.Common/Extensions/DownloadEndpointExtensions.cs index 8114d0b6..39b81979 100644 --- a/backend/src/CCE.Api.Common/Extensions/DownloadEndpointExtensions.cs +++ b/backend/src/CCE.Api.Common/Extensions/DownloadEndpointExtensions.cs @@ -16,12 +16,19 @@ public static IEndpointRouteBuilder MapDownloadEndpoints( group.MapGet("{id:guid}", async ( Guid id, - DownloadType type, + int? type, DownloadServiceFactory factory, HttpContext httpContext, CancellationToken ct) => { - var service = factory.Create(type); + DownloadType downloadType = type switch + { + 1 => DownloadType.Asset, + 2 => DownloadType.Image, + _ => DownloadType.Asset + }; + + var service = factory.Create(downloadType); var result = await service.DownloadAsync(id, ct).ConfigureAwait(false); if (result is null) return Results.NotFound();