Files
MilkyShots/Lactose/Controllers/MediaController.cs
T
REDCODE a811221228 perf(media): cache thumbnails and previews in browser for 24h
Thumbnails/previews are immutable per asset, so responses are marked
Cache-Control: public, max-age=86400, eliminating repeat fetches on scroll
and back-navigation.
2026-08-11 13:10:46 +02:00

147 lines
5.6 KiB
C#

using Butter.Types;
using Lactose.Configuration;
using Lactose.Repositories;
using Lactose.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
namespace Lactose.Controllers;
/// <summary>
/// Serves media files (original, thumbnail, preview) for assets.
/// </summary>
/// <param name="authService">Authentication service.</param>
/// <param name="mediaRepository">Media repository.</param>
/// <param name="assetRepository">Asset repository.</param>
/// <param name="signKeyOptions">JWT signing key configuration.</param>
[ApiController]
[Authorize]
[AllowAnonymous]
[Route("api/[controller]")]
public class MediaController(
LactoseAuthService authService,
IMediaRepository mediaRepository,
IAssetRepository assetRepository,
IOptions<SignKeyConfiguration> signKeyOptions
) : ControllerBase {
/// <summary>
/// Gets the original image file for an asset.
/// </summary>
/// <param name="id">The asset ID.</param>
/// <returns>The physical image file, or 404 if not found or access denied.</returns>
[HttpGet("{id}")]
[HttpGet("original/{id}")]
[EnableRateLimiting("media_original")]
public ActionResult GetImage(Guid id) {
var user = GetUserFromRequest();
var asset = assetRepository.Find(id);
if (asset == null) return NotFound();
if (CanAccessAssetDirectly(user, asset.DeletedAt)) {
return PhysicalFile(asset.OriginalPath, asset.MimeType);
}
var data = mediaRepository.GetOriginalData(id, user?.Id, user?.AccessLevel ?? EAccessLevel.User);
if (data == null) return NotFound();
return PhysicalFile(data.Path, data.MimeType);
}
/// <summary>
/// Gets the thumbnail image for an asset.
/// </summary>
/// <param name="id">The asset ID.</param>
/// <returns>The physical thumbnail file, or 404 if not found or access denied.</returns>
[HttpGet("thumb/{id}")]
[EnableRateLimiting("media_thumb")]
public ActionResult GetThumb(Guid id) {
var user = GetUserFromRequest();
var asset = assetRepository.Find(id);
if (asset == null) return NotFound();
// Thumbnails are immutable per asset — allow browser/proxy caching
Response.Headers.CacheControl = "public, max-age=86400";
if (CanAccessAssetDirectly(user, asset.DeletedAt)) {
if (string.IsNullOrEmpty(asset.ThumbnailPath)) return NotFound();
return PhysicalFile(asset.ThumbnailPath, "image/webp");
}
var data = mediaRepository.GetThumbData(id, user?.Id, user?.AccessLevel ?? EAccessLevel.User);
if (data == null) return NotFound();
return PhysicalFile(data.Path, data.MimeType);
}
/// <summary>
/// Gets the preview image for an asset.
/// </summary>
/// <param name="id">The asset ID.</param>
/// <returns>The physical preview file, or 404 if not found or access denied.</returns>
[HttpGet("preview/{id}")]
[EnableRateLimiting("media_preview")]
public ActionResult GetPreview(Guid id) {
var user = GetUserFromRequest();
var asset = assetRepository.Find(id);
if (asset == null) return NotFound();
// Previews are immutable per asset — allow browser/proxy caching
Response.Headers.CacheControl = "public, max-age=86400";
if (CanAccessAssetDirectly(user, asset.DeletedAt)) {
if (string.IsNullOrEmpty(asset.PreviewPath)) return NotFound();
return PhysicalFile(asset.PreviewPath, "image/webp");
}
var data = mediaRepository.GetPreviewData(id, user?.Id, user?.AccessLevel ?? EAccessLevel.User);
if (data == null) return NotFound();
return PhysicalFile(data.Path, data.MimeType);
}
/// <summary>
/// Extracts the authenticated user from the request, first trying the Authorization header,
/// then falling back to the <c>token</c> query parameter for browser image requests.
/// </summary>
private LactoseAuthenticatedUser? GetUserFromRequest() {
var user = authService.GetUserData(User);
if (user != null) return user;
var token = Request.Query["token"].FirstOrDefault();
if (string.IsNullOrEmpty(token)) return null;
try {
var handler = new JwtSecurityTokenHandler();
var key = signKeyOptions.Value.GetSecurityKey();
var principal = handler.ValidateToken(token, new TokenValidationParameters {
IssuerSigningKey = key,
ValidateIssuer = false,
ValidateAudience = false,
ValidateLifetime = true
}, out _);
return authService.GetUserData(principal);
} catch {
return null;
}
}
/// <summary>
/// Determines whether the requester can access asset files directly without repository-level filtering.
/// </summary>
/// <param name="user">The authenticated user, if any.</param>
/// <param name="deletedAt">The asset deletion timestamp.</param>
/// <returns><see langword="true"/> for admins, or for curators when the asset is not deleted; otherwise <see langword="false"/>.</returns>
private static bool CanAccessAssetDirectly(LactoseAuthenticatedUser? user, DateTime? deletedAt) {
return user?.AccessLevel switch {
EAccessLevel.Admin => true,
EAccessLevel.Curator => deletedAt == null,
_ => false
};
}
}