Media: uploads stripped of metadata, attachments both ways, a remote media proxy
- /api/v1/media and /api/v2/media (and GET/PUT /api/v1/media/:id):
images go through libvips (NetVips, its native build bundled):
autorotated, every kind of metadata dropped (EXIF, GPS, XMP, IPTC,
comments), capped at 4096 px, with a 640 px preview and a blurhash
(own encoder, the reference algorithm); animated GIFs are re-encoded;
video and audio are remuxed by ffmpeg with -map_metadata -1, never
re-encoded, and a video gets a still preview. Files get random names
under /var/lib/privapub/media, outside the web root deploys replace, and
are served at /media/files with nosniff and a sandbox CSP.
- media_ids on create and edit (four at most, the persona's own, each used
once); notes carry them as Document attachments with alt text, blurhash,
focalPoint and size; inbound attachments were already kept.
- avatar and header uploads in update_credentials, cropped to 400x400 and
1500x500, federated with Update{Person}.
- Remote media reaches clients only through /media/proxy/{hmac}/{url},
fetched by the guarded client (no SVG, 40 MB cap) and cached outside the
served root, trimmed to 5 GB; foreign avatars and headers use it too, so
a client never contacts another server.
- MediaJanitor deletes uploads left unattached for a day.
- nginx accepts 100 MB bodies on the upload endpoints only (applied on Max).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
1 parent
8f75317050
commit
4a713f3fb6
22 files changed
+1030
-26
No files matched your search
@@ -0,0 +1,160 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Infrastructure.Http;
|
||||
using PrivaPub.Models.Media;
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace PrivaPub.Domain.Media
|
||||
{
|
||||
public interface IMediaProxy
|
||||
{
|
||||
string Wrap(string remoteUrl);
|
||||
Task<(string Path, string ContentType)> Fetch(string signature, string encodedUrl, CancellationToken token);
|
||||
}
|
||||
|
||||
public class MediaProxy : IMediaProxy
|
||||
{
|
||||
readonly ILocalActorService _localActors;
|
||||
readonly IFederationHttp _http;
|
||||
readonly IMediaService _media;
|
||||
readonly IOptionsMonitor<MediaOptions> _options;
|
||||
byte[] _key;
|
||||
|
||||
public MediaProxy(ILocalActorService localActors, IFederationHttp http, IMediaService media, IOptionsMonitor<MediaOptions> options)
|
||||
{
|
||||
_localActors = localActors;
|
||||
_http = http;
|
||||
_media = media;
|
||||
_options = options;
|
||||
}
|
||||
|
||||
byte[] Key => _key ??= LoadKey();
|
||||
|
||||
public string Wrap(string remoteUrl)
|
||||
{
|
||||
if (string.IsNullOrEmpty(remoteUrl) || remoteUrl.StartsWith(_localActors.BaseAddress + "/", StringComparison.OrdinalIgnoreCase))
|
||||
return remoteUrl;
|
||||
var encoded = Base64Url(Encoding.UTF8.GetBytes(remoteUrl));
|
||||
return $"{_localActors.BaseAddress}/media/proxy/{Sign(remoteUrl)}/{encoded}";
|
||||
}
|
||||
|
||||
public async Task<(string Path, string ContentType)> Fetch(string signature, string encodedUrl, CancellationToken token)
|
||||
{
|
||||
string url;
|
||||
try
|
||||
{
|
||||
url = Encoding.UTF8.GetString(FromBase64Url(encodedUrl));
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
if (!CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(signature ?? string.Empty), Encoding.ASCII.GetBytes(Sign(url))))
|
||||
return default;
|
||||
|
||||
var name = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(url)));
|
||||
var directory = System.IO.Path.Combine(_media.ProxyRoot, name[..2]);
|
||||
var path = System.IO.Path.Combine(directory, name);
|
||||
var typePath = path + ".type";
|
||||
if (File.Exists(path) && File.Exists(typePath))
|
||||
{
|
||||
File.SetLastWriteTimeUtc(path, DateTime.UtcNow);
|
||||
return (path, await File.ReadAllTextAsync(typePath, token));
|
||||
}
|
||||
|
||||
var (bytes, contentType) = await _http.GetMedia(url, _options.CurrentValue.MaxProxiedBytes, token);
|
||||
if (bytes == default)
|
||||
return default;
|
||||
Directory.CreateDirectory(directory);
|
||||
await File.WriteAllBytesAsync(path, bytes, token);
|
||||
await File.WriteAllTextAsync(typePath, contentType, token);
|
||||
return (path, contentType);
|
||||
}
|
||||
|
||||
string Sign(string url) => Base64Url(HMACSHA256.HashData(Key, Encoding.UTF8.GetBytes(url))[..16]);
|
||||
|
||||
static byte[] LoadKey()
|
||||
{
|
||||
var secret = DB.Default.Find<MediaSecret>().ExecuteFirstAsync().GetAwaiter().GetResult();
|
||||
if (secret == default)
|
||||
{
|
||||
secret = new MediaSecret { Key = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) };
|
||||
DB.Default.SaveAsync(secret).GetAwaiter().GetResult();
|
||||
secret = DB.Default.Find<MediaSecret>().ExecuteFirstAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
return Convert.FromBase64String(secret.Key);
|
||||
}
|
||||
|
||||
static string Base64Url(byte[] bytes) => Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
|
||||
static byte[] FromBase64Url(string value)
|
||||
{
|
||||
var padded = value.Replace('-', '+').Replace('_', '/');
|
||||
return Convert.FromBase64String(padded + new string('=', (4 - padded.Length % 4) % 4));
|
||||
}
|
||||
}
|
||||
|
||||
public class MediaJanitor : BackgroundService
|
||||
{
|
||||
static readonly TimeSpan Interval = TimeSpan.FromHours(1);
|
||||
static readonly TimeSpan UnattachedLifetime = TimeSpan.FromDays(1);
|
||||
|
||||
readonly IMediaService _media;
|
||||
readonly IOptionsMonitor<MediaOptions> _options;
|
||||
readonly ILogger<MediaJanitor> _logger;
|
||||
|
||||
public MediaJanitor(IMediaService media, IOptionsMonitor<MediaOptions> options, ILogger<MediaJanitor> logger)
|
||||
{
|
||||
_media = media;
|
||||
_options = options;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(Interval, stoppingToken);
|
||||
var cutoff = DateTime.UtcNow - UnattachedLifetime;
|
||||
foreach (var stale in await DB.Default.Find<MediaAttachment>().Match(m => m.PostId == null && m.CreatedAt < cutoff).Limit(500).ExecuteAsync(stoppingToken))
|
||||
await _media.Delete(stale);
|
||||
TrimProxyCache();
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "{Service} pass failed", nameof(MediaJanitor));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void TrimProxyCache()
|
||||
{
|
||||
var directory = new DirectoryInfo(_media.ProxyRoot);
|
||||
if (!directory.Exists)
|
||||
return;
|
||||
var files = directory.EnumerateFiles("*", SearchOption.AllDirectories).Where(f => f.Extension != ".type").OrderBy(f => f.LastWriteTimeUtc).ToList();
|
||||
var total = files.Sum(f => f.Length);
|
||||
foreach (var file in files)
|
||||
{
|
||||
if (total <= _options.CurrentValue.ProxyCacheBytes)
|
||||
break;
|
||||
total -= file.Length;
|
||||
file.Delete();
|
||||
var type = new FileInfo(file.FullName + ".type");
|
||||
if (type.Exists)
|
||||
type.Delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user