Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28b581b6b1 | ||
|
|
4a713f3fb6 | ||
|
|
8f75317050 | ||
|
|
525b5368a1 |
No files matched your search
@@ -398,3 +398,6 @@ FodyWeavers.xsd
|
||||
# JetBrains Rider
|
||||
*.sln.iml
|
||||
|
||||
|
||||
PrivaPub/media-store/
|
||||
PrivaPub/media-store-proxy/
|
||||
@@ -90,6 +90,8 @@ PrivaPub/ ASP.NET Core Web API, net10.0
|
||||
Social/ FollowService (local in-process, remote Follow/Accept), Notifications
|
||||
Timelines/ Fanout (TimelineEntry rows, Mastodon's home rules), TimelineService
|
||||
Privacy/ VisibilityPolicy (IsPublic expression, CanSee)
|
||||
Relationships/ RelationshipService (blocks, mutes, account domain blocks; Hidden), ReportService
|
||||
Media/ MediaService (libvips, ffmpeg remux, blurhash), MediaProxy, MediaJanitor
|
||||
Domain/Statuses/ StatusService: publish, edit, remove, favourite, reblog, for a persona (both client APIs use it)
|
||||
Api/Mastodon/
|
||||
Auth/ OpenIddict setup (keys in Mongo), MastodonScopes, TokenController, OAuthPruner
|
||||
@@ -190,6 +192,15 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
|
||||
5. Unsupported features answer empty lists or 422 with a message, never 404 or 500, so clients degrade.
|
||||
6. Advertise `4.2.0 (compatible; PrivaPub)` until grouped notifications exist.
|
||||
|
||||
## Media invariants
|
||||
|
||||
1. **No upload keeps its metadata.** Images are re-encoded by libvips with `keep=none`; audio and video are remuxed with
|
||||
`-map_metadata -1`. `MediaProcessingTests` checks EXIF and XMP are gone.
|
||||
2. Files live under `Media:Root` (`/var/lib/privapub/media`), never in the published directory; the proxy cache is the
|
||||
sibling `media-proxy`, which `/media/files` does not serve.
|
||||
3. **A client never contacts a remote server for media:** every remote URL the API returns goes through
|
||||
`IMediaProxy.Wrap`, an HMAC-signed `/media/proxy/` URL fetched by `IFederationHttp.GetMedia`.
|
||||
|
||||
## Privacy invariants
|
||||
|
||||
- **No root id in federation output, NodeInfo or logs, no IP next to an identity in logs, and no `ex.Message` to a
|
||||
@@ -199,6 +210,7 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
|
||||
- **One username space:** personas, groups and the instance reserve their name in `ReservedName` (unique index) before
|
||||
they are saved; `LocalActorService.TryReserveUserName` is the only way to claim one.
|
||||
- **Per-avatar state stays per avatar:** blocks, mutes, notifications, follows. Nothing may relate sibling avatars.
|
||||
- **Blocks never federate,** and reports leave as `Flag` from the instance actor.
|
||||
- **Location-ranged posts never federate.**
|
||||
|
||||
## Data
|
||||
|
||||
+10
-3
@@ -67,9 +67,16 @@ Received:
|
||||
| `Like` | counted and notified, on posts the liker could see |
|
||||
| `Announce` | counted and notified for local posts; shown to followers of the announcer, with the original refetched from its origin |
|
||||
| `Delete` | deletes the object, or the actor and its follows |
|
||||
| `Flag` | becomes a report for this server's moderators |
|
||||
|
||||
Sent: `Follow`, `Undo{Follow}`, `Create{Note}`, `Update{Note}`, `Update{Person}`, `Delete{Tombstone}`, `Accept{Follow}`,
|
||||
`Announce` (communities). A deleted post answers 410 with a `Tombstone`.
|
||||
`Reject{Follow}`, `Like`, `Announce` and their `Undo`, `Flag`. A deleted post answers 410 with a `Tombstone`.
|
||||
|
||||
- **Attachments** are `Document`s with `mediaType`, `name` (alt text), `blurhash`, `focalPoint`, `width` and `height`.
|
||||
Uploaded files have all metadata removed.
|
||||
- **Pinned posts** are the actor's `featured` collection (`/trophies`); `featuredTags` is `/tattoos`.
|
||||
- **Blocks are never sent.** A blocked account is sent `Reject{Follow}` if it followed, and is unfollowed.
|
||||
- **Reports** are sent as `Flag` by the instance actor, never by the reporting account.
|
||||
|
||||
A `Create`'s `Note` carries Mastodon's `content`, `contentMap`, `summary` and `sensitive`, plus `Mention` and `Hashtag`
|
||||
tags. A post's title becomes `name` and is also the first, bold line of `content`, because Mastodon does not show
|
||||
@@ -94,7 +101,7 @@ followers-only posts are recognised by the author's own `followers` collection.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Avatars cannot send likes or boosts yet (that arrives with the Mastodon client API), and media uploads and polls are not
|
||||
implemented.
|
||||
- Polls and custom emoji are not implemented.
|
||||
- Remote media is fetched through this server's proxy when a local client displays it.
|
||||
- Collections expose counts, not members.
|
||||
- Only `rsa-sha256`-style keys are verified. RFC 9421 signatures are planned.
|
||||
@@ -0,0 +1,102 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using NetVips;
|
||||
|
||||
using PrivaPub.Domain.Media;
|
||||
using PrivaPub.Domain.Statuses;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Models.Media;
|
||||
using PrivaPub.Tests.Support;
|
||||
|
||||
namespace PrivaPub.Tests.Domain
|
||||
{
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class MediaFlowTests : IAsyncLifetime
|
||||
{
|
||||
Harness _harness;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||
_harness = await Harness.Start();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_harness != default)
|
||||
await _harness.DisposeAsync();
|
||||
}
|
||||
|
||||
static byte[] Png(int width, int height)
|
||||
{
|
||||
using var image = (Image.Black(width, height, bands: 3) + new double[] { 10, 120, 200 }).Cast(Enums.BandFormat.Uchar);
|
||||
return image.WriteToBuffer(".png");
|
||||
}
|
||||
|
||||
static IFormFile Upload(byte[] bytes, string contentType) =>
|
||||
new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "picture") { Headers = new HeaderDictionary(), ContentType = contentType };
|
||||
|
||||
[Fact]
|
||||
public async Task An_upload_is_attached_once_and_federated_with_its_alt_text()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (_, alice) = await _harness.Persona("alice");
|
||||
var (_, mallory) = await _harness.Persona("mallory");
|
||||
|
||||
var upload = await _harness.Media.Upload(alice, Upload(Png(300, 200), "image/png"), "a blue square", "0.25,-0.5", token);
|
||||
Assert.True(upload.Ok);
|
||||
Assert.True(File.Exists(Path.Combine(_harness.Media.Root, upload.Attachment.FilePath)));
|
||||
|
||||
var stolen = await _harness.Statuses.Publish(mallory, new StatusDraft { Text = "mine", MediaIds = new[] { upload.Attachment.ID } }, token);
|
||||
Assert.False(stolen.Ok);
|
||||
|
||||
var posted = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "look", MediaIds = new[] { upload.Attachment.ID } }, token);
|
||||
Assert.True(posted.Ok);
|
||||
Assert.Equal(posted.Post.ID, (await DB.Default.Find<MediaAttachment>().OneAsync(upload.Attachment.ID, token)).PostId);
|
||||
|
||||
var note = ActivityPubRenderer.Note(posted.Post, alice, default, default);
|
||||
var attachment = note["attachment"]![0]!;
|
||||
Assert.Equal("a blue square", attachment["name"]!.GetValue<string>());
|
||||
Assert.Equal(300, attachment["width"]!.GetValue<int>());
|
||||
Assert.Equal(-0.5f, attachment["focalPoint"]![1]!.GetValue<float>());
|
||||
Assert.StartsWith("https://privapub.test/media/files/", attachment["url"]!.GetValue<string>());
|
||||
|
||||
var reused = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "again", MediaIds = new[] { upload.Attachment.ID } }, token);
|
||||
Assert.False(reused.Ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unsupported_files_are_refused()
|
||||
{
|
||||
var (_, alice) = await _harness.Persona("alice");
|
||||
|
||||
var outcome = await _harness.Media.Upload(alice, Upload(System.Text.Encoding.UTF8.GetBytes("<svg/>"), "image/svg+xml"), default, default, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(outcome.Ok);
|
||||
Assert.Equal(422, outcome.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_proxy_serves_signed_remote_media_and_nothing_else()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var path = $"/files/{Guid.NewGuid():N}.png";
|
||||
_harness.Peer.ServeFile(path, Png(10, 10), "image/png");
|
||||
var proxy = new MediaProxy(_harness.Local, Peer.Http(), _harness.Media, new StaticOptions<MediaOptions>(new MediaOptions()));
|
||||
|
||||
var wrapped = proxy.Wrap(_harness.Peer.A + path);
|
||||
var parts = new Uri(wrapped).AbsolutePath.Split('/');
|
||||
var (file, contentType) = await proxy.Fetch(parts[3], parts[4], token);
|
||||
var (tampered, _) = await proxy.Fetch(parts[3].Replace(parts[3][0], parts[3][0] == 'A' ? 'B' : 'A'), parts[4], token);
|
||||
|
||||
Assert.StartsWith("https://privapub.test/media/proxy/", wrapped);
|
||||
Assert.Equal("image/png", contentType);
|
||||
Assert.True(File.Exists(file));
|
||||
Assert.StartsWith(_harness.Media.ProxyRoot, file);
|
||||
Assert.Null(tampered);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using NetVips;
|
||||
|
||||
using PrivaPub.Domain.Media;
|
||||
|
||||
namespace PrivaPub.Tests.Domain
|
||||
{
|
||||
public class MediaProcessingTests
|
||||
{
|
||||
static byte[] JpegWithMetadata(int width, int height)
|
||||
{
|
||||
using var image = (Image.Black(width, height, bands: 3) + new double[] { 200, 40, 90 }).Cast(Enums.BandFormat.Uchar);
|
||||
using var tagged = image.Mutate(m =>
|
||||
{
|
||||
m.Set(GValue.GStrType, "exif-ifd0-ImageDescription", "where I live");
|
||||
m.Set(GValue.GStrType, "exif-ifd0-Artist", "Alice Smith");
|
||||
m.Set(GValue.GStrType, "exif-ifd2-UserComment", "at home");
|
||||
m.Set(GValue.BlobType, "xmp-data", System.Text.Encoding.UTF8.GetBytes("<x:xmpmeta xmlns:x='adobe:ns:meta/'><secret/></x:xmpmeta>"));
|
||||
});
|
||||
return tagged.WriteToBuffer(".jpg");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Uploaded_images_lose_every_kind_of_metadata()
|
||||
{
|
||||
var input = JpegWithMetadata(800, 600);
|
||||
using (var original = Image.NewFromBuffer(input))
|
||||
Assert.Contains("exif-data", original.GetFields());
|
||||
|
||||
var processed = MediaService.ProcessImage(input, 4096, 640, animated: false);
|
||||
|
||||
using var output = Image.NewFromBuffer(processed.Bytes);
|
||||
var fields = output.GetFields();
|
||||
Assert.DoesNotContain("exif-data", fields);
|
||||
Assert.DoesNotContain("xmp-data", fields);
|
||||
Assert.DoesNotContain("iptc-data", fields);
|
||||
Assert.DoesNotContain(fields, f => f.StartsWith("exif-ifd"));
|
||||
Assert.Equal((800, 600), (processed.Width, processed.Height));
|
||||
Assert.Equal("image/jpeg", processed.ContentType);
|
||||
using var preview = Image.NewFromBuffer(processed.Preview);
|
||||
Assert.Equal(640, Math.Max(preview.Width, preview.Height));
|
||||
Assert.DoesNotContain("exif-data", preview.GetFields());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Large_images_are_capped()
|
||||
{
|
||||
var processed = MediaService.ProcessImage(JpegWithMetadata(5000, 2500), 4096, 640, animated: false);
|
||||
|
||||
Assert.Equal((4096, 2048), (processed.Width, processed.Height));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_blurhash_is_well_formed()
|
||||
{
|
||||
var processed = MediaService.ProcessImage(JpegWithMetadata(64, 64), 4096, 640, animated: false);
|
||||
|
||||
Assert.Equal(28, processed.Blurhash.Length);
|
||||
Assert.Equal('L', processed.Blurhash[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_white_image_encodes_white_as_its_average()
|
||||
{
|
||||
var pixels = Enumerable.Range(0, 16 * 16).SelectMany(_ => new byte[] { 255, 255, 255 }).ToArray();
|
||||
|
||||
var hash = Blurhash.Encode(pixels, 16, 16);
|
||||
|
||||
Assert.Equal(28, hash.Length);
|
||||
Assert.Equal("TSUA", hash[2..6]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.ClientModels.Post;
|
||||
using PrivaPub.ClientModels.Social;
|
||||
using PrivaPub.Federation.Objects;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.Tests.Support;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace PrivaPub.Tests.Domain
|
||||
{
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class RelationshipTests : IAsyncLifetime
|
||||
{
|
||||
Harness _harness;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||
_harness = await Harness.Start();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_harness != default)
|
||||
await _harness.DisposeAsync();
|
||||
}
|
||||
|
||||
static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority);
|
||||
|
||||
[Fact]
|
||||
public async Task A_block_cuts_follows_both_ways_and_hides_the_account()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (aliceRoot, alice) = await _harness.Persona("alice");
|
||||
var (bobRoot, bob) = await _harness.Persona("bob");
|
||||
await _harness.Follows.Follow(aliceRoot, new FollowForm { AvatarId = alice.Id, Target = bob.UserName }, token);
|
||||
await _harness.Follows.Follow(bobRoot, new FollowForm { AvatarId = bob.Id, Target = alice.UserName }, token);
|
||||
|
||||
await _harness.Relationships.Block(alice, bob.Uri, bob.Id, token);
|
||||
await _harness.Posts.InsertPost(bobRoot, new InsertPostForm { AvatarId = bob.Id, Text = $"hey @{alice.UserName}" }, token);
|
||||
|
||||
Assert.False(await DB.Default.Find<Following>().Match(f => f.AvatarId == alice.Id || f.AvatarId == bob.Id).ExecuteAnyAsync(token));
|
||||
Assert.False(await DB.Default.Find<Follower>().Match(f => f.LocalActorId == alice.Id || f.LocalActorId == bob.Id).ExecuteAnyAsync(token));
|
||||
Assert.False(await DB.Default.Find<TimelineEntry>().Match(e => e.AvatarId == alice.Id && e.AuthorAccountId == bob.Id).ExecuteAnyAsync(token));
|
||||
Assert.False(await DB.Default.Find<Notification>().Match(n => n.AvatarId == alice.Id).ExecuteAnyAsync(token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_blocked_remote_account_is_refused_when_it_follows()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (_, alice) = await _harness.Persona("alice");
|
||||
var mallory = new RemoteActor(_harness.Peer, "mallory");
|
||||
await _harness.Relationships.Block(alice, mallory.Id, default, token);
|
||||
|
||||
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
|
||||
{
|
||||
["id"] = $"{Origin(mallory)}/follows/{Guid.NewGuid():N}", ["type"] = "Follow", ["actor"] = mallory.Id, ["object"] = alice.Uri
|
||||
});
|
||||
|
||||
Assert.False(await DB.Default.Find<Follower>().Match(f => f.LocalActorId == alice.Id).ExecuteAnyAsync(token));
|
||||
Assert.Equal("Reject", Assert.Single(await _harness.Outgoing(mallory.Id + "/inbox"))["type"]!.GetValue<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_muted_account_stays_out_of_home_and_notifications()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (aliceRoot, alice) = await _harness.Persona("alice");
|
||||
var (bobRoot, bob) = await _harness.Persona("bob");
|
||||
await _harness.Follows.Follow(aliceRoot, new FollowForm { AvatarId = alice.Id, Target = bob.UserName }, token);
|
||||
await _harness.Relationships.Mute(alice, bob.Uri, bob.Id, hideNotifications: true, default, token);
|
||||
|
||||
await _harness.Posts.InsertPost(bobRoot, new InsertPostForm { AvatarId = bob.Id, Text = $"hey @{alice.UserName}" }, token);
|
||||
|
||||
Assert.False(await DB.Default.Find<TimelineEntry>().Match(e => e.AvatarId == alice.Id).ExecuteAnyAsync(token));
|
||||
Assert.False(await DB.Default.Find<Notification>().Match(n => n.AvatarId == alice.Id && n.Type == NotificationType.Mention).ExecuteAnyAsync(token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_report_is_forwarded_by_the_instance_not_the_persona()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (_, alice) = await _harness.Persona("alice");
|
||||
var mallory = new RemoteActor(_harness.Peer, "mallory");
|
||||
var foreign = await _harness.Remote.GetActor(mallory.Id, refresh: false, token);
|
||||
|
||||
var report = await _harness.Reports.File(alice, foreign.ID, Array.Empty<string>(), "spam everywhere", "spam", forward: true, token);
|
||||
|
||||
Assert.True(report.Forwarded);
|
||||
var flag = Assert.Single(await _harness.Outgoing(mallory.Id + "/inbox"));
|
||||
Assert.Equal("Flag", flag["type"]!.GetValue<string>());
|
||||
Assert.EndsWith("/peasants/privapub", flag["actor"]!.GetValue<string>());
|
||||
Assert.DoesNotContain(alice.UserName, flag.ToJsonString());
|
||||
Assert.DoesNotContain(alice.Id, flag.ToJsonString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_inbound_flag_becomes_a_report_for_moderators()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (root, alice) = await _harness.Persona("alice");
|
||||
var reporter = new RemoteActor(_harness.Peer, "reporter");
|
||||
await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = "bad post" }, token);
|
||||
var post = await DB.Default.Find<PrivaPub.Models.Post.Post>().Match(p => p.GroupUserId == alice.Id).ExecuteSingleAsync(token);
|
||||
|
||||
await _harness.Deliver(reporter, "/human-centipede", new JsonObject
|
||||
{
|
||||
["id"] = $"{Origin(reporter)}/flags/{Guid.NewGuid():N}", ["type"] = "Flag", ["actor"] = reporter.Id,
|
||||
["content"] = "please look", ["object"] = new JsonArray(alice.Uri, post.ObjectURI)
|
||||
});
|
||||
|
||||
var report = await DB.Default.Find<Report>().Match(r => r.TargetAccountId == alice.Id).ExecuteSingleAsync(token);
|
||||
Assert.Equal(reporter.Id, report.ReporterActorURI);
|
||||
Assert.Equal(new[] { post.ID }, report.PostIds);
|
||||
Assert.Equal("please look", report.Comment);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ 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;
|
||||
@@ -51,14 +53,19 @@ namespace PrivaPub.Tests.Support
|
||||
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 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);
|
||||
Statuses = new StatusService(Db, Local, Remote, Delivery, Content, Outbox, Fanout);
|
||||
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; }
|
||||
@@ -74,9 +81,12 @@ namespace PrivaPub.Tests.Support
|
||||
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
|
||||
|
||||
@@ -19,6 +19,7 @@ namespace PrivaPub.Tests.Support
|
||||
readonly WebApplication _app;
|
||||
readonly ConcurrentDictionary<string, string> _documents = new();
|
||||
readonly ConcurrentDictionary<string, (int Status, TimeSpan Delay)> _answers = new();
|
||||
readonly ConcurrentDictionary<string, (byte[] Bytes, string ContentType)> _files = new();
|
||||
|
||||
public int Port { get; }
|
||||
public string A => $"http://127.0.0.1:{Port}";
|
||||
@@ -41,6 +42,12 @@ namespace PrivaPub.Tests.Support
|
||||
{
|
||||
peer.Requests.Enqueue(new(context.Request.Method, context.Request.Path, context.Request.Headers["Signature"].ToString()));
|
||||
var key = context.Request.Path.Value;
|
||||
if (peer._files.TryGetValue(key, out var file))
|
||||
{
|
||||
context.Response.ContentType = file.ContentType;
|
||||
await context.Response.Body.WriteAsync(file.Bytes);
|
||||
return;
|
||||
}
|
||||
if (peer._answers.TryGetValue(key, out var answer))
|
||||
{
|
||||
if (answer.Delay > TimeSpan.Zero)
|
||||
@@ -63,6 +70,8 @@ namespace PrivaPub.Tests.Support
|
||||
|
||||
public void Serve(string path, string json) => _documents[path] = json;
|
||||
|
||||
public void ServeFile(string path, byte[] bytes, string contentType) => _files[path] = (bytes, contentType);
|
||||
|
||||
public void Answer(string path, int status, TimeSpan delay = default) => _answers[path] = (status, delay);
|
||||
|
||||
public static FederationHttp Http(IMemoryCache cache = default, IDomainBlocks blocks = default)
|
||||
|
||||
@@ -5,7 +5,9 @@ using MongoDB.Entities;
|
||||
using PrivaPub.Api.Mastodon.Entities;
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Api.Mastodon.Mappers;
|
||||
using PrivaPub.Domain.Media;
|
||||
using PrivaPub.Domain.Privacy;
|
||||
using PrivaPub.Domain.Relationships;
|
||||
using PrivaPub.Domain.Social;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Outbox;
|
||||
@@ -27,10 +29,12 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
readonly IRemoteActorService _remoteActors;
|
||||
readonly IFollowService _follows;
|
||||
readonly IOutboxPublisher _outbox;
|
||||
readonly IRelationshipService _relationships;
|
||||
|
||||
public AccountsController(MastodonMapper mapper, DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors,
|
||||
IFollowService follows, IOutboxPublisher outbox)
|
||||
IFollowService follows, IOutboxPublisher outbox, IRelationshipService relationships)
|
||||
{
|
||||
_relationships = relationships;
|
||||
_mapper = mapper;
|
||||
_dbEntities = dbEntities;
|
||||
_localActors = localActors;
|
||||
@@ -42,10 +46,19 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
[HttpGet("/api/v1/accounts/verify_credentials"), Scope("read:accounts")]
|
||||
public async Task<IActionResult> VerifyCredentials(CancellationToken token) => Json(await _mapper.Local(Me, withSource: true, token));
|
||||
|
||||
[HttpPatch("/api/v1/accounts/update_credentials"), Scope("write:accounts")]
|
||||
public async Task<IActionResult> UpdateCredentials(CancellationToken token)
|
||||
[HttpPatch("/api/v1/accounts/update_credentials"), Scope("write:accounts"), RequestSizeLimit(20 * 1024 * 1024),
|
||||
RequestFormLimits(MultipartBodyLengthLimit = 20 * 1024 * 1024)]
|
||||
public async Task<IActionResult> UpdateCredentials([FromServices] IMediaService media, CancellationToken token)
|
||||
{
|
||||
var avatar = await _dbEntities.Avatars.MatchID(Me.Id).ExecuteFirstAsync(token);
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync(token);
|
||||
if (form.Files["avatar"] is { } picture && await media.ProfileImage(picture, 400, 400, token) is { } pictureUrl)
|
||||
avatar.PictureURL = pictureUrl;
|
||||
if (form.Files["header"] is { } header && await media.ProfileImage(header, 1500, 500, token) is { } headerUrl)
|
||||
avatar.ThumbnailURL = headerUrl;
|
||||
}
|
||||
if (Params.Has("display_name"))
|
||||
avatar.Name = Params.Get("display_name")?.Trim();
|
||||
if (Params.Has("note"))
|
||||
@@ -136,7 +149,13 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
if (local == default && remote == default)
|
||||
return NotFoundError();
|
||||
if (Params.Bool("pinned") == true)
|
||||
{
|
||||
if (local == default)
|
||||
return Json(Array.Empty<Status>());
|
||||
var pinIds = (await DB.Default.Find<Pin>().Match(p => p.AvatarId == local.Id).Sort(p => p.ID, Order.Descending).ExecuteAsync(token)).Select(p => p.PostId).ToList();
|
||||
var pinnedPosts = await _dbEntities.Posts.Match(p => pinIds.Contains(p.ID) && !p.DeletedAt.HasValue).Match(VisibilityPolicy.IsPublic).ExecuteAsync(token);
|
||||
return Json(await _mapper.Statuses(pinIds.Select(id => pinnedPosts.FirstOrDefault(p => p.ID == id)).Where(p => p != default).ToList(), MyId, token));
|
||||
}
|
||||
|
||||
var query = local != default
|
||||
? _dbEntities.Posts.Match(p => p.GroupUserId == local.Id && !p.IsFederatedCopy && !p.DeletedAt.HasValue)
|
||||
@@ -220,6 +239,83 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
return Json(await Relationship(id, token));
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/accounts/{id}/block"), Scope("write:blocks")]
|
||||
public async Task<IActionResult> Block(string id, CancellationToken token)
|
||||
{
|
||||
var (local, remote) = await Find(id, token);
|
||||
if (local == default && remote == default || local?.Id == MyId)
|
||||
return NotFoundError();
|
||||
await _relationships.Block(Me, local?.Uri ?? remote.ActorURI, id, token);
|
||||
return Json(await Relationship(id, token));
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/accounts/{id}/unblock"), Scope("write:blocks")]
|
||||
public async Task<IActionResult> Unblock(string id, CancellationToken token)
|
||||
{
|
||||
var (local, remote) = await Find(id, token);
|
||||
if (local == default && remote == default)
|
||||
return NotFoundError();
|
||||
await _relationships.Unblock(Me, local?.Uri ?? remote.ActorURI, token);
|
||||
return Json(await Relationship(id, token));
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/accounts/{id}/mute"), Scope("write:mutes")]
|
||||
public async Task<IActionResult> Mute(string id, CancellationToken token)
|
||||
{
|
||||
var (local, remote) = await Find(id, token);
|
||||
if (local == default && remote == default || local?.Id == MyId)
|
||||
return NotFoundError();
|
||||
var duration = Params.Int("duration") is { } seconds and > 0 ? TimeSpan.FromSeconds(seconds) : (TimeSpan?)null;
|
||||
await _relationships.Mute(Me, local?.Uri ?? remote.ActorURI, id, Params.Bool("notifications") != false, duration, token);
|
||||
return Json(await Relationship(id, token));
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/accounts/{id}/unmute"), Scope("write:mutes")]
|
||||
public async Task<IActionResult> Unmute(string id, CancellationToken token)
|
||||
{
|
||||
var (local, remote) = await Find(id, token);
|
||||
if (local == default && remote == default)
|
||||
return NotFoundError();
|
||||
await _relationships.Unmute(Me, local?.Uri ?? remote.ActorURI, token);
|
||||
return Json(await Relationship(id, token));
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/blocks"), Scope("read:blocks")]
|
||||
public async Task<IActionResult> Blocks(CancellationToken token)
|
||||
{
|
||||
var blocks = await Page.From(Params, Limit(40, 80)).Fetch(DB.Default.Find<Block>().Match(b => b.AvatarId == MyId), b => b.ID, token);
|
||||
var accounts = await _mapper.Accounts(blocks.Select(b => b.TargetAccountId), token);
|
||||
Link("/api/v1/blocks", blocks.LastOrDefault()?.ID, blocks.FirstOrDefault()?.ID);
|
||||
return Json(blocks.Select(b => accounts.GetValueOrDefault(b.TargetAccountId)).Where(a => a != default).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/mutes"), Scope("read:mutes")]
|
||||
public async Task<IActionResult> Mutes(CancellationToken token)
|
||||
{
|
||||
var mutes = await Page.From(Params, Limit(40, 80)).Fetch(DB.Default.Find<Mute>().Match(m => m.AvatarId == MyId), m => m.ID, token);
|
||||
var accounts = await _mapper.Accounts(mutes.Select(m => m.TargetAccountId), token);
|
||||
Link("/api/v1/mutes", mutes.LastOrDefault()?.ID, mutes.FirstOrDefault()?.ID);
|
||||
return Json(mutes.Select(m => accounts.GetValueOrDefault(m.TargetAccountId)).Where(a => a != default).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/domain_blocks"), Scope("read:blocks")]
|
||||
public async Task<IActionResult> DomainBlocks(CancellationToken token) =>
|
||||
Json((await DB.Default.Find<AccountDomainBlock>().Match(b => b.AvatarId == MyId).Sort(b => b.Domain, Order.Ascending).ExecuteAsync(token)).Select(b => b.Domain).ToList());
|
||||
|
||||
[HttpPost("/api/v1/domain_blocks"), Scope("write:blocks")]
|
||||
public async Task<IActionResult> BlockDomain(CancellationToken token)
|
||||
{
|
||||
await _relationships.BlockDomain(Me, Params.Get("domain"), token);
|
||||
return Json(new { });
|
||||
}
|
||||
|
||||
[HttpDelete("/api/v1/domain_blocks"), Scope("write:blocks")]
|
||||
public async Task<IActionResult> UnblockDomain(CancellationToken token)
|
||||
{
|
||||
await _relationships.UnblockDomain(Me, Params.Get("domain"), token);
|
||||
return Json(new { });
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/follow_requests"), Scope("read:follows")]
|
||||
public async Task<IActionResult> FollowRequests(CancellationToken token)
|
||||
{
|
||||
@@ -280,9 +376,18 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
var uri = local?.Uri ?? remote?.ActorURI;
|
||||
var following = uri == default ? default : await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.TargetActorURI == uri).ExecuteFirstAsync(token);
|
||||
var followedBy = uri == default ? default : await _dbEntities.Followers.Match(f => f.LocalActorId == MyId && f.ActorURI == uri).ExecuteFirstAsync(token);
|
||||
var blocking = uri != default && await DB.Default.Find<Block>().Match(b => b.AvatarId == MyId && b.TargetActorURI == uri).ExecuteAnyAsync(token);
|
||||
var mute = uri == default ? default : await DB.Default.Find<Mute>().Match(m => m.AvatarId == MyId && m.TargetActorURI == uri).ExecuteFirstAsync(token);
|
||||
var blockedBy = local != default && await DB.Default.Find<Block>().Match(b => b.AvatarId == local.Id && b.TargetActorURI == Me.Uri).ExecuteAnyAsync(token);
|
||||
var domainBlocked = remote != default && await DB.Default.Find<AccountDomainBlock>().Match(b => b.AvatarId == MyId && b.Domain == remote.Domain).ExecuteAnyAsync(token);
|
||||
return new Relationship
|
||||
{
|
||||
Id = id,
|
||||
Blocking = blocking,
|
||||
BlockedBy = blockedBy,
|
||||
Muting = mute != default && (mute.ExpiresAt == default || mute.ExpiresAt > DateTime.UtcNow),
|
||||
MutingNotifications = mute?.HideNotifications == true,
|
||||
DomainBlocking = domainBlocked,
|
||||
Following = following?.State == FollowState.Accepted,
|
||||
Requested = following?.State == FollowState.Requested,
|
||||
ShowingReblogs = following?.ShowReblogs ?? false,
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Domain.Media;
|
||||
using PrivaPub.Models.Media;
|
||||
|
||||
using System.Globalization;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
public class MediaController : MastodonController
|
||||
{
|
||||
const long UploadLimit = 100L * 1024 * 1024;
|
||||
|
||||
readonly IMediaService _media;
|
||||
readonly IMediaProxy _proxy;
|
||||
|
||||
public MediaController(IMediaService media, IMediaProxy proxy)
|
||||
{
|
||||
_media = media;
|
||||
_proxy = proxy;
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/media"), HttpPost("/api/v2/media"), Scope("write:media"), RequestSizeLimit(UploadLimit),
|
||||
RequestFormLimits(MultipartBodyLengthLimit = UploadLimit)]
|
||||
public async Task<IActionResult> Upload(CancellationToken token)
|
||||
{
|
||||
if (!Request.HasFormContentType)
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: File can't be blank");
|
||||
var form = await Request.ReadFormAsync(token);
|
||||
var outcome = await _media.Upload(Me, form.Files["file"], form["description"], form["focus"], token);
|
||||
return outcome.Ok ? Json(View(outcome.Attachment)) : Error(outcome.Status, outcome.Error);
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/media/{id}"), Scope("write:media")]
|
||||
public async Task<IActionResult> Get(string id, CancellationToken token)
|
||||
{
|
||||
var attachment = await DB.Default.Find<MediaAttachment>().Match(m => m.ID == id && m.OwnerAvatarId == MyId).ExecuteFirstAsync(token);
|
||||
return attachment == default ? NotFoundError() : Json(View(attachment));
|
||||
}
|
||||
|
||||
[HttpPut("/api/v1/media/{id}"), Scope("write:media")]
|
||||
public async Task<IActionResult> Update(string id, CancellationToken token)
|
||||
{
|
||||
var attachment = await DB.Default.Find<MediaAttachment>().Match(m => m.ID == id && m.OwnerAvatarId == MyId).ExecuteFirstAsync(token);
|
||||
if (attachment == default)
|
||||
return NotFoundError();
|
||||
if (Params.Has("description"))
|
||||
attachment.Description = Params.Get("description")?.Trim() is { Length: > 0 } description ? description[..Math.Min(description.Length, 1500)] : default;
|
||||
if (Params.Get("focus")?.Split(',') is [var x, var y]
|
||||
&& float.TryParse(x, NumberStyles.Float, CultureInfo.InvariantCulture, out var fx) && float.TryParse(y, NumberStyles.Float, CultureInfo.InvariantCulture, out var fy))
|
||||
attachment.Focus = new[] { Math.Clamp(fx, -1, 1), Math.Clamp(fy, -1, 1) };
|
||||
await DB.Default.SaveAsync(attachment, token);
|
||||
return Json(View(attachment));
|
||||
}
|
||||
|
||||
[HttpGet("/media/proxy/{signature}/{encoded}"), AllowAnonymous, ApiExplorerSettings(IgnoreApi = true)]
|
||||
public async Task<IActionResult> Proxy(string signature, string encoded, CancellationToken token)
|
||||
{
|
||||
var (path, contentType) = await _proxy.Fetch(signature, encoded, token);
|
||||
if (path == default)
|
||||
return NotFound();
|
||||
Response.Headers["X-Content-Type-Options"] = "nosniff";
|
||||
Response.Headers["Content-Security-Policy"] = "default-src 'none'; sandbox";
|
||||
Response.Headers["Cache-Control"] = "public, max-age=604800";
|
||||
return PhysicalFile(path, contentType);
|
||||
}
|
||||
|
||||
object View(MediaAttachment attachment) => new
|
||||
{
|
||||
id = attachment.ID,
|
||||
type = attachment.Kind,
|
||||
url = _media.Url(attachment.FilePath),
|
||||
preview_url = _media.Url(attachment.PreviewPath ?? attachment.FilePath),
|
||||
remote_url = default(string),
|
||||
text_url = default(string),
|
||||
meta = new
|
||||
{
|
||||
original = attachment.Width.HasValue && attachment.Height > 0
|
||||
? new { width = attachment.Width, height = attachment.Height, size = $"{attachment.Width}x{attachment.Height}", aspect = (double)attachment.Width / attachment.Height.Value }
|
||||
: default,
|
||||
focus = attachment.Focus is { Length: 2 } ? new { x = attachment.Focus[0], y = attachment.Focus[1] } : default
|
||||
},
|
||||
description = attachment.Description,
|
||||
blurhash = attachment.Blurhash
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Api.Mastodon.Mappers;
|
||||
using PrivaPub.Domain.Relationships;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
public class ReportsController : MastodonController
|
||||
{
|
||||
readonly IReportService _reports;
|
||||
readonly MastodonMapper _mapper;
|
||||
|
||||
public ReportsController(IReportService reports, MastodonMapper mapper)
|
||||
{
|
||||
_reports = reports;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/reports"), Scope("write:reports")]
|
||||
public async Task<IActionResult> Create(CancellationToken token)
|
||||
{
|
||||
var report = await _reports.File(Me, Params.Get("account_id"), Params.List("status_ids"), Params.Get("comment"), Params.Get("category"),
|
||||
Params.Bool("forward") == true, token);
|
||||
if (report == default)
|
||||
return NotFoundError();
|
||||
return Json(new
|
||||
{
|
||||
id = report.ID,
|
||||
action_taken = false,
|
||||
action_taken_at = default(string),
|
||||
category = report.Category,
|
||||
comment = report.Comment ?? string.Empty,
|
||||
forwarded = report.Forwarded,
|
||||
created_at = MastodonJson.Time(report.CreatedAt),
|
||||
status_ids = report.PostIds,
|
||||
rule_ids = Array.Empty<string>(),
|
||||
target_account = await _mapper.Account(report.TargetAccountId, token)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,18 +112,6 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
[HttpGet("/api/v1/followed_tags"), Scope("read:follows")]
|
||||
public IActionResult FollowedTags() => Json(Array.Empty<object>());
|
||||
|
||||
[HttpGet("/api/v1/blocks"), Scope("read:blocks")]
|
||||
public IActionResult Blocks() => Json(Array.Empty<object>());
|
||||
|
||||
[HttpGet("/api/v1/mutes"), Scope("read:mutes")]
|
||||
public IActionResult Mutes() => Json(Array.Empty<object>());
|
||||
|
||||
[HttpGet("/api/v1/domain_blocks"), Scope("read:blocks")]
|
||||
public IActionResult DomainBlocks() => Json(Array.Empty<string>());
|
||||
|
||||
[HttpGet("/api/v1/bookmarks"), Scope("read:bookmarks")]
|
||||
public IActionResult Bookmarks() => Json(Array.Empty<object>());
|
||||
|
||||
[HttpGet("/api/v1/endorsements"), Scope("read:accounts")]
|
||||
public IActionResult Endorsements() => Json(Array.Empty<object>());
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
public class StatusesController : MastodonController
|
||||
{
|
||||
const int MaxContext = 200;
|
||||
public const int MaxPins = 5;
|
||||
|
||||
readonly IStatusService _statuses;
|
||||
readonly MastodonMapper _mapper;
|
||||
@@ -43,8 +44,6 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
if (existing != default)
|
||||
return Json(await _mapper.Status(existing, MyId, token));
|
||||
}
|
||||
if (Params.List("media_ids").Count > 0)
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are not supported yet");
|
||||
if (Params.Has("poll[options]"))
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Polls are not supported yet");
|
||||
|
||||
@@ -56,7 +55,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
Sensitive = Params.Bool("sensitive") ?? Me.Settings.DefaultSensitive,
|
||||
Visibility = Visibility(Params.Get("visibility") ?? Me.Settings.DefaultVisibility),
|
||||
InReplyTo = Params.Get("in_reply_to_id"),
|
||||
Language = Params.Get("language") ?? Me.Settings.DefaultLanguage
|
||||
Language = Params.Get("language") ?? Me.Settings.DefaultLanguage,
|
||||
MediaIds = Params.List("media_ids")
|
||||
}, token);
|
||||
if (!outcome.Ok)
|
||||
return Error(outcome.Status, outcome.Error);
|
||||
@@ -87,15 +87,14 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
[HttpPut("/api/v1/statuses/{id}"), Scope("write:statuses")]
|
||||
public async Task<IActionResult> Edit(string id, CancellationToken token)
|
||||
{
|
||||
if (Params.List("media_ids").Count > 0)
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are not supported yet");
|
||||
var outcome = await _statuses.Edit(Me, id, new StatusDraft
|
||||
{
|
||||
Text = Params.Get("status"),
|
||||
PlainText = true,
|
||||
SpoilerText = Params.Get("spoiler_text"),
|
||||
Sensitive = Params.Bool("sensitive") ?? false,
|
||||
Language = Params.Get("language")
|
||||
Language = Params.Get("language"),
|
||||
MediaIds = Params.Has("media_ids") ? Params.List("media_ids") : default
|
||||
}, token);
|
||||
return outcome.Ok ? Json(await _mapper.Status(outcome.Post, MyId, token)) : Error(outcome.Status, outcome.Error);
|
||||
}
|
||||
@@ -227,10 +226,41 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/statuses/{id}/bookmark"), Scope("write:bookmarks")]
|
||||
public Task<IActionResult> Bookmark(string id, CancellationToken token) => Unchanged(id, token);
|
||||
public async Task<IActionResult> Bookmark(string id, CancellationToken token)
|
||||
{
|
||||
var post = await Visible(id, token);
|
||||
if (post == default)
|
||||
return NotFoundError();
|
||||
try
|
||||
{
|
||||
await DB.Default.SaveAsync(new Models.Social.Bookmark { AvatarId = MyId, PostId = post.ID }, token);
|
||||
}
|
||||
catch (MongoDB.Driver.MongoWriteException ex) when (ex.WriteError?.Category == MongoDB.Driver.ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
}
|
||||
return Json(await _mapper.Status(post, MyId, token));
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/statuses/{id}/unbookmark"), Scope("write:bookmarks")]
|
||||
public Task<IActionResult> Unbookmark(string id, CancellationToken token) => Unchanged(id, token);
|
||||
public async Task<IActionResult> Unbookmark(string id, CancellationToken token)
|
||||
{
|
||||
await DB.Default.DeleteAsync<Models.Social.Bookmark>(b => b.AvatarId == MyId && b.PostId == id);
|
||||
return await Unchanged(id, token);
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/bookmarks"), Scope("read:bookmarks")]
|
||||
public async Task<IActionResult> Bookmarks(CancellationToken token)
|
||||
{
|
||||
var bookmarks = await Page.From(Params, Limit()).Fetch(DB.Default.Find<Models.Social.Bookmark>().Match(b => b.AvatarId == MyId), b => b.ID, token);
|
||||
var ids = bookmarks.Select(b => b.PostId).ToList();
|
||||
var posts = (await _dbEntities.Posts.Match(p => ids.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token)).ToDictionary(p => p.ID);
|
||||
var visible = new List<PostEntity>();
|
||||
foreach (var postId in ids.Where(posts.ContainsKey))
|
||||
if (await VisibilityPolicy.CanSee(posts[postId], MyId, token))
|
||||
visible.Add(posts[postId]);
|
||||
Link("/api/v1/bookmarks", bookmarks.LastOrDefault()?.ID, bookmarks.FirstOrDefault()?.ID);
|
||||
return Json(await _mapper.Statuses(visible, MyId, token));
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/statuses/{id}/mute"), Scope("write:mutes")]
|
||||
public Task<IActionResult> Mute(string id, CancellationToken token) => Unchanged(id, token);
|
||||
@@ -239,10 +269,32 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
public Task<IActionResult> Unmute(string id, CancellationToken token) => Unchanged(id, token);
|
||||
|
||||
[HttpPost("/api/v1/statuses/{id}/pin"), Scope("write:accounts")]
|
||||
public IActionResult Pin(string id) => Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Pinning is not supported yet");
|
||||
public async Task<IActionResult> Pin(string id, CancellationToken token)
|
||||
{
|
||||
var post = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue && p.ReblogOfPostId == null)
|
||||
.ExecuteFirstAsync(token);
|
||||
if (post == default)
|
||||
return NotFoundError();
|
||||
if (post.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted))
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Only public posts can be pinned");
|
||||
if (await DB.Default.CountAsync<Models.Social.Pin>(p => p.AvatarId == MyId, token) >= MaxPins)
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, $"Validation failed: You can pin at most {MaxPins} posts");
|
||||
try
|
||||
{
|
||||
await DB.Default.SaveAsync(new Models.Social.Pin { AvatarId = MyId, PostId = post.ID }, token);
|
||||
}
|
||||
catch (MongoDB.Driver.MongoWriteException ex) when (ex.WriteError?.Category == MongoDB.Driver.ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
}
|
||||
return Json(await _mapper.Status(post, MyId, token));
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/statuses/{id}/unpin"), Scope("write:accounts")]
|
||||
public Task<IActionResult> Unpin(string id, CancellationToken token) => Unchanged(id, token);
|
||||
public async Task<IActionResult> Unpin(string id, CancellationToken token)
|
||||
{
|
||||
await DB.Default.DeleteAsync<Models.Social.Pin>(p => p.AvatarId == MyId && p.PostId == id);
|
||||
return await Unchanged(id, token);
|
||||
}
|
||||
|
||||
async Task<IActionResult> Toggle(Task<StatusOutcome> action, string id, CancellationToken token)
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Entities;
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Domain.Media;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Models.Federation;
|
||||
@@ -21,13 +22,17 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
{
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly ILocalActorService _localActors;
|
||||
readonly IMediaProxy _proxy;
|
||||
|
||||
public MastodonMapper(DbEntities dbEntities, ILocalActorService localActors)
|
||||
public MastodonMapper(DbEntities dbEntities, ILocalActorService localActors, IMediaProxy proxy = default)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
_localActors = localActors;
|
||||
_proxy = proxy;
|
||||
}
|
||||
|
||||
string Proxied(string url) => _proxy == default ? url : _proxy.Wrap(url);
|
||||
|
||||
string MissingAvatar => $"{_localActors.BaseAddress}/media/missing-avatar.png";
|
||||
string MissingHeader => $"{_localActors.BaseAddress}/media/missing-header.png";
|
||||
|
||||
@@ -101,10 +106,10 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
Note = foreign.Biography ?? string.Empty,
|
||||
Url = foreign.Url ?? foreign.ActorURI,
|
||||
Uri = foreign.ActorURI,
|
||||
Avatar = foreign.PictureURL ?? MissingAvatar,
|
||||
AvatarStatic = foreign.PictureURL ?? MissingAvatar,
|
||||
Header = foreign.ThumbnailURL ?? MissingHeader,
|
||||
HeaderStatic = foreign.ThumbnailURL ?? MissingHeader
|
||||
Avatar = Proxied(foreign.PictureURL) ?? MissingAvatar,
|
||||
AvatarStatic = Proxied(foreign.PictureURL) ?? MissingAvatar,
|
||||
Header = Proxied(foreign.ThumbnailURL) ?? MissingHeader,
|
||||
HeaderStatic = Proxied(foreign.ThumbnailURL) ?? MissingHeader
|
||||
};
|
||||
|
||||
public async Task<Account> Account(string id, CancellationToken token) =>
|
||||
@@ -147,6 +152,13 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
var favourited = viewerId == default
|
||||
? new HashSet<string>()
|
||||
: (await _dbEntities.Favourites.Match(f => f.AccountId == viewerId && ids.Contains(f.PostId)).ExecuteAsync(token)).Select(f => f.PostId).ToHashSet();
|
||||
var bookmarked = viewerId == default
|
||||
? new HashSet<string>()
|
||||
: (await DB.Default.Find<Bookmark>().Match(b => b.AvatarId == viewerId && ids.Contains(b.PostId)).ExecuteAsync(token)).Select(b => b.PostId).ToHashSet();
|
||||
var pinned = (await DB.Default.Find<Pin>().Match(p => ids.Contains(p.PostId)).ExecuteAsync(token)).Select(p => p.PostId).ToHashSet();
|
||||
var hidden = viewerId == default
|
||||
? new HashSet<string>()
|
||||
: await Domain.Relationships.Hidden.AuthorsHiddenFrom(viewerId, all.Select(p => p.ActorURI), forNotifications: false, token);
|
||||
var reblogged = viewerId == default
|
||||
? new HashSet<string>()
|
||||
: (await _dbEntities.Posts.Match(p => p.AuthorAccountId == viewerId && ids.Contains(p.ReblogOfPostId) && !p.DeletedAt.HasValue).ExecuteAsync(token))
|
||||
@@ -172,6 +184,8 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
FavouritesCount = post.FavouritesCount,
|
||||
Favourited = favourited.Contains(post.ID),
|
||||
Reblogged = reblogged.Contains(post.ID),
|
||||
Bookmarked = bookmarked.Contains(post.ID),
|
||||
Pinned = pinned.Contains(post.ID),
|
||||
Sensitive = post.HasContentWarning,
|
||||
SpoilerText = post.SpoilerText ?? (post.HasContentWarning ? post.Title ?? ActivityPubRenderer.ContentWarning : string.Empty),
|
||||
Visibility = Visibility(post.Visibility),
|
||||
@@ -186,6 +200,9 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
var mapped = new List<Status>();
|
||||
foreach (var post in posts)
|
||||
{
|
||||
if (hidden.Contains(post.ActorURI) || post.ReblogOfPostId != default && originals.TryGetValue(post.ReblogOfPostId, out var hiddenOriginal)
|
||||
&& hidden.Contains(hiddenOriginal.ActorURI))
|
||||
continue;
|
||||
var status = Map(post);
|
||||
if (status == default)
|
||||
continue;
|
||||
@@ -232,9 +249,9 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
return new StatusMention { Id = account.ID, Username = account.UserName, Acct = $"{account.UserName}@{account.Domain}", Url = account.Url ?? account.ActorURI };
|
||||
}
|
||||
|
||||
static MediaAttachment Media(PostMedia media) => new()
|
||||
MediaAttachment Media(PostMedia media) => new()
|
||||
{
|
||||
Id = media.Id.ToString("N"),
|
||||
Id = media.AttachmentId ?? media.Id.ToString("N"),
|
||||
Type = media.ContentType switch
|
||||
{
|
||||
{ } type when type.StartsWith("image/gif") => "gifv",
|
||||
@@ -243,14 +260,18 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
{ } type when type.StartsWith("audio/") => "audio",
|
||||
_ => "unknown"
|
||||
},
|
||||
Url = media.URL ?? media.RemoteURL,
|
||||
PreviewUrl = media.URL ?? media.RemoteURL,
|
||||
Url = media.URL ?? Proxied(media.RemoteURL),
|
||||
PreviewUrl = media.PreviewURL ?? media.URL ?? Proxied(media.RemoteURL),
|
||||
RemoteUrl = media.RemoteURL,
|
||||
Description = media.Description,
|
||||
Blurhash = media.Blurhash,
|
||||
Meta = media.Width.HasValue && media.Height.HasValue
|
||||
? new { original = new { width = media.Width, height = media.Height, aspect = (double)media.Width / media.Height.Value } }
|
||||
: default
|
||||
Meta = new
|
||||
{
|
||||
original = media.Width.HasValue && media.Height > 0
|
||||
? new { width = media.Width, height = media.Height, size = $"{media.Width}x{media.Height}", aspect = (double)media.Width / media.Height.Value }
|
||||
: default,
|
||||
focus = media.Focus is { Length: 2 } ? new { x = media.Focus[0], y = media.Focus[1] } : default
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,47 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.ClientModels;
|
||||
using PrivaPub.Extensions;
|
||||
using PrivaPub.Models.Social;
|
||||
|
||||
namespace PrivaPub.Controllers.ClientToServer
|
||||
{
|
||||
[ApiController,
|
||||
Route("clientapi/moderator")]
|
||||
Route("clientapi/moderator"),
|
||||
Authorize(Policy = Policies.IsModerator)]
|
||||
public class ModeratorController : ControllerBase
|
||||
{
|
||||
[HttpGet, Route("/clientapi/moderator/reports")]
|
||||
public async Task<IActionResult> Reports([FromQuery] bool resolved, CancellationToken token) =>
|
||||
Ok((await DB.Default.Find<Report>().Match(r => r.IsResolved == resolved).Sort(r => r.ID, Order.Descending).Limit(100).ExecuteAsync(token))
|
||||
.Select(r => new
|
||||
{
|
||||
r.ID,
|
||||
Reporter = r.ReporterAvatarId != default ? "local" : r.ReporterActorURI,
|
||||
r.TargetAccountId,
|
||||
r.TargetActorURI,
|
||||
r.PostIds,
|
||||
r.ObjectURIs,
|
||||
r.Category,
|
||||
r.Comment,
|
||||
r.Forwarded,
|
||||
r.IsResolved,
|
||||
r.CreatedAt,
|
||||
r.ResolvedAt
|
||||
}));
|
||||
|
||||
[HttpPost, Route("/clientapi/moderator/reports/{id}/resolve")]
|
||||
public async Task<IActionResult> Resolve(string id, CancellationToken token)
|
||||
{
|
||||
var result = await DB.Default.Update<Report>().MatchID(id)
|
||||
.Modify(r => r.IsResolved, true)
|
||||
.Modify(r => r.ResolvedAt, DateTime.UtcNow)
|
||||
.Modify(r => r.ResolvedBy, User.GetUserId())
|
||||
.ExecuteAsync(token);
|
||||
return result.MatchedCount == 0 ? NotFound() : Ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Domain.Social;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Outbox;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace PrivaPub.Domain.Relationships
|
||||
{
|
||||
public interface IRelationshipService
|
||||
{
|
||||
Task Block(LocalActor me, string targetActorUri, string targetAccountId, CancellationToken token);
|
||||
Task Unblock(LocalActor me, string targetActorUri, CancellationToken token);
|
||||
Task Mute(LocalActor me, string targetActorUri, string targetAccountId, bool hideNotifications, TimeSpan? duration, CancellationToken token);
|
||||
Task Unmute(LocalActor me, string targetActorUri, CancellationToken token);
|
||||
Task BlockDomain(LocalActor me, string domain, CancellationToken token);
|
||||
Task UnblockDomain(LocalActor me, string domain, CancellationToken token);
|
||||
}
|
||||
|
||||
public class RelationshipService : IRelationshipService
|
||||
{
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly IFollowService _follows;
|
||||
readonly IDeliveryService _delivery;
|
||||
|
||||
public RelationshipService(DbEntities dbEntities, IFollowService follows, IDeliveryService delivery)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
_follows = follows;
|
||||
_delivery = delivery;
|
||||
}
|
||||
|
||||
public async Task Block(LocalActor me, string targetActorUri, string targetAccountId, CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
await DB.Default.SaveAsync(new Block { AvatarId = me.Id, TargetAccountId = targetAccountId, TargetActorURI = targetActorUri }, token);
|
||||
}
|
||||
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
}
|
||||
|
||||
await _follows.UnfollowAs(me, targetActorUri, token);
|
||||
var follower = await _dbEntities.Followers.Match(f => f.LocalActorId == me.Id && f.ActorURI == targetActorUri).ExecuteFirstAsync(token);
|
||||
if (follower != default)
|
||||
{
|
||||
await DB.Default.DeleteAsync<Follower>(follower.ID);
|
||||
if (!targetActorUri.StartsWith(me.BaseAddress + "/", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(follower.FollowActivityURI))
|
||||
{
|
||||
var reject = new JsonObject
|
||||
{
|
||||
["@context"] = ActivityPubRenderer.ActivityStreams,
|
||||
["id"] = me.ActivityUri($"reject-{follower.ID}-{DateTime.UtcNow.Ticks}"),
|
||||
["type"] = "Reject",
|
||||
["actor"] = me.Uri,
|
||||
["object"] = new JsonObject { ["id"] = follower.FollowActivityURI, ["type"] = "Follow", ["actor"] = targetActorUri, ["object"] = me.Uri }
|
||||
};
|
||||
await _delivery.Enqueue(me, new[] { follower.InboxURL }, reject, token);
|
||||
}
|
||||
else
|
||||
await DB.Default.DeleteAsync<Following>(f => f.TargetActorURI == me.Uri && f.AvatarId == targetAccountId);
|
||||
}
|
||||
await Forget(me, targetAccountId, token);
|
||||
}
|
||||
|
||||
public async Task Unblock(LocalActor me, string targetActorUri, CancellationToken token) =>
|
||||
await DB.Default.DeleteAsync<Block>(b => b.AvatarId == me.Id && b.TargetActorURI == targetActorUri);
|
||||
|
||||
public async Task Mute(LocalActor me, string targetActorUri, string targetAccountId, bool hideNotifications, TimeSpan? duration, CancellationToken token)
|
||||
{
|
||||
await DB.Default.Update<Mute>()
|
||||
.Match(m => m.AvatarId == me.Id && m.TargetActorURI == targetActorUri)
|
||||
.Modify(m => m.TargetAccountId, targetAccountId)
|
||||
.Modify(m => m.HideNotifications, hideNotifications)
|
||||
.Modify(m => m.ExpiresAt, duration.HasValue && duration > TimeSpan.Zero ? DateTime.UtcNow + duration : null)
|
||||
.Modify(b => b.SetOnInsert(m => m.CreatedAt, DateTime.UtcNow))
|
||||
.Option(o => o.IsUpsert = true)
|
||||
.ExecuteAsync(token);
|
||||
await Forget(me, targetAccountId, token);
|
||||
}
|
||||
|
||||
public async Task Unmute(LocalActor me, string targetActorUri, CancellationToken token) =>
|
||||
await DB.Default.DeleteAsync<Mute>(m => m.AvatarId == me.Id && m.TargetActorURI == targetActorUri);
|
||||
|
||||
public async Task BlockDomain(LocalActor me, string domain, CancellationToken token)
|
||||
{
|
||||
domain = domain?.Trim().ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(domain) || Uri.CheckHostName(domain) != UriHostNameType.Dns)
|
||||
return;
|
||||
try
|
||||
{
|
||||
await DB.Default.SaveAsync(new AccountDomainBlock { AvatarId = me.Id, Domain = domain }, token);
|
||||
}
|
||||
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
}
|
||||
foreach (var following in await _dbEntities.Followings.Match(f => f.AvatarId == me.Id && !f.TargetIsLocal).ExecuteAsync(token))
|
||||
if (Hidden.HostMatches(following.TargetActorURI, domain))
|
||||
await _follows.UnfollowAs(me, following.TargetActorURI, token);
|
||||
foreach (var follower in await _dbEntities.Followers.Match(f => f.LocalActorId == me.Id).ExecuteAsync(token))
|
||||
if (Hidden.HostMatches(follower.ActorURI, domain))
|
||||
await DB.Default.DeleteAsync<Follower>(follower.ID);
|
||||
}
|
||||
|
||||
public async Task UnblockDomain(LocalActor me, string domain, CancellationToken token)
|
||||
{
|
||||
domain = domain?.Trim().ToLowerInvariant();
|
||||
await DB.Default.DeleteAsync<AccountDomainBlock>(b => b.AvatarId == me.Id && b.Domain == domain);
|
||||
}
|
||||
|
||||
static async Task Forget(LocalActor me, string targetAccountId, CancellationToken token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(targetAccountId))
|
||||
return;
|
||||
await DB.Default.DeleteAsync<TimelineEntry>(e => e.AvatarId == me.Id && e.AuthorAccountId == targetAccountId);
|
||||
await DB.Default.DeleteAsync<Notification>(n => n.AvatarId == me.Id && n.FromAccountId == targetAccountId);
|
||||
}
|
||||
}
|
||||
|
||||
public static class Hidden
|
||||
{
|
||||
public static bool HostMatches(string actorUri, string domain) =>
|
||||
Uri.TryCreate(actorUri, UriKind.Absolute, out var uri)
|
||||
&& (uri.Host.Equals(domain, StringComparison.OrdinalIgnoreCase) || uri.Host.EndsWith("." + domain, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public static async Task<HashSet<string>> AuthorsHiddenFrom(string avatarId, IEnumerable<string> actorUris, bool forNotifications, CancellationToken token)
|
||||
{
|
||||
var uris = actorUris.Where(u => !string.IsNullOrEmpty(u)).Distinct().ToList();
|
||||
var hidden = new HashSet<string>(StringComparer.Ordinal);
|
||||
if (string.IsNullOrEmpty(avatarId) || uris.Count == 0)
|
||||
return hidden;
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var block in await DB.Default.Find<Block>().Match(b => b.AvatarId == avatarId && uris.Contains(b.TargetActorURI)).ExecuteAsync(token))
|
||||
hidden.Add(block.TargetActorURI);
|
||||
foreach (var mute in await DB.Default.Find<Mute>().Match(m => m.AvatarId == avatarId && uris.Contains(m.TargetActorURI)).ExecuteAsync(token))
|
||||
if ((mute.ExpiresAt == null || mute.ExpiresAt > now) && (!forNotifications || mute.HideNotifications))
|
||||
hidden.Add(mute.TargetActorURI);
|
||||
var domains = await DB.Default.Find<AccountDomainBlock>().Match(b => b.AvatarId == avatarId).ExecuteAsync(token);
|
||||
foreach (var uri in uris)
|
||||
if (domains.Any(d => HostMatches(uri, d.Domain)))
|
||||
hidden.Add(uri);
|
||||
return hidden;
|
||||
}
|
||||
|
||||
public static async Task<HashSet<string>> RecipientsHiding(IEnumerable<string> avatarIds, string actorUri, CancellationToken token)
|
||||
{
|
||||
var ids = avatarIds.Distinct().ToList();
|
||||
var hiding = new HashSet<string>(StringComparer.Ordinal);
|
||||
if (ids.Count == 0 || string.IsNullOrEmpty(actorUri))
|
||||
return hiding;
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var block in await DB.Default.Find<Block>().Match(b => ids.Contains(b.AvatarId) && b.TargetActorURI == actorUri).ExecuteAsync(token))
|
||||
hiding.Add(block.AvatarId);
|
||||
foreach (var mute in await DB.Default.Find<Mute>().Match(m => ids.Contains(m.AvatarId) && m.TargetActorURI == actorUri).ExecuteAsync(token))
|
||||
if (mute.ExpiresAt == null || mute.ExpiresAt > now)
|
||||
hiding.Add(mute.AvatarId);
|
||||
foreach (var block in await DB.Default.Find<AccountDomainBlock>().Match(b => ids.Contains(b.AvatarId)).ExecuteAsync(token))
|
||||
if (HostMatches(actorUri, block.Domain))
|
||||
hiding.Add(block.AvatarId);
|
||||
return hiding;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Outbox;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace PrivaPub.Domain.Relationships
|
||||
{
|
||||
public interface IReportService
|
||||
{
|
||||
Task<Report> File(LocalActor reporter, string targetAccountId, IReadOnlyList<string> postIds, string comment, string category, bool forward,
|
||||
CancellationToken token);
|
||||
}
|
||||
|
||||
public class ReportService : IReportService
|
||||
{
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly ILocalActorService _localActors;
|
||||
readonly IDeliveryService _delivery;
|
||||
|
||||
public ReportService(DbEntities dbEntities, ILocalActorService localActors, IDeliveryService delivery)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
_localActors = localActors;
|
||||
_delivery = delivery;
|
||||
}
|
||||
|
||||
public async Task<Report> File(LocalActor reporter, string targetAccountId, IReadOnlyList<string> postIds, string comment, string category,
|
||||
bool forward, CancellationToken token)
|
||||
{
|
||||
var localTarget = await _dbEntities.Avatars.MatchID(targetAccountId).ExecuteFirstAsync(token);
|
||||
var remoteTarget = localTarget == default ? await _dbEntities.ForeignAvatars.MatchID(targetAccountId).ExecuteFirstAsync(token) : default;
|
||||
if (localTarget == default && remoteTarget == default)
|
||||
return default;
|
||||
var targetUri = localTarget != default ? _localActors.FromAvatar(localTarget).Uri : remoteTarget.ActorURI;
|
||||
var posts = postIds.Count == 0
|
||||
? new List<Models.Post.Post>()
|
||||
: await _dbEntities.Posts.Match(p => postIds.Contains(p.ID) && p.ActorURI == targetUri).ExecuteAsync(token);
|
||||
|
||||
var report = new Report
|
||||
{
|
||||
ReporterAvatarId = reporter.Id,
|
||||
TargetAccountId = targetAccountId,
|
||||
TargetActorURI = targetUri,
|
||||
PostIds = posts.Select(p => p.ID).ToList(),
|
||||
ObjectURIs = posts.Select(p => p.ObjectURI).ToList(),
|
||||
Comment = comment?.Trim(),
|
||||
Category = category is "spam" or "legal" or "violation" ? category : "other",
|
||||
Forwarded = forward && remoteTarget != default
|
||||
};
|
||||
await DB.Default.SaveAsync(report, token);
|
||||
|
||||
if (report.Forwarded)
|
||||
{
|
||||
var instance = await _localActors.GetInstanceActor(token);
|
||||
var flag = new JsonObject
|
||||
{
|
||||
["@context"] = ActivityPubRenderer.ActivityStreams,
|
||||
["id"] = instance.ActivityUri($"flag-{report.ID}"),
|
||||
["type"] = "Flag",
|
||||
["actor"] = instance.Uri,
|
||||
["content"] = report.Comment ?? string.Empty,
|
||||
["object"] = new JsonArray(report.ObjectURIs.Prepend(targetUri).Select(u => (JsonNode)u).ToArray())
|
||||
};
|
||||
await _delivery.Enqueue(instance, new[] { remoteTarget.SharedInboxURL ?? remoteTarget.InboxURL }, flag, token);
|
||||
}
|
||||
return report;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ namespace PrivaPub.Domain.Social
|
||||
{
|
||||
if (string.IsNullOrEmpty(avatarId) || avatarId == fromAccountId)
|
||||
return;
|
||||
if ((await Relationships.Hidden.AuthorsHiddenFrom(avatarId, new[] { fromActorUri }, forNotifications: true, token)).Count > 0)
|
||||
return;
|
||||
try
|
||||
{
|
||||
await DB.Default.SaveAsync(new Notification
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -53,6 +53,7 @@ namespace PrivaPub.Domain.Timelines
|
||||
break;
|
||||
}
|
||||
|
||||
recipients.ExceptWith(await Relationships.Hidden.RecipientsHiding(recipients, post.ActorURI, token));
|
||||
foreach (var avatarId in recipients)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -42,6 +42,8 @@ namespace PrivaPub.Federation.Actors
|
||||
public string Outbox => $"{Uri}/anus";
|
||||
public string Followers => $"{Uri}/groupies";
|
||||
public string Following => $"{Uri}/stalking";
|
||||
public string Featured => $"{Uri}/trophies";
|
||||
public string FeaturedTags => $"{Uri}/tattoos";
|
||||
public string SharedInbox => $"{BaseAddress}/human-centipede";
|
||||
public string Domain => new Uri(BaseAddress).Authority;
|
||||
public string Handle => $"{UserName}@{Domain}";
|
||||
|
||||
@@ -5,6 +5,7 @@ using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
@@ -112,6 +113,34 @@ namespace PrivaPub.Federation.Controllers
|
||||
return local is not { IsFederated: true } ? NotFound() : Activity(ActivityPubRenderer.OrderedCollection(local.Following, 0, default));
|
||||
}
|
||||
|
||||
[HttpGet, Route("{actor}/trophies")]
|
||||
public async Task<IActionResult> Featured(string actor, CancellationToken token)
|
||||
{
|
||||
var local = await _localActors.FindByUserName(actor, token);
|
||||
if (local is not { IsFederated: true })
|
||||
return NotFound();
|
||||
var pinIds = (await DB.Default.Find<Pin>().Match(p => p.AvatarId == local.Id).Sort(p => p.ID, Order.Descending).ExecuteAsync(token))
|
||||
.Select(p => p.PostId).ToList();
|
||||
var posts = pinIds.Count == 0
|
||||
? new List<PostEntity>()
|
||||
: await _dbEntities.Posts.Match(p => pinIds.Contains(p.ID) && p.GroupUserId == local.Id && p.ReblogOfPostId == null)
|
||||
.Match(VisibilityPolicy.IsPublic).ExecuteAsync(token);
|
||||
var notes = new List<JsonNode>();
|
||||
foreach (var id in pinIds)
|
||||
if (posts.FirstOrDefault(p => p.ID == id) is { } post)
|
||||
notes.Add(ActivityPubRenderer.Note(post, local, default, post.InReplyToURI));
|
||||
return Activity(ActivityPubRenderer.OrderedCollection(local.Featured, notes.Count, notes));
|
||||
}
|
||||
|
||||
[HttpGet, Route("{actor}/tattoos")]
|
||||
public async Task<IActionResult> FeaturedTags(string actor, CancellationToken token)
|
||||
{
|
||||
var local = await _localActors.FindByUserName(actor, token);
|
||||
return local is not { IsFederated: true }
|
||||
? NotFound()
|
||||
: Activity(ActivityPubRenderer.OrderedCollection(local.FeaturedTags, 0, Enumerable.Empty<JsonNode>()));
|
||||
}
|
||||
|
||||
[HttpGet, Route("{actor}/scribbles/{postId}")]
|
||||
public async Task<IActionResult> Post(string actor, string postId, CancellationToken token)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using static PrivaPub.Federation.Objects.ActivityJson;
|
||||
|
||||
namespace PrivaPub.Federation.Inbox.Handlers
|
||||
{
|
||||
public class FlagHandler : IActivityHandler
|
||||
{
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly ILocalActorService _localActors;
|
||||
|
||||
public FlagHandler(DbEntities dbEntities, ILocalActorService localActors)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
_localActors = localActors;
|
||||
}
|
||||
|
||||
public string Type => "Flag";
|
||||
|
||||
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
|
||||
{
|
||||
var objects = activity["object"] switch
|
||||
{
|
||||
JsonArray array => array.Select(Id).Where(i => i != default).ToList(),
|
||||
JsonNode single when Id(single) is { } id => new List<string> { id },
|
||||
_ => new List<string>()
|
||||
};
|
||||
|
||||
LocalActor target = default;
|
||||
var postUris = new List<string>();
|
||||
foreach (var uri in objects.Take(50))
|
||||
{
|
||||
var local = await _localActors.FindByUri(uri, token);
|
||||
if (local is { Kind: LocalActorKind.Person } && uri.TrimEnd('/') == local.Uri)
|
||||
target ??= local;
|
||||
else
|
||||
postUris.Add(uri);
|
||||
}
|
||||
var posts = postUris.Count == 0
|
||||
? new List<Models.Post.Post>()
|
||||
: await _dbEntities.Posts.Match(p => postUris.Contains(p.ObjectURI) && !p.IsFederatedCopy).ExecuteAsync(token);
|
||||
if (target == default && posts.Count > 0)
|
||||
target = await _localActors.FindById(LocalActorKind.Person, posts[0].GroupUserId, token);
|
||||
if (target == default)
|
||||
return;
|
||||
|
||||
await DB.Default.SaveAsync(new Report
|
||||
{
|
||||
ReporterActorURI = actor.ActorURI,
|
||||
TargetAccountId = target.Id,
|
||||
TargetActorURI = target.Uri,
|
||||
PostIds = posts.Where(p => p.GroupUserId == target.Id).Select(p => p.ID).ToList(),
|
||||
ObjectURIs = posts.Where(p => p.GroupUserId == target.Id).Select(p => p.ObjectURI).ToList(),
|
||||
Comment = Value(activity, "content")
|
||||
}, token);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,20 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
var target = await _localActors.FindByUri(Id(follow["object"]), token);
|
||||
if (target is not { IsFederated: true } || target.Kind == LocalActorKind.Application)
|
||||
return;
|
||||
if (target.Kind == LocalActorKind.Person
|
||||
&& await DB.Default.Find<Models.Social.Block>().Match(b => b.AvatarId == target.Id && b.TargetActorURI == follower.ActorURI).ExecuteAnyAsync(token))
|
||||
{
|
||||
var reject = new JsonObject
|
||||
{
|
||||
["@context"] = ActivityPubRenderer.ActivityStreams,
|
||||
["id"] = target.ActivityUri($"reject-{Guid.NewGuid():N}"),
|
||||
["type"] = "Reject",
|
||||
["actor"] = target.Uri,
|
||||
["object"] = follow.DeepClone()
|
||||
};
|
||||
await _delivery.Enqueue(target, new[] { follower.InboxURL }, reject, token);
|
||||
return;
|
||||
}
|
||||
|
||||
var existing = await _dbEntities.Followers
|
||||
.Match(f => f.LocalActorId == target.Id && f.LocalActorKind == target.Kind && f.ActorURI == follower.ActorURI)
|
||||
|
||||
@@ -76,6 +76,8 @@ namespace PrivaPub.Federation.Rendering
|
||||
["outbox"] = actor.Outbox,
|
||||
["followers"] = actor.Followers,
|
||||
["following"] = actor.Following,
|
||||
["featured"] = actor.Featured,
|
||||
["featuredTags"] = actor.FeaturedTags,
|
||||
["published"] = Day(actor.Published),
|
||||
["manuallyApprovesFollowers"] = actor.ManuallyApprovesFollowers,
|
||||
["discoverable"] = actor.Discoverable,
|
||||
@@ -168,6 +170,28 @@ namespace PrivaPub.Federation.Rendering
|
||||
};
|
||||
if (!string.IsNullOrEmpty(post.Language))
|
||||
note["contentMap"] = new JsonObject { [post.Language] = content };
|
||||
var attachments = post.Media.Where(m => !string.IsNullOrEmpty(m.URL)).Select(m =>
|
||||
{
|
||||
var attachment = new JsonObject
|
||||
{
|
||||
["type"] = "Document",
|
||||
["mediaType"] = m.ContentType,
|
||||
["url"] = m.URL,
|
||||
["name"] = m.Description
|
||||
};
|
||||
if (!string.IsNullOrEmpty(m.Blurhash))
|
||||
attachment["blurhash"] = m.Blurhash;
|
||||
if (m.Focus is { Length: 2 })
|
||||
attachment["focalPoint"] = new JsonArray(m.Focus[0], m.Focus[1]);
|
||||
if (m.Width.HasValue && m.Height.HasValue)
|
||||
{
|
||||
attachment["width"] = m.Width;
|
||||
attachment["height"] = m.Height;
|
||||
}
|
||||
return (JsonNode)attachment;
|
||||
}).ToArray();
|
||||
if (attachments.Length > 0)
|
||||
note["attachment"] = new JsonArray(attachments);
|
||||
if (!string.IsNullOrEmpty(post.Title))
|
||||
note["name"] = post.Title;
|
||||
var summary = post.SpoilerText ?? (post.HasContentWarning ? post.Title ?? ContentWarning : default);
|
||||
|
||||
@@ -87,6 +87,18 @@ namespace PrivaPub.Infrastructure.Data
|
||||
.Key(m => m.Timeline, KeyType.Ascending)
|
||||
.Option(o => o.Unique = true)
|
||||
.CreateAsync(token);
|
||||
foreach (var pair in new (Func<Task>, string)[]
|
||||
{
|
||||
(() => DB.Default.Index<Block>().Key(b => b.AvatarId, KeyType.Ascending).Key(b => b.TargetActorURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "block"),
|
||||
(() => DB.Default.Index<Mute>().Key(m => m.AvatarId, KeyType.Ascending).Key(m => m.TargetActorURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "mute"),
|
||||
(() => DB.Default.Index<AccountDomainBlock>().Key(b => b.AvatarId, KeyType.Ascending).Key(b => b.Domain, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "domain block"),
|
||||
(() => DB.Default.Index<Bookmark>().Key(b => b.AvatarId, KeyType.Ascending).Key(b => b.PostId, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "bookmark"),
|
||||
(() => DB.Default.Index<Pin>().Key(p => p.AvatarId, KeyType.Ascending).Key(p => p.PostId, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "pin")
|
||||
})
|
||||
await pair.Item1();
|
||||
await Plain<Block>(token, b => b.TargetActorURI);
|
||||
await Plain<Mute>(token, m => m.TargetActorURI);
|
||||
await Plain<Report>(token, r => r.IsResolved, r => r.ID);
|
||||
await Unique<DomainBlock>(b => b.Domain, Builders<DomainBlock>.Filter.Type(b => b.Domain, BsonType.String), token);
|
||||
await Unique<RemoteInstance>(i => i.Host, Builders<RemoteInstance>.Filter.Type(i => i.Host, BsonType.String), token);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ namespace PrivaPub.Infrastructure.Http
|
||||
bool IsAllowed(Uri target);
|
||||
Task<FetchedJson> GetJson(string url, string accept, Action<HttpRequestMessage> sign, CancellationToken token);
|
||||
Task<HttpResponseMessage> Send(HttpRequestMessage request, CancellationToken token);
|
||||
Task<(byte[] Bytes, string ContentType)> GetMedia(string url, long maxBytes, CancellationToken token);
|
||||
}
|
||||
|
||||
public class FederationHttp : IFederationHttp
|
||||
@@ -132,6 +133,45 @@ namespace PrivaPub.Infrastructure.Http
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(byte[] Bytes, string ContentType)> GetMedia(string url, long maxBytes, CancellationToken token)
|
||||
{
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var target) || !IsAllowed(target))
|
||||
return default;
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(token);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(60));
|
||||
try
|
||||
{
|
||||
for (var hop = 0; hop <= MaxRedirects; hop++)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, target);
|
||||
request.Headers.Accept.ParseAdd("image/*, video/*, audio/*");
|
||||
using var response = await _httpClientFactory.CreateClient(ClientName).SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token);
|
||||
if (IsRedirect(response.StatusCode))
|
||||
{
|
||||
var location = response.Headers.Location;
|
||||
var next = location == default ? default : location.IsAbsoluteUri ? location : new Uri(target, location);
|
||||
if (!IsAllowed(next))
|
||||
return default;
|
||||
target = next;
|
||||
continue;
|
||||
}
|
||||
var mediaType = response.Content.Headers.ContentType?.MediaType?.ToLowerInvariant();
|
||||
if (!response.IsSuccessStatusCode || mediaType == default
|
||||
|| !(mediaType.StartsWith("image/") || mediaType.StartsWith("video/") || mediaType.StartsWith("audio/"))
|
||||
|| mediaType.Contains("svg") || response.Content.Headers.ContentLength > maxBytes)
|
||||
return default;
|
||||
var bytes = await ReadBounded(response.Content, (int)Math.Min(maxBytes, int.MaxValue), timeout.Token);
|
||||
return bytes == default ? default : (bytes, mediaType);
|
||||
}
|
||||
return default;
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or BlockedDestinationException or OperationCanceledException && !token.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogInformation("Media {Url} refused: {Reason}", url, ex.Message);
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<HttpResponseMessage> Send(HttpRequestMessage request, CancellationToken token)
|
||||
{
|
||||
if (!IsAllowed(request.RequestUri))
|
||||
|
||||
@@ -19,6 +19,7 @@ using PrivaPub.Federation.Inbox;
|
||||
using PrivaPub.Federation.Moderation;
|
||||
using PrivaPub.Federation.Inbox.Handlers;
|
||||
using PrivaPub.Domain.Content;
|
||||
using PrivaPub.Domain.Relationships;
|
||||
using PrivaPub.Domain.Social;
|
||||
using PrivaPub.Domain.Statuses;
|
||||
using PrivaPub.Domain.Timelines;
|
||||
@@ -72,6 +73,7 @@ namespace PrivaPub.Middleware
|
||||
.AddSingleton<IActivityHandler, UndoHandler>()
|
||||
.AddSingleton<IActivityHandler, LikeHandler>()
|
||||
.AddSingleton<IActivityHandler, AnnounceHandler>()
|
||||
.AddSingleton<IActivityHandler, FlagHandler>()
|
||||
.AddSingleton<IActivityHandler, CreateHandler>()
|
||||
.AddSingleton<IActivityHandler, DeleteHandler>()
|
||||
.AddSingleton<IActivityHandler, UpdateHandler>()
|
||||
@@ -138,6 +140,8 @@ namespace PrivaPub.Middleware
|
||||
.AddTransient<IGroupUsersService, GroupUsersService>()
|
||||
.AddTransient<IPostsService, PostsService>()
|
||||
.AddTransient<IStatusService, StatusService>()
|
||||
.AddTransient<IRelationshipService, RelationshipService>()
|
||||
.AddTransient<IReportService, ReportService>()
|
||||
.AddTransient<IFollowService, FollowService>()
|
||||
.AddTransient<ITimelineService, TimelineService>()
|
||||
.AddSingleton<AppConfigurationService>()
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
namespace PrivaPub.Models.Media
|
||||
{
|
||||
public class MediaAttachment : Entity
|
||||
{
|
||||
public string OwnerAvatarId { get; set; }
|
||||
public string PostId { get; set; }
|
||||
public string Kind { get; set; }
|
||||
public string ContentType { get; set; }
|
||||
public string FilePath { get; set; }
|
||||
public string PreviewPath { get; set; }
|
||||
public long Size { get; set; }
|
||||
public string Description { get; set; }
|
||||
public string Blurhash { get; set; }
|
||||
public int? Width { get; set; }
|
||||
public int? Height { get; set; }
|
||||
public float[] Focus { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? AttachedAt { get; set; }
|
||||
}
|
||||
|
||||
public class MediaSecret : Entity
|
||||
{
|
||||
public string Key { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ namespace PrivaPub.Models.Post
|
||||
public string Path { get; set; }
|
||||
public string URL { get; set; }
|
||||
public string RemoteURL { get; set; }
|
||||
public string PreviewURL { get; set; }
|
||||
public string AttachmentId { get; set; }
|
||||
public string Description { get; set; }
|
||||
public string Blurhash { get; set; }
|
||||
public int? Width { get; set; }
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
namespace PrivaPub.Models.Social
|
||||
{
|
||||
public class Block : Entity
|
||||
{
|
||||
public string AvatarId { get; set; }
|
||||
public string TargetAccountId { get; set; }
|
||||
public string TargetActorURI { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class Mute : Entity
|
||||
{
|
||||
public string AvatarId { get; set; }
|
||||
public string TargetAccountId { get; set; }
|
||||
public string TargetActorURI { get; set; }
|
||||
public bool HideNotifications { get; set; } = true;
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class AccountDomainBlock : Entity
|
||||
{
|
||||
public string AvatarId { get; set; }
|
||||
public string Domain { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class Bookmark : Entity
|
||||
{
|
||||
public string AvatarId { get; set; }
|
||||
public string PostId { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class Pin : Entity
|
||||
{
|
||||
public string AvatarId { get; set; }
|
||||
public string PostId { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class Report : Entity
|
||||
{
|
||||
public string ReporterAvatarId { get; set; }
|
||||
public string ReporterActorURI { get; set; }
|
||||
public string TargetAccountId { get; set; }
|
||||
public string TargetActorURI { get; set; }
|
||||
public List<string> PostIds { get; set; } = new();
|
||||
public List<string> ObjectURIs { get; set; } = new();
|
||||
public string Category { get; set; } = "other";
|
||||
public string Comment { get; set; }
|
||||
public bool Forwarded { get; set; }
|
||||
public bool IsResolved { get; set; }
|
||||
public string ResolvedBy { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? ResolvedAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,14 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FFMpegCore" Version="5.5.0" />
|
||||
<PackageReference Include="HtmlSanitizer" Version="9.2.1039" />
|
||||
<PackageReference Include="MailKit" Version="4.18.0" />
|
||||
<PackageReference Include="Markdig" Version="1.4.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
|
||||
<PackageReference Include="MongoDB.Entities" Version="25.1.0" />
|
||||
<PackageReference Include="NetVips" Version="3.2.0" />
|
||||
<PackageReference Include="NetVips.Native.linux-x64" Version="8.18.7" />
|
||||
<PackageReference Include="OpenIddict.AspNetCore" Version="7.7.1" />
|
||||
<PackageReference Include="OpenIddict.MongoDb" Version="7.7.1" />
|
||||
<PackageReference Include="PasswordGenerator" Version="3.0.0" />
|
||||
|
||||
@@ -58,6 +58,10 @@ try
|
||||
.PrivaPubOAuth(builder.Environment)
|
||||
.AddScoped<PrivaPub.Api.Mastodon.Mappers.MastodonMapper>()
|
||||
.AddScoped<PrivaPub.Api.Mastodon.Mappers.AccountSearch>()
|
||||
.Configure<PrivaPub.Domain.Media.MediaOptions>(builder.Configuration.GetSection("Media"))
|
||||
.AddSingleton<PrivaPub.Domain.Media.IMediaService, PrivaPub.Domain.Media.MediaService>()
|
||||
.AddSingleton<PrivaPub.Domain.Media.IMediaProxy, PrivaPub.Domain.Media.MediaProxy>()
|
||||
.AddHostedService<PrivaPub.Domain.Media.MediaJanitor>()
|
||||
.PrivaPubMiddlewareConfiguration();
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -124,6 +128,19 @@ try
|
||||
app.UseCors("DefaultCORS");
|
||||
|
||||
app.UseStaticFiles();
|
||||
var mediaRoot = app.Services.GetRequiredService<PrivaPub.Domain.Media.IMediaService>().Root;
|
||||
Directory.CreateDirectory(mediaRoot);
|
||||
app.UseStaticFiles(new StaticFileOptions
|
||||
{
|
||||
FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(mediaRoot),
|
||||
RequestPath = "/media/files",
|
||||
OnPrepareResponse = context =>
|
||||
{
|
||||
context.Context.Response.Headers["X-Content-Type-Options"] = "nosniff";
|
||||
context.Context.Response.Headers["Content-Security-Policy"] = "default-src 'none'; sandbox";
|
||||
context.Context.Response.Headers["Cache-Control"] = "public, max-age=31536000, immutable";
|
||||
}
|
||||
});
|
||||
|
||||
app.UseRequestLocalization(await localizationService.Get());
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"Media": {
|
||||
"Root": "/var/lib/privapub/media"
|
||||
},
|
||||
"MongoSettings": {
|
||||
"Database": "PrivaPub",
|
||||
"LogsDatabase": "logs",
|
||||
|
||||
@@ -35,6 +35,19 @@ server {
|
||||
default_type "text/plain";
|
||||
}
|
||||
|
||||
location ~ ^/api/v[12]/media$|^/api/v1/accounts/update_credentials$ {
|
||||
client_max_body_size 100m;
|
||||
proxy_request_buffering off;
|
||||
proxy_pass http://127.0.0.1:6970;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:6970;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ Written 2026-10-01 from the original 2023 code, the decePubClient UI, a federati
|
||||
- [x] P0 Security baseline: v1.1.0, deployed and verified 2026-10-01
|
||||
- [x] P1.1 Infrastructure and content model: v1.2.0, deployed and verified 2026-10-01
|
||||
- [x] P1.2 Social graph and timelines: v1.3.0, deployed and verified 2026-10-01
|
||||
- [ ] P2 Mastodon client API
|
||||
- [x] P2 Mastodon client API: v1.4.0, deployed 2026-10-01; OAuth and the anonymous API verified on production, the signed-in API verified locally (no real-client login on production yet)
|
||||
- [ ] P3 Social features
|
||||
- [ ] P4 Groups and privacy features
|
||||
- [ ] P5 FEPs and polish
|
||||
|
||||
Reference in new issue
Block a user