Compare commits

..
3 Commits
Author SHA1 Message Date
thepraandClaude Opus 5.5 e6729c77e7 P6 done: personas can be quoted, under the owner's default of anyone, automatically
Build / Build (push) Successful in 1m7s
Deploy / privapub.thepra.dev (push) Successful in 1m15s
- Our public and unlisted posts state interactionPolicy.canQuote. The policy comes from the post, then the persona
  (`source[quote_policy]`: public, followers or nobody; default public as the owner chose), and is always nobody for
  followers-only posts and DMs.
- QuoteRequests are answered with Accept{result} naming a parrot-licence at /peasants/{name}/parrot-licences/{id}
  (the route name the owner chose), or with Reject. Followers-only checks that the requester really follows.
- A licence is a QuoteAuthorization naming both posts; revoking it (POST /api/v1/statuses/:id/quotes/:quoting_id/revoke)
  marks it 410, sends Delete{licence} to the quoter and the persona's followers, and revokes our own copy of the quote.
- A quote that arrives with one of our licences is accepted only if that licence is ours, unrevoked and names exactly
  that quoting post. One persona quoting another gets a licence too.
- Mastodon API: quote_approval for our posts (automatic, followers, current_user), `quote_approval_policy` when posting,
  PUT /api/v1/statuses/:id/interaction_policy, `source.quote_policy`.

Checked live: GoToSocial still accepts our posts with the policy stated, and leaves likes, replies and boosts open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 19:53:13 +02:00
thepraandClaude Opus 5.5 f9658a8e7a P6: remote video and audio play through the proxy
Build / Build (push) Successful in 37s
Deploy / privapub.thepra.dev (push) Successful in 57s
- The media proxy streams ranged requests from the origin (passing the range on, never caching), downloads and caches
  whole files otherwise, and serves cached files with range support. A PeerTube video is never fetched whole for one
  viewer, and clients still never contact the remote host.
- PeerTube's fragmented MP4 files inside an HLS entry are read as variants, so HLS-only instances play too.
- A remote Video or Audio post becomes one playable Mastodon attachment: the best MP4 up to 720p that carries both
  sound and picture, with its poster and duration; the card is kept only when nothing is playable.
- nginx: /media/proxy/ with proxy_buffering off and a 600 s read timeout (applied on the box, with a backup).

Checked live: a GoToSocial image through the proxy answers 206 with exactly the asked range when streamed, 200 when
cached, and 206 with the right Content-Range from the cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 18:59:14 +02:00
thepraandClaude Opus 5.5 6f1ab0073c P6: quote posts, received and sent (FEP-044f and the older keys)
Build / Build (push) Successful in 35s
Deploy / privapub.thepra.dev (push) Successful in 54s
- Received quotes: read from `quote`, `quoteUrl`, `quoteUri`, `_misskey_quote` or a FEP-e232 Link tag; the quoted post
  is fetched once; a quoteAuthorization stamp is verified field by field on the quoted author's origin; a consent
  quote without a stamp is pending; an older-key quote of a public post is shown; Delete of a stamp revokes. Counts
  and a `quote` notification follow the accepted state. The `quote-inline` fallback survives sanitising and is removed
  from content when the real quote is shown.
- Personas quote through `quoted_status_id`: posts that state a quote policy get a QuoteRequest and stay pending until
  an Accept brings a stamp we can verify, then an Update adds quoteAuthorization; posts that state none are quoted
  the older way, without `quote`; another persona's posts cannot be quoted yet (we issue no stamps). Quoting posts
  are delivered to the quoted author too.
- Mastodon API: Status.quote (with the quoted status one level deep), quotes_count, quote_approval from the remote
  policy, GET /api/v1/statuses/:id/quotes, `quote` notifications, and api_versions.mastodon = 7.

Checked live: GoToSocial's author-only quote policy is respected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 18:53:28 +02:00
40 changed files with 1229 additions and 70 deletions

No files matched your search

+23
View File
@@ -48,6 +48,7 @@ actor-scoped route gets a name in the same spirit, agreed with the owner, and a
| Notes | `/peasants/{name}/scribbles/{id}` | | Notes | `/peasants/{name}/scribbles/{id}` |
| Activities | `/peasants/{name}/grunts/{id}` (`create-{postId}` resolves) | | Activities | `/peasants/{name}/grunts/{id}` (`create-{postId}` resolves) |
| DM context | `/peasants/{name}/whispers/{id}` | | DM context | `/peasants/{name}/whispers/{id}` |
| Quote permission (FEP-044f `QuoteAuthorization`) | `/peasants/{name}/parrot-licences/{id}` |
| Token refresh | `/clientapi/user/sniff/again` | | Token refresh | `/clientapi/user/sniff/again` |
Agreed for later phases: `/gossip`, `/drool`, `/echoes` (replies, likes, shares), `/trophies` (featured), `/tattoos` Agreed for later phases: `/gossip`, `/drool`, `/echoes` (replies, likes, shares), `/trophies` (featured), `/tattoos`
@@ -212,6 +213,16 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
sibling `media-proxy`, which `/media/files` does not serve. 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 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`. `IMediaProxy.Wrap`, an HMAC-signed `/media/proxy/` URL fetched by `IFederationHttp.GetMedia`.
4. **The proxy serves three ways:**
- **Cached:** a file already cached is served from disk, ranges included.
- **Downloaded:** a request without a `Range` is downloaded whole, up to `Media:MaxProxiedBytes`, then cached.
- **Streamed:** a ranged request, or anything too big to cache, is streamed from the origin with the range passed on,
and never cached. That is how remote video plays.
nginx has a `/media/proxy/` location with `proxy_buffering off` and a 600 s read timeout for those streams.
5. **Remote video and audio become one playable attachment** in the Mastodon API (`MastodonMapper.Playable`): the best MP4
up to 720p that carries both sound and picture, including PeerTube's fragmented files inside an HLS entry. HLS
playlists themselves are not rewritten.
## Privacy invariants ## Privacy invariants
@@ -261,6 +272,18 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
- **Link previews follow owner decision 1** (`Domain/Content/LinkPreviews.cs`): only public posts, queued on arrival with - **Link previews follow owner decision 1** (`Domain/Content/LinkPreviews.cs`): only public posts, queued on arrival with
0–60 s of jitter, one cached `LinkPreview` per address for the whole server, never fetched when someone reads. 0–60 s of jitter, one cached `LinkPreview` per address for the whole server, never fetched when someone reads.
`LinkPreviews.Wanted` is called where posts are saved (`CreateHandler`, `StoreContext`, `StatusService.Publish`). `LinkPreviews.Wanted` is called where posts are saved (`CreateHandler`, `StoreContext`, `StatusService.Publish`).
- **Quote states come from `QuoteService.Resolve`:**
- with a stamp: accepted only if `Verified` (fetched, on the quoted author's origin, naming both posts exactly);
- a FEP-044f `quote` without a stamp: pending;
- older keys only: accepted when the quoted post is public.
`QuotesCount` moves with the accepted state, never with the raw key. When a persona quotes, `QuotePermission` decides:
asking first for posts that state a policy, quoting at once for posts that state none, refusing otherwise. Only `quote`
of a post that asks for consent puts `quote` in our JSON; older-key quotes leave it out, as Sharkey learned they must.
- **Our quote policy is `ActivityPubRenderer.QuotableBy`:** the post's `LocalQuotePolicy`, falling back to the persona's
`Settings.QuotePolicy` (default `public`, owner decision), and always `nobody` for anything but public and unlisted. The
same rule writes `canQuote`, answers `QuoteRequest`s (`QuoteService.ReceiveRequest`) and fills `quote_approval`, so they
cannot disagree. A persona quoting another persona gets a parrot-licence too, so other servers see an approved quote.
- **A server is described on arrival, never on read.** The first record from a host enqueues `DescribeInstance` (its - **A server is described on arrival, never on read.** The first record from a host enqueues `DescribeInstance` (its
NodeInfo, at most once a week, into `RemoteInstance`), so opening the details view tells nobody anything. NodeInfo, at most once a week, into `RemoteInstance`), so opening the details view tells nobody anything.
- **Post ids are the timeline order, so they follow arrival, not `published`.** `PrivacyIds.Arrived` gives a remote post - **Post ids are the timeline order, so they follow arrival, not `published`.** `PrivacyIds.Arrived` gives a remote post
+24 -1
View File
@@ -50,6 +50,7 @@ actor document, a collection, NodeInfo or a delivery relates two avatars of the
| Followers / following | `/peasants/{name}/groupies`, `/peasants/{name}/stalking` | | Followers / following | `/peasants/{name}/groupies`, `/peasants/{name}/stalking` |
| Objects | `/peasants/{name}/scribbles/{id}` | | Objects | `/peasants/{name}/scribbles/{id}` |
| Activities | `/peasants/{name}/grunts/{id}` | | Activities | `/peasants/{name}/grunts/{id}` |
| Quote permissions (`QuoteAuthorization`) | `/peasants/{name}/parrot-licences/{id}` |
| Direct-message context | `/peasants/{name}/whispers/{id}` | | Direct-message context | `/peasants/{name}/whispers/{id}` |
| Profile and post pages | `/@{name}`, `/@{name}/{id}` | | Profile and post pages | `/@{name}`, `/@{name}/{id}` |
@@ -117,6 +118,27 @@ content, one per choice, counted once per voter. Counts are refreshed with an `U
minutes. Our own votes on other servers' polls are sent the same way to the poll's author only, without `published`. minutes. Our own votes on other servers' polls are sent the same way to the poll's author only, without `published`.
An incoming `Update` without a newer `updated` only refreshes counts and details; it is never recorded as an edit. An incoming `Update` without a newer `updated` only refreshes counts and details; it is never recorded as an edit.
**Quotes** (FEP-044f, with the older keys):
- **Received.** A quote is read from `quote`, `quoteUrl`, `quoteUri`, `_misskey_quote` or a FEP-e232 `Link` tag.
- With a `quoteAuthorization`, the stamp is fetched and must be a `QuoteAuthorization` on the quoted author's origin,
attributed to them, naming exactly the two posts.
- A FEP-044f `quote` without a stamp is pending until an `Update` brings one.
- A quote made with the older keys only, by software that asks nobody, is shown when the quoted post is public.
- A `Delete` of a stamp revokes the quote.
- **Sent.** Our accounts quote with every key, plus the `RE:` fallback.
- When the quoted post states an `interactionPolicy.canQuote` that allows us, `quote` is included and a `QuoteRequest`
(with the quoting post as `instrument`) goes to the quoted author. The quote stays pending until their `Accept` brings a
stamp we can verify; the post is then updated with `quoteAuthorization`.
- Posts that state no policy are quoted the older way, without `quote`.
- Quoting posts are also delivered to the quoted author.
- **Quoting our accounts.** Our public and unlisted posts state `interactionPolicy.canQuote`. By default anyone may quote,
automatically (as on Mastodon); an account can choose followers only or nobody, and so can each post.
- A `QuoteRequest` is answered with `Accept{result}` naming a stamp at `/peasants/{name}/parrot-licences/{id}`, or
with `Reject`.
- A stamp is a `QuoteAuthorization` naming both posts. A revoked stamp answers 410 and is announced with
`Delete{stamp}`.
- Followers-only posts and DMs can never be quoted.
**Custom emoji** (`Emoji` tags) are read on posts, display names, bios and profile fields, at most 64 per object. **Custom emoji** (`Emoji` tags) are read on posts, display names, bios and profile fields, at most 64 per object.
**Profiles** keep their header, profile fields, `manuallyApprovesFollowers`, `published`, `movedTo`, `indexable`, **Profiles** keep their header, profile fields, `manuallyApprovesFollowers`, `published`, `movedTo`, `indexable`,
`memorial` and avatar and header descriptions. A post's title becomes `name` and is also the first, bold line of `content`, because Mastodon does not show `memorial` and avatar and header descriptions. A post's title becomes `name` and is also the first, bold line of `content`, because Mastodon does not show
@@ -166,6 +188,7 @@ Posts with a location (shown to nearby users of this server) never leave the ser
## Known limitations ## Known limitations
- Our own posts carry no custom emoji: emoji are received and shown, not offered. - Our own posts carry no custom emoji: emoji are received and shown, not offered.
- Remote media is fetched through this server's proxy when a local client displays it. - Remote media is fetched through this server's proxy when a local client displays it. Video and audio are streamed
through it with byte ranges passed on, so a PeerTube file is never fetched whole for one viewer.
- Collections expose counts, not members. - Collections expose counts, not members.
- Only `rsa-sha256`-style keys are verified. RFC 9421 signatures are planned. - Only `rsa-sha256`-style keys are verified. RFC 9421 signatures are planned.
@@ -52,13 +52,15 @@ namespace PrivaPub.Tests.Federation
var db = new DbEntities(); var db = new DbEntities();
_blocks = new DomainBlocks(NullLogger<DomainBlocks>.Instance); _blocks = new DomainBlocks(NullLogger<DomainBlocks>.Instance);
_receiver = new InboxReceiver(_local, remote, queue, _blocks, NullLogger<InboxReceiver>.Instance); _receiver = new InboxReceiver(_local, remote, queue, _blocks, NullLogger<InboxReceiver>.Instance);
var remotePosts = new RemotePosts(db, _local, remote, _blocks, queue, new ObjectRecords(queue), new NoPreviews());
var quotes = new QuoteService(db, remote, remotePosts, _local, delivery, new OutboxPublisher(db, _local, delivery));
_processor = new InboxProcessor(remote, new IActivityHandler[] _processor = new InboxProcessor(remote, new IActivityHandler[]
{ {
new FollowHandler(db, _local, remote, delivery), new FollowHandler(db, _local, remote, delivery),
new UndoHandler(db, _local, new Reactions(db, delivery)), new UndoHandler(db, _local, new Reactions(db, delivery)),
new CreateHandler(db, _local, remote, delivery, _blocks, new Fanout(db), new RemotePosts(db, _local, remote, _blocks, queue, new ObjectRecords(queue), new NoPreviews()), new GroupDistributor(delivery), new ObjectRecords(queue), new PollService(db, _local, delivery, queue), new NoPreviews()), new CreateHandler(db, _local, remote, delivery, _blocks, new Fanout(db), remotePosts, new GroupDistributor(delivery), new ObjectRecords(queue), new PollService(db, _local, delivery, queue), new NoPreviews(), quotes),
new DeleteHandler(db, _local, remote, delivery, new GroupDistributor(delivery)), new DeleteHandler(db, _local, remote, delivery, new GroupDistributor(delivery), quotes),
new UpdateHandler(db, _local, remote, new GroupDistributor(delivery), new ObjectRecords(queue)) new UpdateHandler(db, _local, remote, new GroupDistributor(delivery), new ObjectRecords(queue), quotes)
}, NullLogger<InboxProcessor>.Instance); }, NullLogger<InboxProcessor>.Instance);
} }
@@ -214,6 +214,30 @@ namespace PrivaPub.Tests.Federation
Assert.Equal(new DateTime(2026, 9, 30, 12, 0, 0, DateTimeKind.Utc), closed.Poll.ClosedAt); Assert.Equal(new DateTime(2026, 9, 30, 12, 0, 0, DateTimeKind.Utc), closed.Poll.ClosedAt);
} }
[Fact]
public void Finds_playable_files_inside_an_hls_only_peertube_video_and_picks_one_with_sound()
{
var note = Parse("""
{
"id": "https://tube.example/videos/watch/2", "type": "Video", "name": "HLS only", "attributedTo": "https://tube.example/accounts/ann",
"url": [
{ "type": "Link", "mediaType": "text/html", "href": "https://tube.example/w/def" },
{ "type": "Link", "mediaType": "application/x-mpegURL", "href": "https://tube.example/master.m3u8", "tag": [
{ "type": "Link", "mediaType": "video/mp4", "href": "https://tube.example/1080-fragmented.mp4", "height": 1080,
"attachment": [{ "type": "PropertyValue", "name": "ffprobe_codec_type", "value": "video" }, { "type": "PropertyValue", "name": "ffprobe_codec_type", "value": "audio" }] },
{ "type": "Link", "mediaType": "video/mp4", "href": "https://tube.example/720-fragmented.mp4", "height": 720,
"attachment": [{ "type": "PropertyValue", "name": "ffprobe_codec_type", "value": "video" }] },
{ "type": "Link", "mediaType": "video/mp4", "href": "https://tube.example/480-fragmented.mp4", "height": 480,
"attachment": [{ "type": "PropertyValue", "name": "ffprobe_codec_type", "value": "video" }, { "type": "PropertyValue", "name": "ffprobe_codec_type", "value": "audio" }] }
] }
]
}
""");
Assert.Equal(3, note.Video.Variants.Count);
Assert.Equal("https://tube.example/480-fragmented.mp4", MastodonMapper.BestVideo(note.Video.Variants).Url);
}
[Fact] [Fact]
public void Normalises_hashtags_like_mastodon() => public void Normalises_hashtags_like_mastodon() =>
Assert.Equal("fedi".Normalize(System.Text.NormalizationForm.FormKC).ToLowerInvariant(), Assert.Single(Parse(""" Assert.Equal("fedi".Normalize(System.Text.NormalizationForm.FormKC).ToLowerInvariant(), Assert.Single(Parse("""
+221
View File
@@ -0,0 +1,221 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Post;
using PrivaPub.Domain.Statuses;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Post;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
[Trait("Category", "Integration")]
public sealed class QuoteTests : 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);
static string NewId(RemoteActor actor, string kind) => $"{Origin(actor)}/{kind}/{Guid.NewGuid():N}";
async Task<Post> Delivered(RemoteActor author, string mentioned, Action<JsonObject> shape = default)
{
var note = new JsonObject
{
["id"] = NewId(author, "notes"), ["type"] = "Note", ["attributedTo"] = author.Id, ["content"] = "<p>hello</p>",
["to"] = new JsonArray(Addressing.Public), ["cc"] = new JsonArray(mentioned),
["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = mentioned, ["name"] = "@someone" })
};
shape?.Invoke(note);
await _harness.Deliver(author, "/human-centipede", new JsonObject { ["id"] = NewId(author, "activities"), ["type"] = "Create", ["actor"] = author.Id, ["object"] = note });
return await DB.Default.Find<Post>().Match(p => p.ObjectURI == note["id"]!.GetValue<string>()).ExecuteFirstAsync(TestContext.Current.CancellationToken);
}
string Stamp(RemoteActor quotedAuthor, string quoting, string quoted)
{
var path = $"/stamps/{Guid.NewGuid():N}";
var id = Origin(quotedAuthor) + path;
_harness.Peer.Serve(path, new JsonObject
{
["@context"] = "https://www.w3.org/ns/activitystreams", ["id"] = id, ["type"] = "QuoteAuthorization",
["attributedTo"] = quotedAuthor.Id, ["interactingObject"] = quoting, ["interactionTarget"] = quoted
}.ToJsonString());
return id;
}
[Fact]
public async Task A_misskey_quote_of_a_public_post_is_shown_and_counted()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var ann = new RemoteActor(_harness.Peer, "ann");
var kitty = new RemoteActor(_harness.Peer, "kitty");
var original = await Delivered(ann, alice.Uri);
var quoting = await Delivered(kitty, alice.Uri, n =>
{
n["_misskey_quote"] = original.ObjectURI;
n["content"] = $"<p>look<span class=\"quote-inline\"><br>RE: <a href=\"{original.ObjectURI}\">{original.ObjectURI}</a></span></p>";
});
Assert.Equal(QuoteState.Accepted, quoting.QuoteState);
Assert.Equal(original.ID, quoting.QuotedPostId);
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(original.ID, token)).QuotesCount);
var status = await new PrivaPub.Api.Mastodon.Mappers.MastodonMapper(_harness.Db, _harness.Local).Status(quoting, alice.Id, token);
Assert.Equal("accepted", status.Quote.State);
Assert.Equal(original.ID, status.Quote.QuotedStatus.Id);
Assert.DoesNotContain("RE:", status.Content);
}
[Fact]
public async Task A_stamped_quote_is_accepted_a_forged_one_is_not_and_revoking_takes_it_back()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var ann = new RemoteActor(_harness.Peer, "ann");
var bob = new RemoteActor(_harness.Peer, "bob");
var original = await Delivered(ann, alice.Uri);
var quotingId = NewId(bob, "notes");
var stamp = Stamp(ann, quotingId, original.ObjectURI);
var forged = Stamp(ann, NewId(bob, "notes"), original.ObjectURI);
var quoting = await Delivered(bob, alice.Uri, n => { n["id"] = quotingId; n["quote"] = original.ObjectURI; n["quoteAuthorization"] = stamp; });
var cheat = await Delivered(bob, alice.Uri, n => { n["quote"] = original.ObjectURI; n["quoteAuthorization"] = forged; });
var waiting = await Delivered(bob, alice.Uri, n => n["quote"] = original.ObjectURI);
Assert.Equal(QuoteState.Accepted, quoting.QuoteState);
Assert.Equal(QuoteState.Unauthorized, cheat.QuoteState);
Assert.Equal(QuoteState.Pending, waiting.QuoteState);
await _harness.Deliver(ann, "/human-centipede", new JsonObject
{
["id"] = NewId(ann, "activities"), ["type"] = "Delete", ["actor"] = ann.Id, ["object"] = stamp
});
Assert.Equal(QuoteState.Revoked, (await DB.Default.Find<Post>().OneAsync(quoting.ID, token)).QuoteState);
Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(original.ID, token)).QuotesCount);
}
[Fact]
public async Task A_persona_asks_before_quoting_and_is_stamped_when_accepted()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var ann = new RemoteActor(_harness.Peer, "ann");
var original = await Delivered(ann, alice.Uri, n => n["interactionPolicy"] = new JsonObject
{
["canQuote"] = new JsonObject { ["automaticApproval"] = new JsonArray(Addressing.Public) }
});
var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "this", QuotedStatusId = original.ID }, token);
Assert.Equal(QuoteState.Pending, outcome.Post.QuoteState);
var request = Assert.Single(await _harness.Outgoing(ann.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "QuoteRequest");
Assert.Equal(original.ObjectURI, request["object"]!.GetValue<string>());
Assert.Equal(original.ObjectURI, request["instrument"]!["quote"]!.GetValue<string>());
await _harness.Deliver(ann, "/human-centipede", new JsonObject
{
["id"] = NewId(ann, "activities"), ["type"] = "Accept", ["actor"] = ann.Id,
["object"] = request["id"]!.GetValue<string>(), ["result"] = Stamp(ann, outcome.Post.ObjectURI, original.ObjectURI)
});
var quoting = await DB.Default.Find<Post>().OneAsync(outcome.Post.ID, token);
Assert.Equal(QuoteState.Accepted, quoting.QuoteState);
Assert.NotNull(quoting.QuoteAuthorizationURI);
Assert.Contains(await _harness.Outgoing(ann.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "Update"
&& a["object"]!["quoteAuthorization"]?.GetValue<string>() == quoting.QuoteAuthorizationURI);
}
[Fact]
public async Task A_legacy_post_is_quoted_without_asking_and_a_persona_that_allows_nobody_cannot_be_quoted()
{
var token = TestContext.Current.CancellationToken;
var (aliceRoot, alice) = await _harness.Persona("alice");
var (bobRoot, bob) = await _harness.Persona("bob");
var kitty = new RemoteActor(_harness.Peer, "kitty");
var legacy = await Delivered(kitty, alice.Uri);
bob.Settings.QuotePolicy = PrivaPub.Models.User.QuotePolicies.Nobody;
var bobs = (await _harness.Statuses.Publish(bob, new StatusDraft { Text = "mine" }, token)).Post;
var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "nice", QuotedStatusId = legacy.ID }, token);
Assert.Equal(QuoteState.Accepted, outcome.Post.QuoteState);
Assert.False(outcome.Post.QuoteByConsent);
Assert.False((await _harness.Statuses.Publish(alice, new StatusDraft { Text = "and this", QuotedStatusId = bobs.ID }, token)).Ok);
Assert.True((await _harness.Statuses.Publish(bob, new StatusDraft { Text = "me again", QuotedStatusId = bobs.ID }, token)).Ok);
}
JsonObject Request(RemoteActor quoter, string quotedUri, string quotingId) => new()
{
["id"] = NewId(quoter, "activities"), ["type"] = "QuoteRequest", ["actor"] = quoter.Id, ["object"] = quotedUri,
["instrument"] = new JsonObject { ["id"] = quotingId, ["type"] = "Note", ["attributedTo"] = quoter.Id, ["quote"] = quotedUri, ["content"] = "<p>so true</p>" }
};
[Fact]
public async Task Anyone_may_quote_a_persona_by_default_and_the_licence_can_be_revoked()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var mallory = new RemoteActor(_harness.Peer, "mallory");
await _harness.Deliver(mallory, "/human-centipede", new JsonObject { ["id"] = NewId(mallory, "follows"), ["type"] = "Follow", ["actor"] = mallory.Id, ["object"] = alice.Uri });
var original = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "quote me" }, token)).Post;
var created = (await _harness.Outgoing(mallory.Id + "/inbox")).Single(a => a["type"]!.GetValue<string>() == "Create")["object"]!;
Assert.Equal(Addressing.Public, created["interactionPolicy"]!["canQuote"]!["automaticApproval"]![0]!.GetValue<string>());
var quotingId = NewId(mallory, "notes");
await _harness.Deliver(mallory, "/human-centipede", Request(mallory, original.ObjectURI, quotingId));
var accept = Assert.Single(await _harness.Outgoing(mallory.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "Accept" && a["result"] != null);
var licence = accept["result"]!.GetValue<string>();
Assert.StartsWith(alice.Uri + "/parrot-licences/", licence);
var quoting = await Delivered(mallory, alice.Uri, n => { n["id"] = quotingId; n["quote"] = original.ObjectURI; n["quoteAuthorization"] = licence; });
Assert.Equal(QuoteState.Accepted, quoting.QuoteState);
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(original.ID, token)).QuotesCount);
Assert.True(await _harness.Quotes.RevokeLicence(original, quoting, token));
Assert.Equal(QuoteState.Revoked, (await DB.Default.Find<Post>().OneAsync(quoting.ID, token)).QuoteState);
Assert.Contains(await _harness.Outgoing(mallory.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "Delete" && a["object"]!.GetValue<string>() == licence);
}
[Fact]
public async Task A_followers_only_persona_turns_a_stranger_down()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
alice.Settings.QuotePolicy = PrivaPub.Models.User.QuotePolicies.Followers;
var original = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "friends only quoting" }, token)).Post;
var stranger = new RemoteActor(_harness.Peer, "stranger");
await _harness.Deliver(stranger, "/human-centipede", Request(stranger, original.ObjectURI, NewId(stranger, "notes")));
Assert.Equal("Reject", Assert.Single(await _harness.Outgoing(stranger.Id + "/inbox"))["type"]!.GetValue<string>());
}
[Fact]
public async Task One_persona_quotes_another_with_a_licence()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var (_, bob) = await _harness.Persona("bob");
var original = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "public thought" }, token)).Post;
var outcome = await _harness.Statuses.Publish(bob, new StatusDraft { Text = "agreed", QuotedStatusId = original.ID }, token);
Assert.True(outcome.Ok);
Assert.Equal(QuoteState.Accepted, outcome.Post.QuoteState);
Assert.StartsWith(alice.Uri + "/parrot-licences/", outcome.Post.QuoteAuthorizationURI);
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(original.ID, token)).QuotesCount);
}
}
}
+10 -7
View File
@@ -47,29 +47,31 @@ namespace PrivaPub.Tests.Support
Polls = new PollService(Db, Local, Delivery, Queue); Polls = new PollService(Db, Local, Delivery, Queue);
Reactions = new Reactions(Db, Delivery); Reactions = new Reactions(Db, Delivery);
RemotePosts = new RemotePosts(Db, Local, Remote, new NoBlocks(), Queue, Records, new NoPreviews()); RemotePosts = new RemotePosts(Db, Local, Remote, new NoBlocks(), Queue, Records, new NoPreviews());
Outbox = new OutboxPublisher(Db, Local, Delivery);
Quotes = new QuoteService(Db, Remote, RemotePosts, Local, Delivery, Outbox);
Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger<InboxReceiver>.Instance); Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger<InboxReceiver>.Instance);
Processor = new InboxProcessor(Remote, new IActivityHandler[] Processor = new InboxProcessor(Remote, new IActivityHandler[]
{ {
new FollowHandler(Db, Local, Remote, Delivery), new FollowHandler(Db, Local, Remote, Delivery),
new AcceptHandler(Db, Local), new AcceptHandler(Db, Local, Quotes),
new RejectHandler(Db, Local), new RejectHandler(Db, Local, Quotes),
new UndoHandler(Db, Local, Reactions), new UndoHandler(Db, Local, Reactions),
new LikeHandler(Db, Reactions), new LikeHandler(Db, Reactions),
new EmojiReactHandler(Db, Reactions), new EmojiReactHandler(Db, Reactions),
new QuoteRequestHandler(Quotes),
new DislikeHandler(Db), new DislikeHandler(Db),
new JoinHandler(Db, Local, Delivery), new JoinHandler(Db, Local, Delivery),
new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote), new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote),
new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts, Groups, Records, Polls, new NoPreviews()), new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts, Groups, Records, Polls, new NoPreviews(), Quotes),
new DeleteHandler(Db, Local, Remote, Delivery, Groups), new DeleteHandler(Db, Local, Remote, Delivery, Groups, Quotes),
new UpdateHandler(Db, Local, Remote, Groups, Records), new UpdateHandler(Db, Local, Remote, Groups, Records, Quotes),
new FlagHandler(Db, Local) new FlagHandler(Db, Local)
}, NullLogger<InboxProcessor>.Instance); }, NullLogger<InboxProcessor>.Instance);
Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance); Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance);
Content = new ContentRenderer(Local, Remote); Content = new ContentRenderer(Local, Remote);
Outbox = new OutboxPublisher(Db, Local, Delivery);
Media = new MediaService(new StaticOptions<MediaOptions>(new MediaOptions { Root = Path.Combine(Path.GetTempPath(), $"privapub-media-{Guid.NewGuid():N}") }), Media = new MediaService(new StaticOptions<MediaOptions>(new MediaOptions { Root = Path.Combine(Path.GetTempPath(), $"privapub-media-{Guid.NewGuid():N}") }),
Local, default, NullLogger<MediaService>.Instance); Local, default, NullLogger<MediaService>.Instance);
Statuses = new StatusService(Db, Local, Remote, Delivery, Content, Outbox, Fanout, Media, Groups, Polls, new NoPreviews()); Statuses = new StatusService(Db, Local, Remote, Delivery, Content, Outbox, Fanout, Media, Groups, Polls, new NoPreviews(), Quotes);
Posts = new PostsService(Db, Local, Statuses, new KeyLocalizer<GenericRes>(), NullLogger<PostsService>.Instance); Posts = new PostsService(Db, Local, Statuses, new KeyLocalizer<GenericRes>(), NullLogger<PostsService>.Instance);
Timelines = new TimelineService(Db, new KeyLocalizer<GenericRes>()); Timelines = new TimelineService(Db, new KeyLocalizer<GenericRes>());
Relationships = new RelationshipService(Db, Follows, Delivery); Relationships = new RelationshipService(Db, Follows, Delivery);
@@ -96,6 +98,7 @@ namespace PrivaPub.Tests.Support
public ObjectRecords Records { get; } public ObjectRecords Records { get; }
public PollService Polls { get; } public PollService Polls { get; }
public Reactions Reactions { get; } public Reactions Reactions { get; }
public QuoteService Quotes { get; }
public TimelineService Timelines { get; } public TimelineService Timelines { get; }
public RelationshipService Relationships { get; } public RelationshipService Relationships { get; }
public ReportService Reports { get; } public ReportService Reports { get; }
@@ -79,6 +79,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
avatar.Settings.DefaultSensitive = sensitive; avatar.Settings.DefaultSensitive = sensitive;
if (Params.Has("source[language]")) if (Params.Has("source[language]"))
avatar.Settings.DefaultLanguage = Params.Get("source[language]"); avatar.Settings.DefaultLanguage = Params.Get("source[language]");
if (Params.Get("source[quote_policy]") is { } quotePolicy && QuotePolicies.IsKnown(quotePolicy))
avatar.Settings.QuotePolicy = quotePolicy;
var fields = new Dictionary<string, string>(); var fields = new Dictionary<string, string>();
for (var i = 0; i < 4; i++) for (var i = 0; i < 4; i++)
{ {
@@ -73,6 +73,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
domain = Domain, domain = Domain,
title = "PrivaPub", title = "PrivaPub",
version = Version, version = Version,
api_versions = new { mastodon = 7 },
source_url = "https://git.thepra.dev/thepra/SocialPub", source_url = "https://git.thepra.dev/thepra/SocialPub",
description = Description, description = Description,
usage = new { users = new { active_month = 0 } }, usage = new { users = new { active_month = 0 } },
@@ -60,13 +60,38 @@ namespace PrivaPub.Api.Mastodon.Controllers
[HttpGet("/media/proxy/{signature}/{encoded}"), AllowAnonymous, ApiExplorerSettings(IgnoreApi = true)] [HttpGet("/media/proxy/{signature}/{encoded}"), AllowAnonymous, ApiExplorerSettings(IgnoreApi = true)]
public async Task<IActionResult> Proxy(string signature, string encoded, CancellationToken token) public async Task<IActionResult> Proxy(string signature, string encoded, CancellationToken token)
{ {
var (path, contentType) = await _proxy.Fetch(signature, encoded, token); var url = _proxy.Verified(signature, encoded);
if (path == default) if (url == default)
return NotFound(); return NotFound();
Response.Headers["X-Content-Type-Options"] = "nosniff"; Response.Headers["X-Content-Type-Options"] = "nosniff";
Response.Headers["Content-Security-Policy"] = "default-src 'none'; sandbox"; Response.Headers["Content-Security-Policy"] = "default-src 'none'; sandbox";
Response.Headers["Cache-Control"] = "public, max-age=604800"; Response.Headers["Cache-Control"] = "public, max-age=604800";
return PhysicalFile(path, contentType); if (_proxy.Cached(url) is { Path: not null } cached)
return PhysicalFile(cached.Path, cached.ContentType, enableRangeProcessing: true);
if (Request.Headers.Range.Count == 0)
{
var (path, contentType) = await _proxy.Fetch(signature, encoded, token);
if (path != default)
return PhysicalFile(path, contentType, enableRangeProcessing: true);
}
return await Stream(url, token);
}
async Task<IActionResult> Stream(string url, CancellationToken token)
{
var range = System.Net.Http.Headers.RangeHeaderValue.TryParse(Request.Headers.Range.ToString(), out var asked) ? asked : default;
using var upstream = await _proxy.Open(url, range, token);
if (upstream == default)
return NotFound();
Response.StatusCode = (int)upstream.StatusCode;
Response.ContentType = upstream.Content.Headers.ContentType?.ToString() ?? "application/octet-stream";
if (upstream.Content.Headers.ContentLength is { } length)
Response.ContentLength = length;
if (upstream.Content.Headers.ContentRange is { } contentRange)
Response.Headers.ContentRange = contentRange.ToString();
Response.Headers.AcceptRanges = "bytes";
await upstream.Content.CopyToAsync(Response.Body, token);
return new EmptyResult();
} }
object View(MediaAttachment attachment) => new object View(MediaAttachment attachment) => new
@@ -9,6 +9,9 @@ using PrivaPub.Api.Mastodon.Mappers;
using PrivaPub.Domain.Privacy; using PrivaPub.Domain.Privacy;
using PrivaPub.Domain.Statuses; using PrivaPub.Domain.Statuses;
using PrivaPub.Models.Post; using PrivaPub.Models.Post;
using PrivaPub.Models.User;
using PrivaPub.Federation.Rendering;
using PrivaPub.Federation.Outbox;
using PrivaPub.StaticServices; using PrivaPub.StaticServices;
using PostEntity = PrivaPub.Models.Post.Post; using PostEntity = PrivaPub.Models.Post.Post;
@@ -24,9 +27,14 @@ namespace PrivaPub.Api.Mastodon.Controllers
readonly MastodonMapper _mapper; readonly MastodonMapper _mapper;
readonly DbEntities _dbEntities; readonly DbEntities _dbEntities;
readonly IMemoryCache _cache; readonly IMemoryCache _cache;
readonly IQuoteService _quotes;
readonly IOutboxPublisher _outbox;
public StatusesController(IStatusService statuses, MastodonMapper mapper, DbEntities dbEntities, IMemoryCache cache) public StatusesController(IStatusService statuses, MastodonMapper mapper, DbEntities dbEntities, IMemoryCache cache, IQuoteService quotes,
IOutboxPublisher outbox)
{ {
_quotes = quotes;
_outbox = outbox;
_statuses = statuses; _statuses = statuses;
_mapper = mapper; _mapper = mapper;
_dbEntities = dbEntities; _dbEntities = dbEntities;
@@ -54,6 +62,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
InReplyTo = Params.Get("in_reply_to_id"), 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"), MediaIds = Params.List("media_ids"),
QuotedStatusId = Params.Get("quoted_status_id"),
QuotePolicy = Params.Get("quote_approval_policy"),
Poll = Params.Has("poll[options]") Poll = Params.Has("poll[options]")
? new PollDraft(Params.List("poll[options]"), Params.Int("poll[expires_in]") ?? 0, Params.Bool("poll[multiple]") == true, ? new PollDraft(Params.List("poll[options]"), Params.Int("poll[expires_in]") ?? 0, Params.Bool("poll[multiple]") == true,
Params.Bool("poll[hide_totals]") == true) Params.Bool("poll[hide_totals]") == true)
@@ -226,6 +236,47 @@ namespace PrivaPub.Api.Mastodon.Controllers
return Json(reblogs.Select(r => accounts.GetValueOrDefault(MastodonMapper.AuthorOf(r))).Where(a => a != default).ToList()); return Json(reblogs.Select(r => accounts.GetValueOrDefault(MastodonMapper.AuthorOf(r))).Where(a => a != default).ToList());
} }
[HttpGet("/api/v1/statuses/{id}/quotes"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Quotes(string id, CancellationToken token)
{
if (await Visible(id, token) == default)
return NotFoundError();
var query = _dbEntities.Posts.Match(p => p.QuotedPostId == id && p.QuoteState == QuoteState.Accepted && !p.DeletedAt.HasValue);
var quotes = await Page.From(Params, Limit()).Fetch(query, p => p.ID, token);
var visible = new List<PostEntity>();
foreach (var quote in quotes)
if (quote.Visibility != PostVisibility.LocalGeo && await VisibilityPolicy.CanSee(quote, MyId, token))
visible.Add(quote);
Link($"/api/v1/statuses/{id}/quotes", quotes.LastOrDefault()?.ID, quotes.FirstOrDefault()?.ID);
return Json(await _mapper.Statuses(visible, MyId, token));
}
[HttpPost("/api/v1/statuses/{id}/quotes/{quotingId}/revoke"), Scope("write:statuses")]
public async Task<IActionResult> RevokeQuote(string id, string quotingId, CancellationToken token)
{
var quoted = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
var quoting = quoted == default ? default : await _dbEntities.Posts.Match(p => p.ID == quotingId && p.QuotedPostId == quoted.ID).ExecuteFirstAsync(token);
if (quoting == default || !await _quotes.RevokeLicence(quoted, quoting, token))
return NotFoundError();
return Json(await _mapper.Status(await _dbEntities.Posts.MatchID(quoting.ID).ExecuteFirstAsync(token), MyId, token));
}
[HttpPut("/api/v1/statuses/{id}/interaction_policy"), Scope("write:statuses")]
public async Task<IActionResult> InteractionPolicy(string id, CancellationToken token)
{
var post = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
if (post == default)
return NotFoundError();
var policy = Params.Get("quote_approval_policy");
if (!QuotePolicies.IsKnown(policy))
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Unknown quote approval policy");
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(p => p.LocalQuotePolicy, policy).ExecuteAsync(token);
post.LocalQuotePolicy = policy;
if (!post.IsLocalOnly)
await _outbox.Publish(Me, post, ActivityPubRenderer.UpdateOf(post, Me, $"policy-{DateTime.UtcNow.Ticks}"), token);
return Json(await _mapper.Status(post, MyId, token));
}
[HttpPost("/api/v1/statuses/{id}/bookmark"), Scope("write:bookmarks")] [HttpPost("/api/v1/statuses/{id}/bookmark"), Scope("write:bookmarks")]
public async Task<IActionResult> Bookmark(string id, CancellationToken token) public async Task<IActionResult> Bookmark(string id, CancellationToken token)
{ {
@@ -164,7 +164,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
[NotificationType.Reblog] = "reblog", [NotificationType.Reblog] = "reblog",
[NotificationType.Update] = "update", [NotificationType.Update] = "update",
[NotificationType.Poll] = "poll", [NotificationType.Poll] = "poll",
[NotificationType.Reaction] = "pleroma:emoji_reaction" [NotificationType.Reaction] = "pleroma:emoji_reaction",
[NotificationType.Quote] = "quote"
}; };
readonly MastodonMapper _mapper; readonly MastodonMapper _mapper;
@@ -59,6 +59,7 @@ namespace PrivaPub.Api.Mastodon.Entities
public List<Field> Fields { get; set; } = new(); public List<Field> Fields { get; set; } = new();
public int FollowRequestsCount { get; set; } public int FollowRequestsCount { get; set; }
public string AttributionDomains { get; set; } public string AttributionDomains { get; set; }
public string QuotePolicy { get; set; } = "public";
} }
public class PrivaPubStatus public class PrivaPubStatus
@@ -161,6 +162,20 @@ namespace PrivaPub.Api.Mastodon.Entities
public int Down { get; set; } public int Down { get; set; }
} }
public class QuoteEntity
{
public string State { get; set; }
public Status QuotedStatus { get; set; }
public string QuotedStatusId { get; set; }
}
public class QuoteApprovalEntity
{
public List<string> Automatic { get; set; } = new();
public List<string> Manual { get; set; } = new();
public string CurrentUser { get; set; } = "denied";
}
public class EmojiReactionEntity public class EmojiReactionEntity
{ {
public string Name { get; set; } public string Name { get; set; }
@@ -253,6 +268,9 @@ namespace PrivaPub.Api.Mastodon.Entities
public PrivaPubStatus Privapub { get; set; } public PrivaPubStatus Privapub { get; set; }
public List<EmojiReactionEntity> EmojiReactions { get; set; } = new(); public List<EmojiReactionEntity> EmojiReactions { get; set; } = new();
public PleromaStatus Pleroma { get; set; } public PleromaStatus Pleroma { get; set; }
public QuoteEntity Quote { get; set; }
public int QuotesCount { get; set; }
public QuoteApprovalEntity QuoteApproval { get; set; }
} }
public class MediaAttachment public class MediaAttachment
+118 -3
View File
@@ -7,6 +7,8 @@ using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Rendering; using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Federation; using PrivaPub.Models.Federation;
using PrivaPub.Models.Group; using PrivaPub.Models.Group;
using PrivaPub.Domain.Privacy;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Post; using PrivaPub.Models.Post;
using PrivaPub.Domain.Statuses; using PrivaPub.Domain.Statuses;
using PrivaPub.Models.Social; using PrivaPub.Models.Social;
@@ -91,6 +93,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
Privacy = actor.Settings.DefaultVisibility ?? "public", Privacy = actor.Settings.DefaultVisibility ?? "public",
Sensitive = actor.Settings.DefaultSensitive, Sensitive = actor.Settings.DefaultSensitive,
Language = actor.Settings.DefaultLanguage, Language = actor.Settings.DefaultLanguage,
QuotePolicy = actor.Settings.QuotePolicy ?? QuotePolicies.Public,
Note = actor.Summary ?? string.Empty, Note = actor.Summary ?? string.Empty,
Fields = actor.Fields.Select(f => new Field { Name = f.Key, Value = f.Value }).ToList(), Fields = actor.Fields.Select(f => new Field { Name = f.Key, Value = f.Value }).ToList(),
FollowRequestsCount = (int)await DB.Default.CountAsync<Follower>(f => f.LocalActorId == actor.Id && !f.IsAccepted, token) FollowRequestsCount = (int)await DB.Default.CountAsync<Follower>(f => f.LocalActorId == actor.Id && !f.IsAccepted, token)
@@ -146,7 +149,10 @@ namespace PrivaPub.Api.Mastodon.Mappers
public async Task<Status> Status(PostEntity post, string viewerId, CancellationToken token) => public async Task<Status> Status(PostEntity post, string viewerId, CancellationToken token) =>
(await Statuses(new[] { post }, viewerId, token)).FirstOrDefault(); (await Statuses(new[] { post }, viewerId, token)).FirstOrDefault();
public async Task<List<Status>> Statuses(IReadOnlyCollection<PostEntity> posts, string viewerId, CancellationToken token) public Task<List<Status>> Statuses(IReadOnlyCollection<PostEntity> posts, string viewerId, CancellationToken token) =>
Statuses(posts, viewerId, nested: false, token);
async Task<List<Status>> Statuses(IReadOnlyCollection<PostEntity> posts, string viewerId, bool nested, CancellationToken token)
{ {
var originalIds = posts.Where(p => p.ReblogOfPostId != default).Select(p => p.ReblogOfPostId).Distinct().ToList(); var originalIds = posts.Where(p => p.ReblogOfPostId != default).Select(p => p.ReblogOfPostId).Distinct().ToList();
var originals = originalIds.Count == 0 var originals = originalIds.Count == 0
@@ -220,7 +226,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
SpoilerText = post.SpoilerText ?? (post.HasContentWarning ? post.Title ?? ActivityPubRenderer.ContentWarning : string.Empty), SpoilerText = post.SpoilerText ?? (post.HasContentWarning ? post.Title ?? ActivityPubRenderer.ContentWarning : string.Empty),
Visibility = Visibility(post.Visibility), Visibility = Visibility(post.Visibility),
Language = post.Language, Language = post.Language,
MediaAttachments = post.Media.Select(Media).ToList(), MediaAttachments = post.Media.Count > 0 ? post.Media.Select(Media).ToList() : Playable(post),
Card = Card(post), Card = Card(post),
Poll = Poll(post, viewerId, ownVotes.GetValueOrDefault(post.ID)), Poll = Poll(post, viewerId, ownVotes.GetValueOrDefault(post.ID)),
Emojis = Emojis(post.Emojis), Emojis = Emojis(post.Emojis),
@@ -233,6 +239,34 @@ namespace PrivaPub.Api.Mastodon.Mappers
return status; return status;
} }
var localAuthors = all.Where(p => !p.IsFederatedCopy && p.GroupUserId != default).Select(p => p.GroupUserId).Distinct().ToList();
var followsAuthor = viewerId == default || localAuthors.Count == 0
? new HashSet<string>()
: await FollowedLocalAuthors(viewerId, localAuthors, token);
var quotedIds = nested ? new List<string>() : all.Where(p => p.QuoteState == QuoteState.Accepted && p.QuotedPostId != default).Select(p => p.QuotedPostId).Distinct().ToList();
var quotedPosts = new List<PostEntity>();
foreach (var quotedPost in quotedIds.Count == 0 ? new List<PostEntity>() : await _dbEntities.Posts.Match(p => quotedIds.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token))
if (quotedPost.Visibility != PostVisibility.LocalGeo && await VisibilityPolicy.CanSee(quotedPost, viewerId, token))
quotedPosts.Add(quotedPost);
var quotedStatuses = quotedPosts.Count == 0
? new Dictionary<string, Status>()
: (await Statuses(quotedPosts, viewerId, nested: true, token)).ToDictionary(q => q.Id);
void Quote(Status status, PostEntity post)
{
status.QuotesCount = post.QuotesCount;
status.QuoteApproval = post.IsFederatedCopy ? Approval(post, viewerId) : LocalApproval(post, viewerId, followsAuthor.Contains(post.GroupUserId));
if (post.QuoteState == QuoteState.None)
return;
var state = post.QuoteState.ToString().ToLowerInvariant();
var quoted = post.QuotedPostId != default && quotedStatuses.TryGetValue(post.QuotedPostId, out var shown) ? shown : default;
status.Quote = nested
? new QuoteEntity { State = state, QuotedStatusId = post.QuotedPostId }
: new QuoteEntity { State = state, QuotedStatus = post.QuoteState == QuoteState.Accepted ? quoted : default, QuotedStatusId = post.QuotedPostId };
if (!nested && post.QuoteState == QuoteState.Accepted && quoted != default)
status.Content = WithoutQuoteFallback(status.Content);
}
var mapped = new List<Status>(); var mapped = new List<Status>();
foreach (var post in posts) foreach (var post in posts)
{ {
@@ -242,10 +276,12 @@ namespace PrivaPub.Api.Mastodon.Mappers
var status = Map(post); var status = Map(post);
if (status == default) if (status == default)
continue; continue;
Quote(status, post);
if (post.ReblogOfPostId != default) if (post.ReblogOfPostId != default)
{ {
if (!originals.TryGetValue(post.ReblogOfPostId, out var original) || Map(original) is not { } inner) if (!originals.TryGetValue(post.ReblogOfPostId, out var original) || Map(original) is not { } inner)
continue; continue;
Quote(inner, original);
status.Reblog = inner; status.Reblog = inner;
status.Content = string.Empty; status.Content = string.Empty;
status.Reblogged = reblogged.Contains(original.ID); status.Reblogged = reblogged.Contains(original.ID);
@@ -257,6 +293,54 @@ namespace PrivaPub.Api.Mastodon.Mappers
public static string AuthorOf(PostEntity post) => post.AuthorAccountId ?? post.GroupUserId; public static string AuthorOf(PostEntity post) => post.AuthorAccountId ?? post.GroupUserId;
static readonly AngleSharp.Html.Parser.HtmlParser Fragments = new();
public static string WithoutQuoteFallback(string html)
{
if (string.IsNullOrEmpty(html) || !html.Contains("quote-inline", StringComparison.Ordinal))
return html;
var document = Fragments.ParseDocument($"<body>{html}</body>");
foreach (var fallback in document.QuerySelectorAll(".quote-inline").ToList())
fallback.Remove();
return document.Body!.InnerHtml;
}
async Task<HashSet<string>> FollowedLocalAuthors(string viewerId, List<string> authorIds, CancellationToken token)
{
var uris = new Dictionary<string, string>();
foreach (var avatar in await _dbEntities.Avatars.Match(a => authorIds.Contains(a.ID)).ExecuteAsync(token))
uris[_localActors.FromAvatar(avatar).Uri] = avatar.ID;
var targets = uris.Keys.ToList();
return (await _dbEntities.Followings.Match(f => f.AvatarId == viewerId && targets.Contains(f.TargetActorURI) && f.State == FollowState.Accepted).ExecuteAsync(token))
.Select(f => uris[f.TargetActorURI]).ToHashSet();
}
static QuoteApprovalEntity LocalApproval(PostEntity post, string viewerId, bool viewerFollows)
{
var policy = ActivityPubRenderer.QuotableBy(post);
var everyone = policy switch { QuotePolicies.Public => new List<string> { "public" }, QuotePolicies.Followers => new List<string> { "followers" }, _ => new List<string>() };
var current = viewerId == default ? "unknown"
: viewerId == post.GroupUserId && post.Visibility is PostVisibility.Public or PostVisibility.Unlisted ? "automatic"
: policy == QuotePolicies.Public || policy == QuotePolicies.Followers && viewerFollows ? "automatic"
: "denied";
return new QuoteApprovalEntity { Automatic = everyone, CurrentUser = current };
}
static QuoteApprovalEntity Approval(PostEntity post, string viewerId)
{
if (!post.IsFederatedCopy || post.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted))
return new QuoteApprovalEntity();
if (post.QuotePolicy is not { } policy)
return new QuoteApprovalEntity { Automatic = new List<string> { "public" }, CurrentUser = viewerId == default ? "unknown" : "automatic" };
static List<string> Named(IEnumerable<string> who) => who.Select(w => Addressing.IsPublic(w) ? "public" : w.EndsWith("/followers") ? "followers" : "unsupported_policy").Distinct().ToList();
return new QuoteApprovalEntity
{
Automatic = Named(policy.Automatic),
Manual = Named(policy.Manual),
CurrentUser = viewerId == default ? "unknown" : policy.Automatic.Any(Addressing.IsPublic) ? "automatic" : policy.Manual.Any(Addressing.IsPublic) ? "manual" : "denied"
};
}
public static string Content(PostEntity post) public static string Content(PostEntity post)
{ {
var content = post.ContentHtml ?? ActivityPubRenderer.Html(post.Text); var content = post.ContentHtml ?? ActivityPubRenderer.Html(post.Text);
@@ -387,11 +471,42 @@ namespace PrivaPub.Api.Mastodon.Mappers
Streams = variant.Streams Streams = variant.Streams
}; };
List<MediaAttachment> Playable(PostEntity post)
{
var (variant, kind, duration) = post.Video is { } video ? (BestVideo(video.Variants), "video", video.DurationSeconds)
: post.Audio is { } audio ? (audio.Variants.FirstOrDefault(), "audio", audio.DurationSeconds)
: default;
if (variant == default)
return new List<MediaAttachment>();
return new List<MediaAttachment>
{
Media(new PostMedia
{
Id = new Guid(System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes(variant.Url))),
RemoteURL = variant.Url,
ContentType = variant.MediaType ?? kind + "/*",
RemotePreviewURL = post.CoverURL,
Width = variant.Width,
Height = variant.Height,
DurationSeconds = duration,
Description = post.Title
})
};
}
public static MediaVariant BestVideo(IReadOnlyCollection<MediaVariant> variants)
{
var playable = variants.Where(v => v.MediaType?.StartsWith("video/") == true && (v.Streams.Count == 0 || v.Streams.Contains("audio") && v.Streams.Contains("video"))).ToList();
return playable.Where(v => v.Height <= 720).OrderByDescending(v => v.Height).FirstOrDefault()
?? playable.OrderBy(v => v.Height ?? int.MaxValue).FirstOrDefault();
}
PreviewCard Card(PostEntity post) PreviewCard Card(PostEntity post)
{ {
if (post.Link is { } link) if (post.Link is { } link)
return Card(link.Href, link.Title ?? post.Title, link.Description ?? (post.Media.Count == 0 ? post.Excerpt : default), link.ImageURL); return Card(link.Href, link.Title ?? post.Title, link.Description ?? (post.Media.Count == 0 ? post.Excerpt : default), link.ImageURL);
if (post.IsFederatedCopy && post.ObjectType is "Article" or "Event" or "Video" or "Audio" && post.Url != default && post.Media.Count == 0) if (post.IsFederatedCopy && post.ObjectType is "Article" or "Event" && post.Url != default && post.Media.Count == 0
|| post.IsFederatedCopy && post.ObjectType is "Video" or "Audio" && post.Url != default && post.Media.Count == 0 && Playable(post).Count == 0)
return Card(post.Url, post.Title, post.Excerpt, post.CoverURL); return Card(post.Url, post.Title, post.Excerpt, post.CoverURL);
return default; return default;
} }
+34 -12
View File
@@ -15,6 +15,9 @@ namespace PrivaPub.Domain.Media
{ {
string Wrap(string remoteUrl); string Wrap(string remoteUrl);
Task<(string Path, string ContentType)> Fetch(string signature, string encodedUrl, CancellationToken token); Task<(string Path, string ContentType)> Fetch(string signature, string encodedUrl, CancellationToken token);
string Verified(string signature, string encodedUrl);
(string Path, string ContentType) Cached(string url);
Task<HttpResponseMessage> Open(string url, System.Net.Http.Headers.RangeHeaderValue range, CancellationToken token);
} }
public class MediaProxy : IMediaProxy public class MediaProxy : IMediaProxy
@@ -43,7 +46,7 @@ namespace PrivaPub.Domain.Media
return $"{_localActors.BaseAddress}/media/proxy/{Sign(remoteUrl)}/{encoded}"; return $"{_localActors.BaseAddress}/media/proxy/{Sign(remoteUrl)}/{encoded}";
} }
public async Task<(string Path, string ContentType)> Fetch(string signature, string encodedUrl, CancellationToken token) public string Verified(string signature, string encodedUrl)
{ {
string url; string url;
try try
@@ -54,19 +57,38 @@ namespace PrivaPub.Domain.Media
{ {
return default; return default;
} }
if (!CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(signature ?? string.Empty), Encoding.ASCII.GetBytes(Sign(url)))) return CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(signature ?? string.Empty), Encoding.ASCII.GetBytes(Sign(url))) ? url : default;
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));
} }
public (string Path, string ContentType) Cached(string url)
{
var (path, typePath) = CachePaths(url);
if (!File.Exists(path) || !File.Exists(typePath))
return default;
File.SetLastWriteTimeUtc(path, DateTime.UtcNow);
return (path, File.ReadAllText(typePath));
}
public Task<HttpResponseMessage> Open(string url, System.Net.Http.Headers.RangeHeaderValue range, CancellationToken token) =>
_http.OpenMedia(url, range, token);
(string Path, string TypePath) CachePaths(string url)
{
var name = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(url)));
var path = System.IO.Path.Combine(_media.ProxyRoot, name[..2], name);
return (path, path + ".type");
}
public async Task<(string Path, string ContentType)> Fetch(string signature, string encodedUrl, CancellationToken token)
{
var url = Verified(signature, encodedUrl);
if (url == default)
return default;
if (Cached(url) is { Path: not null } cached)
return cached;
var (path, typePath) = CachePaths(url);
var directory = System.IO.Path.GetDirectoryName(path);
var (bytes, contentType) = await _http.GetMedia(url, _options.CurrentValue.MaxProxiedBytes, token); var (bytes, contentType) = await _http.GetMedia(url, _options.CurrentValue.MaxProxiedBytes, token);
if (bytes == default) if (bytes == default)
return default; return default;
+1 -20
View File
@@ -233,30 +233,11 @@ namespace PrivaPub.Domain.Statuses
return JobOutcome.Done; return JobOutcome.Done;
await Closing(post, author, token); await Closing(post, author, token);
if (!post.IsLocalOnly) if (!post.IsLocalOnly)
await _outbox.Publish(author, post, Update(post, author), token); await _outbox.Publish(author, post, ActivityPubRenderer.UpdateOf(post, author, $"poll-{DateTime.UtcNow.Ticks}"), token);
return JobOutcome.Done; return JobOutcome.Done;
} }
protected virtual Task Closing(PostEntity post, LocalActor author, CancellationToken token) => Task.CompletedTask; protected virtual Task Closing(PostEntity post, LocalActor author, CancellationToken token) => Task.CompletedTask;
static JsonObject Update(PostEntity post, LocalActor author)
{
var question = post.Visibility == PostVisibility.Direct
? ActivityPubRenderer.DirectNote(post, author, Array.Empty<(string, string)>(), post.ContextURI)
: ActivityPubRenderer.Note(post, author, default, post.InReplyToURI);
question["to"] = new JsonArray(post.To.Select(t => (JsonNode)t).ToArray());
question["cc"] = new JsonArray(post.Cc.Select(c => (JsonNode)c).ToArray());
return new JsonObject
{
["@context"] = ActivityPubRenderer.Context(),
["id"] = author.ActivityUri($"update-{post.ID}-poll-{DateTime.UtcNow.Ticks}"),
["type"] = "Update",
["actor"] = author.Uri,
["to"] = question["to"]!.DeepClone(),
["cc"] = question["cc"]!.DeepClone(),
["object"] = question
};
}
} }
public class PollCloseJob : PollRefreshJob public class PollCloseJob : PollRefreshJob
+304
View File
@@ -0,0 +1,304 @@
using MongoDB.Entities;
using PrivaPub.Domain.Social;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Federation;
using PrivaPub.Models.User;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using static PrivaPub.Federation.Objects.ActivityJson;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Domain.Statuses
{
public interface IQuoteService
{
Task Resolve(PostEntity post, NoteDocument note, CancellationToken token);
Task Revoke(string stampUri, CancellationToken token);
Task<bool> Verified(string stampUri, string quotingUri, PostEntity quoted, CancellationToken token);
Task<QuotePermission> Permission(PostEntity quoted, LocalActor author, CancellationToken token);
Task<string> Grant(LocalActor author, PostEntity quoted, string quotingUri, string quoterUri, CancellationToken token);
Task ReceiveRequest(JsonNode request, ForeignAvatar actor, CancellationToken token);
Task<bool> RevokeLicence(PostEntity quoted, PostEntity quoting, CancellationToken token);
Task Request(LocalActor author, PostEntity post, PostEntity quoted, JsonObject note, CancellationToken token);
Task<bool> Answered(JsonNode answer, ForeignAvatar actor, bool accepted, CancellationToken token);
}
public enum QuotePermission
{
Denied,
Granted,
AskFirst
}
public class QuoteService : IQuoteService
{
readonly DbEntities _dbEntities;
readonly IRemoteActorService _remoteActors;
readonly IRemotePosts _remotePosts;
readonly ILocalActorService _localActors;
readonly IDeliveryService _delivery;
readonly IOutboxPublisher _outbox;
public QuoteService(DbEntities dbEntities, IRemoteActorService remoteActors, IRemotePosts remotePosts, ILocalActorService localActors,
IDeliveryService delivery, IOutboxPublisher outbox)
{
_dbEntities = dbEntities;
_remoteActors = remoteActors;
_remotePosts = remotePosts;
_localActors = localActors;
_delivery = delivery;
_outbox = outbox;
}
const string RequestPrefix = "quote-request-";
const string LicencePath = "/parrot-licences/";
public static string LicenceUri(LocalActor author, string licenceId) => author.Uri + LicencePath + licenceId;
async Task<bool> MayQuote(PostEntity quoted, string quoterUri, CancellationToken token)
{
var author = await _localActors.FindById(LocalActorKind.Person, quoted.GroupUserId, token);
if (author == default)
return false;
if (quoterUri == author.Uri)
return quoted.Visibility is PostVisibility.Public or PostVisibility.Unlisted;
return ActivityPubRenderer.QuotableBy(quoted) switch
{
QuotePolicies.Public => true,
QuotePolicies.Followers => await Follows(quoterUri, author, token),
_ => false
};
}
async Task<bool> Follows(string followerUri, LocalActor author, CancellationToken token)
{
if (await _dbEntities.Followers.Match(f => f.LocalActorId == author.Id && f.ActorURI == followerUri && f.IsAccepted).ExecuteAnyAsync(token))
return true;
var local = await _localActors.FindByUri(followerUri, token);
return local != default
&& await _dbEntities.Followings.Match(f => f.AvatarId == local.Id && f.TargetActorURI == author.Uri && f.State == FollowState.Accepted).ExecuteAnyAsync(token);
}
public async Task<string> Grant(LocalActor author, PostEntity quoted, string quotingUri, string quoterUri, CancellationToken token)
{
var existing = await DB.Default.Find<QuoteLicence>().Match(l => l.PostId == quoted.ID && l.QuotingObjectURI == quotingUri && l.RevokedAt == null).ExecuteFirstAsync(token);
if (existing != default)
return LicenceUri(author, existing.ID);
var licence = new QuoteLicence { PostId = quoted.ID, AuthorAvatarId = author.Id, QuotingObjectURI = quotingUri, QuoterActorURI = quoterUri };
await DB.Default.SaveAsync(licence, token);
return LicenceUri(author, licence.ID);
}
public async Task ReceiveRequest(JsonNode request, ForeignAvatar actor, CancellationToken token)
{
var objectUri = Id(request["object"]);
var instrument = request["instrument"];
var quotingUri = Id(instrument);
if (objectUri == default || quotingUri == default || !Origin.Same(quotingUri, actor.ActorURI)
|| instrument is JsonObject embedded && Id(embedded["attributedTo"]) is { } by && by != actor.ActorURI)
return;
var quoted = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
var author = quoted == default ? default : await _localActors.FindById(LocalActorKind.Person, quoted.GroupUserId, token);
if (author == default || string.IsNullOrEmpty(actor.InboxURL))
return;
var answer = new JsonObject
{
["@context"] = ActivityPubRenderer.Context(),
["actor"] = author.Uri,
["object"] = Id(request),
["to"] = new JsonArray(actor.ActorURI)
};
if (await MayQuote(quoted, actor.ActorURI, token))
{
answer["type"] = "Accept";
answer["result"] = await Grant(author, quoted, quotingUri, actor.ActorURI, token);
answer["id"] = author.ActivityUri($"accept-quote-{Guid.NewGuid():N}");
}
else
{
answer["type"] = "Reject";
answer["id"] = author.ActivityUri($"reject-quote-{Guid.NewGuid():N}");
}
await _delivery.Enqueue(author, new[] { actor.InboxURL }, answer, token);
}
public async Task<bool> RevokeLicence(PostEntity quoted, PostEntity quoting, CancellationToken token)
{
var licence = await DB.Default.Find<QuoteLicence>().Match(l => l.PostId == quoted.ID && l.QuotingObjectURI == quoting.ObjectURI && l.RevokedAt == null).ExecuteFirstAsync(token);
var author = licence == default ? default : await _localActors.FindById(LocalActorKind.Person, quoted.GroupUserId, token);
if (author == default)
return false;
await DB.Default.Update<QuoteLicence>().MatchID(licence.ID).Modify(l => l.RevokedAt, DateTime.UtcNow).ExecuteAsync(token);
var uri = LicenceUri(author, licence.ID);
await Revoke(uri, token);
var quoter = quoting.IsFederatedCopy ? await _dbEntities.ForeignAvatars.Match(f => f.ActorURI == quoting.ActorURI).ExecuteFirstAsync(token) : default;
var inboxes = (await _delivery.FollowerInboxes(author, token)).Append(quoter?.InboxURL).Where(i => !string.IsNullOrEmpty(i)).Distinct().ToList();
if (inboxes.Count > 0)
await _delivery.Enqueue(author, inboxes, new JsonObject
{
["@context"] = ActivityPubRenderer.Context(),
["id"] = author.ActivityUri($"revoke-quote-{licence.ID}"),
["type"] = "Delete",
["actor"] = author.Uri,
["object"] = uri,
["to"] = new JsonArray(Addressing.Public)
}, token);
return true;
}
async Task<bool> OurLicence(string stampUri, PostEntity quoting, PostEntity quoted, CancellationToken token)
{
var marker = stampUri?.LastIndexOf(LicencePath, StringComparison.Ordinal) ?? -1;
if (marker < 0 || !stampUri.StartsWith(_localActors.BaseAddress + "/", StringComparison.OrdinalIgnoreCase))
return false;
var id = stampUri[(marker + LicencePath.Length)..];
return await DB.Default.Find<QuoteLicence>().Match(l => l.ID == id && l.PostId == quoted.ID && l.QuotingObjectURI == quoting.ObjectURI && l.RevokedAt == null)
.ExecuteAnyAsync(token);
}
public async Task<QuotePermission> Permission(PostEntity quoted, LocalActor author, CancellationToken token)
{
if (!quoted.IsFederatedCopy)
return await MayQuote(quoted, author.Uri, token) ? QuotePermission.Granted : QuotePermission.Denied;
if (quoted.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted))
return QuotePermission.Denied;
if (quoted.QuotePolicy is not { } policy)
return QuotePermission.Granted;
return policy.Automatic.Concat(policy.Manual).Any(Addressing.IsPublic) || policy.Automatic.Concat(policy.Manual).Contains(author.Uri)
? QuotePermission.AskFirst
: QuotePermission.Denied;
}
public async Task Request(LocalActor author, PostEntity post, PostEntity quoted, JsonObject note, CancellationToken token)
{
var owner = await _dbEntities.ForeignAvatars.Match(f => f.ActorURI == quoted.ActorURI).ExecuteFirstAsync(token);
if (string.IsNullOrEmpty(owner?.InboxURL))
return;
await _delivery.Enqueue(author, new[] { owner.InboxURL }, new JsonObject
{
["@context"] = ActivityPubRenderer.Context(),
["id"] = author.ActivityUri(RequestPrefix + post.ID),
["type"] = "QuoteRequest",
["actor"] = author.Uri,
["object"] = quoted.ObjectURI,
["instrument"] = note.DeepClone(),
["to"] = new JsonArray(quoted.ActorURI)
}, token);
}
public async Task<bool> Answered(JsonNode answer, ForeignAvatar actor, bool accepted, CancellationToken token)
{
var request = answer["object"];
var requestId = Id(request);
var marker = requestId?.LastIndexOf("/grunts/" + RequestPrefix, StringComparison.Ordinal) ?? -1;
if (marker < 0 && !(request is JsonObject && Value(request, "type") == "QuoteRequest"))
return false;
if (marker < 0)
return true;
var postId = requestId[(marker + "/grunts/".Length + RequestPrefix.Length)..];
var post = await _dbEntities.Posts.Match(p => p.ID == postId && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
var author = post == default ? default : await _localActors.FindById(LocalActorKind.Person, post.GroupUserId, token);
if (author == default || author.ActivityUri(RequestPrefix + post.ID) != requestId || post.QuoteState != QuoteState.Pending)
return true;
var quoted = await _dbEntities.Posts.MatchID(post.QuotedPostId).ExecuteFirstAsync(token);
if (quoted?.ActorURI != actor.ActorURI)
return true;
if (!accepted)
{
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(p => p.QuoteState, QuoteState.Rejected).ExecuteAsync(token);
return true;
}
var stamp = Id(answer["result"]);
if (stamp == default || !await Verified(stamp, post.ObjectURI, quoted, token))
return true;
post.QuoteAuthorizationURI = stamp;
post.QuoteState = QuoteState.Accepted;
await DB.Default.Update<PostEntity>().MatchID(post.ID)
.Modify(p => p.QuoteAuthorizationURI, stamp)
.Modify(p => p.QuoteState, QuoteState.Accepted)
.ExecuteAsync(token);
await DB.Default.Update<PostEntity>().MatchID(quoted.ID).Modify(b => b.Inc(p => p.QuotesCount, 1)).ExecuteAsync(token);
if (!post.IsLocalOnly)
await _outbox.Publish(author, post, ActivityPubRenderer.UpdateOf(post, author, "quote-approved"), token);
return true;
}
public async Task Resolve(PostEntity post, NoteDocument note, CancellationToken token)
{
if (note.QuoteUri == default && !note.QuoteDeleted)
return;
var quoted = note.QuoteUri == default
? default
: await _dbEntities.Posts.Match(p => p.ObjectURI == note.QuoteUri && !p.DeletedAt.HasValue).ExecuteFirstAsync(token)
?? await _remotePosts.StoreContext(note.QuoteUri, RemotePosts.MaxDepth, token);
var state = note.QuoteDeleted ? QuoteState.Deleted
: quoted == default ? QuoteState.Pending
: note.QuoteAuthorization != default
? (quoted.IsFederatedCopy ? await Verified(note.QuoteAuthorization, post.ObjectURI, quoted, token) : await OurLicence(note.QuoteAuthorization, post, quoted, token))
? QuoteState.Accepted
: QuoteState.Unauthorized
: note.QuotesByConsent ? QuoteState.Pending
: quoted.Visibility is PostVisibility.Public or PostVisibility.Unlisted ? QuoteState.Accepted
: QuoteState.Unauthorized;
var wasCounted = post.QuoteState == QuoteState.Accepted ? post.QuotedPostId : default;
await DB.Default.Update<PostEntity>().MatchID(post.ID)
.Modify(p => p.QuoteURI, note.QuoteUri)
.Modify(p => p.QuotedPostId, quoted?.ID)
.Modify(p => p.QuoteState, state)
.Modify(p => p.QuoteAuthorizationURI, state == QuoteState.Accepted ? note.QuoteAuthorization : default)
.ExecuteAsync(token);
post.QuotedPostId = quoted?.ID;
post.QuoteState = state;
var nowCounted = state == QuoteState.Accepted ? quoted?.ID : default;
if (wasCounted == nowCounted)
return;
if (wasCounted != default)
await DB.Default.Update<PostEntity>().MatchID(wasCounted).Modify(b => b.Inc(p => p.QuotesCount, -1)).ExecuteAsync(token);
if (nowCounted != default)
{
await DB.Default.Update<PostEntity>().MatchID(nowCounted).Modify(b => b.Inc(p => p.QuotesCount, 1)).ExecuteAsync(token);
if (!quoted.IsFederatedCopy)
await Notifications.Add(quoted.GroupUserId, NotificationType.Quote, post.AuthorAccountId, post.ActorURI, post.ID, token);
}
}
public async Task Revoke(string stampUri, CancellationToken token)
{
var revoked = await _dbEntities.Posts.Match(p => p.QuoteAuthorizationURI == stampUri && p.QuoteState == QuoteState.Accepted).ExecuteAsync(token);
foreach (var post in revoked)
{
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(p => p.QuoteState, QuoteState.Revoked).ExecuteAsync(token);
if (post.QuotedPostId != default)
await DB.Default.Update<PostEntity>().MatchID(post.QuotedPostId).Modify(b => b.Inc(p => p.QuotesCount, -1)).ExecuteAsync(token);
}
}
public async Task<bool> Verified(string stampUri, string quotingUri, PostEntity quoted, CancellationToken token)
{
if (!Origin.Same(stampUri, quoted.ActorURI))
return false;
using var fetched = await _remoteActors.FetchObject(stampUri, token);
if (fetched == default)
return false;
var stamp = JsonNode.Parse(fetched.Root.GetRawText());
return Value(stamp, "type") == "QuoteAuthorization"
&& Id(stamp["attributedTo"]) == quoted.ActorURI
&& Id(stamp["interactingObject"]) == quotingUri
&& Id(stamp["interactionTarget"]) == quoted.ObjectURI;
}
}
}
+31 -2
View File
@@ -13,6 +13,7 @@ using PrivaPub.Models.Federation;
using PrivaPub.Models.Group; using PrivaPub.Models.Group;
using PrivaPub.Models.Media; using PrivaPub.Models.Media;
using PrivaPub.Models.Post; using PrivaPub.Models.Post;
using PrivaPub.Models.User;
using PrivaPub.Models.Social; using PrivaPub.Models.Social;
using PrivaPub.StaticServices; using PrivaPub.StaticServices;
@@ -40,6 +41,8 @@ namespace PrivaPub.Domain.Statuses
public double? Longitude { get; init; } public double? Longitude { get; init; }
public double? RangeKm { get; init; } public double? RangeKm { get; init; }
public PollDraft Poll { get; init; } public PollDraft Poll { get; init; }
public string QuotedStatusId { get; init; }
public string QuotePolicy { get; init; }
} }
public sealed record StatusOutcome(PostEntity Post, int Status = StatusCodes.Status200OK, string Error = default) public sealed record StatusOutcome(PostEntity Post, int Status = StatusCodes.Status200OK, string Error = default)
@@ -72,12 +75,14 @@ namespace PrivaPub.Domain.Statuses
readonly IGroupDistributor _groups; readonly IGroupDistributor _groups;
readonly IPollService _polls; readonly IPollService _polls;
readonly ILinkPreviews _previews; readonly ILinkPreviews _previews;
readonly IQuoteService _quotes;
public StatusService(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery, public StatusService(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
IContentRenderer content, IOutboxPublisher outbox, IFanout fanout, IMediaService media, IGroupDistributor groups, IPollService polls, IContentRenderer content, IOutboxPublisher outbox, IFanout fanout, IMediaService media, IGroupDistributor groups, IPollService polls,
ILinkPreviews previews) ILinkPreviews previews, IQuoteService quotes)
{ {
_previews = previews; _previews = previews;
_quotes = quotes;
_polls = polls; _polls = polls;
_media = media; _media = media;
_groups = groups; _groups = groups;
@@ -115,6 +120,18 @@ namespace PrivaPub.Domain.Statuses
if (parent != default && !await VisibilityPolicy.CanSee(parent, author.Id, token)) if (parent != default && !await VisibilityPolicy.CanSee(parent, author.Id, token))
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found"); return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
PostEntity quoted = default;
var quotePermission = QuotePermission.Denied;
if (!string.IsNullOrEmpty(draft.QuotedStatusId))
{
quoted = await _dbEntities.Posts.Match(p => p.ID == draft.QuotedStatusId && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token);
if (quoted == default || !await VisibilityPolicy.CanSee(quoted, author.Id, token))
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
quotePermission = await _quotes.Permission(quoted, author, token);
if (quotePermission == QuotePermission.Denied)
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: This post cannot be quoted");
}
var located = draft.Latitude.HasValue || draft.Longitude.HasValue; 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 if (located && (draft.Latitude is not (>= -90 and <= 90) || draft.Longitude is not (>= -180 and <= 180) || group != default
|| draft.Visibility == PostVisibility.Direct)) || draft.Visibility == PostVisibility.Direct))
@@ -166,11 +183,19 @@ namespace PrivaPub.Domain.Statuses
InReplyToAccountId = parent?.AuthorAccountId ?? parent?.GroupUserId, InReplyToAccountId = parent?.AuthorAccountId ?? parent?.GroupUserId,
IsLocalOnly = isLocalOnly, IsLocalOnly = isLocalOnly,
ActorURI = author.Uri, ActorURI = author.Uri,
Poll = draft.Poll == default ? default : _polls.Create(draft.Poll) Poll = draft.Poll == default ? default : _polls.Create(draft.Poll),
QuoteURI = quoted?.ObjectURI,
QuotedPostId = quoted?.ID,
QuoteByConsent = quoted != default && (quoted.QuotePolicy != default || !quoted.IsFederatedCopy),
QuoteState = quoted == default ? QuoteState.None : quotePermission == QuotePermission.Granted ? QuoteState.Accepted : QuoteState.Pending
}; };
post.ID = (string)post.GenerateNewID(); post.ID = (string)post.GenerateNewID();
post.ObjectURI = author.PostUri(post.ID); post.ObjectURI = author.PostUri(post.ID);
post.Url = author.PostHtmlUrl(post.ID); post.Url = author.PostHtmlUrl(post.ID);
post.LocalQuotePolicy = QuotePolicies.IsKnown(draft.QuotePolicy) ? draft.QuotePolicy : author.Settings.QuotePolicy ?? QuotePolicies.Public;
if (quoted is { IsFederatedCopy: false } && post.QuoteState == QuoteState.Accepted
&& await _localActors.FindById(LocalActorKind.Person, quoted.GroupUserId, token) is { } quotedAuthor)
post.QuoteAuthorizationURI = await _quotes.Grant(quotedAuthor, quoted, post.ObjectURI, author.Uri, token);
if (located) if (located)
{ {
post.Geo = new Coordinates2D(Math.Round(draft.Longitude.Value, 2), Math.Round(draft.Latitude.Value, 2)); post.Geo = new Coordinates2D(Math.Round(draft.Longitude.Value, 2), Math.Round(draft.Latitude.Value, 2));
@@ -210,6 +235,10 @@ namespace PrivaPub.Domain.Statuses
await DB.Default.Update<PostEntity>().MatchID(parent.ID).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token); await DB.Default.Update<PostEntity>().MatchID(parent.ID).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token);
await _fanout.Distribute(post, token); await _fanout.Distribute(post, token);
await _polls.Scheduled(post, token); await _polls.Scheduled(post, token);
if (post.QuoteState == QuoteState.Accepted)
await DB.Default.Update<PostEntity>().MatchID(quoted.ID).Modify(b => b.Inc(p => p.QuotesCount, 1)).ExecuteAsync(token);
if (post.QuoteState == QuoteState.Pending && create?["object"] is JsonObject quotingNote)
await _quotes.Request(author, post, quoted, quotingNote, token);
await _previews.Wanted(post, token); await _previews.Wanted(post, token);
if (create != default) if (create != default)
await _outbox.Publish(author, post, create, token); await _outbox.Publish(author, post, create, token);
@@ -199,6 +199,33 @@ namespace PrivaPub.Federation.Controllers
return Activity(note); return Activity(note);
} }
[HttpGet, Route("{actor}/parrot-licences/{licenceId}")]
public async Task<IActionResult> ParrotLicence(string actor, string licenceId, CancellationToken token)
{
var author = await _localActors.FindByUserName(actor, token);
var licence = author == default ? default : await DB.Default.Find<QuoteLicence>().Match(l => l.ID == licenceId && l.AuthorAvatarId == author.Id).ExecuteFirstAsync(token);
var quoted = licence == default ? default : await _dbEntities.Posts.MatchID(licence.PostId).ExecuteFirstAsync(token);
if (quoted == default)
return NotFound();
var uri = Domain.Statuses.QuoteService.LicenceUri(author, licence.ID);
if (licence.RevokedAt.HasValue || quoted.DeletedAt.HasValue)
return new ContentResult
{
Content = new JsonObject { ["@context"] = ActivityPubRenderer.ActivityStreams, ["id"] = uri, ["type"] = "Tombstone", ["formerType"] = "QuoteAuthorization" }.ToJsonString(),
ContentType = ActivityContentType,
StatusCode = StatusCodes.Status410Gone
};
return Activity(new JsonObject
{
["@context"] = ActivityPubRenderer.Context(),
["id"] = uri,
["type"] = "QuoteAuthorization",
["attributedTo"] = author.Uri,
["interactingObject"] = licence.QuotingObjectURI,
["interactionTarget"] = quoted.ObjectURI
});
}
[HttpGet, Route("{actor}/grunts/{activityId}")] [HttpGet, Route("{actor}/grunts/{activityId}")]
public async Task<IActionResult> Grunt(string actor, string activityId, CancellationToken token) public async Task<IActionResult> Grunt(string actor, string activityId, CancellationToken token)
{ {
@@ -1,5 +1,6 @@
using MongoDB.Entities; using MongoDB.Entities;
using PrivaPub.Domain.Statuses;
using PrivaPub.Federation.Actors; using PrivaPub.Federation.Actors;
using PrivaPub.Models.Social; using PrivaPub.Models.Social;
using PrivaPub.Models.User; using PrivaPub.Models.User;
@@ -15,17 +16,21 @@ namespace PrivaPub.Federation.Inbox.Handlers
{ {
readonly DbEntities _dbEntities; readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors; readonly ILocalActorService _localActors;
readonly IQuoteService _quotes;
public AcceptHandler(DbEntities dbEntities, ILocalActorService localActors) public AcceptHandler(DbEntities dbEntities, ILocalActorService localActors, IQuoteService quotes)
{ {
_dbEntities = dbEntities; _dbEntities = dbEntities;
_localActors = localActors; _localActors = localActors;
_quotes = quotes;
} }
public virtual string Type => "Accept"; public virtual string Type => "Accept";
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token) public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
{ {
if (await _quotes.Answered(activity, actor, accepted: Type == "Accept", token))
return;
var following = await FindFollowing(activity["object"], actor, _dbEntities, _localActors, token); var following = await FindFollowing(activity["object"], actor, _dbEntities, _localActors, token);
if (following == default) if (following == default)
return; return;
@@ -56,7 +61,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
public class RejectHandler : AcceptHandler public class RejectHandler : AcceptHandler
{ {
public RejectHandler(DbEntities dbEntities, ILocalActorService localActors) : base(dbEntities, localActors) public RejectHandler(DbEntities dbEntities, ILocalActorService localActors, IQuoteService quotes) : base(dbEntities, localActors, quotes)
{ {
} }
@@ -39,10 +39,12 @@ namespace PrivaPub.Federation.Inbox.Handlers
readonly IObjectRecords _records; readonly IObjectRecords _records;
readonly IPollService _polls; readonly IPollService _polls;
readonly ILinkPreviews _previews; readonly ILinkPreviews _previews;
readonly IQuoteService _quotes;
public CreateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery, public CreateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
IDomainBlocks domainBlocks, IFanout fanout, IRemotePosts remotePosts, IGroupDistributor groups, IObjectRecords records, IPollService polls, ILinkPreviews previews) IDomainBlocks domainBlocks, IFanout fanout, IRemotePosts remotePosts, IGroupDistributor groups, IObjectRecords records, IPollService polls, ILinkPreviews previews, IQuoteService quotes)
{ {
_quotes = quotes;
_previews = previews; _previews = previews;
_records = records; _records = records;
_polls = polls; _polls = polls;
@@ -152,6 +154,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
} }
await _records.Record(note, post, ObjectPath.Delivered, refetched, token); await _records.Record(note, post, ObjectPath.Delivered, refetched, token);
await _previews.Wanted(post, token); await _previews.Wanted(post, token);
await _quotes.Resolve(post, note, token);
if (parent != default) if (parent != default)
await DB.Default.Update<PostEntity>().MatchID(parent.ID).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token); await DB.Default.Update<PostEntity>().MatchID(parent.ID).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token);
@@ -1,5 +1,6 @@
using MongoDB.Entities; using MongoDB.Entities;
using PrivaPub.Domain.Statuses;
using PrivaPub.Federation.Actors; using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Objects; using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Outbox; using PrivaPub.Federation.Outbox;
@@ -27,10 +28,12 @@ namespace PrivaPub.Federation.Inbox.Handlers
readonly IRemoteActorService _remoteActors; readonly IRemoteActorService _remoteActors;
readonly IDeliveryService _delivery; readonly IDeliveryService _delivery;
readonly IGroupDistributor _groups; readonly IGroupDistributor _groups;
readonly IQuoteService _quotes;
public DeleteHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery, public DeleteHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
IGroupDistributor groups) IGroupDistributor groups, IQuoteService quotes)
{ {
_quotes = quotes;
_groups = groups; _groups = groups;
_dbEntities = dbEntities; _dbEntities = dbEntities;
_localActors = localActors; _localActors = localActors;
@@ -72,7 +75,10 @@ namespace PrivaPub.Federation.Inbox.Handlers
.ExecuteAsync(token); .ExecuteAsync(token);
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI).ExecuteFirstAsync(token); var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI).ExecuteFirstAsync(token);
if (post == default) if (post == default)
{
await _quotes.Revoke(objectUri, token);
return; return;
}
await DB.Default.DeleteAsync<PostEntity>(post.ID); await DB.Default.DeleteAsync<PostEntity>(post.ID);
await DB.Default.DeleteAsync<ObjectRecord>(r => r.PostId == post.ID || r.ObjectURI == objectUri); await DB.Default.DeleteAsync<ObjectRecord>(r => r.PostId == post.ID || r.ObjectURI == objectUri);
await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == post.ID || e.ReblogOfPostId == post.ID); await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == post.ID || e.ReblogOfPostId == post.ID);
@@ -0,0 +1,21 @@
using PrivaPub.Domain.Statuses;
using PrivaPub.Models.User;
using System.Text.Json.Nodes;
namespace PrivaPub.Federation.Inbox.Handlers
{
public class QuoteRequestHandler : IActivityHandler
{
readonly IQuoteService _quotes;
public QuoteRequestHandler(IQuoteService quotes)
{
_quotes = quotes;
}
public string Type => "QuoteRequest";
public Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token) => _quotes.ReceiveRequest(activity, actor, token);
}
}
@@ -1,6 +1,7 @@
using MongoDB.Entities; using MongoDB.Entities;
using PrivaPub.Federation.Actors; using PrivaPub.Federation.Actors;
using PrivaPub.Domain.Statuses;
using PrivaPub.Federation.Objects; using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Outbox; using PrivaPub.Federation.Outbox;
using PrivaPub.Models.Post; using PrivaPub.Models.Post;
@@ -24,10 +25,12 @@ namespace PrivaPub.Federation.Inbox.Handlers
readonly IRemoteActorService _remoteActors; readonly IRemoteActorService _remoteActors;
readonly IGroupDistributor _groups; readonly IGroupDistributor _groups;
readonly IObjectRecords _records; readonly IObjectRecords _records;
readonly IQuoteService _quotes;
public UpdateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IGroupDistributor groups, public UpdateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IGroupDistributor groups,
IObjectRecords records) IObjectRecords records, IQuoteService quotes)
{ {
_quotes = quotes;
_records = records; _records = records;
_groups = groups; _groups = groups;
_dbEntities = dbEntities; _dbEntities = dbEntities;
@@ -56,6 +59,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
return; return;
post.Poll = note.Poll ?? post.Poll; post.Poll = note.Poll ?? post.Poll;
post.QuotePolicy = note.QuotePolicy;
post.Video = note.Video ?? post.Video; post.Video = note.Video ?? post.Video;
post.Audio = note.Audio ?? post.Audio; post.Audio = note.Audio ?? post.Audio;
post.Event = note.Event ?? post.Event; post.Event = note.Event ?? post.Event;
@@ -63,6 +67,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
{ {
await DB.Default.SaveAsync(post, token); await DB.Default.SaveAsync(post, token);
await _records.Revise(note, Id(activity), token); await _records.Revise(note, Id(activity), token);
await Requote(post, note, token);
return; return;
} }
@@ -102,11 +107,18 @@ namespace PrivaPub.Federation.Inbox.Handlers
post.UpdateDate = DateTime.UtcNow; post.UpdateDate = DateTime.UtcNow;
await DB.Default.SaveAsync(post, token); await DB.Default.SaveAsync(post, token);
await _records.Revise(note, Id(activity), token); await _records.Revise(note, Id(activity), token);
await Requote(post, note, token);
if (!string.IsNullOrEmpty(post.GroupId) && await _localActors.FindById(Models.Federation.LocalActorKind.Group, post.GroupId, token) is { IsCircle: false } community) 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); await _groups.Announce(community, activity.AsObject(), post.ObjectURI, isNewPost: false, token);
} }
static bool IsEdit(NoteDocument note, PostEntity post) => static bool IsEdit(NoteDocument note, PostEntity post) =>
note.Updated is { } updated && updated > (post.EditedAt ?? post.CreationDate); note.Updated is { } updated && updated > (post.EditedAt ?? post.CreationDate);
async Task Requote(PostEntity post, NoteDocument note, CancellationToken token)
{
if (note.QuoteUri != post.QuoteURI || note.QuoteAuthorization != post.QuoteAuthorizationURI || post.QuoteState != QuoteState.Accepted)
await _quotes.Resolve(post, note, token);
}
} }
} }
+1
View File
@@ -82,6 +82,7 @@ namespace PrivaPub.Federation.Inbox
Excerpt = note.Excerpt, Excerpt = note.Excerpt,
Source = note.Source, Source = note.Source,
Poll = note.Poll, Poll = note.Poll,
QuotePolicy = note.QuotePolicy,
Emojis = note.Emojis.ToList(), Emojis = note.Emojis.ToList(),
CoverURL = note.CoverURL, CoverURL = note.CoverURL,
Link = note.Link, Link = note.Link,
@@ -38,7 +38,7 @@ namespace PrivaPub.Federation.Objects
{ {
KeepChildNodes = true KeepChildNodes = true
}; };
foreach (var allowed in new[] { "mention", "hashtag", "ellipsis", "invisible" }) foreach (var allowed in new[] { "mention", "hashtag", "ellipsis", "invisible", "quote-inline" })
sanitizer.AllowedClasses.Add(allowed); sanitizer.AllowedClasses.Add(allowed);
sanitizer.RemovingCssClass += (_, e) => e.Cancel = MicroformatClass().IsMatch(e.CssClass); sanitizer.RemovingCssClass += (_, e) => e.Cancel = MicroformatClass().IsMatch(e.CssClass);
sanitizer.RemovingTag += (_, e) => sanitizer.RemovingTag += (_, e) =>
+15 -1
View File
@@ -25,6 +25,9 @@ namespace PrivaPub.Federation.Objects
public string Context { get; init; } public string Context { get; init; }
public string Audience { get; init; } public string Audience { get; init; }
public string QuoteUri { get; init; } public string QuoteUri { get; init; }
public string QuoteAuthorization { get; init; }
public bool QuotesByConsent { get; init; }//FEP-044f `quote`, as opposed to the legacy keys that ask nobody
public bool QuoteDeleted { get; init; }
public DateTime Published { get; init; } public DateTime Published { get; init; }
public DateTime? Updated { get; init; } public DateTime? Updated { get; init; }
public IReadOnlyList<string> To { get; init; } = Array.Empty<string>(); public IReadOnlyList<string> To { get; init; } = Array.Empty<string>();
@@ -34,6 +37,7 @@ namespace PrivaPub.Federation.Objects
public IReadOnlyList<PostMedia> Attachments { get; init; } = Array.Empty<PostMedia>(); public IReadOnlyList<PostMedia> Attachments { get; init; } = Array.Empty<PostMedia>();
public PostSource Source { get; init; } public PostSource Source { get; init; }
public PostPoll Poll { get; init; } public PostPoll Poll { get; init; }
public InteractionRule QuotePolicy { get; init; }
public IReadOnlyList<CustomEmoji> Emojis { get; init; } = Array.Empty<CustomEmoji>(); public IReadOnlyList<CustomEmoji> Emojis { get; init; } = Array.Empty<CustomEmoji>();
public string CoverURL { get; init; } public string CoverURL { get; init; }
public PostLink Link { get; init; } public PostLink Link { get; init; }
@@ -79,7 +83,10 @@ namespace PrivaPub.Federation.Objects
InReplyTo = Id(note["inReplyTo"]), InReplyTo = Id(note["inReplyTo"]),
Context = Id(note["context"]) ?? Value(note, "conversation"), Context = Id(note["context"]) ?? Value(note, "conversation"),
Audience = Id(note["audience"]), Audience = Id(note["audience"]),
QuoteUri = Value(note, "quote") ?? Value(note, "quoteUrl") ?? Value(note, "quoteUri") ?? Value(note, "_misskey_quote"), QuoteUri = Id(note["quote"]) ?? Value(note, "quoteUrl") ?? Value(note, "quoteUri") ?? Value(note, "_misskey_quote") ?? QuoteLink(note),
QuoteAuthorization = Id(note["quoteAuthorization"]),
QuotesByConsent = note.ContainsKey("quote"),
QuoteDeleted = note["quote"] is JsonObject quoted && Value(quoted, "type") == "Tombstone",
Published = Time(Value(note, "published")) ?? DateTime.UtcNow, Published = Time(Value(note, "published")) ?? DateTime.UtcNow,
Updated = Time(Value(note, "updated")), Updated = Time(Value(note, "updated")),
To = List(note["to"]), To = List(note["to"]),
@@ -97,6 +104,7 @@ namespace PrivaPub.Federation.Objects
Attachments = Attachments(note), Attachments = Attachments(note),
Source = ObjectShapes.Source(note), Source = ObjectShapes.Source(note),
Poll = ObjectShapes.Poll(note), Poll = ObjectShapes.Poll(note),
QuotePolicy = ObjectShapes.QuotePolicy(note),
Emojis = ObjectShapes.Emojis(note["tag"]), Emojis = ObjectShapes.Emojis(note["tag"]),
CoverURL = ObjectShapes.Cover(note), CoverURL = ObjectShapes.Cover(note),
Link = ObjectShapes.Link(note), Link = ObjectShapes.Link(note),
@@ -169,6 +177,12 @@ namespace PrivaPub.Federation.Objects
}; };
} }
static string QuoteLink(JsonObject note) =>
Tags(note, "Link")
.Where(t => Value(t, "mediaType") is { } type && (type.StartsWith("application/activity+json") || type.StartsWith("application/ld+json")))
.Select(t => Value(t, "href"))
.FirstOrDefault(href => Origin.Of(href) != default);
static string FirstOf(JsonNode map) => static string FirstOf(JsonNode map) =>
map is JsonObject languages ? languages.Select(l => l.Value is JsonValue v && v.TryGetValue<string>(out var text) ? text : default).FirstOrDefault(t => t != default) : default; map is JsonObject languages ? languages.Select(l => l.Value is JsonValue v && v.TryGetValue<string>(out var text) ? text : default).FirstOrDefault(t => t != default) : default;
+22 -1
View File
@@ -18,6 +18,7 @@ namespace PrivaPub.Federation.Objects
const int CoverMaxWidth = 1280; const int CoverMaxWidth = 1280;
const int MaxEmojis = 64; const int MaxEmojis = 64;
const int MaxPollOptions = 20; const int MaxPollOptions = 20;
const int MaxAudience = 32;
static readonly Dictionary<string, string> MediaTypesByExtension = new(StringComparer.OrdinalIgnoreCase) static readonly Dictionary<string, string> MediaTypesByExtension = new(StringComparer.OrdinalIgnoreCase)
{ {
@@ -97,6 +98,23 @@ namespace PrivaPub.Federation.Objects
.Take(MaxEmojis) .Take(MaxEmojis)
.ToList(); .ToList();
public static InteractionRule QuotePolicy(JsonObject note)
{
if (note["interactionPolicy"] is not JsonObject policy || policy["canQuote"] is not JsonObject canQuote)
return default;
static List<string> Who(JsonNode node) => node switch
{
JsonArray array => array.Select(Id).Where(id => id != default).Take(MaxAudience).ToList(),
JsonNode single when Id(single) is { } id => new List<string> { id },
_ => new List<string>()
};
return new InteractionRule
{
Automatic = Who(canQuote["automaticApproval"] ?? canQuote["always"]),
Manual = Who(canQuote["manualApproval"] ?? canQuote["approvalRequired"])
};
}
public static PostPoll Poll(JsonObject note) public static PostPoll Poll(JsonObject note)
{ {
if (Value(note, "type") != "Question") if (Value(note, "type") != "Question")
@@ -199,10 +217,13 @@ namespace PrivaPub.Federation.Objects
if (Value(note, "type") != "Video") if (Value(note, "type") != "Video")
return default; return default;
var links = Objects(note["url"]).ToList(); var links = Objects(note["url"]).ToList();
var nested = links.Where(l => Value(l, "mediaType")?.Equals("application/x-mpegURL", StringComparison.OrdinalIgnoreCase) == true)
.SelectMany(l => Objects(l["tag"]))
.ToList();
return new VideoDetails return new VideoDetails
{ {
DurationSeconds = Seconds(note["duration"]), DurationSeconds = Seconds(note["duration"]),
Variants = Variants(links, "video/", "audio/"), Variants = Variants(links.Concat(nested).ToList(), "video/", "audio/"),
PlaylistURL = links.Where(l => Value(l, "mediaType")?.Equals("application/x-mpegURL", StringComparison.OrdinalIgnoreCase) == true) PlaylistURL = links.Where(l => Value(l, "mediaType")?.Equals("application/x-mpegURL", StringComparison.OrdinalIgnoreCase) == true)
.Select(l => Value(l, "href")).FirstOrDefault(IsWeb), .Select(l => Value(l, "href")).FirstOrDefault(IsWeb),
IsLive = Bool(note, "isLiveBroadcast") == true, IsLive = Bool(note, "isLiveBroadcast") == true,
@@ -60,6 +60,16 @@ namespace PrivaPub.Federation.Outbox
inboxes.Add(parentAuthor.InboxURL); inboxes.Add(parentAuthor.InboxURL);
} }
if (post.Visibility is PostVisibility.Public or PostVisibility.Unlisted && !string.IsNullOrEmpty(post.QuotedPostId))
{
var quoted = await _dbEntities.Posts.MatchID(post.QuotedPostId).ExecuteFirstAsync(token);
var quotedAuthor = quoted is { IsFederatedCopy: true }
? await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == quoted.ActorURI).ExecuteFirstAsync(token)
: default;
if (!string.IsNullOrEmpty(quotedAuthor?.InboxURL))
inboxes.Add(quotedAuthor.InboxURL);
}
return inboxes.Where(i => !string.IsNullOrEmpty(i)).Distinct(StringComparer.Ordinal).ToList(); return inboxes.Where(i => !string.IsNullOrEmpty(i)).Distinct(StringComparer.Ordinal).ToList();
} }
@@ -39,6 +39,22 @@ namespace PrivaPub.Federation.Rendering
["blurhash"] = "toot:blurhash", ["blurhash"] = "toot:blurhash",
["votersCount"] = "toot:votersCount", ["votersCount"] = "toot:votersCount",
["Emoji"] = "toot:Emoji", ["Emoji"] = "toot:Emoji",
["quote"] = new JsonObject { ["@id"] = "https://w3id.org/fep/044f#quote", ["@type"] = "@id" },
["quoteAuthorization"] = new JsonObject { ["@id"] = "https://w3id.org/fep/044f#quoteAuthorization", ["@type"] = "@id" },
["QuoteRequest"] = "https://w3id.org/fep/044f#QuoteRequest",
["QuoteAuthorization"] = "https://w3id.org/fep/044f#QuoteAuthorization",
["interactingObject"] = new JsonObject { ["@id"] = "gts:interactingObject", ["@type"] = "@id" },
["interactionTarget"] = new JsonObject { ["@id"] = "gts:interactionTarget", ["@type"] = "@id" },
["misskey"] = "https://misskey-hub.net/ns#",
["_misskey_quote"] = "misskey:_misskey_quote",
["fedibird"] = "http://fedibird.com/ns#",
["quoteUri"] = "fedibird:quoteUri",
["quoteUrl"] = "as:quoteUrl",
["gts"] = "https://gotosocial.org/ns#",
["interactionPolicy"] = new JsonObject { ["@id"] = "gts:interactionPolicy", ["@type"] = "@id" },
["canQuote"] = new JsonObject { ["@id"] = "gts:canQuote", ["@type"] = "@id" },
["automaticApproval"] = new JsonObject { ["@id"] = "gts:automaticApproval", ["@type"] = "@id" },
["manualApproval"] = new JsonObject { ["@id"] = "gts:manualApproval", ["@type"] = "@id" },
["litepub"] = "http://litepub.social/ns#", ["litepub"] = "http://litepub.social/ns#",
["EmojiReact"] = "litepub:EmojiReact", ["EmojiReact"] = "litepub:EmojiReact",
["focalPoint"] = new JsonObject { ["@container"] = "@list", ["@id"] = "toot:focalPoint" }, ["focalPoint"] = new JsonObject { ["@container"] = "@list", ["@id"] = "toot:focalPoint" },
@@ -160,6 +176,50 @@ namespace PrivaPub.Federation.Rendering
return note; return note;
} }
public static JsonObject UpdateOf(PostEntity post, LocalActor author, string reason)
{
var note = post.Visibility == PostVisibility.Direct
? DirectNote(post, author, Array.Empty<(string, string)>(), post.ContextURI)
: Note(post, author, default, post.InReplyToURI);
note["to"] = new JsonArray(post.To.Select(t => (JsonNode)t).ToArray());
note["cc"] = new JsonArray(post.Cc.Select(c => (JsonNode)c).ToArray());
return new JsonObject
{
["@context"] = Context(),
["id"] = author.ActivityUri($"update-{post.ID}-{reason}"),
["type"] = "Update",
["actor"] = author.Uri,
["to"] = note["to"]!.DeepClone(),
["cc"] = note["cc"]!.DeepClone(),
["object"] = note
};
}
public static string QuotableBy(PostEntity post) =>
post.Visibility is PostVisibility.Public or PostVisibility.Unlisted ? post.LocalQuotePolicy ?? Models.User.QuotePolicies.Nobody : Models.User.QuotePolicies.Nobody;
static void AddQuote(JsonObject note, PostEntity post, ref string content)
{
if (string.IsNullOrEmpty(post.QuoteURI))
return;
var link = WebUtility.HtmlEncode(post.QuoteURI);
content += $"<p class=\"quote-inline\">RE: <a href=\"{link}\">{link}</a></p>";
if (post.QuoteByConsent)
note["quote"] = post.QuoteURI;
if (!string.IsNullOrEmpty(post.QuoteAuthorizationURI))
note["quoteAuthorization"] = post.QuoteAuthorizationURI;
note["_misskey_quote"] = post.QuoteURI;
note["quoteUri"] = post.QuoteURI;
note["quoteUrl"] = post.QuoteURI;
(note["tag"] as JsonArray)?.Add(new JsonObject
{
["type"] = "Link",
["mediaType"] = "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"",
["href"] = post.QuoteURI,
["name"] = $"RE: {post.QuoteURI}"
});
}
static void AddPoll(JsonObject note, PostPoll poll) static void AddPoll(JsonObject note, PostPoll poll)
{ {
note["type"] = "Question"; note["type"] = "Question";
@@ -211,6 +271,21 @@ namespace PrivaPub.Federation.Rendering
})) }))
.ToArray()) .ToArray())
}; };
AddQuote(note, post, ref content);
if (!post.IsFederatedCopy)
note["interactionPolicy"] = new JsonObject
{
["canQuote"] = new JsonObject
{
["automaticApproval"] = new JsonArray((JsonNode)(QuotableBy(post) switch
{
Models.User.QuotePolicies.Public => Objects.Addressing.Public,
Models.User.QuotePolicies.Followers => author.Followers,
_ => author.Uri
}))
}
};
note["content"] = content;
if (!string.IsNullOrEmpty(post.Language)) if (!string.IsNullOrEmpty(post.Language))
note["contentMap"] = new JsonObject { [post.Language] = content }; note["contentMap"] = new JsonObject { [post.Language] = content };
if (post.Poll is { } poll) if (post.Poll is { } poll)
+1
View File
@@ -116,6 +116,7 @@ namespace PrivaPub.Infrastructure.Data
await DB.Default.Index<Reaction>().Key(r => r.PostId, KeyType.Ascending).Key(r => r.ActorURI, KeyType.Ascending).Key(r => r.Emoji, KeyType.Ascending) await DB.Default.Index<Reaction>().Key(r => r.PostId, KeyType.Ascending).Key(r => r.ActorURI, KeyType.Ascending).Key(r => r.Emoji, KeyType.Ascending)
.Option(o => o.Unique = true).CreateAsync(token); .Option(o => o.Unique = true).CreateAsync(token);
await Plain<Reaction>(token, r => r.ActivityURI); await Plain<Reaction>(token, r => r.ActivityURI);
await DB.Default.Index<QuoteLicence>().Key(l => l.PostId, KeyType.Ascending).Key(l => l.QuotingObjectURI, KeyType.Ascending).CreateAsync(token);
await Unique<Domain.Content.LinkPreview>(p => p.Url, Builders<Domain.Content.LinkPreview>.Filter.Type(p => p.Url, BsonType.String), token); await Unique<Domain.Content.LinkPreview>(p => p.Url, Builders<Domain.Content.LinkPreview>.Filter.Type(p => p.Url, BsonType.String), token);
} }
@@ -23,6 +23,7 @@ namespace PrivaPub.Infrastructure.Http
Task<FetchedJson> GetJson(string url, string accept, Action<HttpRequestMessage> sign, CancellationToken token); Task<FetchedJson> GetJson(string url, string accept, Action<HttpRequestMessage> sign, CancellationToken token);
bool FailedTemporarily(string url); bool FailedTemporarily(string url);
Task<(Uri FinalUri, string Html)> GetPage(string url, CancellationToken token); Task<(Uri FinalUri, string Html)> GetPage(string url, CancellationToken token);
Task<HttpResponseMessage> OpenMedia(string url, System.Net.Http.Headers.RangeHeaderValue range, CancellationToken token);
Task<HttpResponseMessage> Send(HttpRequestMessage request, CancellationToken token); Task<HttpResponseMessage> Send(HttpRequestMessage request, CancellationToken token);
Task<(byte[] Bytes, string ContentType)> GetMedia(string url, long maxBytes, CancellationToken token); Task<(byte[] Bytes, string ContentType)> GetMedia(string url, long maxBytes, CancellationToken token);
} }
@@ -229,6 +230,46 @@ namespace PrivaPub.Infrastructure.Http
} }
} }
public async Task<HttpResponseMessage> OpenMedia(string url, System.Net.Http.Headers.RangeHeaderValue range, CancellationToken token)
{
if (!Uri.TryCreate(url, UriKind.Absolute, out var target) || !IsAllowed(target))
return default;
try
{
for (var hop = 0; hop <= MaxRedirects; hop++)
{
using var request = new HttpRequestMessage(HttpMethod.Get, target);
request.Headers.Accept.ParseAdd("video/*, audio/*, image/*");
request.Headers.Range = range;
var response = await _httpClientFactory.CreateClient(ClientName).SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token);
if (IsRedirect(response.StatusCode))
{
var location = response.Headers.Location;
response.Dispose();
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.Contains("svg")
|| !(mediaType.StartsWith("video/") || mediaType.StartsWith("audio/") || mediaType.StartsWith("image/") || mediaType == "application/octet-stream"))
{
response.Dispose();
return default;
}
return response;
}
return default;
}
catch (Exception ex) when (ex is HttpRequestException or BlockedDestinationException or OperationCanceledException && !token.IsCancellationRequested)
{
_logger.LogInformation("Media stream {Url} refused: {Reason}", url, ex.Message);
return default;
}
}
const int MaxPageBytes = 512 * 1024; const int MaxPageBytes = 512 * 1024;
static async Task<byte[]> ReadPrefix(HttpContent content, int limit, CancellationToken token) static async Task<byte[]> ReadPrefix(HttpContent content, int limit, CancellationToken token)
@@ -78,6 +78,7 @@ namespace PrivaPub.Middleware
.AddSingleton<IActivityHandler, LikeHandler>() .AddSingleton<IActivityHandler, LikeHandler>()
.AddSingleton<IActivityHandler, DislikeHandler>() .AddSingleton<IActivityHandler, DislikeHandler>()
.AddSingleton<IActivityHandler, EmojiReactHandler>() .AddSingleton<IActivityHandler, EmojiReactHandler>()
.AddSingleton<IActivityHandler, QuoteRequestHandler>()
.AddSingleton<IReactions, Reactions>() .AddSingleton<IReactions, Reactions>()
.AddSingleton<LinkPreviews>() .AddSingleton<LinkPreviews>()
.AddSingleton<ILinkPreviews>(services => services.GetRequiredService<LinkPreviews>()) .AddSingleton<ILinkPreviews>(services => services.GetRequiredService<LinkPreviews>())
@@ -92,6 +93,7 @@ namespace PrivaPub.Middleware
.AddSingleton<IRemotePosts, RemotePosts>() .AddSingleton<IRemotePosts, RemotePosts>()
.AddSingleton<IObjectRecords, ObjectRecords>() .AddSingleton<IObjectRecords, ObjectRecords>()
.AddSingleton<IPollService, PollService>() .AddSingleton<IPollService, PollService>()
.AddSingleton<IQuoteService, QuoteService>()
.AddSingleton<IJobHandler, PollRefreshJob>() .AddSingleton<IJobHandler, PollRefreshJob>()
.AddSingleton<IJobHandler, PollCloseJob>() .AddSingleton<IJobHandler, PollCloseJob>()
.AddSingleton<IJobHandler, InstanceDescriber>() .AddSingleton<IJobHandler, InstanceDescriber>()
@@ -0,0 +1,14 @@
using MongoDB.Entities;
namespace PrivaPub.Models.Federation
{
public class QuoteLicence : Entity
{
public string PostId { get; set; }//the quoted local post
public string AuthorAvatarId { get; set; }
public string QuotingObjectURI { get; set; }
public string QuoterActorURI { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? RevokedAt { get; set; }
}
}
+19
View File
@@ -57,6 +57,14 @@ namespace PrivaPub.Models.Post
public string Url { get; set; } public string Url { get; set; }
public string ContextURI { get; set; } public string ContextURI { get; set; }
public string QuoteURI { get; set; } public string QuoteURI { get; set; }
public string QuotedPostId { get; set; }//our copy of the quoted post, once fetched
public QuoteState QuoteState { get; set; }
public string QuoteAuthorizationURI { get; set; }//FEP-044f: the quoted author's stamp
public bool QuoteByConsent { get; set; }
public string LocalQuotePolicy { get; set; }//who may quote this local post: QuotePolicies.Public, Followers or Nobody//our quote of a post whose server asks for consent (FEP-044f), so `quote` is sent
public int QuotesCount { get; set; }
[BsonIgnoreIfNull]
public InteractionRule QuotePolicy { get; set; }//a remote post's interactionPolicy.canQuote; null when it states none
public List<string> To { get; set; } = new(); public List<string> To { get; set; } = new();
public List<string> Cc { get; set; } = new(); public List<string> Cc { get; set; } = new();
@@ -73,6 +81,17 @@ namespace PrivaPub.Models.Post
public string UpdateReason { get; set; } public string UpdateReason { get; set; }
} }
public enum QuoteState
{
None,
Pending,
Accepted,
Rejected,
Revoked,
Deleted,
Unauthorized
}
public enum PostVisibility public enum PostVisibility
{ {
Public, Public,
+6
View File
@@ -23,6 +23,12 @@ namespace PrivaPub.Models.Post
public int Votes { get; set; } public int Votes { get; set; }
} }
public class InteractionRule
{
public List<string> Automatic { get; set; } = new();//actor or collection URIs; Public for anyone
public List<string> Manual { get; set; } = new();
}
public class PostSource public class PostSource
{ {
public string Content { get; set; } public string Content { get; set; }
+2 -1
View File
@@ -25,6 +25,7 @@ namespace PrivaPub.Models.Social
Reblog, Reblog,
Update, Update,
Poll, Poll,
Reaction Reaction,
Quote
} }
} }
+10
View File
@@ -110,6 +110,16 @@ namespace PrivaPub.Models.User
public string DefaultVisibility { get; set; } = "public"; public string DefaultVisibility { get; set; } = "public";
public bool DefaultSensitive { get; set; } public bool DefaultSensitive { get; set; }
public string DefaultLanguage { get; set; } public string DefaultLanguage { get; set; }
public string QuotePolicy { get; set; } = QuotePolicies.Public;//owner decision: anyone may quote a public post, automatically
}
public static class QuotePolicies
{
public const string Public = "public";
public const string Followers = "followers";
public const string Nobody = "nobody";
public static bool IsKnown(string value) => value is Public or Followers or Nobody;
} }
public enum AvatarServer public enum AvatarServer
+12
View File
@@ -48,6 +48,18 @@ server {
proxy_read_timeout 300s; proxy_read_timeout 300s;
} }
location ^~ /media/proxy/ {
proxy_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 600s;
}
location / { location / {
proxy_pass http://127.0.0.1:6970; proxy_pass http://127.0.0.1:6970;
proxy_http_version 1.1; proxy_http_version 1.1;
+16 -8
View File
@@ -11,7 +11,7 @@ Written 2026-10-01 from the original 2023 code, the decePubClient UI, a federati
- [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) - [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)
- [x] P4 Groups and privacy features: v1.6.0, deployed 2026-10-01; communities, circles and local-only located posts verified by tests. v1.6.1 adds the pasture (`tools/pasture/`): live interop with GoToSocial 0.22.1 passes all 25 checks, three runs in a row. Lemmy and a live Mastodon circle member are not run yet; the pasture has GoToSocial only - [x] P4 Groups and privacy features: v1.6.0, deployed 2026-10-01; communities, circles and local-only located posts verified by tests. v1.6.1 adds the pasture (`tools/pasture/`): live interop with GoToSocial 0.22.1 passes all 25 checks, three runs in a row. Lemmy and a live Mastodon circle member are not run yet; the pasture has GoToSocial only
- [x] P5 Lose nothing: v1.7.0 to v1.9.1, deployed 2026-10-01; parsing, typed details, provenance, downvotes, tombstones, federated blocks, all checked live against GoToSocial. Book reviews and forum threads keep their raw form only (typed in P7 and P8) - [x] P5 Lose nothing: v1.7.0 to v1.9.1, deployed 2026-10-01; parsing, typed details, provenance, downvotes, tombstones, federated blocks, all checked live against GoToSocial. Book reviews and forum threads keep their raw form only (typed in P7 and P8)
- [ ] P6 Emoji, polls, quotes, reactions, cards, players - [x] P6 Emoji, polls, quotes, reactions, cards, players: v1.10.0 to v1.15.0, deployed 2026-10-01; polls, link cards, ranged video streaming and quote policies checked live against GoToSocial
- [ ] P7 Threads, communities, moderation, the social graph - [ ] P7 Threads, communities, moderation, the social graph
- [ ] P8 Signatures, discovery, the long tail - [ ] P8 Signatures, discovery, the long tail
@@ -128,6 +128,8 @@ and circles (see Owner decisions).
| PeerTube views | **Never sent.** The remote video file is still downloaded through our proxy when it is played; that cannot be avoided without pre-downloading video. | | PeerTube views | **Never sent.** The remote video file is still downloaded through our proxy when it is played; that cannot be avoided without pre-downloading video. |
| Bluesky bridging (Bridgy Fed) | **Allowed per persona**, with a clear warning that the posts become far more widely copied. Leaving the bridge must work, through a federated `Block` sent to the bridge. Persona creation dates are moved back by a random number of days, so personas created the same day no longer share a date. | | Bluesky bridging (Bridgy Fed) | **Allowed per persona**, with a clear warning that the posts become far more widely copied. Leaving the bridge must work, through a federated `Block` sent to the bridge. Persona creation dates are moved back by a random number of days, so personas created the same day no longer share a date. |
| Reactions and votes | **Public, with a one-time notice.** The client tells each persona once, before its first reaction or vote, that these are public and visible as that persona. | | Reactions and votes | **Public, with a one-time notice.** The client tells each persona once, before its first reaction or vote, that these are public and visible as that persona. |
| Who may quote a persona (default) | **Anyone, automatically**, as on Mastodon, for public and unlisted posts only. Each persona can change its default (followers only, or nobody) and each post can be changed; a granted quote can be revoked. Followers-only posts and DMs can never be quoted. |
| Quote permission address | `/peasants/{name}/parrot-licences/{id}` |
## Libraries (researched; no maintained .NET ActivityPub library exists, so Letterbook and Iceshrimp.NET both wrote their own) ## Libraries (researched; no maintained .NET ActivityPub library exists, so Letterbook and Iceshrimp.NET both wrote their own)
@@ -171,6 +173,7 @@ were agreed on 2026-10-01.
| Group members | `/peasants/{group}/flock` | *new* | | Group members | `/peasants/{group}/flock` | *new* |
| Group moderators | `/peasants/{group}/wardens` | *new* | | Group moderators | `/peasants/{group}/wardens` | *new* |
| DM conversation context | `/peasants/{name}/whispers/{id}` | *new*, replaces `/conversations/{id}` | | DM conversation context | `/peasants/{name}/whispers/{id}` | *new*, replaces `/conversations/{id}` |
| Quote permission (FEP-044f stamp) | `/peasants/{name}/parrot-licences/{id}` | chosen by the owner, 2026-10-01 |
Routes outside the actor namespace stay conventional, because other software looks for them by name: Routes outside the actor namespace stay conventional, because other software looks for them by name:
- `/.well-known/*` and `/nodeinfo/*`; - `/.well-known/*` and `/nodeinfo/*`;
@@ -453,9 +456,12 @@ it, raw where it doesn't.
- **Polls** in and out, with the Misskey, Pleroma and PieFed vote shapes. A count refresh is never an edit (done in - **Polls** in and out, with the Misskey, Pleroma and PieFed vote shapes. A count refresh is never an edit (done in
v1.10.0; checked live both ways against GoToSocial). v1.10.0; checked live both ways against GoToSocial).
- **Quotes (FEP-044f):** - **Quotes (FEP-044f):**
- read every key; verify `QuoteAuthorization` on all of its fields; handle revocation; - read every key; verify `QuoteAuthorization` on all of its fields; handle revocation (done in v1.13.0);
- be quotable: `canQuote`, answer `QuoteRequest`, serve and revoke stamps; - personas quote others: `quoted_status_id`, `QuoteRequest`, `Accept{result}` verified, `Update` with
- then advertise `api_versions.mastodon ≥ 7` (4.5.0). `quoteAuthorization`; legacy quotes for posts that state no policy (done in v1.13.0); `api_versions.mastodon = 7`;
- be quotable (done in v1.15.0): `canQuote` on our posts (owner's default: anyone, automatically), `QuoteRequest`
answered with a parrot-licence or `Reject`, licences served and revoked; personas set `source[quote_policy]`, posts
`quote_approval_policy`; `POST /statuses/:id/quotes/:quoting_id/revoke`, `PUT /statuses/:id/interaction_policy`.
- **Emoji reactions** in all three inbound forms, plus outbound `EmojiReact`, exposed as `emoji_reactions` (done in - **Emoji reactions** in all three inbound forms, plus outbound `EmojiReact`, exposed as `emoji_reactions` (done in
v1.11.0; Pleroma's `/api/v1/pleroma/statuses/:id/reactions` endpoints and `pleroma:emoji_reaction` notifications). v1.11.0; Pleroma's `/api/v1/pleroma/statuses/:id/reactions` endpoints and `pleroma:emoji_reaction` notifications).
- **Link cards** (done in v1.12.0): - **Link cards** (done in v1.12.0):
@@ -463,11 +469,13 @@ it, raw where it doesn't.
- otherwise the server reads the page (owner decision 1): public posts only, 0–60 s after arrival, cached per address - otherwise the server reads the page (owner decision 1): public posts only, 0–60 s after arrival, cached per address
for 7 days across the whole server, `Federation:FetchLinkPreviews` to switch it off. Checked live against a for 7 days across the whole server, `Federation:FetchLinkPreviews` to switch it off. Checked live against a
GoToSocial profile page. GoToSocial profile page.
- **Media:** - **Media** (done in v1.14.0 except where noted):
- video playback through the proxy (Range requests, HLS playlist rewriting, the poster), with a `video` card; - video playback through the proxy: range requests streamed from the origin, the poster, a playable attachment picked
from MP4 or fragmented MP4 files (HLS playlists are not rewritten; not needed while PeerTube publishes the files);
- audio attachments; - audio attachments;
- an article reader view; - JPEG/PNG for Pixelfed: uploads were already re-encoded to JPEG, PNG or GIF;
- JPEG/PNG renditions kept for Pixelfed. - an article reader view: the data is in `Status.privapub` (title, excerpt, cover, full HTML in `content`); the view
itself belongs to the client.
#### P7 Threads, communities and the social graph #### P7 Threads, communities and the social graph
- **Thread backfill:** - **Thread backfill:**
+5
View File
@@ -114,6 +114,11 @@ gts_poll_on_pp=$(curl -s -H "$PH" "$P/api/v1/timelines/home" | j "print(next(s['
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/polls/$gts_poll_on_pp/votes" -d 'choices[]=0' curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/polls/$gts_poll_on_pp/votes" -d 'choices[]=0'
until_true 20 '[ "$(gcurl -s -H "$GH" "$G/api/v1/statuses/$gts_poll" | j "print(d[\"poll\"][\"options\"][0][\"votes_count\"])")" = "1" ]' && ok "alice's vote counts on GoToSocial" || ko "vote not counted on GoToSocial" until_true 20 '[ "$(gcurl -s -H "$GH" "$G/api/v1/statuses/$gts_poll" | j "print(d[\"poll\"][\"options\"][0][\"votes_count\"])")" = "1" ]' && ok "alice's vote counts on GoToSocial" || ko "vote not counted on GoToSocial"
echo "quotes"
quote_target=$(curl -s -H "$PH" "$P/api/v1/timelines/home" | j "print(next(s['id'] for s in d if 'red or blue' in s['content']))")
[ "$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "$PH" $P/api/v1/statuses -d "status=quoting&quoted_status_id=$quote_target")" = "422" ] \
&& ok "GoToSocial's author-only quote policy is respected" || ko "quoted a post whose author forbids it"
echo "link previews" echo "link previews"
linked=$(curl -s -X POST -H "$PH" $P/api/v1/statuses --data-urlencode 'status=have a look https://gts.test/@gtsuser' -d 'visibility=public' | j "print(d['id'])") linked=$(curl -s -X POST -H "$PH" $P/api/v1/statuses --data-urlencode 'status=have a look https://gts.test/@gtsuser' -d 'visibility=public' | j "print(d['id'])")
until_true 45 '[ -n "$(curl -s -H "$PH" "$P/api/v1/statuses/$linked" | j "print((d[\"card\"] or {}).get(\"title\") or \"\")")" ]' && ok "the server builds a card for a linked page" || ko "no card for the linked page" until_true 45 '[ -n "$(curl -s -H "$PH" "$P/api/v1/statuses/$linked" | j "print((d[\"card\"] or {}).get(\"title\") or \"\")")" ]' && ok "the server builds a card for a linked page" || ko "no card for the linked page"