Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
}
}
27 changes: 0 additions & 27 deletions backend/src/CCE.Api.External/Endpoints/AssetEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down
18 changes: 0 additions & 18 deletions backend/src/CCE.Api.External/Endpoints/MediaPublicEndpoints.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<IFileStorage>("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,
Expand Down
2 changes: 2 additions & 0 deletions backend/src/CCE.Api.External/Program.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -125,6 +126,7 @@
app.MapMediaPublicEndpoints();
app.MapVerificationEndpoints();
app.MapCountryCodesPublicEndpoints();
app.MapDownloadEndpoints("/api");

app.MapGet("/health", async (IMediator mediator) =>
{
Expand Down
26 changes: 0 additions & 26 deletions backend/src/CCE.Api.Internal/Endpoints/AssetEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
2 changes: 2 additions & 0 deletions backend/src/CCE.Api.Internal/Program.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions backend/src/CCE.Application/Content/Download/DownloadServices.cs
Original file line number Diff line number Diff line change
@@ -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<DownloadResult?> DownloadAsync(Guid id, CancellationToken ct);
}

public sealed class AssetDownloadService(ICceDbContext db, IFileStorage storage)
: IDownloadService
{
public async Task<DownloadResult?> 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<DownloadResult?> 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<AssetDownloadService>(),
DownloadType.Image => sp.GetRequiredService<MediaDownloadService>(),
_ => throw new NotSupportedException($"Download type '{type}' is not supported.")
};
}
8 changes: 8 additions & 0 deletions backend/src/CCE.Domain/Content/DownloadType.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace CCE.Domain.Content;

public enum DownloadType
{
None = 0,
Asset = 1,
Image = 2,
}
6 changes: 6 additions & 0 deletions backend/src/CCE.Infrastructure/DependencyInjection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -177,6 +178,11 @@ public static IServiceCollection AddInfrastructure(
services.Configure<MediaUploadOptions>(configuration.GetSection(MediaUploadOptions.SectionName));
services.AddTransient<IClamAvScanner, ClamAvScanner>();
services.AddSingleton<IHtmlSanitizer, HtmlSanitizerWrapper>();

// Download services (factory pattern — asset / image)
services.AddScoped<AssetDownloadService>();
services.AddScoped<MediaDownloadService>();
services.AddScoped<DownloadServiceFactory>();
services.AddScoped<IAssetRepository, AssetRepository>();
// ResourceCategory uses IRepository<ResourceCategory, Guid> (registered below)
services.AddScoped<IResourceRepository, ResourceRepository>();
Expand Down