Thumbnails/previews are immutable per asset, so responses are marked Cache-Control: public, max-age=86400, eliminating repeat fetches on scroll and back-navigation.
147 lines
5.6 KiB
C#
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
|
|
};
|
|
}
|
|
}
|