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,91 @@
|
||||
namespace PrivaPub.Domain.Media
|
||||
{
|
||||
public static class Blurhash
|
||||
{
|
||||
const string Characters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~";
|
||||
|
||||
public static string Encode(byte[] rgb, int width, int height, int componentsX = 4, int componentsY = 3)
|
||||
{
|
||||
var factors = new double[componentsY, componentsX, 3];
|
||||
for (var j = 0; j < componentsY; j++)
|
||||
for (var i = 0; i < componentsX; i++)
|
||||
{
|
||||
var normalisation = i == 0 && j == 0 ? 1 : 2;
|
||||
double r = 0, g = 0, b = 0;
|
||||
for (var y = 0; y < height; y++)
|
||||
for (var x = 0; x < width; x++)
|
||||
{
|
||||
var basis = normalisation * Math.Cos(Math.PI * i * x / width) * Math.Cos(Math.PI * j * y / height);
|
||||
var offset = (y * width + x) * 3;
|
||||
r += basis * ToLinear(rgb[offset]);
|
||||
g += basis * ToLinear(rgb[offset + 1]);
|
||||
b += basis * ToLinear(rgb[offset + 2]);
|
||||
}
|
||||
var scale = 1.0 / (width * height);
|
||||
factors[j, i, 0] = r * scale;
|
||||
factors[j, i, 1] = g * scale;
|
||||
factors[j, i, 2] = b * scale;
|
||||
}
|
||||
|
||||
var hash = new System.Text.StringBuilder();
|
||||
hash.Append(Base83((componentsX - 1) + (componentsY - 1) * 9, 1));
|
||||
|
||||
double maximum = 0;
|
||||
for (var j = 0; j < componentsY; j++)
|
||||
for (var i = 0; i < componentsX; i++)
|
||||
if (i != 0 || j != 0)
|
||||
for (var c = 0; c < 3; c++)
|
||||
maximum = Math.Max(maximum, Math.Abs(factors[j, i, c]));
|
||||
double maximumValue;
|
||||
if (componentsX * componentsY > 1)
|
||||
{
|
||||
var quantised = (int)Math.Max(0, Math.Min(82, Math.Floor(maximum * 166 - 0.5)));
|
||||
maximumValue = (quantised + 1) / 166.0;
|
||||
hash.Append(Base83(quantised, 1));
|
||||
}
|
||||
else
|
||||
{
|
||||
maximumValue = 1;
|
||||
hash.Append(Base83(0, 1));
|
||||
}
|
||||
|
||||
var dc = (ToSrgb(factors[0, 0, 0]) << 16) + (ToSrgb(factors[0, 0, 1]) << 8) + ToSrgb(factors[0, 0, 2]);
|
||||
hash.Append(Base83(dc, 4));
|
||||
for (var j = 0; j < componentsY; j++)
|
||||
for (var i = 0; i < componentsX; i++)
|
||||
{
|
||||
if (i == 0 && j == 0)
|
||||
continue;
|
||||
int Quantise(double value) => (int)Math.Max(0, Math.Min(18, Math.Floor(SignPow(value / maximumValue, 0.5) * 9 + 9.5)));
|
||||
var ac = Quantise(factors[j, i, 0]) * 19 * 19 + Quantise(factors[j, i, 1]) * 19 + Quantise(factors[j, i, 2]);
|
||||
hash.Append(Base83(ac, 2));
|
||||
}
|
||||
return hash.ToString();
|
||||
}
|
||||
|
||||
static double ToLinear(byte value)
|
||||
{
|
||||
var v = value / 255.0;
|
||||
return v <= 0.04045 ? v / 12.92 : Math.Pow((v + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
|
||||
static int ToSrgb(double value)
|
||||
{
|
||||
var v = Math.Max(0, Math.Min(1, value));
|
||||
return v <= 0.0031308 ? (int)(v * 12.92 * 255 + 0.5) : (int)((1.055 * Math.Pow(v, 1 / 2.4) - 0.055) * 255 + 0.5);
|
||||
}
|
||||
|
||||
static double SignPow(double value, double exponent) => Math.CopySign(Math.Pow(Math.Abs(value), exponent), value);
|
||||
|
||||
static string Base83(int value, int length)
|
||||
{
|
||||
var result = new char[length];
|
||||
for (var i = 1; i <= length; i++)
|
||||
{
|
||||
var digit = value / (int)Math.Pow(83, length - i) % 83;
|
||||
result[i - 1] = Characters[digit];
|
||||
}
|
||||
return new string(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace PrivaPub.Domain.Media
|
||||
{
|
||||
public class MediaOptions
|
||||
{
|
||||
public string Root { get; set; }
|
||||
public long MaxImageBytes { get; set; } = 16 * 1024 * 1024;
|
||||
public long MaxVideoBytes { get; set; } = 99 * 1024 * 1024;
|
||||
public long MaxProxiedBytes { get; set; } = 40 * 1024 * 1024;
|
||||
public long ProxyCacheBytes { get; set; } = 5L * 1024 * 1024 * 1024;
|
||||
public int MaxImageSide { get; set; } = 4096;
|
||||
public int PreviewSide { get; set; } = 640;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
using FFMpegCore;
|
||||
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using NetVips;
|
||||
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Models.Media;
|
||||
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace PrivaPub.Domain.Media
|
||||
{
|
||||
public sealed record MediaOutcome(MediaAttachment Attachment, int Status = StatusCodes.Status200OK, string Error = default)
|
||||
{
|
||||
public bool Ok => Error == default;
|
||||
public static MediaOutcome Fail(int status, string error) => new(default, status, error);
|
||||
}
|
||||
|
||||
public sealed record ProcessedImage(byte[] Bytes, string Extension, string ContentType, int Width, int Height, byte[] Preview, string Blurhash);
|
||||
|
||||
public interface IMediaService
|
||||
{
|
||||
string Root { get; }
|
||||
string ProxyRoot { get; }
|
||||
string Url(string relativePath);
|
||||
Task<MediaOutcome> Upload(LocalActor owner, IFormFile file, string description, string focus, CancellationToken token);
|
||||
Task<string> ProfileImage(IFormFile file, int width, int height, CancellationToken token);
|
||||
Task Delete(MediaAttachment attachment);
|
||||
}
|
||||
|
||||
public class MediaService : IMediaService
|
||||
{
|
||||
static readonly string[] ImageTypes = { "image/jpeg", "image/png", "image/gif", "image/webp", "image/heic", "image/heif", "image/avif" };
|
||||
static readonly Dictionary<string, string> AvTypes = new()
|
||||
{
|
||||
["video/mp4"] = "mp4", ["video/quicktime"] = "mp4", ["video/webm"] = "webm",
|
||||
["audio/mpeg"] = "mp3", ["audio/mp3"] = "mp3", ["audio/ogg"] = "ogg", ["audio/wav"] = "wav", ["audio/x-wav"] = "wav",
|
||||
["audio/mp4"] = "m4a", ["audio/x-m4a"] = "m4a", ["audio/flac"] = "flac", ["audio/webm"] = "webm"
|
||||
};
|
||||
|
||||
readonly IOptionsMonitor<MediaOptions> _options;
|
||||
readonly ILocalActorService _localActors;
|
||||
readonly IWebHostEnvironment _environment;
|
||||
readonly ILogger<MediaService> _logger;
|
||||
|
||||
public MediaService(IOptionsMonitor<MediaOptions> options, ILocalActorService localActors, IWebHostEnvironment environment, ILogger<MediaService> logger)
|
||||
{
|
||||
_options = options;
|
||||
_localActors = localActors;
|
||||
_environment = environment;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public string Root => Path.GetFullPath(_options.CurrentValue.Root ?? Path.Combine(_environment.ContentRootPath, "media-store"));
|
||||
|
||||
public string ProxyRoot => Root.TrimEnd(Path.DirectorySeparatorChar) + "-proxy";
|
||||
|
||||
public string Url(string relativePath) => relativePath == default ? default : $"{_localActors.BaseAddress}/media/files/{relativePath.Replace('\\', '/')}";
|
||||
|
||||
public async Task<MediaOutcome> Upload(LocalActor owner, IFormFile file, string description, string focus, CancellationToken token)
|
||||
{
|
||||
if (file == default || file.Length == 0)
|
||||
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: File can't be blank");
|
||||
var options = _options.CurrentValue;
|
||||
var contentType = file.ContentType?.Split(';')[0].Trim().ToLowerInvariant();
|
||||
|
||||
var attachment = new MediaAttachment
|
||||
{
|
||||
OwnerAvatarId = owner.Id,
|
||||
Description = Clean(description, 1500),
|
||||
Focus = Focus(focus)
|
||||
};
|
||||
if (ImageTypes.Contains(contentType))
|
||||
{
|
||||
if (file.Length > options.MaxImageBytes)
|
||||
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: File is too big");
|
||||
await using var stream = file.OpenReadStream();
|
||||
using var buffer = new MemoryStream();
|
||||
await stream.CopyToAsync(buffer, token);
|
||||
ProcessedImage processed;
|
||||
try
|
||||
{
|
||||
processed = ProcessImage(buffer.ToArray(), options.MaxImageSide, options.PreviewSide, contentType == "image/gif");
|
||||
}
|
||||
catch (VipsException ex)
|
||||
{
|
||||
_logger.LogInformation("Refused an image upload: {Error}", ex.Message);
|
||||
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: The file is not a readable image");
|
||||
}
|
||||
attachment.Kind = contentType == "image/gif" ? "gifv" : "image";
|
||||
attachment.ContentType = processed.ContentType;
|
||||
attachment.FilePath = await Save(processed.Bytes, processed.Extension, token);
|
||||
attachment.PreviewPath = await Save(processed.Preview, "jpg", token);
|
||||
attachment.Width = processed.Width;
|
||||
attachment.Height = processed.Height;
|
||||
attachment.Blurhash = processed.Blurhash;
|
||||
attachment.Size = processed.Bytes.Length;
|
||||
}
|
||||
else if (contentType != default && AvTypes.TryGetValue(contentType, out var extension))
|
||||
{
|
||||
if (file.Length > options.MaxVideoBytes)
|
||||
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: File is too big");
|
||||
var outcome = await ProcessAv(file, extension, contentType, attachment, token);
|
||||
if (!outcome.Ok)
|
||||
return outcome;
|
||||
}
|
||||
else
|
||||
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: File type is not supported");
|
||||
|
||||
await DB.Default.SaveAsync(attachment, token);
|
||||
return new MediaOutcome(attachment);
|
||||
}
|
||||
|
||||
public async Task<string> ProfileImage(IFormFile file, int width, int height, CancellationToken token)
|
||||
{
|
||||
if (file == default || file.Length == 0 || file.Length > _options.CurrentValue.MaxImageBytes)
|
||||
return default;
|
||||
await using var stream = file.OpenReadStream();
|
||||
using var buffer = new MemoryStream();
|
||||
await stream.CopyToAsync(buffer, token);
|
||||
try
|
||||
{
|
||||
using var image = Image.ThumbnailBuffer(buffer.ToArray(), width, height: height, crop: Enums.Interesting.Centre, size: Enums.Size.Down);
|
||||
using var flat = Flatten(image);
|
||||
return Url(await Save(flat.WriteToBuffer(".jpg[Q=85,keep=none]"), "jpg", token));
|
||||
}
|
||||
catch (VipsException)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
public Task Delete(MediaAttachment attachment)
|
||||
{
|
||||
foreach (var path in new[] { attachment.FilePath, attachment.PreviewPath }.Where(p => p != default))
|
||||
{
|
||||
var full = Path.Combine(Root, path);
|
||||
if (File.Exists(full))
|
||||
File.Delete(full);
|
||||
}
|
||||
return DB.Default.DeleteAsync<MediaAttachment>(attachment.ID);
|
||||
}
|
||||
|
||||
public static ProcessedImage ProcessImage(byte[] input, int maxSide, int previewSide, bool animated)
|
||||
{
|
||||
using var loaded = animated ? Image.NewFromBuffer(input, kwargs: new VOption { { "n", -1 } }) : Image.NewFromBuffer(input);
|
||||
using var rotated = animated ? loaded.Copy() : loaded.Autorot();
|
||||
var pageHeight = animated && rotated.Contains("page-height") ? (int)rotated.Get("page-height") : rotated.Height;
|
||||
var longest = Math.Max(rotated.Width, pageHeight);
|
||||
|
||||
byte[] bytes;
|
||||
string extension, contentType;
|
||||
int width, height;
|
||||
if (animated)
|
||||
{
|
||||
bytes = rotated.WriteToBuffer(".gif");
|
||||
(extension, contentType, width, height) = ("gif", "image/gif", rotated.Width, pageHeight);
|
||||
}
|
||||
else
|
||||
{
|
||||
using var resized = longest > maxSide ? rotated.ThumbnailImage(maxSide, height: maxSide, size: Enums.Size.Down) : rotated.Copy();
|
||||
var keepsAlpha = resized.HasAlpha();
|
||||
bytes = keepsAlpha ? resized.WriteToBuffer(".png[keep=none]") : resized.WriteToBuffer(".jpg[Q=88,keep=none]");
|
||||
(extension, contentType, width, height) = keepsAlpha ? ("png", "image/png", resized.Width, resized.Height) : ("jpg", "image/jpeg", resized.Width, resized.Height);
|
||||
}
|
||||
|
||||
using var firstFrame = animated ? rotated.Crop(0, 0, rotated.Width, pageHeight) : rotated.Copy();
|
||||
using var preview = firstFrame.ThumbnailImage(previewSide, height: previewSide, size: Enums.Size.Down);
|
||||
using var previewFlat = Flatten(preview);
|
||||
var previewBytes = previewFlat.WriteToBuffer(".jpg[Q=80,keep=none]");
|
||||
|
||||
using var tiny = firstFrame.ThumbnailImage(32, height: 32);
|
||||
using var tinyFlat = Flatten(tiny);
|
||||
var pixels = tinyFlat.WriteToMemory();
|
||||
return new ProcessedImage(bytes, extension, contentType, width, height, previewBytes, Blurhash.Encode(pixels, tinyFlat.Width, tinyFlat.Height));
|
||||
}
|
||||
|
||||
static Image Flatten(Image image)
|
||||
{
|
||||
var srgb = image.Interpretation == Enums.Interpretation.Srgb ? image.Copy() : image.Colourspace(Enums.Interpretation.Srgb);
|
||||
var flat = srgb.HasAlpha() ? srgb.Flatten(background: new double[] { 255, 255, 255 }) : srgb.Copy();
|
||||
srgb.Dispose();
|
||||
var bands = flat.Bands > 3 ? flat.ExtractBand(0, n: 3) : flat.Bands == 1 ? flat.Bandjoin(flat, flat) : flat.Copy();
|
||||
flat.Dispose();
|
||||
using var cast = bands;
|
||||
return bands.Format == Enums.BandFormat.Uchar ? bands.Copy() : bands.Cast(Enums.BandFormat.Uchar);
|
||||
}
|
||||
|
||||
async Task<MediaOutcome> ProcessAv(IFormFile file, string extension, string contentType, MediaAttachment attachment, CancellationToken token)
|
||||
{
|
||||
var temp = Path.Combine(Path.GetTempPath(), $"privapub-{Guid.NewGuid():N}");
|
||||
var input = temp + ".in";
|
||||
var output = temp + "." + extension;
|
||||
try
|
||||
{
|
||||
await using (var target = File.Create(input))
|
||||
await file.CopyToAsync(target, token);
|
||||
var probe = await FFProbe.AnalyseAsync(input, cancellationToken: token);
|
||||
var isVideo = contentType.StartsWith("video/") && probe.PrimaryVideoStream != default;
|
||||
await FFMpegArguments.FromFileInput(input)
|
||||
.OutputToFile(output, true, o => o.WithCustomArgument("-map 0 -map_metadata -1 -map_chapters -1 -c copy" + (extension is "mp4" or "m4a" ? " -movflags +faststart" : string.Empty)))
|
||||
.CancellableThrough(token)
|
||||
.ProcessAsynchronously();
|
||||
|
||||
attachment.Kind = isVideo ? "video" : "audio";
|
||||
attachment.ContentType = extension switch { "mp4" => "video/mp4", "webm" => isVideo ? "video/webm" : "audio/webm", "m4a" => "audio/mp4", "mp3" => "audio/mpeg", _ => contentType };
|
||||
attachment.FilePath = await Save(await File.ReadAllBytesAsync(output, token), extension, token);
|
||||
attachment.Size = new FileInfo(output).Length;
|
||||
if (isVideo)
|
||||
{
|
||||
attachment.Width = probe.PrimaryVideoStream.Width;
|
||||
attachment.Height = probe.PrimaryVideoStream.Height;
|
||||
var frame = temp + ".png";
|
||||
await FFMpeg.SnapshotAsync(input, frame, captureTime: TimeSpan.FromSeconds(Math.Min(1, probe.Duration.TotalSeconds / 2)));
|
||||
var still = ProcessImage(await File.ReadAllBytesAsync(frame, token), 640, _options.CurrentValue.PreviewSide, false);
|
||||
attachment.PreviewPath = await Save(still.Preview, "jpg", token);
|
||||
attachment.Blurhash = still.Blurhash;
|
||||
File.Delete(frame);
|
||||
}
|
||||
return new MediaOutcome(attachment);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogInformation(ex, "Refused an audio or video upload");
|
||||
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: The file could not be processed");
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var path in new[] { input, output })
|
||||
if (File.Exists(path))
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
async Task<string> Save(byte[] bytes, string extension, CancellationToken token)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var relative = Path.Combine(now.ToString("yyyy", CultureInfo.InvariantCulture), now.ToString("MM", CultureInfo.InvariantCulture),
|
||||
$"{Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16))}.{extension}");
|
||||
var full = Path.Combine(Root, relative);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(full)!);
|
||||
await File.WriteAllBytesAsync(full, bytes, token);
|
||||
return relative.Replace('\\', '/');
|
||||
}
|
||||
|
||||
static string Clean(string value, int max) =>
|
||||
string.IsNullOrWhiteSpace(value) ? default : value.Trim().Length <= max ? value.Trim() : value.Trim()[..max];
|
||||
|
||||
static float[] Focus(string focus)
|
||||
{
|
||||
var parts = focus?.Split(',');
|
||||
if (parts is not { Length: 2 }
|
||||
|| !float.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var x)
|
||||
|| !float.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var y))
|
||||
return default;
|
||||
return new[] { Math.Clamp(x, -1, 1), Math.Clamp(y, -1, 1) };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using MongoDB.Driver;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Domain.Content;
|
||||
using PrivaPub.Domain.Media;
|
||||
using PrivaPub.Domain.Privacy;
|
||||
using PrivaPub.Domain.Social;
|
||||
using PrivaPub.Domain.Timelines;
|
||||
@@ -10,6 +11,7 @@ using PrivaPub.Federation.Outbox;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Group;
|
||||
using PrivaPub.Models.Media;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.StaticServices;
|
||||
@@ -33,6 +35,7 @@ namespace PrivaPub.Domain.Statuses
|
||||
public string Language { get; init; }
|
||||
public string ConversationId { get; init; }
|
||||
public IReadOnlyList<string> Recipients { get; init; } = Array.Empty<string>();
|
||||
public IReadOnlyList<string> MediaIds { get; init; }
|
||||
}
|
||||
|
||||
public sealed record StatusOutcome(PostEntity Post, int Status = StatusCodes.Status200OK, string Error = default)
|
||||
@@ -61,10 +64,12 @@ namespace PrivaPub.Domain.Statuses
|
||||
readonly IContentRenderer _content;
|
||||
readonly IOutboxPublisher _outbox;
|
||||
readonly IFanout _fanout;
|
||||
readonly IMediaService _media;
|
||||
|
||||
public StatusService(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
|
||||
IContentRenderer content, IOutboxPublisher outbox, IFanout fanout)
|
||||
IContentRenderer content, IOutboxPublisher outbox, IFanout fanout, IMediaService media)
|
||||
{
|
||||
_media = media;
|
||||
_dbEntities = dbEntities;
|
||||
_localActors = localActors;
|
||||
_remoteActors = remoteActors;
|
||||
@@ -76,7 +81,10 @@ namespace PrivaPub.Domain.Statuses
|
||||
|
||||
public async Task<StatusOutcome> Publish(LocalActor author, StatusDraft draft, CancellationToken token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(draft.Text))
|
||||
var media = await Media(author, draft.MediaIds, default, token);
|
||||
if (media == default)
|
||||
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are invalid");
|
||||
if (string.IsNullOrWhiteSpace(draft.Text) && media.Count == 0)
|
||||
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Text can't be blank");
|
||||
|
||||
LocalActor group = default;
|
||||
@@ -94,7 +102,7 @@ namespace PrivaPub.Domain.Statuses
|
||||
if (parent != default && !await VisibilityPolicy.CanSee(parent, author.Id, token))
|
||||
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
|
||||
|
||||
var rendered = draft.PlainText ? await _content.PlainText(draft.Text, token) : await _content.Markdown(draft.Text, token);
|
||||
var rendered = draft.PlainText ? await _content.PlainText(draft.Text ?? string.Empty, token) : await _content.Markdown(draft.Text ?? string.Empty, token);
|
||||
var isLocalOnly = group is { IsFederated: false };
|
||||
var visibility = isLocalOnly ? PostVisibility.Circle : draft.Visibility is PostVisibility.Circle or PostVisibility.LocalGeo ? PostVisibility.Public : draft.Visibility;
|
||||
var post = new PostEntity
|
||||
@@ -112,6 +120,7 @@ namespace PrivaPub.Domain.Statuses
|
||||
Language = Clean(draft.Language),
|
||||
Mentions = rendered.Mentions.Select(ToMention).ToList(),
|
||||
Tags = rendered.Tags.ToList(),
|
||||
Media = media.Select(ToPostMedia).ToList(),
|
||||
AnsweringToPostId = parent?.ID,
|
||||
InReplyToURI = parent?.ObjectURI ?? (IsRemoteUri(draft.InReplyTo) ? draft.InReplyTo : default),
|
||||
InReplyToAccountId = parent?.AuthorAccountId ?? parent?.GroupUserId,
|
||||
@@ -150,6 +159,7 @@ namespace PrivaPub.Domain.Statuses
|
||||
post.ActivityURI = create?["id"]?.GetValue<string>();
|
||||
|
||||
await DB.Default.SaveAsync(post, token);
|
||||
await Attach(media, post.ID, token);
|
||||
if (parent != default)
|
||||
await DB.Default.Update<PostEntity>().MatchID(parent.ID).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token);
|
||||
await _fanout.Distribute(post, token);
|
||||
@@ -165,7 +175,10 @@ namespace PrivaPub.Domain.Statuses
|
||||
var post = await Own(author, postId, token);
|
||||
if (post == default)
|
||||
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
|
||||
if (string.IsNullOrWhiteSpace(draft.Text))
|
||||
var media = draft.MediaIds == default ? default : await Media(author, draft.MediaIds, post.ID, token);
|
||||
if (draft.MediaIds != default && media == default)
|
||||
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are invalid");
|
||||
if (string.IsNullOrWhiteSpace(draft.Text) && (media ?? new List<MediaAttachment>()).Count == 0 && post.Media.Count == 0)
|
||||
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Text can't be blank");
|
||||
|
||||
post.Revisions.Add(new PostRevision
|
||||
@@ -180,7 +193,12 @@ namespace PrivaPub.Domain.Statuses
|
||||
post.Revisions.RemoveRange(0, post.Revisions.Count - MaxRevisions);
|
||||
|
||||
var plain = draft.PlainText || post.ContentFormat == ContentFormat.Plain;
|
||||
var rendered = plain ? await _content.PlainText(draft.Text, token) : await _content.Markdown(draft.Text, token);
|
||||
var rendered = plain ? await _content.PlainText(draft.Text ?? string.Empty, token) : await _content.Markdown(draft.Text ?? string.Empty, token);
|
||||
if (media != default)
|
||||
{
|
||||
post.Media = media.Select(ToPostMedia).ToList();
|
||||
await Attach(media, post.ID, token);
|
||||
}
|
||||
post.Title = draft.Title == default ? post.Title : Clean(draft.Title);
|
||||
post.SpoilerText = Clean(draft.SpoilerText);
|
||||
post.HasContentWarning = draft.Sensitive || post.SpoilerText != default;
|
||||
@@ -461,6 +479,40 @@ namespace PrivaPub.Domain.Statuses
|
||||
return (await _dbEntities.ForeignAvatars.MatchID(post.AuthorAccountId).ExecuteFirstAsync(token))?.InboxURL;
|
||||
}
|
||||
|
||||
async Task<List<MediaAttachment>> Media(LocalActor author, IReadOnlyList<string> ids, string postId, CancellationToken token)
|
||||
{
|
||||
if (ids == default || ids.Count == 0)
|
||||
return new List<MediaAttachment>();
|
||||
if (ids.Count > 4)
|
||||
return default;
|
||||
var wanted = ids.Distinct().ToList();
|
||||
var found = await DB.Default.Find<MediaAttachment>()
|
||||
.Match(m => wanted.Contains(m.ID) && m.OwnerAvatarId == author.Id && (m.PostId == null || m.PostId == postId))
|
||||
.ExecuteAsync(token);
|
||||
return found.Count == wanted.Count ? wanted.Select(id => found.First(m => m.ID == id)).ToList() : default;
|
||||
}
|
||||
|
||||
static async Task Attach(IEnumerable<MediaAttachment> media, string postId, CancellationToken token)
|
||||
{
|
||||
var ids = media.Select(m => m.ID).ToList();
|
||||
if (ids.Count > 0)
|
||||
await DB.Default.Update<MediaAttachment>().Match(m => ids.Contains(m.ID))
|
||||
.Modify(m => m.PostId, postId).Modify(m => m.AttachedAt, DateTime.UtcNow).ExecuteAsync(token);
|
||||
}
|
||||
|
||||
PostMedia ToPostMedia(MediaAttachment attachment) => new()
|
||||
{
|
||||
AttachmentId = attachment.ID,
|
||||
ContentType = attachment.ContentType,
|
||||
URL = _media.Url(attachment.FilePath),
|
||||
PreviewURL = _media.Url(attachment.PreviewPath ?? attachment.FilePath),
|
||||
Description = attachment.Description,
|
||||
Blurhash = attachment.Blurhash,
|
||||
Width = attachment.Width,
|
||||
Height = attachment.Height,
|
||||
Focus = attachment.Focus
|
||||
};
|
||||
|
||||
static JsonObject Undo(LocalActor actor, JsonObject inner, string activityId) => new()
|
||||
{
|
||||
["@context"] = ActivityPubRenderer.ActivityStreams,
|
||||
|
||||
Reference in new issue
Block a user