Compare commits

...
8 Commits
Author SHA1 Message Date
thepraandClaude Opus 5.5 51f2c10c6f Community actors name their moderators the way Lemmy reads them
Build / Build (push) Successful in 41s
Deploy / privapub.thepra.dev (push) Successful in 1m1s
attributedTo points at /wardens and postingRestrictedToMods reflects the
posting policy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 12:31:13 +02:00
thepraandClaude Opus 5.5 a5d9a89445 Communities follow FEP-1b12, circles federate to their members only
Communities:
- a post addressed to a community (to, cc or audience) is accepted
  according to its posting policy - followers, anyone, or moderators -
  and GroupDistributor announces the whole activity with `audience` to
  the community's followers, plus the object for new posts so Mastodon
  shows them; updates and deletes of community content are announced too;
- top-level posts are Pages with a name (the title, or a headline from
  the text); /flock counts members, /wardens lists moderators;
- a Mastodon client posts into a community by mentioning it, or into a
  remote group, which sets `audience`;
- an Announce of an activity from a remote group a persona follows (Lemmy)
  is followed through: the object is fetched from its own origin, kept with
  its AudienceURI, and fanned out to the group's local followers; updates
  are applied in place and deletes checked against the origin.

Circles stop being local-only: an undiscoverable Group actor whose follows
are all requests the owner approves; posts addressed to the circle and its
/flock and delivered to members' own inboxes, never announced, never
public; a remote member's post into the circle is accepted from members
only. SignedFetchAuthorizer serves circle posts and collections only to a
signed request from a member or a member server's instance actor - 404 for
anyone else. Circles never surface in search, lookups, mentions, account
ids or profile pages.

Federation:SecureMode requires a valid signature on every GET under
/peasants except the instance actor. Group forms take a posting policy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 12:30:57 +02:00
thepraandClaude Opus 5.5 0ccbcd558f P3 is deployed
Build / Build (push) Successful in 32s
v1.5.0 is live; the media directory, libvips and the proxy's signature
check are in place on the box. No upload has been made in production yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 12:24:30 +02:00
thepraandClaude Opus 5.5 c00865d805 Located posts: local only, rounded, matched against their own radius
A post given a latitude and longitude becomes LocalGeo: its position is
rounded to two decimals (about a kilometre) and stored as a 2dsphere
point, its radius clamped to 1-50 km, and it is local only - no Create,
no delivery, no outbox, never in the Mastodon API or on a profile page.
/clientapi/post/nearby takes the viewer's position for the query only
(it is neither stored nor logged), runs $geoNear and keeps posts within
each post's own radius, minus authors the viewer blocks or mutes. A
located post cannot be direct or in a group.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 12:24:30 +02:00
thepraandClaude Opus 5.5 28b581b6b1 FEDERATION.md and CLAUDE.md describe media, blocks, pins and reports
Build / Build (push) Successful in 35s
Deploy / privapub.thepra.dev (push) Successful in 52s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 12:22:08 +02:00
thepraandClaude Opus 5.5 4a713f3fb6 Media: uploads stripped of metadata, attachments both ways, a remote media proxy
- /api/v1/media and /api/v2/media (and GET/PUT /api/v1/media/:id):
  images go through libvips (NetVips, its native build bundled):
  autorotated, every kind of metadata dropped (EXIF, GPS, XMP, IPTC,
  comments), capped at 4096 px, with a 640 px preview and a blurhash
  (own encoder, the reference algorithm); animated GIFs are re-encoded;
  video and audio are remuxed by ffmpeg with -map_metadata -1, never
  re-encoded, and a video gets a still preview. Files get random names
  under /var/lib/privapub/media, outside the web root deploys replace, and
  are served at /media/files with nosniff and a sandbox CSP.
- media_ids on create and edit (four at most, the persona's own, each used
  once); notes carry them as Document attachments with alt text, blurhash,
  focalPoint and size; inbound attachments were already kept.
- avatar and header uploads in update_credentials, cropped to 400x400 and
  1500x500, federated with Update{Person}.
- Remote media reaches clients only through /media/proxy/{hmac}/{url},
  fetched by the guarded client (no SVG, 40 MB cap) and cached outside the
  served root, trimmed to 5 GB; foreign avatars and headers use it too, so
  a client never contacts another server.
- MediaJanitor deletes uploads left unattached for a day.
- nginx accepts 100 MB bodies on the upload endpoints only (applied on Max).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 12:22:08 +02:00
thepraandClaude Opus 5.5 8f75317050 Blocks, mutes, bookmarks, pins and reports
- Blocks are per persona and never federate (a Block activity would tell
  the other server who blocked whom): the blocked account is removed as a
  follower, with a Reject{Follow} if it is remote, unfollowed, cleared from
  home and notifications, and refused with Reject if it follows again.
- Mutes (optionally timed, optionally sparing notifications) and per-
  persona domain blocks keep authors out of home timelines, notifications
  and every status list the API returns; the boosts of a hidden author's
  posts are hidden too.
- Bookmarks and pins (at most five, public or unlisted, own posts) with
  their Mastodon endpoints and flags; pinned posts are the actor's
  `featured` collection at /trophies, and `featuredTags` points at
  /tattoos.
- Reports: /api/v1/reports stores the report and, when forwarding to a
  remote account, sends Flag from the instance actor, so the reporting
  persona is never named to the other server. An inbound Flag about a local
  persona or its posts becomes a report; moderators list and resolve them
  under /clientapi/moderator/reports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 12:13:57 +02:00
thepraandClaude Opus 5.5 525b5368a1 P2 is deployed
Build / Build (push) Successful in 39s
v1.4.0 serves OAuth and the Mastodon client API; tools/smoke/mastodon-api.sh
passes against production. The signed-in side was verified locally with two
personas of one login; no real client has logged into production yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 12:12:28 +02:00
65 changed files with 2669 additions and 108 deletions

No files matched your search

+3
View File
@@ -398,3 +398,6 @@ FodyWeavers.xsd
# JetBrains Rider
*.sln.iml
PrivaPub/media-store/
PrivaPub/media-store-proxy/
+19 -2
View File
@@ -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
@@ -164,8 +166,13 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
- the shared inbox is advertised in `endpoints.sharedInbox`.
7. **Remote HTML is sanitized before it is stored** (`ContentSanitizer`); `Post.ContentHtml` is what is shown,
`ContentFormat` says what `Text` holds. Remote names are plain text.
8. **Circles never federate.** A circle's actor, collections, WebFinger and inbox answer 404, and its posts are
`IsLocalOnly`. Only communities are Group actors.
8. **Circles federate to members only.** A circle is an undiscoverable Group actor that takes follow requests (the owner
approves); its posts are addressed to the circle and its `/flock`, delivered to members' personal inboxes, never
announced, and served only to a signed request from a member or a member's instance actor
(`SignedFetchAuthorizer`), 404 otherwise. Circles never appear in search, lookups, mentions or profile pages.
**Communities** are FEP-1b12 groups: `GroupDistributor` announces the whole activity (plus the object for new posts,
for Mastodon), top-level posts are `Page`s with a `name`, posting follows `Group.PostingPolicy`.
Located posts (`LocalGeo`) are the only local-only posts.
9. **A DM joins a conversation only by `DmGroup.ParticipantsKey`**, the exact set of its participants; a remote
`context` decides nothing. DMs are `Post`s with `Visibility = Direct` and a `ConversationId` (`DmPost` is legacy).
10. **Nothing slow happens inside a request.** Deliveries and inbox processing are `Job`s (`Infrastructure/Jobs`):
@@ -190,6 +197,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 +215,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
+26 -7
View File
@@ -47,10 +47,18 @@ The names are the project's own and are stable; resolve actors through WebFinger
A group is either a **community** or a **circle**.
- A community is a `Group` actor. It accepts `Follow` and re-shares (`Announce`) posts from its followers that address
it. Full FEP-1b12 behaviour (announcing activities, `audience`, moderation) is planned.
- A circle is private and does not federate yet: its actor, collections and WebFinger answer 404, and its posts are never
delivered.
- A **community** follows [FEP-1b12](https://codeberg.org/fediverse/fep/src/branch/main/fep/1b12/fep-1b12.md). Posts
addressed to it (in `to`, `cc` or `audience`) are accepted according to its posting policy (followers, anyone, or
moderators only) and the group `Announce`s the whole activity, with `audience` set, to its followers. A new post is
also announced as an object so Mastodon shows it. Updates and deletes of community content are announced too. A
top-level post is a `Page` with a `name`. Members are counted at `/flock`; moderators are listed at `/wardens`, which
the actor's `attributedTo` points to, with `postingRestrictedToMods` as Lemmy expects. A mention of a community posts into it.
- A **circle** is private. Its actor is not discoverable and every follow is a request. Its posts are addressed to the
circle and its members collection, delivered to each member's own inbox and never announced. They are served only
to a signed request from a member, or from the instance actor of a member's server; anyone else gets 404. A
Mastodon member's replies reach only the people they mention.
- Announces from **remote** groups (Lemmy communities) are followed through to the activity: the object is fetched
from its own origin, never taken from the announce.
## Activities
@@ -67,9 +75,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
@@ -78,6 +93,10 @@ tags. A post's title becomes `name` and is also the first, bold line of `content
Visibility is expressed in `to`/`cc` the way Mastodon does it: public, unlisted, followers-only and direct. Inbound
followers-only posts are recognised by the author's own `followers` collection.
## Local-only posts
Posts with a location (shown to nearby users of this server) never leave the server, in any form.
## Security rules a peer will notice
- **Signatures.** Inbox POSTs must be signed over `(request-target)`, `host`, `digest` and `date` (or `(created)`). The
@@ -94,7 +113,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.
@@ -25,6 +25,7 @@ namespace PrivaPub.ClientModels.Group
public string InvitationPassword { get; set; }
public bool IsCommunity { get; set; }
public string PostingPolicy { get; set; }//followers (default), anyone, moderators
public bool IsDiscoverable { get; set; } = true;
public bool ManuallyApprovesMembers { get; set; }
}
@@ -20,6 +20,7 @@ namespace PrivaPub.ClientModels.Group
public bool RemoveInvitationPassword { get; set; }
public bool RegenerateInvitationCode { get; set; }
public bool? IsDiscoverable { get; set; }
public string PostingPolicy { get; set; }
public bool? ManuallyApprovesMembers { get; set; }
}
}
+1
View File
@@ -9,6 +9,7 @@ namespace PrivaPub.ClientModels.Group
public string Url { get; set; }
public string Handle { get; set; }
public bool IsCommunity { get; set; }
public string PostingPolicy { get; set; }
public bool IsDiscoverable { get; set; }
public bool ManuallyApprovesMembers { get; set; }
public bool IsOwner { get; set; }
@@ -24,6 +24,15 @@ namespace PrivaPub.ClientModels.Post
public string SpoilerText { get; set; }
public string Visibility { get; set; }//public (default), unlisted, followersonly
[Range(-90, 90, ErrorMessageResourceName = "Range", ErrorMessageResourceType = typeof(ErrorsResource))]
public double? Latitude { get; set; }
[Range(-180, 180, ErrorMessageResourceName = "Range", ErrorMessageResourceType = typeof(ErrorsResource))]
public double? Longitude { get; set; }
[Range(1, 50, ErrorMessageResourceName = "Range", ErrorMessageResourceType = typeof(ErrorsResource))]
public double? RangeKm { get; set; }
}
public class UpdatePostForm
+2
View File
@@ -17,6 +17,8 @@ namespace PrivaPub.ClientModels.Post
public bool IsFederatedCopy { get; set; }
public DateTime CreationDate { get; set; }
public ViewPost ReblogOf { get; set; }
public double? RangeKm { get; set; }
public double? DistanceKm { get; set; }
}
public class ViewDmGroup
+102
View File
@@ -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]);
}
}
}
+77
View File
@@ -0,0 +1,77 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Post;
using PrivaPub.Domain.Privacy;
using PrivaPub.Federation.Outbox;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.Post;
using PrivaPub.Tests.Support;
using System.Text.Json;
namespace PrivaPub.Tests.Domain
{
[Trait("Category", "Integration")]
public sealed class NearbyTests : 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();
}
[Fact]
public async Task A_located_post_reaches_only_those_within_its_radius_and_never_leaves_the_server()
{
var token = TestContext.Current.CancellationToken;
var latitude = 10 + Random.Shared.NextDouble() * 50;
var longitude = 10 + Random.Shared.NextDouble() * 50;
var (aliceRoot, alice) = await _harness.Persona("alice");
var (bobRoot, bob) = await _harness.Persona("bob");
var (carolRoot, carol) = await _harness.Persona("carol");
var follower = new RemoteActor(_harness.Peer, "follower");
await _harness.FollowedBy(alice, follower);
var created = await _harness.Posts.InsertPost(aliceRoot, new InsertPostForm
{
AvatarId = alice.Id, Text = "free bread at the bakery", Latitude = latitude, Longitude = longitude, RangeKm = 5
}, token);
var post = await DB.Default.Find<Post>().OneAsync(((ViewPost)created.Data).Id, token);
var three = (List<ViewPost>)(await _harness.Posts.Nearby(bobRoot, bob.Id, latitude + 0.027, longitude, token)).Data;
var eight = (List<ViewPost>)(await _harness.Posts.Nearby(carolRoot, carol.Id, latitude + 0.072, longitude, token)).Data;
Assert.Equal(PostVisibility.LocalGeo, post.Visibility);
Assert.Equal(Math.Round(latitude, 2), post.Geo.Coordinates[1]);
Assert.Contains(three, p => p.Id == post.ID);
Assert.InRange(three.First(p => p.Id == post.ID).DistanceKm.Value, 2.0, 4.0);
Assert.DoesNotContain(eight, p => p.Id == post.ID);
Assert.False(await VisibilityPolicy.CanSee(post, bob.Id, token));
var outgoing = (await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.Deliver).ExecuteAsync(token))
.Select(j => JsonSerializer.Deserialize<DeliveryPayload>(j.Payload))
.Where(p => p.Body.Contains(post.ID));
Assert.Empty(outgoing);
}
[Fact]
public async Task A_located_post_cannot_be_direct_or_in_a_group()
{
var (root, alice) = await _harness.Persona("alice");
var result = await _harness.Statuses.Publish(alice, new PrivaPub.Domain.Statuses.StatusDraft
{
Text = "x", Latitude = 45, Longitude = 7, Visibility = PostVisibility.Direct
}, TestContext.Current.CancellationToken);
Assert.False(result.Ok);
}
}
}
+122
View File
@@ -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);
}
}
}
+198
View File
@@ -0,0 +1,198 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Social;
using PrivaPub.Domain.Statuses;
using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Rendering;
using PrivaPub.Federation.Signing;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Group;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
using GroupEntity = PrivaPub.Models.Group.Group;
namespace PrivaPub.Tests.Federation
{
[Trait("Category", "Integration")]
public sealed class GroupTests : 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();
}
async Task<(GroupEntity Entity, PrivaPub.Federation.Actors.LocalActor Actor)> Group(GroupKind kind, PostingPolicy policy, string ownerId, params string[] remoteMembers)
{
var (privateKey, publicKey) = PrivaPub.Federation.Actors.Keys.NewKeyPair();
var group = new GroupEntity
{
UserName = $"{kind}{Guid.NewGuid():N}"[..20].ToLowerInvariant(),
Kind = kind,
PostingPolicy = policy,
PrivateKey = privateKey,
PublicKey = publicKey,
Members = new() { new GroupMember { AvatarId = ownerId, Role = GroupRole.Owner } }
};
group.Members.AddRange(remoteMembers.Select(m => new GroupMember { AvatarId = m, IsForeign = true }));
await DB.Default.SaveAsync(group);
return (group, _harness.Local.FromGroup(group));
}
static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority);
static JsonObject Create(RemoteActor author, IEnumerable<string> to, string audience = default)
{
var note = new JsonObject
{
["id"] = $"{Origin(author)}/notes/{Guid.NewGuid():N}",
["type"] = "Note",
["attributedTo"] = author.Id,
["content"] = "<p>to the group</p>",
["to"] = new JsonArray(to.Select(t => (JsonNode)t).ToArray())
};
if (audience != default)
note["audience"] = audience;
return new JsonObject { ["id"] = $"{Origin(author)}/activities/{Guid.NewGuid():N}", ["type"] = "Create", ["actor"] = author.Id, ["object"] = note };
}
[Fact]
public async Task A_follower_posting_to_a_community_is_announced_activity_and_object()
{
var token = TestContext.Current.CancellationToken;
var (_, owner) = await _harness.Persona("owner");
var (_, community) = await Group(GroupKind.Community, PostingPolicy.Followers, owner.Id);
var lemmy = new RemoteActor(_harness.Peer, "lemmy");
var stranger = new RemoteActor(_harness.Peer, "stranger");
await _harness.FollowedBy(community, lemmy);
await _harness.Deliver(lemmy, "/human-centipede", Create(lemmy, new[] { community.Uri, Addressing.Public }, community.Uri));
await _harness.Deliver(stranger, "/human-centipede", Create(stranger, new[] { community.Uri, Addressing.Public }, community.Uri));
Assert.True(await DB.Default.Find<Post>().Match(p => p.GroupId == community.Id && p.ActorURI == lemmy.Id).ExecuteAnyAsync(token));
Assert.False(await DB.Default.Find<Post>().Match(p => p.GroupId == community.Id && p.ActorURI == stranger.Id).ExecuteAnyAsync(token));
var announces = (await _harness.Outgoing(lemmy.SharedInbox)).Where(a => a["actor"]!.GetValue<string>() == community.Uri).ToList();
Assert.Equal(2, announces.Count);
Assert.Contains(announces, a => a["object"] is JsonObject inner && inner["type"]!.GetValue<string>() == "Create" && a["audience"]!.GetValue<string>() == community.Uri);
Assert.Contains(announces, a => a["object"] is JsonValue);
}
[Fact]
public async Task An_open_community_takes_posts_from_anyone()
{
var token = TestContext.Current.CancellationToken;
var (_, owner) = await _harness.Persona("owner");
var (_, community) = await Group(GroupKind.Community, PostingPolicy.Anyone, owner.Id);
var stranger = new RemoteActor(_harness.Peer, "stranger");
await _harness.Deliver(stranger, "/human-centipede", Create(stranger, new[] { community.Uri, Addressing.Public }));
Assert.True(await DB.Default.Find<Post>().Match(p => p.GroupId == community.Id && p.ActorURI == stranger.Id).ExecuteAnyAsync(token));
}
[Fact]
public async Task Mentioning_a_community_posts_into_it_as_a_titled_page()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var (_, community) = await Group(GroupKind.Community, PostingPolicy.Anyone, alice.Id);
var follower = new RemoteActor(_harness.Peer, "follower");
await _harness.FollowedBy(community, follower);
var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = $"@{community.UserName} a new thread", PlainText = true }, token);
Assert.Equal(community.Id, outcome.Post.GroupId);
var note = ActivityPubRenderer.Note(outcome.Post, alice, community, default);
Assert.Equal("Page", note["type"]!.GetValue<string>());
Assert.Equal(community.Uri, note["audience"]!.GetValue<string>());
Assert.False(string.IsNullOrEmpty(note["name"]!.GetValue<string>()));
Assert.Contains(await _harness.Outgoing(follower.SharedInbox), a => a["type"]!.GetValue<string>() == "Announce");
}
[Fact]
public async Task A_circle_post_goes_only_to_members_and_only_members_may_read_it()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var member = new RemoteActor(_harness.Peer, "member");
var outsider = new RemoteActor(_harness.Peer, "outsider");
var (circleEntity, circle) = await Group(GroupKind.Circle, PostingPolicy.Followers, alice.Id, member.Id);
await _harness.Remote.GetActor(member.Id, refresh: false, token);
var follower = new RemoteActor(_harness.Peer, "follower");
await _harness.FollowedBy(alice, follower);
var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "just us", GroupId = circle.Id }, token);
Assert.Equal(PostVisibility.Circle, outcome.Post.Visibility);
var create = Assert.Single(await _harness.Outgoing(member.Id + "/inbox"));
Assert.Equal(new[] { circle.Uri, circle.Flock }, create["object"]!["to"]!.AsArray().Select(t => t!.GetValue<string>()));
Assert.DoesNotContain(Addressing.Public, create.ToJsonString());
Assert.Empty(await _harness.Outgoing(follower.SharedInbox));
Assert.Empty((await _harness.Outgoing(member.SharedInbox)).Where(a => a["type"]!.GetValue<string>() == "Announce"));
var authorizer = new SignedFetchAuthorizer(_harness.Remote);
var path = new Uri(outcome.Post.ObjectURI).AbsolutePath;
var asMember = await authorizer.Requester(member.Get(Harness.Host, path), token);
var asOutsider = await authorizer.Requester(outsider.Get(Harness.Host, path), token);
Assert.True(SignedFetchAuthorizer.MayReadCircle(circleEntity, asMember));
Assert.False(SignedFetchAuthorizer.MayReadCircle(circleEntity, asOutsider));
Assert.False(SignedFetchAuthorizer.MayReadCircle(circleEntity, default));
Assert.True(circle.IsCircle);
Assert.False(circle.Discoverable);
}
[Fact]
public async Task Only_circle_members_can_post_into_a_circle()
{
var token = TestContext.Current.CancellationToken;
var (aliceRoot, alice) = await _harness.Persona("alice");
var member = new RemoteActor(_harness.Peer, "member");
var outsider = new RemoteActor(_harness.Peer, "outsider");
var (_, circle) = await Group(GroupKind.Circle, PostingPolicy.Followers, alice.Id, member.Id);
await _harness.Deliver(member, "/human-centipede", Create(member, new[] { circle.Uri, circle.Flock }));
await _harness.Deliver(outsider, "/human-centipede", Create(outsider, new[] { circle.Uri, circle.Flock }));
var stored = await DB.Default.Find<Post>().Match(p => p.GroupId == circle.Id).ExecuteAsync(token);
Assert.Equal(member.Id, Assert.Single(stored).ActorURI);
Assert.Equal(PostVisibility.Circle, stored[0].Visibility);
Assert.True(await DB.Default.Find<TimelineEntry>().Match(e => e.AvatarId == alice.Id && e.PostId == stored[0].ID).ExecuteAnyAsync(token));
}
[Fact]
public async Task A_followed_remote_community_announcing_a_post_puts_it_in_home()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var lemmyCommunity = new RemoteActor(_harness.Peer, "cats", type: "Group");
var poster = new RemoteActor(_harness.Peer, "poster");
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = lemmyCommunity.Id }, token);
await DB.Default.Update<Following>().Match(f => f.AvatarId == alice.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token);
var create = Create(poster, new[] { lemmyCommunity.Id, Addressing.Public }, lemmyCommunity.Id);
var noteId = create["object"]!["id"]!.GetValue<string>();
_harness.Peer.Serve(new Uri(noteId).AbsolutePath, create["object"]!.ToJsonString());
await _harness.Deliver(lemmyCommunity, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(lemmyCommunity)}/activities/announce/{Guid.NewGuid():N}", ["type"] = "Announce", ["actor"] = lemmyCommunity.Id,
["to"] = new JsonArray(Addressing.Public), ["object"] = create
});
var post = await DB.Default.Find<Post>().Match(p => p.ObjectURI == noteId).ExecuteSingleAsync(token);
Assert.Equal(lemmyCommunity.Id, post.AudienceURI);
Assert.True(await DB.Default.Find<TimelineEntry>().Match(e => e.AvatarId == alice.Id && e.PostId == post.ID).ExecuteAnyAsync(token));
}
}
}
@@ -54,9 +54,9 @@ namespace PrivaPub.Tests.Federation
{
new FollowHandler(db, _local, remote, delivery),
new UndoHandler(db, _local),
new CreateHandler(db, _local, remote, delivery, _blocks, new Fanout(db), new RemotePosts(db, _local, remote, _blocks, queue)),
new DeleteHandler(db, _local, remote, delivery),
new UpdateHandler(db, _local, remote)
new CreateHandler(db, _local, remote, delivery, _blocks, new Fanout(db), new RemotePosts(db, _local, remote, _blocks, queue), new GroupDistributor(delivery)),
new DeleteHandler(db, _local, remote, delivery, new GroupDistributor(delivery)),
new UpdateHandler(db, _local, remote, new GroupDistributor(delivery))
}, NullLogger<InboxProcessor>.Instance);
}
@@ -323,7 +323,7 @@ namespace PrivaPub.Tests.Federation
}
[Fact]
public async Task A_circle_is_not_a_federated_actor()
public async Task A_circle_only_takes_follow_requests()
{
var token = TestContext.Current.CancellationToken;
var (privateKey, publicKey) = Keys.NewKeyPair();
@@ -339,8 +339,13 @@ namespace PrivaPub.Tests.Federation
["object"] = actor.Uri
};
Assert.False(actor.IsFederated);
Assert.Equal(404, (await Deliver(bob, "/human-centipede", follow)).StatusCode);
Assert.True(actor.IsCircle);
Assert.True(actor.ManuallyApprovesFollowers);
Assert.False(actor.Discoverable);
Assert.Equal(202, (await Deliver(bob, "/human-centipede", follow)).StatusCode);
var request = await DB.Default.Find<Follower>().Match(f => f.LocalActorId == circle.ID).ExecuteSingleAsync(token);
Assert.False(request.IsAccepted);
Assert.DoesNotContain(circle.Members, m => m.AvatarId == bob.Id);
}
}
}
+17 -5
View File
@@ -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;
@@ -39,6 +41,7 @@ namespace PrivaPub.Tests.Support
Remote = new RemoteActorService(Peer.Http(cache), Local, cache, Db);
Delivery = new DeliveryService(Db, Queue);
Fanout = new Fanout(Db);
Groups = new GroupDistributor(Delivery);
RemotePosts = new RemotePosts(Db, Local, Remote, new NoBlocks(), Queue);
Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger<InboxReceiver>.Instance);
Processor = new InboxProcessor(Remote, new IActivityHandler[]
@@ -48,17 +51,22 @@ namespace PrivaPub.Tests.Support
new RejectHandler(Db, Local),
new UndoHandler(Db, Local),
new LikeHandler(Db),
new AnnounceHandler(Db, Local, RemotePosts, Fanout),
new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts),
new DeleteHandler(Db, Local, Remote, Delivery),
new UpdateHandler(Db, Local, Remote)
new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote),
new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts, Groups),
new DeleteHandler(Db, Local, Remote, Delivery, Groups),
new UpdateHandler(Db, Local, Remote, Groups),
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, Groups);
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 +82,13 @@ namespace PrivaPub.Tests.Support
public OutboxPublisher Outbox { get; }
public PostsService Posts { get; }
public StatusService Statuses { get; }
public MediaService Media { get; }
public GroupDistributor Groups { 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
+9
View File
@@ -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)
+20 -2
View File
@@ -14,13 +14,16 @@ namespace PrivaPub.Tests.Support
{
readonly RSA _key = RSA.Create(2048);
public RemoteActor(Peer peer, string name, string origin = default)
public RemoteActor(Peer peer, string name, string origin = default, string type = "Person")
{
Name = $"{name}{Guid.NewGuid():N}"[..20];
Id = $"{origin ?? peer.A}/users/{Name}";
Type = type;
peer.Serve($"/users/{Name}", Document().ToJsonString());
}
public string Type { get; }
public string Name { get; }
public string Id { get; }
public string KeyId => Id + "#main-key";
@@ -29,7 +32,7 @@ namespace PrivaPub.Tests.Support
public JsonObject Document() => new()
{
["id"] = Id,
["type"] = "Person",
["type"] = Type,
["preferredUsername"] = Name,
["inbox"] = Id + "/inbox",
["followers"] = Id + "/followers",
@@ -41,6 +44,21 @@ namespace PrivaPub.Tests.Support
}
};
public HttpRequest Get(string host, string path)
{
var date = DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture);
var signingString = $"(request-target): get {path}\nhost: {host}\ndate: {date}";
var signature = Convert.ToBase64String(_key.SignData(Encoding.UTF8.GetBytes(signingString), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
var context = new DefaultHttpContext();
context.Request.Method = "GET";
context.Request.Host = new HostString(host);
context.Request.Path = path;
context.Features.Get<IHttpRequestFeature>().RawTarget = path;
context.Request.Headers["Date"] = date;
context.Request.Headers["Signature"] = $"keyId=\"{KeyId}\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date\",signature=\"{signature}\"";
return context.Request;
}
public HttpRequest Post(string host, string path, JsonNode activity)
{
var body = Encoding.UTF8.GetBytes(activity.ToJsonString());
@@ -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"))
@@ -101,7 +114,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
if (parts.Length == 1 || parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase))
{
var local = await _localActors.FindByUserName(parts[0], token);
return local is { IsFederated: true, Kind: not LocalActorKind.Application } ? Json(await _mapper.Local(local, false, token)) : NotFoundError();
return local is { IsFederated: true, IsCircle: false, Kind: not LocalActorKind.Application } ? Json(await _mapper.Local(local, false, token)) : NotFoundError();
}
var userName = parts[0];
var domain = parts[1].ToLowerInvariant();
@@ -136,7 +149,13 @@ namespace PrivaPub.Api.Mastodon.Controllers
if (local == default && remote == default)
return NotFoundError();
if (Params.Bool("pinned") == true)
return Json(Array.Empty<Status>());
{
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)
{
@@ -269,7 +365,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
if (avatar is { DeletionAt: null })
return (_localActors.FromAvatar(avatar), default);
var group = await _dbEntities.Groups.MatchID(id).ExecuteFirstAsync(token);
if (group is { DeletionAt: null } && _localActors.FromGroup(group) is { IsFederated: true } community)
if (group is { DeletionAt: null } && _localActors.FromGroup(group) is { IsFederated: true, IsCircle: false } community)
return (community, default);
return (default, await _dbEntities.ForeignAvatars.MatchID(id).ExecuteFirstAsync(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)
});
}
}
}
@@ -56,7 +56,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
{
var local = await _localActors.FindByUri(q, token);
var foreign = local == default ? (resolve ? await _remoteActors.GetActor(q, refresh: false, token) : default) : default;
if (local is { IsFederated: true })
if (local is { IsFederated: true, IsCircle: false })
results.Accounts.Add(await _mapper.Local(local, false, token));
else if (foreign != default)
results.Accounts.Add(_mapper.Foreign(foreign));
@@ -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)
{
+35 -13
View File
@@ -2,9 +2,11 @@ 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;
using PrivaPub.Models.Group;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
@@ -21,13 +23,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 +107,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) =>
@@ -119,7 +125,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
foreach (var avatar in await _dbEntities.Avatars.Match(a => wanted.Contains(a.ID) && !a.DeletionAt.HasValue).ExecuteAsync(token))
accounts[avatar.ID] = await Local(_localActors.FromAvatar(avatar), false, token);
foreach (var group in await _dbEntities.Groups.Match(g => wanted.Contains(g.ID) && !g.DeletionAt.HasValue).ExecuteAsync(token))
foreach (var group in await _dbEntities.Groups.Match(g => wanted.Contains(g.ID) && !g.DeletionAt.HasValue && g.Kind == GroupKind.Community).ExecuteAsync(token))
accounts[group.ID] = await Local(_localActors.FromGroup(group), false, token);
foreach (var foreign in await _dbEntities.ForeignAvatars.Match(f => wanted.Contains(f.ID)).ExecuteAsync(token))
accounts[foreign.ID] = Foreign(foreign);
@@ -147,6 +153,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 +185,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 +201,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 +250,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 +261,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();
}
}
}
@@ -32,6 +32,10 @@ namespace PrivaPub.Controllers.ClientToServer
public async Task<IActionResult> Insert(InsertPostForm form, CancellationToken token) =>
!ModelState.IsValid ? Invalid() : Answer(await _postsService.InsertPost(User.GetUserId(), form, token));
[HttpGet, Route("/clientapi/post/nearby")]
public async Task<IActionResult> Nearby([FromQuery] string avatarId, [FromQuery] double latitude, [FromQuery] double longitude, CancellationToken token) =>
Answer(await _postsService.Nearby(User.GetUserId(), avatarId, latitude, longitude, token));
[HttpPost, Route("/clientapi/post/update")]
public async Task<IActionResult> Update(UpdatePostForm form, CancellationToken token) =>
!ModelState.IsValid ? Invalid() : Answer(await _postsService.UpdatePost(User.GetUserId(), form, token));
+1 -1
View File
@@ -122,7 +122,7 @@ namespace PrivaPub.Domain.Content
if (parts.Length == 1 || parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase))
{
var local = await _localActors.FindByUserName(parts[0], token);
return local is { IsFederated: true, Kind: not LocalActorKind.Application }
return local is { IsFederated: true, IsCircle: false, Kind: not LocalActorKind.Application }
? new ResolvedMention(local.Uri, local.UserName, local.Domain, local.Uri, true, local.Id, local.Inbox)
: default;
}
+91
View File
@@ -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);
}
}
}
+13
View File
@@ -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;
}
}
+160
View File
@@ -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();
}
}
}
}
+263
View File
@@ -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;
}
}
}
+1 -1
View File
@@ -95,7 +95,7 @@ namespace PrivaPub.Domain.Social
var (local, remote) = await ResolveTarget(target, token);
if (local == default && remote == default)
return default;
if (local != default && (local.Id == follower.Id || !local.IsFederated || local.Kind == LocalActorKind.Application))
if (local != default && (local.Id == follower.Id || !local.IsFederated || local.IsCircle || local.Kind == LocalActorKind.Application))
return default;
var targetUri = local?.Uri ?? remote.ActorURI;
+2
View File
@@ -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
+118 -10
View File
@@ -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,10 @@ 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 double? Latitude { get; init; }
public double? Longitude { get; init; }
public double? RangeKm { get; init; }
}
public sealed record StatusOutcome(PostEntity Post, int Status = StatusCodes.Status200OK, string Error = default)
@@ -61,10 +67,14 @@ namespace PrivaPub.Domain.Statuses
readonly IContentRenderer _content;
readonly IOutboxPublisher _outbox;
readonly IFanout _fanout;
readonly IMediaService _media;
readonly IGroupDistributor _groups;
public StatusService(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
IContentRenderer content, IOutboxPublisher outbox, IFanout fanout)
IContentRenderer content, IOutboxPublisher outbox, IFanout fanout, IMediaService media, IGroupDistributor groups)
{
_media = media;
_groups = groups;
_dbEntities = dbEntities;
_localActors = localActors;
_remoteActors = remoteActors;
@@ -76,14 +86,17 @@ 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;
if (!string.IsNullOrEmpty(draft.GroupId))
{
var groupEntity = await _dbEntities.Groups.MatchID(draft.GroupId).ExecuteFirstAsync(token);
if (groupEntity == default || groupEntity.DeletionAt.HasValue || !groupEntity.Members.Any(m => !m.IsForeign && m.AvatarId == author.Id))
if (groupEntity == default || groupEntity.DeletionAt.HasValue || !await MayPost(groupEntity, author, token))
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Group not found");
group = _localActors.FromGroup(groupEntity);
}
@@ -94,9 +107,35 @@ 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 isLocalOnly = group is { IsFederated: false };
var visibility = isLocalOnly ? PostVisibility.Circle : draft.Visibility is PostVisibility.Circle or PostVisibility.LocalGeo ? PostVisibility.Public : draft.Visibility;
var located = draft.Latitude.HasValue || draft.Longitude.HasValue;
if (located && (draft.Latitude is not (>= -90 and <= 90) || draft.Longitude is not (>= -180 and <= 180) || group != default
|| draft.Visibility == PostVisibility.Direct))
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: A located post needs a valid position and no group or recipients");
var rendered = draft.PlainText ? await _content.PlainText(draft.Text ?? string.Empty, token) : await _content.Markdown(draft.Text ?? string.Empty, token);
string audienceUri = default;
if (group == default && !located && draft.Visibility is PostVisibility.Public or PostVisibility.Unlisted)
foreach (var mention in rendered.Mentions)
{
if (mention.IsLocal)
{
var mentioned = await _dbEntities.Groups.MatchID(mention.AccountId).ExecuteFirstAsync(token);
if (mentioned is { DeletionAt: null, Kind: GroupKind.Community } && await MayPost(mentioned, author, token))
{
group = _localActors.FromGroup(mentioned);
break;
}
}
else if (await _dbEntities.ForeignAvatars.MatchID(mention.AccountId).ExecuteFirstAsync(token) is { AvatarType: Models.User.AvatarType.Group } remoteGroup)
{
audienceUri = remoteGroup.ActorURI;
break;
}
}
var isLocalOnly = located;
var visibility = located ? PostVisibility.LocalGeo
: group is { IsCircle: true } ? PostVisibility.Circle
: draft.Visibility is PostVisibility.Circle or PostVisibility.LocalGeo ? PostVisibility.Public : draft.Visibility;
var post = new PostEntity
{
GroupUserId = author.Id,
@@ -112,6 +151,8 @@ namespace PrivaPub.Domain.Statuses
Language = Clean(draft.Language),
Mentions = rendered.Mentions.Select(ToMention).ToList(),
Tags = rendered.Tags.ToList(),
Media = media.Select(ToPostMedia).ToList(),
AudienceURI = audienceUri,
AnsweringToPostId = parent?.ID,
InReplyToURI = parent?.ObjectURI ?? (IsRemoteUri(draft.InReplyTo) ? draft.InReplyTo : default),
InReplyToAccountId = parent?.AuthorAccountId ?? parent?.GroupUserId,
@@ -121,6 +162,11 @@ namespace PrivaPub.Domain.Statuses
post.ID = (string)post.GenerateNewID();
post.ObjectURI = author.PostUri(post.ID);
post.Url = author.PostHtmlUrl(post.ID);
if (located)
{
post.Geo = new Coordinates2D(Math.Round(draft.Longitude.Value, 2), Math.Round(draft.Latitude.Value, 2));
post.RangeKm = (float)Math.Clamp(draft.RangeKm ?? 5, 1, 50);
}
JsonObject create = default;
if (visibility == PostVisibility.Direct)
@@ -150,13 +196,14 @@ 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);
if (create != default)
await _outbox.Publish(author, post, create, token);
if (group is { IsFederated: true } && visibility is PostVisibility.Public or PostVisibility.Unlisted)
await _delivery.EnqueueToFollowers(group, ActivityPubRenderer.Announce(group, post.ObjectURI, $"announce-{post.ID}"), token);
if (create != default && group is { IsCircle: false } && visibility is PostVisibility.Public or PostVisibility.Unlisted)
await _groups.Announce(group, create, post.ObjectURI, isNewPost: string.IsNullOrEmpty(post.InReplyToURI), token);
return new StatusOutcome(post);
}
@@ -165,7 +212,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 +230,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;
@@ -214,6 +269,8 @@ namespace PrivaPub.Domain.Statuses
["object"] = note
};
await _outbox.Publish(author, post, update, token);
if (group is { IsCircle: false })
await _groups.Announce(group, update, post.ObjectURI, isNewPost: false, token);
}
return new StatusOutcome(post);
}
@@ -244,6 +301,9 @@ namespace PrivaPub.Domain.Statuses
var delete = ActivityPubRenderer.Delete(author, post.ObjectURI, $"delete-{post.ID}",
new JsonArray(post.To.Select(t => (JsonNode)t).ToArray()), new JsonArray(post.Cc.Select(c => (JsonNode)c).ToArray()));
await _delivery.Enqueue(author, audience, delete, token);
var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
if (group is { IsCircle: false })
await _groups.Announce(group, delete, post.ObjectURI, isNewPost: false, token);
}
return new StatusOutcome(post);
}
@@ -461,6 +521,54 @@ namespace PrivaPub.Domain.Statuses
return (await _dbEntities.ForeignAvatars.MatchID(post.AuthorAccountId).ExecuteFirstAsync(token))?.InboxURL;
}
async Task<bool> MayPost(Models.Group.Group group, LocalActor author, CancellationToken token)
{
var member = group.Members.FirstOrDefault(m => !m.IsForeign && m.AvatarId == author.Id);
if (group.Kind == GroupKind.Circle)
return member != default;
return group.PostingPolicy switch
{
PostingPolicy.Anyone => true,
PostingPolicy.Moderators => member?.Role is GroupRole.Owner or GroupRole.Moderator,
_ => member != default || await _dbEntities.Followings
.Match(f => f.AvatarId == author.Id && f.TargetAccountId == group.ID && f.State == FollowState.Accepted).ExecuteAnyAsync(token)
};
}
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,
+4
View File
@@ -40,6 +40,9 @@ namespace PrivaPub.Domain.Timelines
foreach (var following in followers)
if (await Shows(following, post, token))
recipients.Add(following.AvatarId);
if (!string.IsNullOrEmpty(post.AudienceURI))
foreach (var member in await _dbEntities.Followings.Match(f => f.TargetActorURI == post.AudienceURI && f.State == FollowState.Accepted).ExecuteAsync(token))
recipients.Add(member.AvatarId);
break;
case PostVisibility.Direct when !string.IsNullOrEmpty(post.ConversationId):
var conversation = await _dbEntities.DmGroups.MatchID(post.ConversationId).ExecuteFirstAsync(token);
@@ -53,6 +56,7 @@ namespace PrivaPub.Domain.Timelines
break;
}
recipients.ExceptWith(await Relationships.Hidden.RecipientsHiding(recipients, post.ActorURI, token));
foreach (var avatarId in recipients)
{
try
@@ -29,6 +29,8 @@ namespace PrivaPub.Federation.Actors
public bool Discoverable { get; init; } = true;
public bool ManuallyApprovesFollowers { get; init; }
public bool IsFederated { get; init; } = true;
public bool IsCircle { get; init; }
public bool PostingRestrictedToModerators { get; init; }
public bool IsBot { get; init; }
public bool Indexable { get; init; }
public AvatarSettings Settings { get; init; } = new();
@@ -42,6 +44,10 @@ 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 Flock => $"{Uri}/flock";
public string Wardens => $"{Uri}/wardens";
public string SharedInbox => $"{BaseAddress}/human-centipede";
public string Domain => new Uri(BaseAddress).Authority;
public string Handle => $"{UserName}@{Domain}";
@@ -217,9 +223,10 @@ namespace PrivaPub.Federation.Actors
ThumbnailURL = group.ThumbnailURL,
PrivateKeyPem = group.PrivateKey,
PublicKeyPem = group.PublicKey,
Discoverable = group.IsDiscoverable,
ManuallyApprovesFollowers = group.ManuallyApprovesMembers,
IsFederated = group.Kind == GroupKind.Community,
Discoverable = group.Kind == GroupKind.Community && group.IsDiscoverable,
ManuallyApprovesFollowers = group.Kind == GroupKind.Circle || group.ManuallyApprovesMembers,
IsCircle = group.Kind == GroupKind.Circle,
PostingRestrictedToModerators = group.PostingPolicy == PostingPolicy.Moderators,
Published = group.CreationDate,
BaseAddress = BaseAddress
};
@@ -1,10 +1,13 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Options;
using Microsoft.AspNetCore.RateLimiting;
using MongoDB.Entities;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
@@ -14,12 +17,15 @@ using PrivaPub.Domain.Privacy;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Rendering;
using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Signing;
using PrivaPub.Infrastructure.Http;
using PrivaPub.Models.Group;
using PrivaPub.Infrastructure;
namespace PrivaPub.Federation.Controllers
{
[ApiController, Route("peasants")]
public class PeasantsController : ControllerBase
public class PeasantsController : ControllerBase, IAsyncActionFilter
{
const string ActivityContentType = "application/activity+json; charset=utf-8";
const int OutboxSize = 20;
@@ -28,10 +34,14 @@ namespace PrivaPub.Federation.Controllers
readonly IInboxReceiver _inbox;
readonly DbEntities _dbEntities;
readonly ILogger<PeasantsController> _logger;
readonly ISignedFetchAuthorizer _fetches;
readonly IOptionsMonitor<FederationOptions> _federation;
public PeasantsController(ILocalActorService localActors, IInboxReceiver inbox, DbEntities dbEntities,
ILogger<PeasantsController> logger)
ILogger<PeasantsController> logger, ISignedFetchAuthorizer fetches, IOptionsMonitor<FederationOptions> federation)
{
_fetches = fetches;
_federation = federation;
_localActors = localActors;
_inbox = inbox;
_dbEntities = dbEntities;
@@ -112,10 +122,72 @@ namespace PrivaPub.Federation.Controllers
return local is not { IsFederated: true } ? NotFound() : Activity(ActivityPubRenderer.OrderedCollection(local.Following, 0, default));
}
[HttpGet, Route("{actor}/flock")]
public async Task<IActionResult> Members(string actor, CancellationToken token)
{
var local = await _localActors.FindByUserName(actor, token);
if (local is not { IsFederated: true, Kind: LocalActorKind.Group })
return NotFound();
var group = await _dbEntities.Groups.MatchID(local.Id).ExecuteFirstAsync(token);
if (local.IsCircle && !SignedFetchAuthorizer.MayReadCircle(group, await _fetches.Requester(Request, token)))
return NotFound();
return Activity(ActivityPubRenderer.OrderedCollection(local.Flock, group.Members.Count, default));
}
[HttpGet, Route("{actor}/wardens")]
public async Task<IActionResult> Moderators(string actor, CancellationToken token)
{
var local = await _localActors.FindByUserName(actor, token);
if (local is not { IsFederated: true, Kind: LocalActorKind.Group })
return NotFound();
var group = await _dbEntities.Groups.MatchID(local.Id).ExecuteFirstAsync(token);
if (local.IsCircle && !SignedFetchAuthorizer.MayReadCircle(group, await _fetches.Requester(Request, token)))
return NotFound();
var moderators = new List<JsonNode>();
foreach (var member in group.Members.Where(m => !m.IsForeign && m.Role is GroupRole.Owner or GroupRole.Moderator))
if (await _localActors.FindById(LocalActorKind.Person, member.AvatarId, token) is { } moderator)
moderators.Add(moderator.Uri);
return Activity(ActivityPubRenderer.OrderedCollection(local.Wardens, moderators.Count, moderators));
}
[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)
{
var (local, post) = await PublicPost(actor, postId, token);
if (post == default && await CirclePost(actor, postId, token) is { } circlePost)
{
var circleNote = ActivityPubRenderer.Note(circlePost.Post, circlePost.Author, circlePost.Circle, circlePost.Post.InReplyToURI);
circleNote["@context"] = ActivityPubRenderer.Context();
return Activity(circleNote);
}
if (post == default)
return await Tombstone(actor, postId, token) ?? NotFound();
if (WantsHtml())
@@ -173,6 +245,20 @@ namespace PrivaPub.Federation.Controllers
return (local, post);
}
async Task<(LocalActor Author, LocalActor Circle, PostEntity Post)?> CirclePost(string actor, string postId, CancellationToken token)
{
var local = await _localActors.FindByUserName(actor, token);
if (local is not { Kind: LocalActorKind.Person })
return default;
var post = await _dbEntities.Posts
.Match(p => p.ID == postId && p.GroupUserId == local.Id && !p.IsFederatedCopy && !p.DeletedAt.HasValue && p.Visibility == PostVisibility.Circle)
.ExecuteFirstAsync(token);
var circle = post == default ? default : await _dbEntities.Groups.MatchID(post.GroupId).ExecuteFirstAsync(token);
if (circle == default || !SignedFetchAuthorizer.MayReadCircle(circle, await _fetches.Requester(Request, token)))
return default;
return (local, _localActors.FromGroup(circle), post);
}
async Task<IActionResult> Tombstone(string actor, string postId, CancellationToken token)
{
var local = await _localActors.FindByUserName(actor, token);
@@ -228,6 +314,19 @@ namespace PrivaPub.Federation.Controllers
&& !accept.Contains("ld+json", StringComparison.OrdinalIgnoreCase);
}
[NonAction]
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
if (_federation.CurrentValue.SecureMode && HttpMethods.IsGet(Request.Method)
&& !string.Equals(context.RouteData.Values["actor"] as string, LocalActorService.InstanceUserName, StringComparison.OrdinalIgnoreCase)
&& await _fetches.Requester(Request, HttpContext.RequestAborted) == default)
{
context.Result = StatusCode(StatusCodes.Status401Unauthorized);
return;
}
await next();
}
IActionResult Answer(InboxResult result)
{
if (result.Error != default)
@@ -26,9 +26,11 @@ namespace PrivaPub.Federation.Inbox.Handlers
readonly ILocalActorService _localActors;
readonly IRemotePosts _remotePosts;
readonly IFanout _fanout;
readonly IRemoteActorService _remoteActors;
public AnnounceHandler(DbEntities dbEntities, ILocalActorService localActors, IRemotePosts remotePosts, IFanout fanout)
public AnnounceHandler(DbEntities dbEntities, ILocalActorService localActors, IRemotePosts remotePosts, IFanout fanout, IRemoteActorService remoteActors)
{
_remoteActors = remoteActors;
_dbEntities = dbEntities;
_localActors = localActors;
_remotePosts = remotePosts;
@@ -41,7 +43,10 @@ namespace PrivaPub.Federation.Inbox.Handlers
{
var inner = activity["object"];
if (inner is JsonObject && Value(inner, "type") is "Create" or "Update" or "Delete" or "Like" or "Dislike" or "Undo" or "Add" or "Remove" or "Block")
{
await GroupActivity(inner, actor, token);
return;
}
var objectUri = Id(inner);
var announceId = Id(activity);
if (objectUri == default || announceId == default)
@@ -96,6 +101,66 @@ namespace PrivaPub.Federation.Inbox.Handlers
await _fanout.Distribute(reblog, token);
}
async Task GroupActivity(JsonNode inner, ForeignAvatar group, CancellationToken token)
{
if (group.AvatarType != AvatarType.Group
|| !await _dbEntities.Followings.Match(f => f.TargetActorURI == group.ActorURI && f.State == FollowState.Accepted).ExecuteAnyAsync(token))
return;
var objectUri = Id(inner["object"]);
switch (Value(inner, "type"))
{
case "Create" when objectUri != default:
if (await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteAnyAsync(token))
return;
var post = await _remotePosts.StoreContext(objectUri, 0, token);
if (post == default)
return;
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(p => p.AudienceURI, group.ActorURI).ExecuteAsync(token);
post.AudienceURI = group.ActorURI;
await _fanout.Distribute(post, token);
break;
case "Update" when objectUri != default:
var stored = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.AudienceURI == group.ActorURI && !p.DeletedAt.HasValue)
.ExecuteFirstAsync(token);
if (stored == default)
return;
using (var fetched = await _remoteActors.FetchObject(objectUri, token))
{
var note = fetched == default ? default : NoteParser.Parse(System.Text.Json.Nodes.JsonNode.Parse(fetched.Root.GetRawText()));
if (note == default || note.AttributedTo != stored.ActorURI)
return;
stored.Revisions.Add(new PostRevision
{
Title = stored.Title,
SpoilerText = stored.SpoilerText,
ContentHtml = stored.ContentHtml,
HasContentWarning = stored.HasContentWarning,
EditedAt = stored.EditedAt ?? stored.CreationDate
});
stored.Title = note.Title;
stored.SpoilerText = note.SpoilerText;
stored.HasContentWarning = note.Sensitive;
stored.Text = note.ContentHtml;
stored.ContentHtml = note.ContentHtml;
stored.Tags = note.Tags.ToList();
stored.Media = note.Attachments.ToList();
stored.EditedAt = note.Updated ?? DateTime.UtcNow;
await DB.Default.SaveAsync(stored, token);
}
break;
case "Delete" when objectUri != default:
var deleted = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.AudienceURI == group.ActorURI).ExecuteFirstAsync(token);
if (deleted == default)
return;
using (var check = await _remoteActors.FetchObject(objectUri, token))
if (check != default && Value(System.Text.Json.Nodes.JsonNode.Parse(check.Root.GetRawText()), "type") != "Tombstone")
return;
await DB.Default.DeleteAsync<PostEntity>(deleted.ID);
await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == deleted.ID);
break;
}
}
static List<string> Strings(JsonNode node) => node switch
{
JsonArray array => array.Select(Id).Where(id => id != default).ToList(),
@@ -33,10 +33,12 @@ namespace PrivaPub.Federation.Inbox.Handlers
readonly IDomainBlocks _domainBlocks;
readonly IFanout _fanout;
readonly IRemotePosts _remotePosts;
readonly IGroupDistributor _groups;
public CreateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
IDomainBlocks domainBlocks, IFanout fanout, IRemotePosts remotePosts)
IDomainBlocks domainBlocks, IFanout fanout, IRemotePosts remotePosts, IGroupDistributor groups)
{
_groups = groups;
_fanout = fanout;
_remotePosts = remotePosts;
_dbEntities = dbEntities;
@@ -85,10 +87,17 @@ namespace PrivaPub.Federation.Inbox.Handlers
var parent = string.IsNullOrEmpty(note.InReplyTo)
? default
: await _dbEntities.Posts.Match(p => p.ObjectURI == note.InReplyTo && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
var group = visibility is PostVisibility.Public or PostVisibility.Unlisted
? localTargets.FirstOrDefault(t => t.Kind == LocalActorKind.Group)
: default;
if (group != default && !await IsAcceptedFollower(group, author.ActorURI, token))
var circle = localTargets.FirstOrDefault(t => t is { Kind: LocalActorKind.Group, IsCircle: true });
if (circle != default)
{
if (!await IsCircleMember(circle, author.ActorURI, token))
return;
visibility = PostVisibility.Circle;
}
var group = circle ?? (visibility is PostVisibility.Public or PostVisibility.Unlisted
? localTargets.FirstOrDefault(t => t is { Kind: LocalActorKind.Group, IsCircle: false })
: default);
if (group is { IsCircle: false } && !await MayPost(group, author.ActorURI, token))
group = default;
var repliesToLocal = parent is { IsFederatedCopy: false } && visibility != PostVisibility.Direct;
@@ -128,11 +137,22 @@ namespace PrivaPub.Federation.Inbox.Handlers
await _fanout.Distribute(post, token);
if (conversation != default)
await DB.Default.Update<DmGroup>().MatchID(conversation.ID).Modify(g => g.UpdatedAt, DateTime.UtcNow).ExecuteAsync(token);
if (group != default)
if (group is { IsCircle: false })
await _groups.Announce(group, activity.AsObject(), note.Id, isNewPost: string.IsNullOrEmpty(note.InReplyTo), token);
}
async Task<bool> IsCircleMember(LocalActor circle, string actorUri, CancellationToken token) =>
await _dbEntities.Groups.Match(g => g.ID == circle.Id && g.Members.Any(m => m.IsForeign && m.AvatarId == actorUri)).ExecuteAnyAsync(token);
async Task<bool> MayPost(LocalActor community, string actorUri, CancellationToken token)
{
var entity = await _dbEntities.Groups.MatchID(community.Id).ExecuteFirstAsync(token);
return entity?.PostingPolicy switch
{
var announce = ActivityPubRenderer.Announce(group, note.Id, $"announce-{post.ID}");
await _delivery.EnqueueToFollowers(group, announce, token);
}
PostingPolicy.Anyone => true,
PostingPolicy.Followers => await IsAcceptedFollower(community, actorUri, token),
_ => false
};
}
async Task<DmGroup> FindOrCreateConversation(List<string> participantUris, string context, CancellationToken token)
@@ -26,9 +26,12 @@ namespace PrivaPub.Federation.Inbox.Handlers
readonly ILocalActorService _localActors;
readonly IRemoteActorService _remoteActors;
readonly IDeliveryService _delivery;
readonly IGroupDistributor _groups;
public DeleteHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery)
public DeleteHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
IGroupDistributor groups)
{
_groups = groups;
_dbEntities = dbEntities;
_localActors = localActors;
_remoteActors = remoteActors;
@@ -67,6 +70,8 @@ namespace PrivaPub.Federation.Inbox.Handlers
return;
await DB.Default.DeleteAsync<PostEntity>(post.ID);
await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == post.ID || e.ReblogOfPostId == post.ID);
if (!string.IsNullOrEmpty(post.GroupId) && await _localActors.FindById(LocalActorKind.Group, post.GroupId, token) is { IsCircle: false } community)
await _groups.Announce(community, activity.AsObject(), post.ObjectURI, isNewPost: false, token);
await DB.Default.DeleteAsync<PostEntity>(p => p.ReblogOfPostId == post.ID);
if (!string.IsNullOrEmpty(post.AnsweringToPostId))
await DB.Default.Update<PostEntity>().MatchID(post.AnsweringToPostId).Modify(b => b.Inc(p => p.RepliesCount, -1)).ExecuteAsync(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)
@@ -2,6 +2,7 @@ using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Outbox;
using PrivaPub.Models.Post;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
@@ -21,9 +22,11 @@ namespace PrivaPub.Federation.Inbox.Handlers
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IRemoteActorService _remoteActors;
readonly IGroupDistributor _groups;
public UpdateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors)
public UpdateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IGroupDistributor groups)
{
_groups = groups;
_dbEntities = dbEntities;
_localActors = localActors;
_remoteActors = remoteActors;
@@ -79,6 +82,8 @@ namespace PrivaPub.Federation.Inbox.Handlers
post.EditedAt = note.Updated ?? DateTime.UtcNow;
post.UpdateDate = DateTime.UtcNow;
await DB.Default.SaveAsync(post, token);
if (!string.IsNullOrEmpty(post.GroupId) && await _localActors.FindById(Models.Federation.LocalActorKind.Group, post.GroupId, token) is { IsCircle: false } community)
await _groups.Announce(community, activity.AsObject(), post.ObjectURI, isNewPost: false, token);
}
}
}
@@ -0,0 +1,48 @@
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Rendering;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;
namespace PrivaPub.Federation.Outbox
{
public interface IGroupDistributor
{
Task Announce(LocalActor group, JsonObject activity, string objectUri, bool isNewPost, CancellationToken token);
}
public class GroupDistributor : IGroupDistributor
{
readonly IDeliveryService _delivery;
public GroupDistributor(IDeliveryService delivery)
{
_delivery = delivery;
}
public async Task Announce(LocalActor group, JsonObject activity, string objectUri, bool isNewPost, CancellationToken token)
{
if (group is not { IsCircle: false, Kind: Models.Federation.LocalActorKind.Group })
return;
var embedded = (JsonObject)activity.DeepClone();
embedded.Remove("@context");
embedded["audience"] ??= group.Uri;
var key = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(activity["id"]?.GetValue<string>() ?? embedded.ToJsonString())))[..24];
var announce = new JsonObject
{
["@context"] = ActivityPubRenderer.Context(),
["id"] = group.ActivityUri($"announce-{key}"),
["type"] = "Announce",
["actor"] = group.Uri,
["to"] = new JsonArray(ActivityPubRenderer.Public),
["cc"] = new JsonArray(group.Followers),
["audience"] = group.Uri,
["object"] = embedded
};
await _delivery.EnqueueToFollowers(group, announce, token);
if (isNewPost && !string.IsNullOrEmpty(objectUri))
await _delivery.EnqueueToFollowers(group, ActivityPubRenderer.Announce(group, objectUri, $"announce-object-{key}"), token);
}
}
}
+15 -8
View File
@@ -32,8 +32,10 @@ namespace PrivaPub.Federation.Outbox
public async Task<IReadOnlyList<string>> Audience(LocalActor author, PostEntity post, CancellationToken token)
{
if (post.Visibility is PostVisibility.Circle or PostVisibility.LocalGeo || post.IsLocalOnly)
if (post.Visibility == PostVisibility.LocalGeo || post.IsLocalOnly)
return Array.Empty<string>();
if (post.Visibility == PostVisibility.Circle)
return await CircleMembers(post.GroupId, token);
var inboxes = new List<string>();
if (post.Visibility is PostVisibility.Public or PostVisibility.Unlisted or PostVisibility.FollowersOnly)
@@ -58,16 +60,21 @@ namespace PrivaPub.Federation.Outbox
inboxes.Add(parentAuthor.InboxURL);
}
if (!string.IsNullOrEmpty(post.GroupId) && post.Visibility is PostVisibility.Public or PostVisibility.Unlisted)
{
var group = await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
if (group is { IsFederated: true })
inboxes.AddRange(await _delivery.FollowerInboxes(group, token));
}
return inboxes.Where(i => !string.IsNullOrEmpty(i)).Distinct(StringComparer.Ordinal).ToList();
}
async Task<IReadOnlyList<string>> CircleMembers(string groupId, CancellationToken token)
{
var circle = string.IsNullOrEmpty(groupId) ? default : await _dbEntities.Groups.MatchID(groupId).ExecuteFirstAsync(token);
if (circle == default)
return Array.Empty<string>();
var remote = circle.Members.Where(m => m.IsForeign).Select(m => m.AvatarId).ToList();
if (remote.Count == 0)
return Array.Empty<string>();
return (await _dbEntities.ForeignAvatars.Match(a => remote.Contains(a.ActorURI)).ExecuteAsync(token))
.Select(a => a.InboxURL).Where(i => !string.IsNullOrEmpty(i)).Distinct(StringComparer.Ordinal).ToList();
}
public async Task Publish(LocalActor author, PostEntity post, JsonObject activity, CancellationToken token)
{
var inboxes = await Audience(author, post, token);
@@ -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,
@@ -95,6 +97,11 @@ namespace PrivaPub.Federation.Rendering
["value"] = FieldValue(field.Value)
}).ToArray())
};
if (actor is { Kind: LocalActorKind.Group, IsCircle: false })
{
document["attributedTo"] = actor.Wardens;
document["postingRestrictedToMods"] = actor.PostingRestrictedToModerators;
}
if (!string.IsNullOrEmpty(actor.PictureURL))
document["icon"] = new JsonObject { ["type"] = "Image", ["url"] = actor.PictureURL };
if (!string.IsNullOrEmpty(actor.ThumbnailURL))
@@ -107,17 +114,25 @@ namespace PrivaPub.Federation.Rendering
var mentions = post.Mentions.Select(m => (JsonNode)m.ActorURI).ToArray();
var (to, cc) = post.Visibility switch
{
PostVisibility.Circle when group != default => (new JsonArray(group.Uri, group.Flock), new JsonArray(mentions)),
PostVisibility.Unlisted => (new JsonArray(author.Followers), new JsonArray(mentions.Prepend(Public).ToArray())),
PostVisibility.FollowersOnly => (new JsonArray(author.Followers), new JsonArray(mentions)),
PostVisibility.Direct => (new JsonArray(mentions), new JsonArray()),
_ => (new JsonArray(Public), new JsonArray(mentions.Prepend(author.Followers).ToArray()))
};
if (group != default)
if (group is { IsCircle: false })
cc.Add(group.Uri);
var note = NoteBody(post, author, to, cc, inReplyTo);
if (group != default)
note["audience"] = group.Uri;
else if (!string.IsNullOrEmpty(post.AudienceURI))
note["audience"] = post.AudienceURI;
if (group is { IsCircle: false } && string.IsNullOrEmpty(inReplyTo))
{
note["type"] = "Page";
note["name"] = post.Title ?? Headline(post);
}
return note;
}
@@ -139,6 +154,13 @@ namespace PrivaPub.Federation.Rendering
return note;
}
static string Headline(PostEntity post)
{
var text = System.Text.RegularExpressions.Regex.Replace(post.ContentHtml ?? post.Text ?? string.Empty, "<[^>]+>", " ");
text = WebUtility.HtmlDecode(System.Text.RegularExpressions.Regex.Replace(text, "\\s+", " ")).Trim();
return text.Length <= 100 ? text : text[..97] + "...";
}
static JsonObject NoteBody(PostEntity post, LocalActor author, JsonArray to, JsonArray cc, string inReplyTo)
{
var content = post.ContentHtml ?? Html(post.Text);
@@ -168,6 +190,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);
@@ -0,0 +1,40 @@
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.User;
using GroupEntity = PrivaPub.Models.Group.Group;
namespace PrivaPub.Federation.Signing
{
public interface ISignedFetchAuthorizer
{
Task<ForeignAvatar> Requester(HttpRequest request, CancellationToken token);
}
public class SignedFetchAuthorizer : ISignedFetchAuthorizer
{
readonly IRemoteActorService _remoteActors;
public SignedFetchAuthorizer(IRemoteActorService remoteActors)
{
_remoteActors = remoteActors;
}
public async Task<ForeignAvatar> Requester(HttpRequest request, CancellationToken token)
{
var parameters = HttpSignatures.Parse(request.Headers["Signature"].ToString());
if (parameters == default || HttpSignatures.CheckRequest(request, parameters, body: default) != default)
return default;
var signingString = HttpSignatures.SigningString(request, parameters);
var actor = await _remoteActors.GetActorByKeyId(parameters.KeyId, refresh: false, token);
if (actor != default && HttpSignatures.Verify(actor.PublicKey, signingString, parameters.Signature))
return actor;
actor = await _remoteActors.GetActorByKeyId(parameters.KeyId, refresh: true, token);
return actor != default && HttpSignatures.Verify(actor.PublicKey, signingString, parameters.Signature) ? actor : default;
}
public static bool MayReadCircle(GroupEntity circle, ForeignAvatar requester) =>
requester != default && circle.Members.Any(m => m.IsForeign
&& (m.AvatarId == requester.ActorURI || requester.AvatarType == AvatarType.Application && Origin.Same(m.AvatarId, requester.ActorURI)));
}
}
+13
View File
@@ -21,6 +21,7 @@ namespace PrivaPub.Infrastructure.Data
await Plain<Post>(token, p => p.ActorURI);
await Plain<Post>(token, p => p.ConversationId, p => p.ID);
await Plain<Post>(token, p => p.InReplyToURI);
await DB.Default.Index<Post>().Key(p => p.Geo, KeyType.Geo2DSphere).CreateAsync(token);
await Unique<DmPost>(p => p.ObjectURI, Builders<DmPost>.Filter.Type(p => p.ObjectURI, BsonType.String), token);
await Plain<DmPost>(token, p => p.GroupId, p => p.ID);
@@ -87,6 +88,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))
@@ -4,5 +4,6 @@ namespace PrivaPub.Infrastructure.Http
{
public bool AllowPrivateNetworks { get; set; }
public bool AllowPlainHttp { get; set; }
public bool SecureMode { get; set; }
}
}
@@ -15,10 +15,12 @@ using PrivaPub.Services.ClientToServer.Private;
using PrivaPub.Services.ClientToServer.Public;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Signing;
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;
@@ -64,6 +66,8 @@ namespace PrivaPub.Middleware
.AddSingleton<IRemoteActorService, RemoteActorService>()
.AddSingleton<IDeliveryService, DeliveryService>()
.AddSingleton<IOutboxPublisher, OutboxPublisher>()
.AddSingleton<IGroupDistributor, GroupDistributor>()
.AddSingleton<ISignedFetchAuthorizer, SignedFetchAuthorizer>()
.AddSingleton<IFanout, Fanout>()
.AddSingleton<IInboxReceiver, InboxReceiver>()
.AddSingleton<IActivityHandler, FollowHandler>()
@@ -72,6 +76,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 +143,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>()
+9
View File
@@ -20,6 +20,8 @@ namespace PrivaPub.Models.Group
public string PublicKey { get; set; }
public GroupKind Kind { get; set; }
public PostingPolicy PostingPolicy { get; set; }
public List<string> Rules { get; set; } = new();
public bool IsDiscoverable { get; set; } = true;
public bool ManuallyApprovesMembers { get; set; }
public string OwnerAvatarId { get; set; }
@@ -49,6 +51,13 @@ namespace PrivaPub.Models.Group
Community
}
public enum PostingPolicy
{
Followers,
Anyone,
Moderators
}
public enum GroupRole
{
Member,
+27
View File
@@ -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; }
}
}
+6
View File
@@ -1,3 +1,4 @@
using MongoDB.Bson.Serialization.Attributes;
using MongoDB.Entities;
namespace PrivaPub.Models.Post
@@ -11,6 +12,7 @@ namespace PrivaPub.Models.Post
public string InReplyToAccountId { get; set; }
public string GroupId { get; set; }
public string ConversationId { get; set; }//DmGroup.ID of a direct post
public string AudienceURI { get; set; }//a remote community the post was made in
public string ReblogOfPostId { get; set; }
public PostVisibility Visibility { get; set; }
public string Title { get; set; }
@@ -24,6 +26,10 @@ namespace PrivaPub.Models.Post
public List<PostMedia> Media { get; set; } = new();
public List<float> Location { get; set; } = new();
public float RangeKm { get; set; } = 5.0f;
[BsonIgnoreIfNull]
public Coordinates2D Geo { get; set; }
[BsonIgnoreIfNull]
public double? Distance { get; set; }
public bool HasContentWarning { get; set; } = false;
public bool IsLocalOnly { get; set; }
+2
View File
@@ -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; }
+60
View File
@@ -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; }
}
}
+3
View File
@@ -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" />
+17
View File
@@ -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());
+12
View File
@@ -116,6 +116,7 @@ namespace PrivaPub.Services
PrivateKey = privateKey,
PublicKey = publicKey,
Kind = form.IsCommunity ? GroupKind.Community : GroupKind.Circle,
PostingPolicy = Policy(form.PostingPolicy) ?? PostingPolicy.Followers,
IsDiscoverable = form.IsDiscoverable,
ManuallyApprovesMembers = form.ManuallyApprovesMembers,
OwnerAvatarId = form.AvatarId,
@@ -157,6 +158,8 @@ namespace PrivaPub.Services
group.Description = form.Description;
if (form.IsDiscoverable.HasValue)
group.IsDiscoverable = form.IsDiscoverable.Value;
if (Policy(form.PostingPolicy) is { } policy)
group.PostingPolicy = policy;
if (form.ManuallyApprovesMembers.HasValue)
group.ManuallyApprovesMembers = form.ManuallyApprovesMembers.Value;
if (form.RemoveInvitationPassword)
@@ -316,6 +319,14 @@ namespace PrivaPub.Services
!string.IsNullOrEmpty(rootUserId) && !string.IsNullOrEmpty(avatarId)
&& await _dbEntities.RootToAvatars.Match(ra => ra.RootId == rootUserId && ra.AvatarId == avatarId).ExecuteAnyAsync(token);
static PostingPolicy? Policy(string value) => value?.ToLowerInvariant() switch
{
"anyone" => PostingPolicy.Anyone,
"moderators" => PostingPolicy.Moderators,
"followers" => PostingPolicy.Followers,
_ => (PostingPolicy?)null
};
static string NewInvitationCode() => $"{Guid.NewGuid():N}{Guid.NewGuid():N}";
ViewGroup ToView(GroupEntity group, string avatarId)
@@ -331,6 +342,7 @@ namespace PrivaPub.Services
Url = actor.Uri,
Handle = actor.Handle,
IsCommunity = group.Kind == GroupKind.Community,
PostingPolicy = group.PostingPolicy.ToString().ToLowerInvariant(),
IsDiscoverable = group.IsDiscoverable,
ManuallyApprovesMembers = group.ManuallyApprovesMembers,
IsOwner = group.OwnerAvatarId == avatarId,
+35 -1
View File
@@ -1,5 +1,6 @@
using Microsoft.Extensions.Localization;
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.ClientModels;
@@ -27,6 +28,7 @@ namespace PrivaPub.Services
Task<WebResult> InsertPost(string rootUserId, InsertPostForm form, CancellationToken token);
Task<WebResult> DeletePost(string rootUserId, DeletePostForm form, CancellationToken token);
Task<WebResult> UpdatePost(string rootUserId, UpdatePostForm form, CancellationToken token);
Task<WebResult> Nearby(string rootUserId, string avatarId, double latitude, double longitude, CancellationToken token);
Task<WebResult> GetPosts(string rootUserId, string avatarId, string groupId, CancellationToken token);
Task<WebResult> InsertDm(string rootUserId, InsertDmForm form, CancellationToken token);
Task<WebResult> GetDms(string rootUserId, string avatarId, string dmGroupId, CancellationToken token);
@@ -68,7 +70,10 @@ namespace PrivaPub.Services
Sensitive = form.HasContentWarning,
Visibility = LocalVisibility(form.Visibility),
InReplyTo = form.AnsweringToPostId,
GroupId = form.GroupId
GroupId = form.GroupId,
Latitude = form.Latitude,
Longitude = form.Longitude,
RangeKm = form.RangeKm
}, token), nameof(InsertPost));
}
@@ -193,6 +198,35 @@ namespace PrivaPub.Services
public async Task<WebResult> Nearby(string rootUserId, string avatarId, double latitude, double longitude, CancellationToken token)
{
var result = new WebResult();
var viewer = await OwnedAvatar(rootUserId, avatarId, token);
if (viewer == default)
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
if (latitude is < -90 or > 90 || longitude is < -180 or > 180)
return result.Invalidate(_localizer["Invalid model."]);
var query = new MongoDB.Bson.BsonDocument
{
["Visibility"] = (int)PostVisibility.LocalGeo,
["DeletedAt"] = MongoDB.Bson.BsonNull.Value
};
var candidates = await DB.Default.GeoNear<PostEntity>(new Coordinates2D(longitude, latitude), p => p.Distance, true, 50_000, default, default, query)
.Limit(200)
.ToListAsync(token);
var near = candidates.Where(p => p.Distance <= p.RangeKm * 1000).ToList();
var hidden = await Domain.Relationships.Hidden.AuthorsHiddenFrom(viewer.Id, near.Select(p => p.ActorURI), forNotifications: false, token);
result.Data = near.Where(p => !hidden.Contains(p.ActorURI)).Select(p =>
{
var view = ToView(p);
view.RangeKm = p.RangeKm;
view.DistanceKm = Math.Round(p.Distance.Value / 1000, 1);
return view;
}).ToList();
return result;
}
async Task<WebResult> Answer(Func<Task<StatusOutcome>> action, string operation)
{
var result = new WebResult();
+1 -1
View File
@@ -61,7 +61,7 @@ namespace PrivaPub.Web.Pages
public async Task<IActionResult> OnGetAsync(string user, CancellationToken token)
{
Actor = await _localActors.FindByUserName(user, token);
if (Actor is not { IsFederated: true } || Actor.Kind == LocalActorKind.Application)
if (Actor is not { IsFederated: true, IsCircle: false } || Actor.Kind == LocalActorKind.Application)
return NotFound();
if (WantsActivityJson())
return Redirect(Actor.Uri);
+3
View File
@@ -1,4 +1,7 @@
{
"Media": {
"Root": "/var/lib/privapub/media"
},
"MongoSettings": {
"Database": "PrivaPub",
"LogsDatabase": "logs",
+13
View File
@@ -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;
+2 -2
View File
@@ -7,8 +7,8 @@ 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
- [ ] P3 Social features
- [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)
- [x] P3 Social features: v1.5.0, deployed 2026-10-01; media, proxy, blocks, mutes, bookmarks, pins and reports verified by tests and locally (no upload on production yet)
- [ ] P4 Groups and privacy features
- [ ] P5 FEPs and polish