Compare commits

..
2 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
25 changed files with 566 additions and 40 deletions

No files matched your search

+15
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
@@ -269,6 +280,10 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
`QuotesCount` moves with the accepted state, never with the raw key. When a persona quotes, `QuotePermission` decides: `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` 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. 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
+10 -2
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}` |
@@ -130,7 +131,13 @@ An incoming `Update` without a newer `updated` only refreshes counts and details
stamp we can verify; the post is then updated with `quoteAuthorization`. stamp we can verify; the post is then updated with `quoteAuthorization`.
- Posts that state no policy are quoted the older way, without `quote`. - Posts that state no policy are quoted the older way, without `quote`.
- Quoting posts are also delivered to the quoted author. - Quoting posts are also delivered to the quoted author.
- **Not yet.** Our own posts cannot be quoted by others yet: we do not issue stamps. - **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`,
@@ -181,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.
@@ -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("""
+65 -3
View File
@@ -139,15 +139,15 @@ namespace PrivaPub.Tests.Federation
} }
[Fact] [Fact]
public async Task A_legacy_post_is_quoted_without_asking_and_another_persona_cannot_be_quoted_yet() 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 token = TestContext.Current.CancellationToken;
var (aliceRoot, alice) = await _harness.Persona("alice"); var (aliceRoot, alice) = await _harness.Persona("alice");
var (bobRoot, bob) = await _harness.Persona("bob"); var (bobRoot, bob) = await _harness.Persona("bob");
var kitty = new RemoteActor(_harness.Peer, "kitty"); var kitty = new RemoteActor(_harness.Peer, "kitty");
var legacy = await Delivered(kitty, alice.Uri); var legacy = await Delivered(kitty, alice.Uri);
await _harness.Posts.InsertPost(bobRoot, new InsertPostForm { AvatarId = bob.Id, Text = "mine" }, token); bob.Settings.QuotePolicy = PrivaPub.Models.User.QuotePolicies.Nobody;
var bobs = await DB.Default.Find<Post>().Match(p => p.GroupUserId == bob.Id).ExecuteFirstAsync(token); 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); var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "nice", QuotedStatusId = legacy.ID }, token);
Assert.Equal(QuoteState.Accepted, outcome.Post.QuoteState); Assert.Equal(QuoteState.Accepted, outcome.Post.QuoteState);
@@ -155,5 +155,67 @@ namespace PrivaPub.Tests.Federation
Assert.False((await _harness.Statuses.Publish(alice, new StatusDraft { Text = "and this", QuotedStatusId = bobs.ID }, token)).Ok); 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); 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);
}
} }
} }
+1
View File
@@ -58,6 +58,7 @@ namespace PrivaPub.Tests.Support
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),
@@ -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++)
{ {
@@ -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;
@@ -55,6 +63,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
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"), 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)
@@ -242,6 +251,32 @@ namespace PrivaPub.Api.Mastodon.Controllers
return Json(await _mapper.Statuses(visible, MyId, token)); 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)
{ {
@@ -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
@@ -93,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)
@@ -225,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),
@@ -238,6 +239,10 @@ 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 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>(); 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)) foreach (var quotedPost in quotedIds.Count == 0 ? new List<PostEntity>() : await _dbEntities.Posts.Match(p => quotedIds.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token))
@@ -250,7 +255,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
void Quote(Status status, PostEntity post) void Quote(Status status, PostEntity post)
{ {
status.QuotesCount = post.QuotesCount; status.QuotesCount = post.QuotesCount;
status.QuoteApproval = Approval(post, viewerId); status.QuoteApproval = post.IsFederatedCopy ? Approval(post, viewerId) : LocalApproval(post, viewerId, followsAuthor.Contains(post.GroupUserId));
if (post.QuoteState == QuoteState.None) if (post.QuoteState == QuoteState.None)
return; return;
var state = post.QuoteState.ToString().ToLowerInvariant(); var state = post.QuoteState.ToString().ToLowerInvariant();
@@ -300,6 +305,27 @@ namespace PrivaPub.Api.Mastodon.Mappers
return document.Body!.InnerHtml; 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) static QuoteApprovalEntity Approval(PostEntity post, string viewerId)
{ {
if (!post.IsFederatedCopy || post.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted)) if (!post.IsFederatedCopy || post.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted))
@@ -445,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;
+114 -6
View File
@@ -25,7 +25,10 @@ namespace PrivaPub.Domain.Statuses
Task Resolve(PostEntity post, NoteDocument note, CancellationToken token); Task Resolve(PostEntity post, NoteDocument note, CancellationToken token);
Task Revoke(string stampUri, CancellationToken token); Task Revoke(string stampUri, CancellationToken token);
Task<bool> Verified(string stampUri, string quotingUri, PostEntity quoted, CancellationToken token); Task<bool> Verified(string stampUri, string quotingUri, PostEntity quoted, CancellationToken token);
QuotePermission Permission(PostEntity quoted, LocalActor author); 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 Request(LocalActor author, PostEntity post, PostEntity quoted, JsonObject note, CancellationToken token);
Task<bool> Answered(JsonNode answer, ForeignAvatar actor, bool accepted, CancellationToken token); Task<bool> Answered(JsonNode answer, ForeignAvatar actor, bool accepted, CancellationToken token);
} }
@@ -59,12 +62,115 @@ namespace PrivaPub.Domain.Statuses
const string RequestPrefix = "quote-request-"; const string RequestPrefix = "quote-request-";
public QuotePermission Permission(PostEntity quoted, LocalActor author) 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) if (!quoted.IsFederatedCopy)
return quoted.GroupUserId == author.Id && quoted.Visibility is PostVisibility.Public or PostVisibility.Unlisted return await MayQuote(quoted, author.Uri, token) ? QuotePermission.Granted : QuotePermission.Denied;
? QuotePermission.Granted
: QuotePermission.Denied;
if (quoted.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted)) if (quoted.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted))
return QuotePermission.Denied; return QuotePermission.Denied;
if (quoted.QuotePolicy is not { } policy) if (quoted.QuotePolicy is not { } policy)
@@ -140,7 +246,9 @@ namespace PrivaPub.Domain.Statuses
var state = note.QuoteDeleted ? QuoteState.Deleted var state = note.QuoteDeleted ? QuoteState.Deleted
: quoted == default ? QuoteState.Pending : quoted == default ? QuoteState.Pending
: note.QuoteAuthorization != default : note.QuoteAuthorization != default
? !quoted.IsFederatedCopy || !await Verified(note.QuoteAuthorization, post.ObjectURI, quoted, token) ? QuoteState.Unauthorized : QuoteState.Accepted ? (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 : note.QuotesByConsent ? QuoteState.Pending
: quoted.Visibility is PostVisibility.Public or PostVisibility.Unlisted ? QuoteState.Accepted : quoted.Visibility is PostVisibility.Public or PostVisibility.Unlisted ? QuoteState.Accepted
: QuoteState.Unauthorized; : QuoteState.Unauthorized;
+7 -1
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;
@@ -41,6 +42,7 @@ namespace PrivaPub.Domain.Statuses
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 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)
@@ -125,7 +127,7 @@ namespace PrivaPub.Domain.Statuses
quoted = await _dbEntities.Posts.Match(p => p.ID == draft.QuotedStatusId && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token); 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)) if (quoted == default || !await VisibilityPolicy.CanSee(quoted, author.Id, token))
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found"); return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
quotePermission = _quotes.Permission(quoted, author); quotePermission = await _quotes.Permission(quoted, author, token);
if (quotePermission == QuotePermission.Denied) if (quotePermission == QuotePermission.Denied)
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: This post cannot be quoted"); return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: This post cannot be quoted");
} }
@@ -190,6 +192,10 @@ namespace PrivaPub.Domain.Statuses
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));
@@ -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)
{ {
@@ -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);
}
}
+4 -1
View File
@@ -217,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,
@@ -43,11 +43,18 @@ namespace PrivaPub.Federation.Rendering
["quoteAuthorization"] = new JsonObject { ["@id"] = "https://w3id.org/fep/044f#quoteAuthorization", ["@type"] = "@id" }, ["quoteAuthorization"] = new JsonObject { ["@id"] = "https://w3id.org/fep/044f#quoteAuthorization", ["@type"] = "@id" },
["QuoteRequest"] = "https://w3id.org/fep/044f#QuoteRequest", ["QuoteRequest"] = "https://w3id.org/fep/044f#QuoteRequest",
["QuoteAuthorization"] = "https://w3id.org/fep/044f#QuoteAuthorization", ["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"] = "https://misskey-hub.net/ns#",
["_misskey_quote"] = "misskey:_misskey_quote", ["_misskey_quote"] = "misskey:_misskey_quote",
["fedibird"] = "http://fedibird.com/ns#", ["fedibird"] = "http://fedibird.com/ns#",
["quoteUri"] = "fedibird:quoteUri", ["quoteUri"] = "fedibird:quoteUri",
["quoteUrl"] = "as:quoteUrl", ["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" },
@@ -188,6 +195,9 @@ namespace PrivaPub.Federation.Rendering
}; };
} }
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) static void AddQuote(JsonObject note, PostEntity post, ref string content)
{ {
if (string.IsNullOrEmpty(post.QuoteURI)) if (string.IsNullOrEmpty(post.QuoteURI))
@@ -262,6 +272,19 @@ namespace PrivaPub.Federation.Rendering
.ToArray()) .ToArray())
}; };
AddQuote(note, post, ref content); 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; 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 };
+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>())
@@ -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; }
}
}
+2 -1
View File
@@ -60,7 +60,8 @@ namespace PrivaPub.Models.Post
public string QuotedPostId { get; set; }//our copy of the quoted post, once fetched public string QuotedPostId { get; set; }//our copy of the quoted post, once fetched
public QuoteState QuoteState { get; set; } public QuoteState QuoteState { get; set; }
public string QuoteAuthorizationURI { get; set; }//FEP-044f: the quoted author's stamp public string QuoteAuthorizationURI { get; set; }//FEP-044f: the quoted author's stamp
public bool QuoteByConsent { get; set; }//our quote of a post whose server asks for consent (FEP-044f), so `quote` is sent 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; } public int QuotesCount { get; set; }
[BsonIgnoreIfNull] [BsonIgnoreIfNull]
public InteractionRule QuotePolicy { get; set; }//a remote post's interactionPolicy.canQuote; null when it states none public InteractionRule QuotePolicy { get; set; }//a remote post's interactionPolicy.canQuote; null when it states none
+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;
+13 -7
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/*`;
@@ -456,8 +459,9 @@ it, raw where it doesn't.
- read every key; verify `QuoteAuthorization` on all of its fields; handle revocation (done in v1.13.0); - read every key; verify `QuoteAuthorization` on all of its fields; handle revocation (done in v1.13.0);
- personas quote others: `quoted_status_id`, `QuoteRequest`, `Accept{result}` verified, `Update` with - personas quote others: `quoted_status_id`, `QuoteRequest`, `Accept{result}` verified, `Update` with
`quoteAuthorization`; legacy quotes for posts that state no policy (done in v1.13.0); `api_versions.mastodon = 7`; `quoteAuthorization`; legacy quotes for posts that state no policy (done in v1.13.0); `api_versions.mastodon = 7`;
- still open: be quotable (`canQuote` on our posts, answer `QuoteRequest`, serve and revoke stamps). It needs a themed - be quotable (done in v1.15.0): `canQuote` on our posts (owner's default: anyone, automatically), `QuoteRequest`
route name for stamps, agreed with the owner, and a default quote policy. 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):
@@ -465,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:**