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..39b81979 --- /dev/null +++ b/backend/src/CCE.Api.Common/Extensions/DownloadEndpointExtensions.cs @@ -0,0 +1,48 @@ +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, + int? type, + DownloadServiceFactory factory, + HttpContext httpContext, + CancellationToken ct) => + { + 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(); + + 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();