- /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
140 lines
5.8 KiB
C#
140 lines
5.8 KiB
C#
using Microsoft.Extensions.Caching.Memory;
|
|
using Microsoft.Extensions.Localization;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
|
|
using MongoDB.Entities;
|
|
|
|
using PrivaPub.Domain.Content;
|
|
using PrivaPub.Domain.Media;
|
|
using PrivaPub.Domain.Relationships;
|
|
using PrivaPub.Domain.Social;
|
|
using PrivaPub.Domain.Statuses;
|
|
using PrivaPub.Domain.Timelines;
|
|
using PrivaPub.Federation.Actors;
|
|
using PrivaPub.Federation.Inbox;
|
|
using PrivaPub.Federation.Inbox.Handlers;
|
|
using PrivaPub.Federation.Outbox;
|
|
using PrivaPub.Infrastructure.Jobs;
|
|
using PrivaPub.Models;
|
|
using PrivaPub.Models.Federation;
|
|
using PrivaPub.Models.Jobs;
|
|
using PrivaPub.Models.User;
|
|
using PrivaPub.Resources;
|
|
using PrivaPub.Services;
|
|
using PrivaPub.StaticServices;
|
|
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
|
|
namespace PrivaPub.Tests.Support
|
|
{
|
|
public sealed class Harness : IAsyncDisposable
|
|
{
|
|
public const string Host = "privapub.test";
|
|
public const string Base = "https://" + Host;
|
|
|
|
Harness(Peer peer)
|
|
{
|
|
Peer = peer;
|
|
var cache = new MemoryCache(new MemoryCacheOptions());
|
|
Local = new LocalActorService(Db, new StaticOptions<AppConfiguration>(new AppConfiguration { BackendBaseAddress = Base }));
|
|
Remote = new RemoteActorService(Peer.Http(cache), Local, cache, Db);
|
|
Delivery = new DeliveryService(Db, Queue);
|
|
Fanout = new Fanout(Db);
|
|
RemotePosts = new RemotePosts(Db, Local, Remote, new NoBlocks(), Queue);
|
|
Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger<InboxReceiver>.Instance);
|
|
Processor = new InboxProcessor(Remote, new IActivityHandler[]
|
|
{
|
|
new FollowHandler(Db, Local, Remote, Delivery),
|
|
new AcceptHandler(Db, Local),
|
|
new RejectHandler(Db, Local),
|
|
new UndoHandler(Db, Local),
|
|
new LikeHandler(Db),
|
|
new AnnounceHandler(Db, Local, RemotePosts, Fanout),
|
|
new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts),
|
|
new DeleteHandler(Db, Local, Remote, Delivery),
|
|
new UpdateHandler(Db, Local, Remote),
|
|
new FlagHandler(Db, Local)
|
|
}, NullLogger<InboxProcessor>.Instance);
|
|
Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance);
|
|
Content = new ContentRenderer(Local, Remote);
|
|
Outbox = new OutboxPublisher(Db, Local, Delivery);
|
|
Media = new MediaService(new StaticOptions<MediaOptions>(new MediaOptions { Root = Path.Combine(Path.GetTempPath(), $"privapub-media-{Guid.NewGuid():N}") }),
|
|
Local, default, NullLogger<MediaService>.Instance);
|
|
Statuses = new StatusService(Db, Local, Remote, Delivery, Content, Outbox, Fanout, Media);
|
|
Posts = new PostsService(Db, Local, Statuses, new KeyLocalizer<GenericRes>(), NullLogger<PostsService>.Instance);
|
|
Timelines = new TimelineService(Db, new KeyLocalizer<GenericRes>());
|
|
Relationships = new RelationshipService(Db, Follows, Delivery);
|
|
Reports = new ReportService(Db, Local, Delivery);
|
|
}
|
|
|
|
public Peer Peer { get; }
|
|
public DbEntities Db { get; } = new();
|
|
public JobQueue Queue { get; } = new();
|
|
public LocalActorService Local { get; }
|
|
public RemoteActorService Remote { get; }
|
|
public DeliveryService Delivery { get; }
|
|
public InboxReceiver Receiver { get; }
|
|
public InboxProcessor Processor { get; }
|
|
public FollowService Follows { get; }
|
|
public ContentRenderer Content { get; }
|
|
public OutboxPublisher Outbox { get; }
|
|
public PostsService Posts { get; }
|
|
public StatusService Statuses { get; }
|
|
public MediaService Media { get; }
|
|
public Fanout Fanout { get; }
|
|
public RemotePosts RemotePosts { get; }
|
|
public TimelineService Timelines { get; }
|
|
public RelationshipService Relationships { get; }
|
|
public ReportService Reports { get; }
|
|
|
|
public async Task FollowedBy(LocalActor local, RemoteActor follower) =>
|
|
await DB.Default.SaveAsync(new Follower
|
|
{
|
|
LocalActorId = local.Id,
|
|
LocalActorKind = local.Kind,
|
|
ActorURI = follower.Id,
|
|
InboxURL = follower.Id + "/inbox",
|
|
SharedInboxURL = follower.SharedInbox
|
|
});
|
|
|
|
public static async Task<Harness> Start() => new(await Peer.Start());
|
|
|
|
public async Task<(string RootId, LocalActor Avatar)> Persona(string name, string rootId = default)
|
|
{
|
|
rootId ??= Guid.NewGuid().ToString("N")[..24];
|
|
var (privateKey, publicKey) = Keys.NewKeyPair();
|
|
var avatar = new Avatar { UserName = $"{name}{Guid.NewGuid():N}"[..20], Name = name, PrivateKey = privateKey, PublicKey = publicKey };
|
|
await DB.Default.SaveAsync(avatar);
|
|
await DB.Default.SaveAsync(new RootToAvatar { RootId = rootId, AvatarId = avatar.ID });
|
|
return (rootId, Local.FromAvatar(avatar));
|
|
}
|
|
|
|
public async Task<InboxResult> Deliver(RemoteActor sender, string path, JsonNode activity)
|
|
{
|
|
var result = await Receiver.Receive(sender.Post(Host, path, activity), default, CancellationToken.None);
|
|
var dedupe = "inbox|" + (activity is JsonObject ? activity["id"]?.GetValue<string>() : default);
|
|
var job = await DB.Default.Find<Job>().Match(j => j.DedupeKey == dedupe).ExecuteFirstAsync();
|
|
if (job != default)
|
|
Assert.Equal(JobResult.Done, (await Processor.Handle(job, CancellationToken.None)).Result);
|
|
return result;
|
|
}
|
|
|
|
public async Task<List<JsonObject>> Outgoing(string inbox) =>
|
|
(await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.Deliver).ExecuteAsync())
|
|
.Select(j => JsonSerializer.Deserialize<DeliveryPayload>(j.Payload))
|
|
.Where(p => p.Inbox == inbox)
|
|
.Select(p => JsonNode.Parse(p.Body)!.AsObject())
|
|
.ToList();
|
|
|
|
public async ValueTask DisposeAsync() => await Peer.DisposeAsync();
|
|
}
|
|
|
|
public sealed class KeyLocalizer<T> : IStringLocalizer<T>
|
|
{
|
|
public LocalizedString this[string name] => new(name, name);
|
|
public LocalizedString this[string name, params object[] arguments] => new(name, string.Format(name, arguments));
|
|
public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures) => Enumerable.Empty<LocalizedString>();
|
|
}
|
|
}
|