Compare commits

..
13 Commits
Author SHA1 Message Date
thepraandClaude Opus 5.5 d8f163b5ce Persona separation is tested, the API is smoke-checked on deploy, OAuth rows are pruned
Build / Build (push) Successful in 59s
Deploy / privapub.thepra.dev (push) Successful in 1m13s
- PersonaSeparationTests: two personas of one login follow the same
  account and post; nothing the API maps for one contains the other's id,
  username or the root id.
- tools/smoke/mastodon-api.sh checks what a client meets first (instance
  v1/v2, discovery, app registration, client credentials, an app token
  refused by a user endpoint, the public timeline, revocation) and, given
  a persona token, verify_credentials, home and notifications. deploy.yml
  runs it after every deploy.
- OAuthPruner removes invalid tokens and authorizations older than two
  weeks, every six hours.
- The consent page no longer sets form-action, which browsers apply to the
  redirect back to the client after the form is posted.

CLAUDE.md gains the Mastodon API layout and invariants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 12:09:19 +02:00
thepraandClaude Opus 5.5 70b4c45eb0 The dead-host test tolerates a loaded machine
It asserted fewer than ten requests to the dead host by the time the live
one arrived, which a busy workstation can exceed without the per-host
limit being broken; the live delivery arriving at all is the point.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 12:07:44 +02:00
thepraandClaude Opus 5.5 0028e96e76 The Mastodon client API: accounts, statuses, timelines, notifications, search
With a token for a persona, a Mastodon client can now:
- accounts: verify/update credentials (display name, note, fields, locked,
  bot, discoverable, indexable, hide collections, posting defaults; the
  change federates as Update{Person}), get, lookup, statuses (paged, by
  visibility to the viewer), relationships, search, follow and unfollow,
  follow requests (authorize and reject answer remote followers with
  Accept or Reject), remove from followers. Followers and following lists
  are shown to their owner only.
- statuses: post (plain text, mentions, hashtags, replies, content
  warnings, the four visibilities, Idempotency-Key), get, edit (PUT),
  delete returning the source for redrafting, context, history, source,
  favourite, reblog and their undos, favourited_by and reblogged_by.
- timelines: home, public (local or remote), tag; favourites;
  conversations; markers.
- notifications: list with types and exclude_types, get, dismiss, clear,
  unread count.
- /api/v2/search, resolving a handle or a URL to an account or a post.

Media, polls, pins, bookmarks, mutes, blocks, lists, filters, trends and
push answer empty lists or a 422 saying they are not supported yet, so
clients degrade instead of failing. Public reads work without a token.
Persona settings (locked, bot, indexable, discoverable) now also shape
the ActivityPub actor.

Fixed on the way: three conditional expressions whose `default` was the
value type's, so a missing limit became 1, a missing flag became false and
an attachment without dimensions became 0x0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 12:05:58 +02:00
thepraandClaude Opus 5.5 b54cdf78b2 One StatusService behind both client APIs, with outbound likes and boosts
Domain/Statuses/StatusService takes a persona, not a root, and is what
/clientapi and the Mastodon API share:
- Publish renders Markdown (clientapi) or plain text (Mastodon clients),
  checks the persona may see the post it replies to, opens or reuses the
  conversation for a direct post from its recipients and mentions, fans
  out and hands the Create to the outbox;
- Edit keeps a revision and sends Update{Note}; Remove soft-deletes and
  returns the post, so a client can delete and redraft;
- Favourite and Reblog work on anything the persona can see and send Like,
  Announce and their Undo to the author (and, for boosts, to followers);
  boosts are refused for anything but public and unlisted posts.

PostsService is now the clientapi wrapper that checks the root owns the
persona. A persona's own boost is a local row: the outbox renders it as
Announce, /grunts/announce-{id} resolves, and object endpoints, profile
pages and NodeInfo skip it. ContentFormat gains Plain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:55:22 +02:00
thepraandClaude Opus 5.5 4d9be37c1c Mastodon clients can sign in: OAuth with a persona per token
OpenIddict 7.7 (MongoDB stores, keys kept in Mongo so tokens survive
restarts) serves /oauth/authorize, /oauth/token, /oauth/revoke and the
discovery documents, including /.well-known/oauth-authorization-server:
- authorization code (PKCE optional) and client credentials, Mastodon's
  scopes including the granular ones, non-expiring reference tokens,
  `created_at` in the token response, and the urn:ietf:wg:oauth:2.0:oob
  page that shows the code;
- /oauth/login signs the private login into a fifteen-minute cookie that
  only /oauth sees (rate limited, antiforgery-protected); /oauth/authorize
  then asks which persona the application acts as. The token's subject
  is that persona's id and nothing else; no root id reaches a token, an
  authorization or a log line;
- the token exchange refuses a persona whose login is banned or deleted,
  and every API request checks the same.

/api/v1/apps registers applications dynamically, /api/v1/apps/
verify_credentials, /api/v1/instance (v1 and v2, "4.2.0 (compatible;
PrivaPub)") and verify_credentials answer in Mastodon's shapes: snake_case
with explicit nulls, Rails-style parameters from query, form or JSON,
{"error": ...} on failure, Link paging. CORS exposes Link.

/api goes to OpenIddict validation and everything else to the existing
JWT; the JWT failure handler no longer sends the exception and stack trace
to the client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:52:31 +02:00
thepraandClaude Opus 5.5 e6a6fe46d7 P1.2 is deployed and verified
Build / Build (push) Successful in 27s
v1.3.0 is live with follows, timelines, notifications, likes, boosts,
edits, tombstones and thread backfill; its indexes exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:42:16 +02:00
thepraandClaude Opus 5.5 7ff3a61e75 FEDERATION.md and CLAUDE.md describe follows, likes, boosts and timelines
Build / Build (push) Successful in 30s
Deploy / privapub.thepra.dev (push) Successful in 45s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:40:02 +02:00
thepraandClaude Opus 5.5 afd2e727d0 A remote delete clears what it touched
Deleting a remote post now removes its timeline entries and the reblogs
of it and lowers its parent's reply count; deleting a remote actor also
drops the follows pointing at it and its entries in home timelines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:39:45 +02:00
thepraandClaude Opus 5.5 b34c71775c Likes and boosts arrive, count, notify, and can be taken back
- Like: a Favourite per account and post (unique), the post's counter, a
  Favourite notification for a local author; only on posts the liker
  could see (public and unlisted, or followers-only and direct when they
  were addressed).
- Announce: the original is always refetched from its own origin (or is
  ours), and must be public or unlisted. A boost of a local post counts
  and notifies; a boost by an account a persona follows is stored as a
  reblog row and fanned out to its followers' home timelines (as a reblog,
  where reblogs are wanted). A boost by a stranger of a stranger's post is
  ignored. FEP-1b12 group announces of activities wait for P4.
- Undo of a Like or an Announce reverses each of them; an Undo naming only
  an id is matched against follows, likes and boosts in turn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:39:10 +02:00
thepraandClaude Opus 5.5 e99b76dbd7 Home timelines, mention notifications, and threads that fetch their parents
Fanout writes a TimelineEntry for every persona a post should reach: the
author, local followers of a local author, local followers of a remote one,
the members of a direct conversation or a circle. Mastodon's home rules
apply when it is written: a reply shows only to followers of both sides
(or to the one replied to), a reblog only where reblogs are wanted. A
mention of a local persona becomes a Mention notification.

Inbound posts are now also kept when a persona follows their author.

RemotePosts holds what CreateHandler and backfill share: building a Post
from a note, and fetching a public parent the first time a reply to it
arrives, so its author is known (a reply to an unknown or non-public
parent stays out of home timelines). Each fetched ancestor queues a
FetchAncestors job for the next one, up to ten deep.

/clientapi/timeline/home and /clientapi/notifications (with
/notifications/read) page by max_id for the persona's own root only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:37:41 +02:00
thepraandClaude Opus 5.5 a76cc34557 Posts reach their audience, edits follow them, deletes leave a tombstone
OutboxPublisher works out who a post goes to, Mastodon's way: followers'
shared inboxes for public, unlisted and followers-only; every mentioned
remote account's own inbox; the recipients only for direct; the parent's
author for a reply; a community's followers for a post in it; nobody for
a circle. Creates, updates and deletes all use it.

- Local posts take a visibility (public, unlisted, followers-only) and a
  spoiler text next to the content-warning flag.
- /clientapi/post/update keeps the previous version as a revision,
  re-renders, and sends Update{Note} with `updated` to the same to/cc.
- Deleting is now soft: the content, title, spoiler, media and revisions
  are cleared, timeline entries removed, the parent's reply count goes
  down, Delete goes to the stored audience, and the object answers 410
  with a Tombstone instead of 404.
- Editing a persona's profile sends Update{Person} to its followers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:34:35 +02:00
thepraandClaude Opus 5.5 61f6ca0676 Personas can follow: locally at once, remotely by Follow and Accept
Following records what a local persona follows, local or remote, with
its state and the Follow activity's id. FollowService (behind
/clientapi/follow, /clientapi/unfollow and /clientapi/following):
- resolves @user, user@host or an actor URI;
- a local account is followed in-process: accepted unless it approves
  followers by hand, a Follower row on the other side, a community gains
  the persona as a member, and a Follow or FollowRequest notification;
- a remote account gets a Follow signed by the persona, at
  /grunts/follow-{id}, and stays Requested until an Accept;
- unfollowing deletes the rows, or sends Undo{Follow} to a remote account.

Inbound Accept and Reject are matched to the Follow by its id (or, for an
embedded Follow without one, by its actor), and only from the account that
was followed. A remote Follow now notifies the persona too.

Notification is the per-persona record P1.2 builds on, deduplicated by
type, persona, sender and post. TimelineEntry and Favourite are created
with their indexes for the next commits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:32:33 +02:00
thepraandClaude Opus 5.5 8af50dad9c P1.1 is deployed and verified
Build / Build (push) Successful in 33s
v1.2.0 serves the themed routes, NodeInfo 2.0 and 2.1 and the day-cut
actor; migrations 1-5 ran and the job queue's indexes exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:31:26 +02:00
80 changed files with 5389 additions and 351 deletions

No files matched your search

+1
View File
@@ -104,4 +104,5 @@ jobs:
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/activity+json' \ code=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/activity+json' \
--data '{"type":"Follow","actor":"https://example.org/users/x","object":"'"$PUBLIC_URL"'/peasants/privapub"}' "$PUBLIC_URL/human-centipede") --data '{"type":"Follow","actor":"https://example.org/users/x","object":"'"$PUBLIC_URL"'/peasants/privapub"}' "$PUBLIC_URL/human-centipede")
[ "$code" = "401" ] || { echo "::error::an unsigned inbox POST answered $code"; exit 1; } [ "$code" = "401" ] || { echo "::error::an unsigned inbox POST answered $code"; exit 1; }
tools/smoke/mastodon-api.sh "$PUBLIC_URL"
echo "::notice::serving $served" echo "::notice::serving $served"
+32 -6
View File
@@ -81,13 +81,23 @@ PrivaPub/ ASP.NET Core Web API, net10.0
Objects/ Origin, ActivityJson, NoteParser, Addressing, ContentSanitizer Objects/ Origin, ActivityJson, NoteParser, Addressing, ContentSanitizer
Moderation/ DomainBlocks (suspend / silence / reject media) Moderation/ DomainBlocks (suspend / silence / reject media)
Signing/ HttpSignatures (draft-cavage sign/verify) Signing/ HttpSignatures (draft-cavage sign/verify)
Inbox/ InboxReceiver (verify, queue, 202) → InboxProcessor (job) → Handlers/{Follow,Undo,Create,Update,Delete} Inbox/ InboxReceiver (verify, queue, 202) → InboxProcessor (job) → Handlers/{Follow,Accept,Reject,
Outbox/ DeliveryService (queues jobs) + DeliveryJobHandler Undo,Create,Update,Delete,Like,Announce}; RemotePosts (build, fetch parents, FetchAncestors)
Outbox/ OutboxPublisher (who a post goes to), DeliveryService (queues jobs) + DeliveryJobHandler
Rendering/ ActivityPubRenderer (Mastodon @context, actors, notes, collections) Rendering/ ActivityPubRenderer (Mastodon @context, actors, notes, collections)
Domain/ Domain/
Content/ ContentRenderer (Markdown or plain text → HTML with h-card mentions and hashtags) Content/ ContentRenderer (Markdown or plain text → HTML with h-card mentions and hashtags)
Social/ FollowService (local in-process, remote Follow/Accept), Notifications
Timelines/ Fanout (TimelineEntry rows, Mastodon's home rules), TimelineService
Privacy/ VisibilityPolicy (IsPublic expression, CanSee) Privacy/ VisibilityPolicy (IsPublic expression, CanSee)
Web/Pages/ Razor: /@{user}, /@{user}/{id} (public posts only, strict CSP, noindex) Domain/Statuses/ StatusService: publish, edit, remove, favourite, reblog, for a persona (both client APIs use it)
Api/Mastodon/
Auth/ OpenIddict setup (keys in Mongo), MastodonScopes, TokenController, OAuthPruner
Infrastructure/ MastodonController (avatar context, scopes, errors, Link), MastodonParams, MastodonJson, Page
Entities/ Mappers/ Mastodon entities; MastodonMapper (Account, Status), AccountSearch
Controllers/ apps, instance, accounts, statuses, timelines, notifications, search, stubs
Web/Pages/ Razor: /@{user}, /@{user}/{id} (public posts only, strict CSP, noindex);
OAuth/: /oauth/login (root password), /oauth/authorize (choose persona, consent)
Services/ RootUsersService, GroupUsersService, PostsService, AppConfigurationService, … Services/ RootUsersService, GroupUsersService, PostsService, AppConfigurationService, …
Models/ Mongo entities: User/, Group/, Post/, Federation/, Jobs/, AppConfiguration Models/ Mongo entities: User/, Group/, Post/, Federation/, Jobs/, AppConfiguration
StaticServices/ DbEntities (Find<T> accessors), AuthTokenManager (JWT), PasswordHasher StaticServices/ DbEntities (Find<T> accessors), AuthTokenManager (JWT), PasswordHasher
@@ -162,8 +172,23 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
leased, retried on Mastodon's curve, at most two per host, paused per host by `RemoteInstance`. The inbox answers leased, retried on Mastodon's curve, at most two per host, paused per host by `RemoteInstance`. The inbox answers
202 once it has verified and queued; a handler must be idempotent (unique `ObjectURI`, job `DedupeKey`). 202 once it has verified and queued; a handler must be idempotent (unique `ObjectURI`, job `DedupeKey`).
11. **Every "may anyone see this" goes through `VisibilityPolicy.IsPublic`;** a persona-specific read uses `CanSee`. 11. **Every "may anyone see this" goes through `VisibilityPolicy.IsPublic`;** a persona-specific read uses `CanSee`.
12. **Remote content is stored only when someone here asked for it:** a local persona addressed or mentioned, a reply to 12. **Remote content is stored only when someone here asked for it:** a persona follows the author, is addressed or
a local post, or a community the author follows. Followers-only is detected by the author's stored `followers` URL. mentioned, it replies to a local post, or it is addressed to a community the author follows; a public parent is
fetched as context. Followers-only is detected by the author's stored `followers` URL.
13. **Home timelines are written, not computed:** every stored or created post goes through `Fanout.Distribute`, and
every delete removes its `TimelineEntry` rows. Local deletes are soft (content cleared, 410 Tombstone).
## Mastodon client API invariants
1. **A token is one persona.** Its subject is the avatar id; the root id lives only in the fifteen-minute `/oauth`
cookie used while choosing the persona, and never in a token, an authorization or a response.
2. `/api/*` authenticates with OpenIddict validation, everything else with the old JWT (`PrivaPub` policy scheme).
Every `/api` request re-checks that the persona's root is neither banned nor deleted (`MastodonController`).
3. Read parameters through `Params` (query, form and JSON merged Rails-style), never MVC binding. A value type read
from a conditional must say `(int?)null`, not `default`: that bug once made every list one item long.
4. Answer with `Json(...)` (snake_case, explicit nulls) or `Error(status, message)`; page lists with `Page` and `Link`.
5. Unsupported features answer empty lists or 422 with a message, never 404 or 500, so clients degrade.
6. Advertise `4.2.0 (compatible; PrivaPub)` until grouped notifications exist.
## Privacy invariants ## Privacy invariants
@@ -227,7 +252,8 @@ the owner's GoToSocial at social.arasaka.software. **Ask before acting from the
- **CI/CD:** push to `master` runs `build.yml` (build + tests) on the instance-wide `build` runner. A `v*` tag runs - **CI/CD:** push to `master` runs `build.yml` (build + tests) on the instance-wide `build` runner. A `v*` tag runs
`deploy.yml`: tests, self-contained linux-x64 publish, snapshot and `mongodump` to `/var/backups/privapub.thepra.dev`, `deploy.yml`: tests, self-contained linux-x64 publish, snapshot and `mongodump` to `/var/backups/privapub.thepra.dev`,
stop → rsync → start, a `127.0.0.1:6970/build.json` health loop with rollback, then public checks (actor, NodeInfo, stop → rsync → start, a `127.0.0.1:6970/build.json` health loop with rollback, then public checks (actor, NodeInfo,
Swagger 404, inbox junk 400, unsigned 401). Swagger 404, inbox junk 400, unsigned 401) and `tools/smoke/mastodon-api.sh` (app registration, client credentials,
discovery, instance, public timeline; pass a persona token as a second argument to check the signed-in side).
- **The box:** Max (`nuvola.xyz`). Unit `privapub` runs as www-data from `/var/www/privapub.thepra.dev` with - **The box:** Max (`nuvola.xyz`). Unit `privapub` runs as www-data from `/var/www/privapub.thepra.dev` with
`ASPNETCORE_ENVIRONMENT=Production`. `ASPNETCORE_ENVIRONMENT=Production`.
- **One-time root setup:** `deploy/max/setup.sh`, run through `../arasaka.software/tools/max/run.sh`. - **One-time root setup:** `deploy/max/setup.sh`, run through `../arasaka.software/tools/max/run.sh`.
+9 -4
View File
@@ -59,13 +59,17 @@ Received:
| Activity | Effect | | Activity | Effect |
|---|---| |---|---|
| `Follow` | follows an avatar or community; `Accept` is sent unless the community approves members by hand | | `Follow` | follows an avatar or community; `Accept` is sent unless the community approves members by hand |
| `Undo{Follow}` | unfollows | | `Accept{Follow}`, `Reject{Follow}` | completes or ends a follow an avatar requested |
| `Create{Note, Article, Page, Question, …}` | stored when it addresses or mentions a local avatar, replies to a local post, or is addressed to a community the author follows | | `Undo{Follow, Like, Announce}` | reverses it |
| `Create{Note, Article, Page, Question, …}` | stored when a local avatar follows the author, is addressed or mentioned, when it replies to a local post, or when it is addressed to a community the author follows; a public parent is fetched to complete the thread |
| `Update{Note}` | replaces the content; the previous version is kept | | `Update{Note}` | replaces the content; the previous version is kept |
| `Update{Person}` | refetches the actor | | `Update{Person}` | refetches the actor |
| `Like` | counted and notified, on posts the liker could see |
| `Announce` | counted and notified for local posts; shown to followers of the announcer, with the original refetched from its origin |
| `Delete` | deletes the object, or the actor and its follows | | `Delete` | deletes the object, or the actor and its follows |
Sent: `Create{Note}`, `Delete{Tombstone}`, `Accept{Follow}`, `Announce` (communities). Sent: `Follow`, `Undo{Follow}`, `Create{Note}`, `Update{Note}`, `Update{Person}`, `Delete{Tombstone}`, `Accept{Follow}`,
`Announce` (communities). A deleted post answers 410 with a `Tombstone`.
A `Create`'s `Note` carries Mastodon's `content`, `contentMap`, `summary` and `sensitive`, plus `Mention` and `Hashtag` A `Create`'s `Note` carries Mastodon's `content`, `contentMap`, `summary` and `sensitive`, plus `Mention` and `Hashtag`
tags. A post's title becomes `name` and is also the first, bold line of `content`, because Mastodon does not show tags. A post's title becomes `name` and is also the first, bold line of `content`, because Mastodon does not show
@@ -90,6 +94,7 @@ followers-only posts are recognised by the author's own `followers` collection.
## Known limitations ## Known limitations
- Likes, boosts, follow requests to remote accounts, media uploads and polls are not implemented yet. - Avatars cannot send likes or boosts yet (that arrives with the Mastodon client API), and media uploads and polls are not
implemented.
- 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.
@@ -19,6 +19,32 @@ namespace PrivaPub.ClientModels.Post
public string GroupId { get; set; } public string GroupId { get; set; }
public string AnsweringToPostId { get; set; } public string AnsweringToPostId { get; set; }
public bool HasContentWarning { get; set; } public bool HasContentWarning { get; set; }
[StringLength(500, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string SpoilerText { get; set; }
public string Visibility { get; set; }//public (default), unlisted, followersonly
}
public class UpdatePostForm
{
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource))]
public string AvatarId { get; set; }
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource))]
public string PostId { get; set; }
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
StringLength(5000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string Text { get; set; }
[StringLength(200, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string Title { get; set; }
public bool HasContentWarning { get; set; }
[StringLength(500, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string SpoilerText { get; set; }
} }
public class InsertDmForm public class InsertDmForm
+1
View File
@@ -16,6 +16,7 @@ namespace PrivaPub.ClientModels.Post
public bool HasContentWarning { get; set; } public bool HasContentWarning { get; set; }
public bool IsFederatedCopy { get; set; } public bool IsFederatedCopy { get; set; }
public DateTime CreationDate { get; set; } public DateTime CreationDate { get; set; }
public ViewPost ReblogOf { get; set; }
} }
public class ViewDmGroup public class ViewDmGroup
@@ -0,0 +1,37 @@
using PrivaPub.ClientModels.Resources;
using System.ComponentModel.DataAnnotations;
namespace PrivaPub.ClientModels.Social
{
public class FollowForm
{
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource))]
public string AvatarId { get; set; }
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
StringLength(2048, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string Target { get; set; }//user@host, @user, or an actor URI
}
public class ViewFollowing
{
public string Id { get; set; }
public string TargetActorURI { get; set; }
public string TargetAccountId { get; set; }
public bool TargetIsLocal { get; set; }
public string State { get; set; }
public DateTime CreatedAt { get; set; }
}
public class ViewNotification
{
public string Id { get; set; }
public string Type { get; set; }
public string FromAccountId { get; set; }
public string FromActorURI { get; set; }
public string PostId { get; set; }
public bool IsRead { get; set; }
public DateTime CreatedAt { get; set; }
}
}
+82
View File
@@ -0,0 +1,82 @@
using Microsoft.AspNetCore.Http;
using PrivaPub.Api.Mastodon.Auth;
using PrivaPub.Api.Mastodon.Infrastructure;
using System.Text;
namespace PrivaPub.Tests.Api
{
public class MastodonParamsTests
{
static async Task<MastodonParams> Read(string query = "", string contentType = default, string body = default)
{
var context = new DefaultHttpContext();
context.Request.QueryString = new QueryString(query);
if (body != default)
{
context.Request.ContentType = contentType;
var bytes = Encoding.UTF8.GetBytes(body);
context.Request.Body = new MemoryStream(bytes);
context.Request.ContentLength = bytes.Length;
}
return await MastodonParams.Read(context.Request, TestContext.Current.CancellationToken);
}
[Fact]
public async Task Absent_values_are_null_not_false_or_zero()
{
var parameters = await Read();
Assert.Null(parameters.Bool("sensitive"));
Assert.Null(parameters.Int("limit"));
Assert.Null(parameters.Get("status"));
Assert.Empty(parameters.List("media_ids"));
}
[Fact]
public async Task Reads_rails_style_arrays_from_the_query()
{
var parameters = await Read("?id[]=1&id[]=2&limit=5&local=true");
Assert.Equal(new[] { "1", "2" }, parameters.List("id"));
Assert.Equal(5, parameters.Int("limit"));
Assert.True(parameters.Bool("local"));
}
[Fact]
public async Task Reads_forms_and_nested_keys()
{
var parameters = await Read(contentType: "application/x-www-form-urlencoded",
body: "status=hi&media_ids[]=a&media_ids[]=b&poll[options][]=x&poll[options][]=y&sensitive=0");
Assert.Equal("hi", parameters.Get("status"));
Assert.Equal(new[] { "a", "b" }, parameters.List("media_ids"));
Assert.Equal(new[] { "x", "y" }, parameters.List("poll[options]"));
Assert.False(parameters.Bool("sensitive"));
}
[Fact]
public async Task Reads_json_bodies_the_same_way()
{
var parameters = await Read(contentType: "application/json",
body: """{"status":"hi","media_ids":["a","b"],"poll":{"options":["x","y"],"expires_in":300},"sensitive":true,"source":{"privacy":"unlisted"}}""");
Assert.Equal(new[] { "a", "b" }, parameters.List("media_ids"));
Assert.Equal(new[] { "x", "y" }, parameters.List("poll[options]"));
Assert.Equal(300, parameters.Int("poll[expires_in]"));
Assert.True(parameters.Bool("sensitive"));
Assert.Equal("unlisted", parameters.Get("source[privacy]"));
}
[Theory]
[InlineData(new[] { "read" }, "read:statuses", true)]
[InlineData(new[] { "read:statuses" }, "read:statuses", true)]
[InlineData(new[] { "read:accounts" }, "read:statuses", false)]
[InlineData(new[] { "write" }, "read:statuses", false)]
[InlineData(new[] { "follow" }, "write:follows", true)]
[InlineData(new[] { "follow" }, "write:statuses", false)]
public void Scopes_grant_like_mastodons(string[] granted, string required, bool expected) =>
Assert.Equal(expected, MastodonScopes.Grants(granted, required));
}
}
@@ -0,0 +1,61 @@
using Microsoft.Extensions.Caching.Memory;
using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Api.Mastodon.Mappers;
using PrivaPub.ClientModels.Post;
using PrivaPub.ClientModels.Social;
using PrivaPub.Models.Post;
using PrivaPub.Tests.Support;
using System.Text.Json;
namespace PrivaPub.Tests.Api
{
[Trait("Category", "Integration")]
public sealed class PersonaSeparationTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
[Fact]
public async Task Nothing_shown_to_one_persona_names_its_sibling()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var (_, sibling) = await _harness.Persona("sibling", root);
var (bobRoot, bob) = await _harness.Persona("bob");
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = bob.UserName }, token);
await _harness.Follows.Follow(root, new FollowForm { AvatarId = sibling.Id, Target = bob.UserName }, token);
await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = sibling.Id, Text = "from the sibling" }, token);
await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = "from alice" }, token);
await _harness.Posts.InsertPost(bobRoot, new InsertPostForm { AvatarId = bob.Id, Text = "hi both" }, token);
var mapper = new MastodonMapper(_harness.Db, _harness.Local);
var account = await mapper.Local(alice, withSource: true, token);
var homeIds = (await _harness.Db.TimelineEntries.Match(e => e.AvatarId == alice.Id).ExecuteAsync(token)).Select(e => e.PostId).ToList();
var home = await _harness.Db.Posts.Match(p => homeIds.Contains(p.ID)).ExecuteAsync(token);
var statuses = await mapper.Statuses(home, alice.Id, token);
var bobAsSeenByAlice = await mapper.Account(bob.Id, token);
var json = JsonSerializer.Serialize(new object[] { account, statuses, bobAsSeenByAlice }, MastodonJson.Options);
Assert.DoesNotContain(sibling.Id, json);
Assert.DoesNotContain(sibling.UserName, json);
Assert.DoesNotContain(root, json);
Assert.Contains("hi both", json);
Assert.Equal(2, bobAsSeenByAlice.FollowersCount);
}
}
}
+124
View File
@@ -0,0 +1,124 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Social;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Domain
{
[Trait("Category", "Integration")]
public sealed class FollowTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
[Fact]
public async Task A_local_follow_is_accepted_at_once_and_notified()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var (_, bob) = await _harness.Persona("bob");
var result = await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = "@" + bob.UserName }, token);
Assert.True(result.IsValid);
var following = await DB.Default.Find<Following>().Match(f => f.AvatarId == alice.Id).ExecuteSingleAsync(token);
Assert.Equal(FollowState.Accepted, following.State);
Assert.True(following.TargetIsLocal);
Assert.True((await DB.Default.Find<Follower>().Match(f => f.LocalActorId == bob.Id && f.ActorURI == alice.Uri).ExecuteSingleAsync(token)).IsAccepted);
Assert.Equal(NotificationType.Follow, (await DB.Default.Find<Notification>().Match(n => n.AvatarId == bob.Id).ExecuteSingleAsync(token)).Type);
Assert.Empty(await _harness.Outgoing(bob.Inbox));
}
[Fact]
public async Task Someone_elses_persona_cannot_be_made_to_follow()
{
var (_, alice) = await _harness.Persona("alice");
var (_, bob) = await _harness.Persona("bob");
var result = await _harness.Follows.Follow("someone-else", new FollowForm { AvatarId = alice.Id, Target = bob.UserName }, TestContext.Current.CancellationToken);
Assert.False(result.IsValid);
Assert.Equal(404, result.StatusCode);
}
[Fact]
public async Task A_remote_follow_is_requested_then_accepted()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var carol = new RemoteActor(_harness.Peer, "carol");
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = carol.Id }, token);
var following = await DB.Default.Find<Following>().Match(f => f.AvatarId == alice.Id).ExecuteSingleAsync(token);
Assert.Equal(FollowState.Requested, following.State);
var follow = Assert.Single(await _harness.Outgoing(carol.Id + "/inbox"));
Assert.Equal("Follow", follow["type"]!.GetValue<string>());
Assert.Equal(alice.Uri, follow["actor"]!.GetValue<string>());
Assert.StartsWith(alice.Uri + "/grunts/follow-", follow["id"]!.GetValue<string>());
var accept = new JsonObject
{
["id"] = $"{carol.Id}#accepts/{Guid.NewGuid():N}",
["type"] = "Accept",
["actor"] = carol.Id,
["object"] = follow.DeepClone()
};
Assert.Equal(202, (await _harness.Deliver(carol, $"/peasants/{alice.UserName}/mouth", accept)).StatusCode);
Assert.Equal(FollowState.Accepted, (await DB.Default.Find<Following>().OneAsync(following.ID, token)).State);
}
[Fact]
public async Task An_accept_from_someone_else_changes_nothing_and_a_reject_ends_the_request()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var carol = new RemoteActor(_harness.Peer, "carol");
var mallory = new RemoteActor(_harness.Peer, "mallory");
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = carol.Id }, token);
var following = await DB.Default.Find<Following>().Match(f => f.AvatarId == alice.Id).ExecuteSingleAsync(token);
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
{
["id"] = $"{mallory.Id}#accepts/{Guid.NewGuid():N}", ["type"] = "Accept", ["actor"] = mallory.Id, ["object"] = following.FollowActivityURI
});
Assert.Equal(FollowState.Requested, (await DB.Default.Find<Following>().OneAsync(following.ID, token)).State);
await _harness.Deliver(carol, "/human-centipede", new JsonObject
{
["id"] = $"{carol.Id}#rejects/{Guid.NewGuid():N}", ["type"] = "Reject", ["actor"] = carol.Id, ["object"] = following.FollowActivityURI
});
Assert.Null(await DB.Default.Find<Following>().OneAsync(following.ID, token));
}
[Fact]
public async Task Unfollowing_a_remote_account_sends_an_undo()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var carol = new RemoteActor(_harness.Peer, "carol");
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = carol.Id }, token);
await _harness.Follows.Unfollow(root, new FollowForm { AvatarId = alice.Id, Target = carol.Id }, token);
Assert.False(await DB.Default.Find<Following>().Match(f => f.AvatarId == alice.Id).ExecuteAnyAsync(token));
var undo = (await _harness.Outgoing(carol.Id + "/inbox")).Single(a => a["type"]!.GetValue<string>() == "Undo");
Assert.Equal("Follow", undo["object"]!["type"]!.GetValue<string>());
}
}
}
+92
View File
@@ -0,0 +1,92 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Post;
using PrivaPub.ClientModels;
using PrivaPub.Models.Post;
using PrivaPub.Models.User;
using PrivaPub.Tests.Support;
namespace PrivaPub.Tests.Domain
{
[Trait("Category", "Integration")]
public sealed class OutboxTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
async Task<ForeignAvatar> Known(RemoteActor actor) =>
await _harness.Remote.GetActor(actor.Id, refresh: false, TestContext.Current.CancellationToken);
[Fact]
public async Task The_audience_follows_mastodons_table()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var follower = new RemoteActor(_harness.Peer, "follower");
var mentioned = await Known(new RemoteActor(_harness.Peer, "mentioned"));
var parentAuthor = await Known(new RemoteActor(_harness.Peer, "parent"));
await _harness.FollowedBy(alice, follower);
var mention = new PostMention { ActorURI = mentioned.ActorURI };
async Task<IReadOnlyList<string>> Audience(PostVisibility visibility, string replyTo = default) =>
await _harness.Outbox.Audience(alice, new Post { Visibility = visibility, Mentions = new() { mention }, To = new() { mentioned.ActorURI }, InReplyToAccountId = replyTo }, token);
Assert.Equal(new[] { follower.SharedInbox, mentioned.InboxURL }, await Audience(PostVisibility.Public));
Assert.Equal(new[] { follower.SharedInbox, mentioned.InboxURL, parentAuthor.InboxURL }, await Audience(PostVisibility.Unlisted, parentAuthor.ID));
Assert.Equal(new[] { follower.SharedInbox, mentioned.InboxURL }, await Audience(PostVisibility.FollowersOnly));
Assert.Equal(new[] { mentioned.InboxURL }, await Audience(PostVisibility.Direct, parentAuthor.ID));
Assert.Empty(await Audience(PostVisibility.Circle));
}
[Fact]
public async Task A_post_is_created_edited_and_deleted_for_its_audience()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var follower = new RemoteActor(_harness.Peer, "follower");
await _harness.FollowedBy(alice, follower);
var created = await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = "first", Visibility = "followersonly" }, token);
var postId = ((ViewPost)created.Data).Id;
await _harness.Posts.UpdatePost(root, new UpdatePostForm { AvatarId = alice.Id, PostId = postId, Text = "second", SpoilerText = "cw" }, token);
await _harness.Posts.DeletePost(root, new DeletePostForm { AvatarId = alice.Id, PostId = postId }, token);
var sent = await _harness.Outgoing(follower.SharedInbox);
Assert.Equal(new[] { "Create", "Update", "Delete" }, sent.Select(a => a["type"]!.GetValue<string>()));
Assert.DoesNotContain(sent[0]["to"]!.AsArray(), n => n!.GetValue<string>().EndsWith("#Public"));
Assert.Equal("cw", sent[1]["object"]!["summary"]!.GetValue<string>());
Assert.NotNull(sent[1]["object"]!["updated"]);
Assert.Equal(sent[0]["to"]!.ToJsonString(), sent[2]["to"]!.ToJsonString());
var stored = await DB.Default.Find<Post>().OneAsync(postId, token);
Assert.NotNull(stored.DeletedAt);
Assert.Null(stored.ContentHtml);
Assert.Empty(stored.Revisions);
}
[Fact]
public async Task Someone_elses_post_cannot_be_edited()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var (otherRoot, bob) = await _harness.Persona("bob");
var created = await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = "mine" }, token);
var result = await _harness.Posts.UpdatePost(otherRoot, new UpdatePostForm { AvatarId = bob.Id, PostId = ((ViewPost)created.Data).Id, Text = "yours" }, token);
Assert.False(result.IsValid);
Assert.Equal(404, result.StatusCode);
}
}
}
+162
View File
@@ -0,0 +1,162 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Post;
using PrivaPub.ClientModels.Social;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Domain
{
[Trait("Category", "Integration")]
public sealed class TimelineTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
async Task<List<ViewPost>> Home(string root, string avatarId) =>
(List<ViewPost>)(await _harness.Timelines.Home(root, avatarId, default, 40, TestContext.Current.CancellationToken)).Data;
async Task Accepted(string root, string avatarId, RemoteActor target)
{
await _harness.Follows.Follow(root, new FollowForm { AvatarId = avatarId, Target = target.Id }, TestContext.Current.CancellationToken);
await DB.Default.Update<Following>().Match(f => f.AvatarId == avatarId && f.TargetActorURI == target.Id)
.Modify(f => f.State, FollowState.Accepted).ExecuteAsync(TestContext.Current.CancellationToken);
}
static JsonObject Create(RemoteActor author, string inReplyTo = default, string text = "hello", params (string Uri, string Name)[] mentions)
{
var origin = new Uri(author.Id).GetLeftPart(UriPartial.Authority);
var note = new JsonObject
{
["id"] = $"{origin}/notes/{Guid.NewGuid():N}",
["type"] = "Note",
["attributedTo"] = author.Id,
["content"] = $"<p>{text}</p>",
["to"] = new JsonArray(Addressing.Public),
["cc"] = new JsonArray(mentions.Select(m => (JsonNode)m.Uri).Prepend(author.Id + "/followers").ToArray()),
["tag"] = new JsonArray(mentions.Select(m => (JsonNode)new JsonObject { ["type"] = "Mention", ["href"] = m.Uri, ["name"] = m.Name }).ToArray())
};
if (inReplyTo != default)
note["inReplyTo"] = inReplyTo;
return new JsonObject { ["id"] = $"{origin}/activities/{Guid.NewGuid():N}", ["type"] = "Create", ["actor"] = author.Id, ["object"] = note };
}
[Fact]
public async Task A_local_post_reaches_the_author_and_local_followers_only()
{
var token = TestContext.Current.CancellationToken;
var (aliceRoot, alice) = await _harness.Persona("alice");
var (bobRoot, bob) = await _harness.Persona("bob");
var (carolRoot, carol) = await _harness.Persona("carol");
await _harness.Follows.Follow(bobRoot, new FollowForm { AvatarId = bob.Id, Target = alice.UserName }, token);
await _harness.Posts.InsertPost(aliceRoot, new InsertPostForm { AvatarId = alice.Id, Text = "for my followers" }, token);
Assert.Single(await Home(aliceRoot, alice.Id));
Assert.Equal("<p>for my followers</p>", Assert.Single(await Home(bobRoot, bob.Id)).ContentHtml);
Assert.Empty(await Home(carolRoot, carol.Id));
Assert.Equal(404, (await _harness.Timelines.Home(carolRoot, bob.Id, default, 20, token)).StatusCode);
}
[Fact]
public async Task A_followed_remote_account_is_kept_and_its_replies_follow_mastodons_rule()
{
var (bobRoot, bob) = await _harness.Persona("bob");
var carol = new RemoteActor(_harness.Peer, "carol");
var dave = new RemoteActor(_harness.Peer, "dave");
await Accepted(bobRoot, bob.Id, carol);
var top = Create(carol, text: "top");
await _harness.Deliver(carol, "/human-centipede", top);
await _harness.Deliver(carol, "/human-centipede", Create(carol, top["object"]!["id"]!.GetValue<string>(), "self reply"));
await _harness.Deliver(dave, "/human-centipede", Create(dave, text: "dave alone"));
var daveRoot = Create(dave, text: "unfollowed root");
await _harness.Deliver(carol, "/human-centipede", Create(carol, $"{new Uri(dave.Id).GetLeftPart(UriPartial.Authority)}/notes/unknown", "reply to a stranger"));
var home = (await Home(bobRoot, bob.Id)).Select(p => p.ContentHtml).ToList();
Assert.Contains("<p>top</p>", home);
Assert.Contains("<p>self reply</p>", home);
Assert.DoesNotContain("<p>dave alone</p>", home);
Assert.DoesNotContain("<p>reply to a stranger</p>", home);
}
[Fact]
public async Task A_reply_brings_its_public_parent_and_the_grandparent_is_queued()
{
var token = TestContext.Current.CancellationToken;
var (bobRoot, bob) = await _harness.Persona("bob");
var carol = new RemoteActor(_harness.Peer, "carol");
var dave = new RemoteActor(_harness.Peer, "dave");
await Accepted(bobRoot, bob.Id, carol);
var origin = new Uri(dave.Id).GetLeftPart(UriPartial.Authority);
var grandparentId = $"{origin}/notes/{Guid.NewGuid():N}";
var parentId = $"{origin}/notes/{Guid.NewGuid():N}";
_harness.Peer.Serve(new Uri(parentId).AbsolutePath, new JsonObject
{
["id"] = parentId, ["type"] = "Note", ["attributedTo"] = dave.Id, ["content"] = "<p>parent</p>",
["to"] = new JsonArray(Addressing.Public), ["inReplyTo"] = grandparentId
}.ToJsonString());
await _harness.Deliver(carol, "/human-centipede", Create(carol, parentId, "reply to dave"));
var parent = await DB.Default.Find<PrivaPub.Models.Post.Post>().Match(p => p.ObjectURI == parentId).ExecuteSingleAsync(token);
var reply = await DB.Default.Find<PrivaPub.Models.Post.Post>().Match(p => p.InReplyToURI == parentId).ExecuteSingleAsync(token);
Assert.Equal(parent.ID, reply.AnsweringToPostId);
Assert.Equal(parent.AuthorAccountId, reply.InReplyToAccountId);
Assert.True(await DB.Default.Find<PrivaPub.Models.Jobs.Job>().Match(j => j.DedupeKey == "ancestors|" + parent.ID).ExecuteAnyAsync(token));
Assert.DoesNotContain("<p>reply to dave</p>", (await Home(bobRoot, bob.Id)).Select(p => p.ContentHtml));
Assert.DoesNotContain("<p>parent</p>", (await Home(bobRoot, bob.Id)).Select(p => p.ContentHtml));
}
[Fact]
public async Task A_remote_delete_takes_the_post_out_of_home()
{
var (bobRoot, bob) = await _harness.Persona("bob");
var carol = new RemoteActor(_harness.Peer, "carol");
await Accepted(bobRoot, bob.Id, carol);
var create = Create(carol, text: "soon gone");
await _harness.Deliver(carol, "/human-centipede", create);
Assert.Single(await Home(bobRoot, bob.Id));
await _harness.Deliver(carol, "/human-centipede", new JsonObject
{
["id"] = $"{new Uri(carol.Id).GetLeftPart(UriPartial.Authority)}/deletes/{Guid.NewGuid():N}", ["type"] = "Delete", ["actor"] = carol.Id,
["object"] = new JsonObject { ["id"] = create["object"]!["id"]!.GetValue<string>(), ["type"] = "Tombstone" }
});
Assert.Empty(await Home(bobRoot, bob.Id));
Assert.False(await DB.Default.Find<TimelineEntry>().Match(e => e.AvatarId == bob.Id).ExecuteAnyAsync(TestContext.Current.CancellationToken));
}
[Fact]
public async Task A_mention_notifies_the_persona()
{
var token = TestContext.Current.CancellationToken;
var (bobRoot, bob) = await _harness.Persona("bob");
var carol = new RemoteActor(_harness.Peer, "carol");
await _harness.Deliver(carol, "/human-centipede", Create(carol, mentions: (bob.Uri, "@" + bob.Handle)));
var notifications = (List<ViewNotification>)(await _harness.Timelines.Notifications(bobRoot, bob.Id, default, 20, token)).Data;
var mention = Assert.Single(notifications);
Assert.Equal("Mention", mention.Type);
Assert.Equal(carol.Id, mention.FromActorURI);
await _harness.Timelines.MarkNotificationsRead(bobRoot, bob.Id, token);
Assert.True(((List<ViewNotification>)(await _harness.Timelines.Notifications(bobRoot, bob.Id, default, 20, token)).Data).Single().IsRead);
}
}
}
@@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging.Abstractions;
using MongoDB.Entities; using MongoDB.Entities;
using PrivaPub.Domain.Timelines;
using PrivaPub.Federation.Actors; using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Inbox.Handlers; using PrivaPub.Federation.Inbox.Handlers;
using PrivaPub.Federation.Inbox; using PrivaPub.Federation.Inbox;
@@ -52,8 +53,8 @@ namespace PrivaPub.Tests.Federation
_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, remote, delivery), new UndoHandler(db, _local),
new CreateHandler(db, _local, remote, delivery, _blocks), new CreateHandler(db, _local, remote, delivery, _blocks, new Fanout(db), new RemotePosts(db, _local, remote, _blocks, queue)),
new DeleteHandler(db, _local, remote, delivery), new DeleteHandler(db, _local, remote, delivery),
new UpdateHandler(db, _local, remote) new UpdateHandler(db, _local, remote)
}, NullLogger<InboxProcessor>.Instance); }, NullLogger<InboxProcessor>.Instance);
@@ -0,0 +1,137 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Post;
using PrivaPub.ClientModels.Social;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
[Trait("Category", "Integration")]
public sealed class InteractionTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
async Task<(string Root, Post Post)> LocalPost(string visibility = default)
{
var (root, alice) = await _harness.Persona("alice");
var view = (ViewPost)(await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = "like me", Visibility = visibility },
TestContext.Current.CancellationToken)).Data;
return (root, await DB.Default.Find<Post>().OneAsync(view.Id));
}
static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority);
[Fact]
public async Task A_like_counts_once_notifies_and_can_be_undone()
{
var token = TestContext.Current.CancellationToken;
var (_, post) = await LocalPost();
var carol = new RemoteActor(_harness.Peer, "carol");
var like = new JsonObject { ["id"] = $"{Origin(carol)}/likes/{Guid.NewGuid():N}", ["type"] = "Like", ["actor"] = carol.Id, ["object"] = post.ObjectURI };
await _harness.Deliver(carol, "/human-centipede", like);
await _harness.Deliver(carol, "/human-centipede", new JsonObject { ["id"] = $"{Origin(carol)}/likes/{Guid.NewGuid():N}", ["type"] = "Like", ["actor"] = carol.Id, ["object"] = post.ObjectURI });
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).FavouritesCount);
Assert.Equal(NotificationType.Favourite, (await DB.Default.Find<Notification>().Match(n => n.AvatarId == post.GroupUserId).ExecuteSingleAsync(token)).Type);
await _harness.Deliver(carol, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(carol)}/undos/{Guid.NewGuid():N}", ["type"] = "Undo", ["actor"] = carol.Id, ["object"] = like
});
Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).FavouritesCount);
}
[Fact]
public async Task A_stranger_cannot_like_a_followers_only_post()
{
var token = TestContext.Current.CancellationToken;
var (_, post) = await LocalPost("followersonly");
var mallory = new RemoteActor(_harness.Peer, "mallory");
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(mallory)}/likes/{Guid.NewGuid():N}", ["type"] = "Like", ["actor"] = mallory.Id, ["object"] = post.ObjectURI
});
Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).FavouritesCount);
}
[Fact]
public async Task A_boost_of_a_local_post_counts_notifies_and_can_be_undone()
{
var token = TestContext.Current.CancellationToken;
var (_, post) = await LocalPost();
var carol = new RemoteActor(_harness.Peer, "carol");
var announce = new JsonObject
{
["id"] = $"{Origin(carol)}/announces/{Guid.NewGuid():N}", ["type"] = "Announce", ["actor"] = carol.Id, ["object"] = post.ObjectURI,
["to"] = new JsonArray(Addressing.Public), ["cc"] = new JsonArray(carol.Id + "/followers")
};
await _harness.Deliver(carol, "/human-centipede", announce);
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).ReblogsCount);
Assert.Equal(NotificationType.Reblog, (await DB.Default.Find<Notification>().Match(n => n.AvatarId == post.GroupUserId).ExecuteSingleAsync(token)).Type);
await _harness.Deliver(carol, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(carol)}/undos/{Guid.NewGuid():N}", ["type"] = "Undo", ["actor"] = carol.Id, ["object"] = announce["id"]!.GetValue<string>()
});
Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).ReblogsCount);
Assert.False(await DB.Default.Find<Post>().Match(p => p.ObjectURI == announce["id"]!.GetValue<string>()).ExecuteAnyAsync(token));
}
[Fact]
public async Task A_followed_account_boosting_a_remote_post_puts_it_in_home()
{
var token = TestContext.Current.CancellationToken;
var (bobRoot, bob) = await _harness.Persona("bob");
var carol = new RemoteActor(_harness.Peer, "carol");
var dave = new RemoteActor(_harness.Peer, "dave");
var stranger = new RemoteActor(_harness.Peer, "stranger");
await _harness.Follows.Follow(bobRoot, new FollowForm { AvatarId = bob.Id, Target = carol.Id }, token);
await DB.Default.Update<Following>().Match(f => f.AvatarId == bob.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token);
var noteId = $"{Origin(dave)}/notes/{Guid.NewGuid():N}";
_harness.Peer.Serve(new Uri(noteId).AbsolutePath, new JsonObject
{
["id"] = noteId, ["type"] = "Note", ["attributedTo"] = dave.Id, ["content"] = "<p>worth sharing</p>", ["to"] = new JsonArray(Addressing.Public)
}.ToJsonString());
await _harness.Deliver(stranger, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(stranger)}/announces/{Guid.NewGuid():N}", ["type"] = "Announce", ["actor"] = stranger.Id, ["object"] = noteId,
["to"] = new JsonArray(Addressing.Public)
});
Assert.False(await DB.Default.Find<Post>().Match(p => p.ObjectURI == noteId).ExecuteAnyAsync(token));
await _harness.Deliver(carol, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(carol)}/announces/{Guid.NewGuid():N}", ["type"] = "Announce", ["actor"] = carol.Id, ["object"] = noteId,
["to"] = new JsonArray(Addressing.Public)
});
var home = (List<ViewPost>)(await _harness.Timelines.Home(bobRoot, bob.Id, default, 20, token)).Data;
var boosted = Assert.Single(home);
Assert.Equal("<p>worth sharing</p>", boosted.ReblogOf.ContentHtml);
Assert.Equal(carol.Id, boosted.AuthorActorURI);
}
}
}
@@ -132,7 +132,7 @@ namespace PrivaPub.Tests.Infrastructure
while (DateTime.UtcNow < deadline && !_peer.Requests.Any(r => r.Path == "/live/inbox")) while (DateTime.UtcNow < deadline && !_peer.Requests.Any(r => r.Path == "/live/inbox"))
await Task.Delay(50, token); await Task.Delay(50, token);
var live = Assert.Single(_peer.Requests, r => r.Path == "/live/inbox"); var live = Assert.Single(_peer.Requests, r => r.Path == "/live/inbox");
Assert.True(_peer.Requests.Count(r => r.Path == "/dead/inbox") < 10); Assert.True(_peer.Requests.Count(r => r.Path == "/dead/inbox") < 30);
while (DateTime.UtcNow < deadline && !await DB.Default.Find<RemoteInstance>().Match(i => i.Host == "localhost").ExecuteAnyAsync(token)) while (DateTime.UtcNow < deadline && !await DB.Default.Find<RemoteInstance>().Match(i => i.Host == "localhost").ExecuteAnyAsync(token))
await Task.Delay(50, token); await Task.Delay(50, token);
await worker.StopAsync(token); await worker.StopAsync(token);
+129
View File
@@ -0,0 +1,129 @@
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Logging.Abstractions;
using MongoDB.Entities;
using PrivaPub.Domain.Content;
using PrivaPub.Domain.Social;
using PrivaPub.Domain.Statuses;
using PrivaPub.Domain.Timelines;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Inbox.Handlers;
using PrivaPub.Federation.Outbox;
using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Models;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.User;
using PrivaPub.Resources;
using PrivaPub.Services;
using PrivaPub.StaticServices;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Support
{
public sealed class Harness : IAsyncDisposable
{
public const string Host = "privapub.test";
public const string Base = "https://" + Host;
Harness(Peer peer)
{
Peer = peer;
var cache = new MemoryCache(new MemoryCacheOptions());
Local = new LocalActorService(Db, new StaticOptions<AppConfiguration>(new AppConfiguration { BackendBaseAddress = Base }));
Remote = new RemoteActorService(Peer.Http(cache), Local, cache, Db);
Delivery = new DeliveryService(Db, Queue);
Fanout = new Fanout(Db);
RemotePosts = new RemotePosts(Db, Local, Remote, new NoBlocks(), Queue);
Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger<InboxReceiver>.Instance);
Processor = new InboxProcessor(Remote, new IActivityHandler[]
{
new FollowHandler(Db, Local, Remote, Delivery),
new AcceptHandler(Db, Local),
new RejectHandler(Db, Local),
new UndoHandler(Db, Local),
new LikeHandler(Db),
new AnnounceHandler(Db, Local, RemotePosts, Fanout),
new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts),
new DeleteHandler(Db, Local, Remote, Delivery),
new UpdateHandler(Db, Local, Remote)
}, NullLogger<InboxProcessor>.Instance);
Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance);
Content = new ContentRenderer(Local, Remote);
Outbox = new OutboxPublisher(Db, Local, Delivery);
Statuses = new StatusService(Db, Local, Remote, Delivery, Content, Outbox, Fanout);
Posts = new PostsService(Db, Local, Statuses, new KeyLocalizer<GenericRes>(), NullLogger<PostsService>.Instance);
Timelines = new TimelineService(Db, new KeyLocalizer<GenericRes>());
}
public Peer Peer { get; }
public DbEntities Db { get; } = new();
public JobQueue Queue { get; } = new();
public LocalActorService Local { get; }
public RemoteActorService Remote { get; }
public DeliveryService Delivery { get; }
public InboxReceiver Receiver { get; }
public InboxProcessor Processor { get; }
public FollowService Follows { get; }
public ContentRenderer Content { get; }
public OutboxPublisher Outbox { get; }
public PostsService Posts { get; }
public StatusService Statuses { get; }
public Fanout Fanout { get; }
public RemotePosts RemotePosts { get; }
public TimelineService Timelines { get; }
public async Task FollowedBy(LocalActor local, RemoteActor follower) =>
await DB.Default.SaveAsync(new Follower
{
LocalActorId = local.Id,
LocalActorKind = local.Kind,
ActorURI = follower.Id,
InboxURL = follower.Id + "/inbox",
SharedInboxURL = follower.SharedInbox
});
public static async Task<Harness> Start() => new(await Peer.Start());
public async Task<(string RootId, LocalActor Avatar)> Persona(string name, string rootId = default)
{
rootId ??= Guid.NewGuid().ToString("N")[..24];
var (privateKey, publicKey) = Keys.NewKeyPair();
var avatar = new Avatar { UserName = $"{name}{Guid.NewGuid():N}"[..20], Name = name, PrivateKey = privateKey, PublicKey = publicKey };
await DB.Default.SaveAsync(avatar);
await DB.Default.SaveAsync(new RootToAvatar { RootId = rootId, AvatarId = avatar.ID });
return (rootId, Local.FromAvatar(avatar));
}
public async Task<InboxResult> Deliver(RemoteActor sender, string path, JsonNode activity)
{
var result = await Receiver.Receive(sender.Post(Host, path, activity), default, CancellationToken.None);
var dedupe = "inbox|" + (activity is JsonObject ? activity["id"]?.GetValue<string>() : default);
var job = await DB.Default.Find<Job>().Match(j => j.DedupeKey == dedupe).ExecuteFirstAsync();
if (job != default)
Assert.Equal(JobResult.Done, (await Processor.Handle(job, CancellationToken.None)).Result);
return result;
}
public async Task<List<JsonObject>> Outgoing(string inbox) =>
(await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.Deliver).ExecuteAsync())
.Select(j => JsonSerializer.Deserialize<DeliveryPayload>(j.Payload))
.Where(p => p.Inbox == inbox)
.Select(p => JsonNode.Parse(p.Body)!.AsObject())
.ToList();
public async ValueTask DisposeAsync() => await Peer.DisposeAsync();
}
public sealed class KeyLocalizer<T> : IStringLocalizer<T>
{
public LocalizedString this[string name] => new(name, name);
public LocalizedString this[string name, params object[] arguments] => new(name, string.Format(name, arguments));
public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures) => Enumerable.Empty<LocalizedString>();
}
}
+1
View File
@@ -24,6 +24,7 @@ namespace PrivaPub.Tests.Support
public string Name { get; } public string Name { get; }
public string Id { get; } public string Id { get; }
public string KeyId => Id + "#main-key"; public string KeyId => Id + "#main-key";
public string SharedInbox => Id.Split("/users/")[0] + "/inbox";
public JsonObject Document() => new() public JsonObject Document() => new()
{ {
@@ -0,0 +1,37 @@
namespace PrivaPub.Api.Mastodon.Auth
{
public static class MastodonScopes
{
public static readonly string[] All =
{
"read", "write", "follow", "push", "profile",
"read:accounts", "read:blocks", "read:bookmarks", "read:favourites", "read:filters", "read:follows", "read:lists",
"read:mutes", "read:notifications", "read:search", "read:statuses",
"write:accounts", "write:blocks", "write:bookmarks", "write:conversations", "write:favourites", "write:filters",
"write:follows", "write:lists", "write:media", "write:mutes", "write:notifications", "write:reports", "write:statuses"
};
public static IReadOnlyList<string> Parse(string scopes)
{
var parsed = (scopes ?? string.Empty).Split(new[] { ' ', '+', ',' }, StringSplitOptions.RemoveEmptyEntries)
.Where(s => All.Contains(s))
.Distinct()
.ToList();
return parsed.Count == 0 ? new[] { "read" } : parsed;
}
public static bool Grants(IEnumerable<string> granted, string required)
{
var scopes = granted as ICollection<string> ?? granted.ToList();
if (scopes.Contains(required))
return true;
var colon = required.IndexOf(':');
if (colon > 0 && scopes.Contains(required[..colon]))
return true;
return required.StartsWith("read:follows") || required.StartsWith("write:follows") || required.StartsWith("write:blocks")
|| required.StartsWith("read:blocks") || required.StartsWith("write:mutes") || required.StartsWith("read:mutes")
? scopes.Contains("follow")
: false;
}
}
}
+44
View File
@@ -0,0 +1,44 @@
using OpenIddict.Abstractions;
namespace PrivaPub.Api.Mastodon.Auth
{
public class OAuthPruner : BackgroundService
{
static readonly TimeSpan Interval = TimeSpan.FromHours(6);
static readonly TimeSpan Threshold = TimeSpan.FromDays(14);
readonly IServiceProvider _services;
readonly ILogger<OAuthPruner> _logger;
public OAuthPruner(IServiceProvider services, ILogger<OAuthPruner> logger)
{
_services = services;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
await Task.Delay(Interval, stoppingToken);
using var scope = _services.CreateScope();
var threshold = DateTimeOffset.UtcNow - Threshold;
var tokens = await scope.ServiceProvider.GetRequiredService<IOpenIddictTokenManager>().PruneAsync(threshold, stoppingToken);
var authorizations = await scope.ServiceProvider.GetRequiredService<IOpenIddictAuthorizationManager>().PruneAsync(threshold, stoppingToken);
if (tokens + authorizations > 0)
_logger.LogInformation("{Service} removed {Tokens} tokens and {Authorizations} authorizations", nameof(OAuthPruner), tokens, authorizations);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "{Service} pass failed", nameof(OAuthPruner));
}
}
}
}
}
+112
View File
@@ -0,0 +1,112 @@
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using MongoDB.Driver;
using MongoDB.Entities;
using OpenIddict.Abstractions;
using OpenIddict.Server;
using OpenIddict.Server.AspNetCore;
using PrivaPub.Models;
using System.Net;
using static OpenIddict.Server.OpenIddictServerEvents;
namespace PrivaPub.Api.Mastodon.Auth
{
public static class OAuthSetup
{
public const string LoginScheme = "PrivaPub.OAuth";
public const string OutOfBand = "urn:ietf:wg:oauth:2.0:oob";
public static IServiceCollection PrivaPubOAuth(this IServiceCollection services, IWebHostEnvironment environment)
{
services.AddSingleton<IMongoDatabase>(_ => DB.Default.Database());
services.AddHostedService<OAuthPruner>();
services.AddAuthentication()
.AddCookie(LoginScheme, options =>
{
options.Cookie.Name = "privapub.oauth";
options.Cookie.Path = "/oauth";
options.Cookie.HttpOnly = true;
options.Cookie.SameSite = SameSiteMode.Lax;
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.ExpireTimeSpan = TimeSpan.FromMinutes(15);
options.SlidingExpiration = false;
options.LoginPath = "/oauth/login";
});
services.AddOpenIddict()
.AddCore(options => options.UseMongoDb())
.AddServer(options =>
{
options.SetAuthorizationEndpointUris("/oauth/authorize")
.SetTokenEndpointUris("/oauth/token")
.SetRevocationEndpointUris("/oauth/revoke")
.SetConfigurationEndpointUris("/.well-known/openid-configuration", "/.well-known/oauth-authorization-server");
options.AllowAuthorizationCodeFlow().AllowClientCredentialsFlow();
options.RegisterScopes(MastodonScopes.All);
options.UseReferenceAccessTokens();
options.SetAccessTokenLifetime(null);
options.SetAuthorizationCodeLifetime(TimeSpan.FromMinutes(10));
options.DisableAccessTokenEncryption();
var aspNetCore = options.UseAspNetCore()
.EnableAuthorizationEndpointPassthrough()
.EnableTokenEndpointPassthrough();
if (!environment.IsProduction())
aspNetCore.DisableTransportSecurityRequirement();
options.AddEventHandler<ApplyAuthorizationResponseContext>(builder => builder.UseInlineHandler(OutOfBandCode)
.SetOrder(OpenIddictServerAspNetCoreHandlers.Authentication.ProcessFormPostResponse.Descriptor.Order - 1_000)
.SetType(OpenIddictServerHandlerType.Custom));
options.AddEventHandler<ApplyTokenResponseContext>(builder => builder.UseInlineHandler(context =>
{
if (context.Response.AccessToken != default)
context.Response["created_at"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
return default;
}));
})
.AddValidation(options =>
{
options.UseLocalServer();
options.UseAspNetCore();
});
services.AddOptions<OpenIddictServerOptions>().Configure<IOptionsMonitor<AppConfiguration>>((options, app) =>
{
options.Issuer = new Uri(app.CurrentValue.BackendBaseAddress.TrimEnd('/') + "/");
options.SigningCredentials.Add(new SigningCredentials(OidcKey.Load(OidcKey.Signing), SecurityAlgorithms.RsaSha256));
options.EncryptionCredentials.Add(new EncryptingCredentials(OidcKey.Load(OidcKey.Encryption),
SecurityAlgorithms.RsaOAEP, SecurityAlgorithms.Aes256CbcHmacSha512));
});
return services;
}
static async ValueTask OutOfBandCode(ApplyAuthorizationResponseContext context)
{
if (context.RedirectUri != OutOfBand || context.Response.Code == default)
return;
var http = context.Transaction.GetHttpRequest()?.HttpContext;
if (http == default)
return;
var code = WebUtility.HtmlEncode(context.Response.Code);
http.Response.StatusCode = StatusCodes.Status200OK;
http.Response.ContentType = "text/html; charset=utf-8";
http.Response.Headers["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'";
http.Response.Headers["Cache-Control"] = "no-store";
await http.Response.WriteAsync($$"""
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Authorization code</title><style>body{font:16px/1.5 system-ui,sans-serif;max-width:480px;margin:48px auto;padding:0 16px}
code{display:block;font-size:1.1rem;padding:12px;border:1px solid #ccc;word-break:break-all}</style></head>
<body><h1>Authorization code</h1><p>Copy this code into the application:</p><code>{{code}}</code></body></html>
""");
context.HandleRequest();
}
}
}
+33
View File
@@ -0,0 +1,33 @@
using Microsoft.IdentityModel.Tokens;
using MongoDB.Entities;
using System.Security.Cryptography;
namespace PrivaPub.Api.Mastodon.Auth
{
public class OidcKey : Entity
{
public string Use { get; set; }
public string PrivateKeyPem { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public const string Signing = "signing";
public const string Encryption = "encryption";
public static RsaSecurityKey Load(string use)
{
var key = DB.Default.Find<OidcKey>().Match(k => k.Use == use).Sort(k => k.CreatedAt, Order.Ascending).ExecuteFirstAsync().GetAwaiter().GetResult();
if (key == default)
{
using var created = RSA.Create(2048);
key = new OidcKey { Use = use, PrivateKeyPem = created.ExportRSAPrivateKeyPem() };
DB.Default.SaveAsync(key).GetAwaiter().GetResult();
key = DB.Default.Find<OidcKey>().Match(k => k.Use == use).Sort(k => k.CreatedAt, Order.Ascending).ExecuteFirstAsync().GetAwaiter().GetResult();
}
var rsa = RSA.Create();
rsa.ImportFromPem(key.PrivateKeyPem);
return new RsaSecurityKey(rsa) { KeyId = key.ID };
}
}
}
@@ -0,0 +1,70 @@
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore;
using PrivaPub.StaticServices;
using System.Security.Claims;
using static OpenIddict.Abstractions.OpenIddictConstants;
namespace PrivaPub.Api.Mastodon.Auth
{
[ApiController]
public class TokenController : ControllerBase
{
readonly DbEntities _dbEntities;
public TokenController(DbEntities dbEntities)
{
_dbEntities = dbEntities;
}
[HttpPost("/oauth/token"), IgnoreAntiforgeryToken, Produces("application/json")]
public async Task<IActionResult> Exchange(CancellationToken token)
{
var request = HttpContext.GetOpenIddictServerRequest();
if (request == default)
return BadRequest();
if (request.IsAuthorizationCodeGrantType())
{
var principal = (await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)).Principal;
if (principal == default || !await Usable(principal.GetClaim(Claims.Subject), token))
return Refuse(Errors.InvalidGrant, "The persona can no longer be used.");
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
if (request.IsClientCredentialsGrantType())
{
var identity = new ClaimsIdentity(TokenValidationParameters.DefaultAuthenticationType, Claims.Name, Claims.Role);
identity.SetClaim(Claims.Subject, "app:" + request.ClientId);
identity.SetScopes(MastodonScopes.Parse(request.Scope));
identity.SetDestinations(_ => new[] { Destinations.AccessToken });
return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
return Refuse(Errors.UnsupportedGrantType, "The grant type is not supported.");
}
async Task<bool> Usable(string avatarId, CancellationToken token)
{
if (string.IsNullOrEmpty(avatarId))
return false;
var link = await _dbEntities.RootToAvatars.Match(r => r.AvatarId == avatarId).ExecuteFirstAsync(token);
var root = link == default ? default : await _dbEntities.RootUsers.MatchID(link.RootId).ExecuteFirstAsync(token);
var avatar = await _dbEntities.Avatars.MatchID(avatarId).ExecuteFirstAsync(token);
return root is { IsBanned: false, DeletedAt: null } && avatar is { DeletionAt: null };
}
ForbidResult Refuse(string error, string description) => Forbid(new AuthenticationProperties(new Dictionary<string, string>
{
[OpenIddictServerAspNetCoreConstants.Properties.Error] = error,
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = description
}), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
}
@@ -0,0 +1,294 @@
using Microsoft.AspNetCore.Mvc;
using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Entities;
using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Api.Mastodon.Mappers;
using PrivaPub.Domain.Privacy;
using PrivaPub.Domain.Social;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Outbox;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Api.Mastodon.Controllers
{
public class AccountsController : MastodonController
{
readonly MastodonMapper _mapper;
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IRemoteActorService _remoteActors;
readonly IFollowService _follows;
readonly IOutboxPublisher _outbox;
public AccountsController(MastodonMapper mapper, DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors,
IFollowService follows, IOutboxPublisher outbox)
{
_mapper = mapper;
_dbEntities = dbEntities;
_localActors = localActors;
_remoteActors = remoteActors;
_follows = follows;
_outbox = outbox;
}
[HttpGet("/api/v1/accounts/verify_credentials"), Scope("read:accounts")]
public async Task<IActionResult> VerifyCredentials(CancellationToken token) => Json(await _mapper.Local(Me, withSource: true, token));
[HttpPatch("/api/v1/accounts/update_credentials"), Scope("write:accounts")]
public async Task<IActionResult> UpdateCredentials(CancellationToken token)
{
var avatar = await _dbEntities.Avatars.MatchID(Me.Id).ExecuteFirstAsync(token);
if (Params.Has("display_name"))
avatar.Name = Params.Get("display_name")?.Trim();
if (Params.Has("note"))
avatar.Biography = Params.Get("note");
if (Params.Bool("locked") is { } locked)
avatar.Settings.IsLocked = locked;
if (Params.Bool("bot") is { } bot)
avatar.Settings.IsBot = bot;
if (Params.Bool("discoverable") is { } discoverable)
avatar.Settings.IsDiscoverable = discoverable;
if (Params.Bool("indexable") is { } indexable)
avatar.Settings.IsIndexable = indexable;
if (Params.Bool("hide_collections") is { } hide)
avatar.Settings.HideCollections = hide;
if (Params.Get("source[privacy]") is { } privacy && privacy is "public" or "unlisted" or "private")
avatar.Settings.DefaultVisibility = privacy;
if (Params.Bool("source[sensitive]") is { } sensitive)
avatar.Settings.DefaultSensitive = sensitive;
if (Params.Has("source[language]"))
avatar.Settings.DefaultLanguage = Params.Get("source[language]");
var fields = new Dictionary<string, string>();
for (var i = 0; i < 4; i++)
{
var name = Params.Get($"fields_attributes[{i}][name]") ?? Params.Get($"fields_attributes[][name]");
if (!string.IsNullOrWhiteSpace(name))
fields[name.Trim()] = Params.Get($"fields_attributes[{i}][value]")?.Trim() ?? string.Empty;
}
if (Params.List("fields_attributes[][name]").Count > 0)
{
var names = Params.List("fields_attributes[][name]");
var values = Params.List("fields_attributes[][value]");
fields = names.Select((n, i) => (n, v: i < values.Count ? values[i] : string.Empty)).Where(f => !string.IsNullOrWhiteSpace(f.n))
.Take(4).ToDictionary(f => f.n.Trim(), f => f.v.Trim());
}
if (fields.Count > 0 || Params.Has("fields_attributes[0][name]"))
avatar.Fields = fields;
avatar.UpdatedAt = DateTime.UtcNow;
await DB.Default.SaveAsync(avatar, token);
var actor = _localActors.FromAvatar(avatar);
await _outbox.PublishProfile(actor, token);
return Json(await _mapper.Local(actor, withSource: true, token));
}
[HttpGet("/api/v1/accounts/lookup"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Lookup(CancellationToken token)
{
var acct = Params.Get("acct")?.Trim().TrimStart('@');
var parts = acct?.Split('@');
if (parts is not { Length: 1 or 2 } || string.IsNullOrEmpty(parts[0]))
return NotFoundError();
var localDomain = new Uri(_localActors.BaseAddress).Authority;
if (parts.Length == 1 || parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase))
{
var local = await _localActors.FindByUserName(parts[0], token);
return local is { IsFederated: true, Kind: not LocalActorKind.Application } ? Json(await _mapper.Local(local, false, token)) : NotFoundError();
}
var userName = parts[0];
var domain = parts[1].ToLowerInvariant();
var foreign = await _dbEntities.ForeignAvatars.Match(f => f.UserName == userName && f.Domain == domain).ExecuteFirstAsync(token);
return foreign == default ? NotFoundError() : Json(_mapper.Foreign(foreign));
}
[HttpGet("/api/v1/accounts/relationships"), Scope("read:follows")]
public async Task<IActionResult> Relationships(CancellationToken token)
{
var relationships = new List<Relationship>();
foreach (var id in Params.List("id").Distinct().Take(40))
relationships.Add(await Relationship(id, token));
return Json(relationships);
}
[HttpGet("/api/v1/accounts/search"), Scope("read:accounts")]
public async Task<IActionResult> Search([FromServices] AccountSearch search, CancellationToken token) =>
Json(await search.Find(Params.Get("q"), Params.Bool("resolve") == true && MyId != default, Limit(), token));
[HttpGet("/api/v1/accounts/{id}"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Get(string id, CancellationToken token)
{
var account = await _mapper.Account(id, token);
return account == default ? NotFoundError() : Json(account);
}
[HttpGet("/api/v1/accounts/{id}/statuses"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Statuses(string id, CancellationToken token)
{
var (local, remote) = await Find(id, token);
if (local == default && remote == default)
return NotFoundError();
if (Params.Bool("pinned") == true)
return Json(Array.Empty<Status>());
var query = local != default
? _dbEntities.Posts.Match(p => p.GroupUserId == local.Id && !p.IsFederatedCopy && !p.DeletedAt.HasValue)
: _dbEntities.Posts.Match(p => p.ActorURI == remote.ActorURI && p.IsFederatedCopy && !p.DeletedAt.HasValue);
var viewerFollows = MyId != default && local != default
&& await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.TargetAccountId == local.Id && f.State == FollowState.Accepted).ExecuteAnyAsync(token);
if (MyId != id)
query.Match(viewerFollows
? p => p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted || p.Visibility == PostVisibility.FollowersOnly
: p => p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted);
else
query.Match(p => p.Visibility != PostVisibility.LocalGeo);
if (Params.Bool("exclude_replies") == true)
query.Match(p => p.InReplyToURI == null);
if (Params.Bool("exclude_reblogs") == true)
query.Match(p => p.ReblogOfPostId == null);
if (Params.Bool("only_media") == true)
query.Match(p => p.Media.Count > 0);
if (Params.Get("tagged") is { } tag)
query.Match(p => p.Tags.Contains(tag.ToLowerInvariant()));
var posts = await Page.From(Params, Limit()).Fetch(query, p => p.ID, token);
var statuses = await _mapper.Statuses(posts, MyId, token);
Link($"/api/v1/accounts/{id}/statuses", posts.LastOrDefault()?.ID, posts.FirstOrDefault()?.ID);
return Json(statuses);
}
[HttpGet("/api/v1/accounts/{id}/followers"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Followers(string id, CancellationToken token)
{
var (local, _) = await Find(id, token);
if (local == default || local.Id != MyId)
return Json(Array.Empty<Account>());
var followers = await _dbEntities.Followers.Match(f => f.LocalActorId == local.Id && f.LocalActorKind == local.Kind && f.IsAccepted)
.Sort(f => f.ID, Order.Descending).Limit(Limit(40, 80)).ExecuteAsync(token);
return Json(await AccountsFor(followers.Select(f => f.ActorURI), token));
}
[HttpGet("/api/v1/accounts/{id}/following"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Following(string id, CancellationToken token)
{
var (local, _) = await Find(id, token);
if (local == default || local.Id != MyId)
return Json(Array.Empty<Account>());
var following = await _dbEntities.Followings.Match(f => f.AvatarId == local.Id && f.State == FollowState.Accepted)
.Sort(f => f.ID, Order.Descending).Limit(Limit(40, 80)).ExecuteAsync(token);
var accounts = await _mapper.Accounts(following.Select(f => f.TargetAccountId), token);
return Json(following.Select(f => accounts.GetValueOrDefault(f.TargetAccountId)).Where(a => a != default).ToList());
}
[HttpPost("/api/v1/accounts/{id}/follow"), Scope("write:follows")]
public async Task<IActionResult> Follow(string id, CancellationToken token)
{
var (local, remote) = await Find(id, token);
if (local == default && remote == default)
return NotFoundError();
var following = await _follows.FollowAs(Me, local?.Uri ?? remote.ActorURI, Params.Bool("reblogs") != false, token);
return following == default ? Error(StatusCodes.Status403Forbidden, "This action is not allowed") : Json(await Relationship(id, token));
}
[HttpPost("/api/v1/accounts/{id}/unfollow"), Scope("write:follows")]
public async Task<IActionResult> Unfollow(string id, CancellationToken token)
{
var (local, remote) = await Find(id, token);
if (local == default && remote == default)
return NotFoundError();
await _follows.UnfollowAs(Me, local?.Uri ?? remote.ActorURI, token);
return Json(await Relationship(id, token));
}
[HttpPost("/api/v1/accounts/{id}/remove_from_followers"), Scope("write:follows")]
public async Task<IActionResult> RemoveFromFollowers(string id, CancellationToken token)
{
var (local, remote) = await Find(id, token);
var uri = local?.Uri ?? remote?.ActorURI;
if (uri == default)
return NotFoundError();
await DB.Default.DeleteAsync<Follower>(f => f.LocalActorId == Me.Id && f.ActorURI == uri);
if (local != default)
await DB.Default.DeleteAsync<Following>(f => f.AvatarId == local.Id && f.TargetActorURI == Me.Uri);
return Json(await Relationship(id, token));
}
[HttpGet("/api/v1/follow_requests"), Scope("read:follows")]
public async Task<IActionResult> FollowRequests(CancellationToken token)
{
var requests = await _dbEntities.Followers.Match(f => f.LocalActorId == Me.Id && f.LocalActorKind == LocalActorKind.Person && !f.IsAccepted)
.Sort(f => f.ID, Order.Descending).Limit(Limit(40, 80)).ExecuteAsync(token);
return Json(await AccountsFor(requests.Select(r => r.ActorURI), token));
}
[HttpPost("/api/v1/follow_requests/{id}/authorize"), Scope("write:follows")]
public async Task<IActionResult> Authorize(string id, CancellationToken token) =>
await _follows.Decide(Me, id, accept: true, token) ? Json(await Relationship(id, token)) : NotFoundError();
[HttpPost("/api/v1/follow_requests/{id}/reject"), Scope("write:follows")]
public async Task<IActionResult> Reject(string id, CancellationToken token) =>
await _follows.Decide(Me, id, accept: false, token) ? Json(await Relationship(id, token)) : NotFoundError();
[HttpGet("/api/v1/accounts/{id}/featured_tags"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult FeaturedTags(string id) => Json(Array.Empty<object>());
[HttpGet("/api/v1/accounts/{id}/lists"), Scope("read:lists")]
public IActionResult Lists(string id) => Json(Array.Empty<object>());
[HttpGet("/api/v1/accounts/familiar_followers"), Scope("read:follows")]
public IActionResult FamiliarFollowers() => Json(Params.List("id").Select(id => new { id, accounts = Array.Empty<Account>() }).ToList());
async Task<List<Account>> AccountsFor(IEnumerable<string> actorUris, CancellationToken token)
{
var accounts = new List<Account>();
foreach (var uri in actorUris)
{
var local = await _localActors.FindByUri(uri, token);
if (local != default)
{
accounts.Add(await _mapper.Local(local, false, token));
continue;
}
var foreign = await _dbEntities.ForeignAvatars.Match(f => f.ActorURI == uri).ExecuteFirstAsync(token);
if (foreign != default)
accounts.Add(_mapper.Foreign(foreign));
}
return accounts;
}
async Task<(LocalActor Local, ForeignAvatar Remote)> Find(string id, CancellationToken token)
{
var avatar = await _dbEntities.Avatars.MatchID(id).ExecuteFirstAsync(token);
if (avatar is { DeletionAt: null })
return (_localActors.FromAvatar(avatar), default);
var group = await _dbEntities.Groups.MatchID(id).ExecuteFirstAsync(token);
if (group is { DeletionAt: null } && _localActors.FromGroup(group) is { IsFederated: true } community)
return (community, default);
return (default, await _dbEntities.ForeignAvatars.MatchID(id).ExecuteFirstAsync(token));
}
async Task<Relationship> Relationship(string id, CancellationToken token)
{
var (local, remote) = await Find(id, token);
var uri = local?.Uri ?? remote?.ActorURI;
var following = uri == default ? default : await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.TargetActorURI == uri).ExecuteFirstAsync(token);
var followedBy = uri == default ? default : await _dbEntities.Followers.Match(f => f.LocalActorId == MyId && f.ActorURI == uri).ExecuteFirstAsync(token);
return new Relationship
{
Id = id,
Following = following?.State == FollowState.Accepted,
Requested = following?.State == FollowState.Requested,
ShowingReblogs = following?.ShowReblogs ?? false,
FollowedBy = followedBy?.IsAccepted == true,
RequestedBy = followedBy is { IsAccepted: false }
};
}
}
}
@@ -0,0 +1,97 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using OpenIddict.Abstractions;
using PrivaPub.Api.Mastodon.Auth;
using PrivaPub.Api.Mastodon.Entities;
using PrivaPub.Api.Mastodon.Infrastructure;
using System.Security.Cryptography;
using static OpenIddict.Abstractions.OpenIddictConstants;
namespace PrivaPub.Api.Mastodon.Controllers
{
public class AppsController : MastodonController
{
readonly IOpenIddictApplicationManager _applications;
public AppsController(IOpenIddictApplicationManager applications)
{
_applications = applications;
}
[HttpPost("/api/v1/apps"), AllowAnonymous]
public async Task<IActionResult> Create(CancellationToken token)
{
var name = Params.Get("client_name")?.Trim();
if (string.IsNullOrEmpty(name) || name.Length > 200)
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Application name can't be blank");
var redirects = Params.List("redirect_uris").SelectMany(r => r.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)).Distinct().ToList();
if (redirects.Count == 0 || redirects.Any(r => !Uri.TryCreate(r, UriKind.Absolute, out var uri) || uri.Scheme is "javascript" or "data"))
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Redirect URI must be an absolute URI.");
var scopes = MastodonScopes.Parse(string.Join(' ', Params.List("scopes")));
var descriptor = new OpenIddictApplicationDescriptor
{
ClientId = Secret(32),
ClientSecret = Secret(32),
ClientType = ClientTypes.Confidential,
ConsentType = ConsentTypes.Explicit,
DisplayName = name,
Permissions =
{
Permissions.Endpoints.Authorization,
Permissions.Endpoints.Token,
Permissions.Endpoints.Revocation,
Permissions.GrantTypes.AuthorizationCode,
Permissions.GrantTypes.ClientCredentials,
Permissions.ResponseTypes.Code
}
};
foreach (var scope in scopes)
descriptor.Permissions.Add(Permissions.Prefixes.Scope + scope);
foreach (var redirect in redirects)
descriptor.RedirectUris.Add(new Uri(redirect));
var website = Params.Get("website");
if (Uri.TryCreate(website, UriKind.Absolute, out var site) && site.Scheme is "https" or "http")
descriptor.Properties["website"] = System.Text.Json.JsonSerializer.SerializeToElement(website);
var application = await _applications.CreateAsync(descriptor, token);
return Json(new Application
{
Id = await _applications.GetIdAsync(application, token),
Name = name,
Website = website,
Scopes = scopes.ToList(),
RedirectUris = redirects,
RedirectUri = string.Join("\n", redirects),
ClientId = descriptor.ClientId,
ClientSecret = descriptor.ClientSecret
});
}
[HttpGet("/api/v1/apps/verify_credentials")]
public async Task<IActionResult> Verify(CancellationToken token)
{
var clientId = User.GetPresenters().FirstOrDefault() ?? User.GetClaim(Claims.ClientId);
var application = clientId == default ? default : await _applications.FindByClientIdAsync(clientId, token);
if (application == default)
return Error(StatusCodes.Status401Unauthorized, "The access token is invalid");
var properties = await _applications.GetPropertiesAsync(application, token);
return Json(new
{
id = await _applications.GetIdAsync(application, token),
name = await _applications.GetDisplayNameAsync(application, token),
website = properties.TryGetValue("website", out var website) ? website.GetString() : default,
scopes = User.GetScopes().ToList(),
redirect_uris = (await _applications.GetRedirectUrisAsync(application, token)).ToList(),
vapid_key = string.Empty
});
}
static string Secret(int bytes) => Convert.ToBase64String(RandomNumberGenerator.GetBytes(bytes)).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
}
@@ -0,0 +1,107 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Domain.Privacy;
using PrivaPub.Federation.Actors;
using PrivaPub.Models.User;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Api.Mastodon.Controllers
{
public class InstanceController : MastodonController
{
public const string Version = "4.2.0 (compatible; PrivaPub)";
const string Description = "A small ActivityPub server where one private login keeps several unlinkable public personas.";
readonly ILocalActorService _localActors;
public InstanceController(ILocalActorService localActors)
{
_localActors = localActors;
}
string Domain => new Uri(_localActors.BaseAddress).Authority;
static object Statuses => new { max_characters = 5000, max_media_attachments = 4, characters_reserved_per_url = 23 };
static object MediaAttachments => new
{
supported_mime_types = new[] { "image/jpeg", "image/png", "image/gif", "image/webp", "video/mp4", "video/webm", "audio/mpeg", "audio/ogg" },
image_size_limit = 16 * 1024 * 1024,
image_matrix_limit = 16_777_216,
video_size_limit = 99 * 1024 * 1024,
video_frame_rate_limit = 60,
video_matrix_limit = 2_304_000
};
static object Polls => new { max_options = 4, max_characters_per_option = 50, min_expiration = 300, max_expiration = 2_629_746 };
[HttpGet("/api/v1/instance"), AllowAnonymous]
public async Task<IActionResult> V1(CancellationToken token)
{
var users = await DB.Default.CountAsync<Avatar>(a => !a.DeletionAt.HasValue, token);
var statuses = await DB.Default.CountAsync<PostEntity>(f => f.Where(p => !p.IsFederatedCopy) & f.Where(VisibilityPolicy.IsPublic), token);
var domains = (await DB.Default.Find<ForeignAvatar, string>().Project(a => a.Domain).ExecuteAsync(token)).Distinct().Count();
return Json(new
{
uri = Domain,
title = "PrivaPub",
short_description = Description,
description = Description,
email = string.Empty,
version = Version,
urls = new { streaming_api = $"wss://{Domain}" },
stats = new { user_count = users, status_count = statuses, domain_count = domains },
thumbnail = $"{_localActors.BaseAddress}/media/missing-header.png",
languages = new[] { "en" },
registrations = false,
approval_required = false,
invites_enabled = false,
configuration = new { accounts = new { max_featured_tags = 0 }, statuses = Statuses, media_attachments = MediaAttachments, polls = Polls },
contact_account = default(object),
rules = Array.Empty<object>()
});
}
[HttpGet("/api/v2/instance"), AllowAnonymous]
public IActionResult V2() => Json(new
{
domain = Domain,
title = "PrivaPub",
version = Version,
source_url = "https://git.thepra.dev/thepra/SocialPub",
description = Description,
usage = new { users = new { active_month = 0 } },
thumbnail = new { url = $"{_localActors.BaseAddress}/media/missing-header.png" },
languages = new[] { "en" },
configuration = new
{
urls = new { streaming = $"wss://{Domain}" },
accounts = new { max_featured_tags = 0, max_pinned_statuses = 0 },
statuses = Statuses,
media_attachments = MediaAttachments,
polls = Polls,
translation = new { enabled = false }
},
registrations = new { enabled = false, approval_required = false, message = default(string) },
contact = new { email = string.Empty, account = default(object) },
rules = Array.Empty<object>()
});
[HttpGet("/api/v1/instance/peers"), AllowAnonymous]
public IActionResult Peers() => Json(Array.Empty<string>());
[HttpGet("/api/v1/instance/activity"), AllowAnonymous]
public IActionResult Activity() => Json(Array.Empty<object>());
[HttpGet("/api/v1/instance/rules"), AllowAnonymous]
public IActionResult Rules() => Json(Array.Empty<object>());
[HttpGet("/api/v1/instance/extended_description"), AllowAnonymous]
public IActionResult ExtendedDescription() => Json(new { updated_at = MastodonJson.Day(DateTime.UtcNow), content = $"<p>{Description}</p>" });
}
}
@@ -0,0 +1,149 @@
using Microsoft.AspNetCore.Mvc;
using PrivaPub.Api.Mastodon.Entities;
using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Api.Mastodon.Mappers;
using PrivaPub.Domain.Privacy;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Outbox;
using PrivaPub.Domain.Social;
using PrivaPub.Models.Post;
using PrivaPub.StaticServices;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Api.Mastodon.Controllers
{
public class SearchController : MastodonController
{
readonly MastodonMapper _mapper;
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IRemoteActorService _remoteActors;
readonly IRemotePosts _remotePosts;
readonly AccountSearch _search;
public SearchController(MastodonMapper mapper, DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors,
IRemotePosts remotePosts, AccountSearch search)
{
_mapper = mapper;
_dbEntities = dbEntities;
_localActors = localActors;
_remoteActors = remoteActors;
_remotePosts = remotePosts;
_search = search;
}
[HttpGet("/api/v2/search"), Scope("read:search", requiresUser: false)]
public async Task<IActionResult> Search(CancellationToken token)
{
var q = Params.Get("q")?.Trim();
var type = Params.Get("type");
var resolve = Params.Bool("resolve") == true && MyId != default;
var results = new SearchResults();
if (string.IsNullOrEmpty(q))
return Json(results);
if (q.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
var post = await _dbEntities.Posts.Match(p => (p.ObjectURI == q || p.Url == q) && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
if (post == default && resolve && type is null or "statuses")
post = await _remotePosts.StoreContext(q, 0, token);
if (post != default && type is null or "statuses" && await VisibilityPolicy.CanSee(post, MyId, token))
results.Statuses.Add(await _mapper.Status(post, MyId, token));
if (results.Statuses.Count == 0 && type is null or "accounts")
{
var local = await _localActors.FindByUri(q, token);
var foreign = local == default ? (resolve ? await _remoteActors.GetActor(q, refresh: false, token) : default) : default;
if (local is { IsFederated: true })
results.Accounts.Add(await _mapper.Local(local, false, token));
else if (foreign != default)
results.Accounts.Add(_mapper.Foreign(foreign));
}
return Json(results);
}
if (type is null or "accounts")
{
results.Accounts = await _search.Find(q, resolve, Limit(), token);
}
if (type is null or "hashtags" && q.TrimStart('#') is { Length: > 0 } tag)
results.Hashtags.Add(new { name = tag.ToLowerInvariant(), url = $"{_localActors.BaseAddress}/tags/{Uri.EscapeDataString(tag.ToLowerInvariant())}", history = Array.Empty<object>() });
return Json(results);
}
}
public class StubsController : MastodonController
{
[HttpGet("/api/v1/custom_emojis"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult CustomEmojis() => Json(Array.Empty<object>());
[HttpGet("/api/v1/filters"), Scope("read:filters")]
public IActionResult FiltersV1() => Json(Array.Empty<object>());
[HttpGet("/api/v2/filters"), Scope("read:filters")]
public IActionResult FiltersV2() => Json(Array.Empty<object>());
[HttpGet("/api/v1/lists"), Scope("read:lists")]
public IActionResult Lists() => Json(Array.Empty<object>());
[HttpGet("/api/v1/announcements"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult Announcements() => Json(Array.Empty<object>());
[HttpGet("/api/v1/trends"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult Trends() => Json(Array.Empty<object>());
[HttpGet("/api/v1/trends/tags"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult TrendTags() => Json(Array.Empty<object>());
[HttpGet("/api/v1/trends/statuses"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult TrendStatuses() => Json(Array.Empty<object>());
[HttpGet("/api/v1/trends/links"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult TrendLinks() => Json(Array.Empty<object>());
[HttpGet("/api/v1/suggestions"), Scope("read")]
public IActionResult SuggestionsV1() => Json(Array.Empty<object>());
[HttpGet("/api/v2/suggestions"), Scope("read")]
public IActionResult SuggestionsV2() => Json(Array.Empty<object>());
[HttpGet("/api/v1/followed_tags"), Scope("read:follows")]
public IActionResult FollowedTags() => Json(Array.Empty<object>());
[HttpGet("/api/v1/blocks"), Scope("read:blocks")]
public IActionResult Blocks() => Json(Array.Empty<object>());
[HttpGet("/api/v1/mutes"), Scope("read:mutes")]
public IActionResult Mutes() => Json(Array.Empty<object>());
[HttpGet("/api/v1/domain_blocks"), Scope("read:blocks")]
public IActionResult DomainBlocks() => Json(Array.Empty<string>());
[HttpGet("/api/v1/bookmarks"), Scope("read:bookmarks")]
public IActionResult Bookmarks() => Json(Array.Empty<object>());
[HttpGet("/api/v1/endorsements"), Scope("read:accounts")]
public IActionResult Endorsements() => Json(Array.Empty<object>());
[HttpGet("/api/v1/featured_tags"), Scope("read:accounts")]
public IActionResult FeaturedTags() => Json(Array.Empty<object>());
[HttpGet("/api/v1/scheduled_statuses"), Scope("read:statuses")]
public IActionResult ScheduledStatuses() => Json(Array.Empty<object>());
[HttpGet("/api/v1/preferences"), Scope("read:accounts")]
public IActionResult Preferences() => Json(new Dictionary<string, object>
{
["posting:default:visibility"] = Me.Settings.DefaultVisibility ?? "public",
["posting:default:sensitive"] = Me.Settings.DefaultSensitive,
["posting:default:language"] = Me.Settings.DefaultLanguage,
["reading:expand:media"] = "default",
["reading:expand:spoilers"] = false
});
[HttpGet("/api/v1/push/subscription"), Scope("push")]
public IActionResult PushSubscription() => Error(StatusCodes.Status404NotFound, "Record not found");
}
}
@@ -0,0 +1,276 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Entities;
using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Api.Mastodon.Mappers;
using PrivaPub.Domain.Privacy;
using PrivaPub.Domain.Statuses;
using PrivaPub.Models.Post;
using PrivaPub.StaticServices;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Api.Mastodon.Controllers
{
public class StatusesController : MastodonController
{
const int MaxContext = 200;
readonly IStatusService _statuses;
readonly MastodonMapper _mapper;
readonly DbEntities _dbEntities;
readonly IMemoryCache _cache;
public StatusesController(IStatusService statuses, MastodonMapper mapper, DbEntities dbEntities, IMemoryCache cache)
{
_statuses = statuses;
_mapper = mapper;
_dbEntities = dbEntities;
_cache = cache;
}
[HttpPost("/api/v1/statuses"), Scope("write:statuses")]
public async Task<IActionResult> Create(CancellationToken token)
{
var idempotency = Request.Headers["Idempotency-Key"].ToString();
var cacheKey = string.IsNullOrEmpty(idempotency) ? default : $"idempotency:{MyId}:{idempotency}";
if (cacheKey != default && _cache.TryGetValue(cacheKey, out string earlier))
{
var existing = await _dbEntities.Posts.MatchID(earlier).ExecuteFirstAsync(token);
if (existing != default)
return Json(await _mapper.Status(existing, MyId, token));
}
if (Params.List("media_ids").Count > 0)
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are not supported yet");
if (Params.Has("poll[options]"))
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Polls are not supported yet");
var outcome = await _statuses.Publish(Me, new StatusDraft
{
Text = Params.Get("status"),
PlainText = true,
SpoilerText = Params.Get("spoiler_text"),
Sensitive = Params.Bool("sensitive") ?? Me.Settings.DefaultSensitive,
Visibility = Visibility(Params.Get("visibility") ?? Me.Settings.DefaultVisibility),
InReplyTo = Params.Get("in_reply_to_id"),
Language = Params.Get("language") ?? Me.Settings.DefaultLanguage
}, token);
if (!outcome.Ok)
return Error(outcome.Status, outcome.Error);
if (cacheKey != default)
_cache.Set(cacheKey, outcome.Post.ID, TimeSpan.FromHours(1));
return Json(await _mapper.Status(outcome.Post, MyId, token));
}
[HttpGet("/api/v1/statuses"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Many(CancellationToken token)
{
var ids = Params.List("id").Take(20).ToList();
var posts = await _dbEntities.Posts.Match(p => ids.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token);
var visible = new List<PostEntity>();
foreach (var post in posts)
if (await VisibilityPolicy.CanSee(post, MyId, token))
visible.Add(post);
return Json(await _mapper.Statuses(visible, MyId, token));
}
[HttpGet("/api/v1/statuses/{id}"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Get(string id, CancellationToken token)
{
var post = await Visible(id, token);
return post == default ? NotFoundError() : Json(await _mapper.Status(post, MyId, token));
}
[HttpPut("/api/v1/statuses/{id}"), Scope("write:statuses")]
public async Task<IActionResult> Edit(string id, CancellationToken token)
{
if (Params.List("media_ids").Count > 0)
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are not supported yet");
var outcome = await _statuses.Edit(Me, id, new StatusDraft
{
Text = Params.Get("status"),
PlainText = true,
SpoilerText = Params.Get("spoiler_text"),
Sensitive = Params.Bool("sensitive") ?? false,
Language = Params.Get("language")
}, token);
return outcome.Ok ? Json(await _mapper.Status(outcome.Post, MyId, token)) : Error(outcome.Status, outcome.Error);
}
[HttpDelete("/api/v1/statuses/{id}"), Scope("write:statuses")]
public async Task<IActionResult> Delete(string id, CancellationToken token)
{
var before = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
if (before == default)
return NotFoundError();
var status = await _mapper.Status(before, MyId, token);
var outcome = await _statuses.Remove(Me, id, token);
if (!outcome.Ok)
return Error(outcome.Status, outcome.Error);
if (status != default)
{
status.Text = before.Text;
status.SpoilerText = before.SpoilerText ?? string.Empty;
}
return Json(status);
}
[HttpGet("/api/v1/statuses/{id}/context"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Context(string id, CancellationToken token)
{
var post = await Visible(id, token);
if (post == default)
return NotFoundError();
var ancestors = new List<PostEntity>();
var cursor = post;
while (!string.IsNullOrEmpty(cursor.AnsweringToPostId) && ancestors.Count < MaxContext)
{
cursor = await _dbEntities.Posts.MatchID(cursor.AnsweringToPostId).ExecuteFirstAsync(token);
if (cursor == default || cursor.DeletedAt.HasValue || !await VisibilityPolicy.CanSee(cursor, MyId, token))
break;
ancestors.Insert(0, cursor);
}
var descendants = new List<PostEntity>();
var frontier = new List<string> { post.ID };
while (frontier.Count > 0 && descendants.Count < MaxContext)
{
var parents = frontier;
var children = await _dbEntities.Posts.Match(p => parents.Contains(p.AnsweringToPostId) && !p.DeletedAt.HasValue && p.ReblogOfPostId == null)
.Sort(p => p.ID, Order.Ascending).Limit(MaxContext).ExecuteAsync(token);
frontier = new List<string>();
foreach (var child in children)
if (await VisibilityPolicy.CanSee(child, MyId, token))
{
descendants.Add(child);
frontier.Add(child.ID);
}
}
return Json(new Context
{
Ancestors = await _mapper.Statuses(ancestors, MyId, token),
Descendants = await _mapper.Statuses(descendants.OrderBy(d => d.ID, StringComparer.Ordinal).ToList(), MyId, token)
});
}
[HttpGet("/api/v1/statuses/{id}/history"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> History(string id, CancellationToken token)
{
var post = await Visible(id, token);
if (post == default)
return NotFoundError();
var account = await _mapper.Account(MastodonMapper.AuthorOf(post), token);
var edits = post.Revisions.Select(r => new StatusEdit
{
Content = r.ContentHtml ?? string.Empty,
SpoilerText = r.SpoilerText ?? string.Empty,
Sensitive = r.HasContentWarning,
CreatedAt = MastodonJson.Time(r.EditedAt),
Account = account
}).ToList();
edits.Add(new StatusEdit
{
Content = MastodonMapper.Content(post),
SpoilerText = post.SpoilerText ?? string.Empty,
Sensitive = post.HasContentWarning,
CreatedAt = MastodonJson.Time(post.EditedAt ?? post.CreationDate),
Account = account
});
return Json(edits);
}
[HttpGet("/api/v1/statuses/{id}/source"), Scope("read:statuses")]
public async Task<IActionResult> Source(string id, CancellationToken token)
{
var post = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
return post == default ? NotFoundError() : Json(new StatusSource { Id = post.ID, Text = post.Text ?? string.Empty, SpoilerText = post.SpoilerText ?? string.Empty });
}
[HttpPost("/api/v1/statuses/{id}/favourite"), Scope("write:favourites")]
public Task<IActionResult> Favourite(string id, CancellationToken token) => Toggle(_statuses.Favourite(Me, id, true, token), id, token);
[HttpPost("/api/v1/statuses/{id}/unfavourite"), Scope("write:favourites")]
public Task<IActionResult> Unfavourite(string id, CancellationToken token) => Toggle(_statuses.Favourite(Me, id, false, token), id, token);
[HttpPost("/api/v1/statuses/{id}/reblog"), Scope("write:statuses")]
public async Task<IActionResult> Reblog(string id, CancellationToken token)
{
var outcome = await _statuses.Reblog(Me, id, true, Visibility(Params.Get("visibility")), token);
return outcome.Ok ? Json(await _mapper.Status(outcome.Post, MyId, token)) : Error(outcome.Status, outcome.Error);
}
[HttpPost("/api/v1/statuses/{id}/unreblog"), Scope("write:statuses")]
public Task<IActionResult> Unreblog(string id, CancellationToken token) => Toggle(_statuses.Reblog(Me, id, false, PostVisibility.Public, token), id, token);
[HttpGet("/api/v1/statuses/{id}/favourited_by"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> FavouritedBy(string id, CancellationToken token)
{
if (await Visible(id, token) == default)
return NotFoundError();
var favourites = await _dbEntities.Favourites.Match(f => f.PostId == id).Sort(f => f.ID, Order.Descending).Limit(Limit(40, 80)).ExecuteAsync(token);
var accounts = await _mapper.Accounts(favourites.Select(f => f.AccountId), token);
return Json(favourites.Select(f => accounts.GetValueOrDefault(f.AccountId)).Where(a => a != default).ToList());
}
[HttpGet("/api/v1/statuses/{id}/reblogged_by"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> RebloggedBy(string id, CancellationToken token)
{
if (await Visible(id, token) == default)
return NotFoundError();
var reblogs = await _dbEntities.Posts.Match(p => p.ReblogOfPostId == id && !p.DeletedAt.HasValue).Sort(p => p.ID, Order.Descending).Limit(Limit(40, 80)).ExecuteAsync(token);
var accounts = await _mapper.Accounts(reblogs.Select(MastodonMapper.AuthorOf), token);
return Json(reblogs.Select(r => accounts.GetValueOrDefault(MastodonMapper.AuthorOf(r))).Where(a => a != default).ToList());
}
[HttpPost("/api/v1/statuses/{id}/bookmark"), Scope("write:bookmarks")]
public Task<IActionResult> Bookmark(string id, CancellationToken token) => Unchanged(id, token);
[HttpPost("/api/v1/statuses/{id}/unbookmark"), Scope("write:bookmarks")]
public Task<IActionResult> Unbookmark(string id, CancellationToken token) => Unchanged(id, token);
[HttpPost("/api/v1/statuses/{id}/mute"), Scope("write:mutes")]
public Task<IActionResult> Mute(string id, CancellationToken token) => Unchanged(id, token);
[HttpPost("/api/v1/statuses/{id}/unmute"), Scope("write:mutes")]
public Task<IActionResult> Unmute(string id, CancellationToken token) => Unchanged(id, token);
[HttpPost("/api/v1/statuses/{id}/pin"), Scope("write:accounts")]
public IActionResult Pin(string id) => Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Pinning is not supported yet");
[HttpPost("/api/v1/statuses/{id}/unpin"), Scope("write:accounts")]
public Task<IActionResult> Unpin(string id, CancellationToken token) => Unchanged(id, token);
async Task<IActionResult> Toggle(Task<StatusOutcome> action, string id, CancellationToken token)
{
var outcome = await action;
if (!outcome.Ok)
return Error(outcome.Status, outcome.Error);
var post = await _dbEntities.Posts.MatchID(id).ExecuteFirstAsync(token);
return Json(await _mapper.Status(post, MyId, token));
}
async Task<IActionResult> Unchanged(string id, CancellationToken token)
{
var post = await Visible(id, token);
return post == default ? NotFoundError() : Json(await _mapper.Status(post, MyId, token));
}
async Task<PostEntity> Visible(string id, CancellationToken token)
{
var post = await _dbEntities.Posts.Match(p => p.ID == id && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
return post != default && post.Visibility != PostVisibility.LocalGeo && await VisibilityPolicy.CanSee(post, MyId, token) ? post : default;
}
static PostVisibility Visibility(string value) => value?.ToLowerInvariant() switch
{
"unlisted" => PostVisibility.Unlisted,
"private" => PostVisibility.FollowersOnly,
"direct" => PostVisibility.Direct,
_ => PostVisibility.Public
};
}
}
@@ -0,0 +1,243 @@
using Microsoft.AspNetCore.Mvc;
using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Entities;
using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Api.Mastodon.Mappers;
using PrivaPub.Domain.Privacy;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.StaticServices;
using PostEntity = PrivaPub.Models.Post.Post;
using NotificationEntity = PrivaPub.Models.Social.Notification;
using MarkerEntity = PrivaPub.Models.Social.Marker;
namespace PrivaPub.Api.Mastodon.Controllers
{
public class TimelinesController : MastodonController
{
readonly MastodonMapper _mapper;
readonly DbEntities _dbEntities;
public TimelinesController(MastodonMapper mapper, DbEntities dbEntities)
{
_mapper = mapper;
_dbEntities = dbEntities;
}
[HttpGet("/api/v1/timelines/home"), Scope("read:statuses")]
public async Task<IActionResult> Home(CancellationToken token)
{
var entries = await Page.From(Params, Limit()).Fetch(_dbEntities.TimelineEntries.Match(e => e.AvatarId == MyId), e => e.PostId, token);
var ids = entries.Select(e => e.PostId).ToList();
var posts = (await _dbEntities.Posts.Match(p => ids.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token)).ToDictionary(p => p.ID);
var statuses = await _mapper.Statuses(ids.Where(posts.ContainsKey).Select(id => posts[id]).ToList(), MyId, token);
Link("/api/v1/timelines/home", entries.LastOrDefault()?.PostId, entries.FirstOrDefault()?.PostId);
return Json(statuses);
}
[HttpGet("/api/v1/timelines/public"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Public(CancellationToken token)
{
var query = _dbEntities.Posts.Match(p => p.Visibility == PostVisibility.Public && !p.DeletedAt.HasValue && p.ReblogOfPostId == null);
if (Params.Bool("local") == true)
query.Match(p => !p.IsFederatedCopy);
if (Params.Bool("remote") == true)
query.Match(p => p.IsFederatedCopy);
if (Params.Bool("only_media") == true)
query.Match(p => p.Media.Count > 0);
return await Respond(query, "/api/v1/timelines/public", token);
}
[HttpGet("/api/v1/timelines/tag/{hashtag}"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Tag(string hashtag, CancellationToken token)
{
var tag = hashtag.TrimStart('#').ToLowerInvariant();
var query = _dbEntities.Posts.Match(p => p.Tags.Contains(tag) && p.Visibility == PostVisibility.Public && !p.DeletedAt.HasValue && p.ReblogOfPostId == null);
if (Params.Bool("local") == true)
query.Match(p => !p.IsFederatedCopy);
return await Respond(query, $"/api/v1/timelines/tag/{hashtag}", token);
}
[HttpGet("/api/v1/timelines/list/{id}"), Scope("read:lists")]
public IActionResult List(string id) => Json(Array.Empty<Status>());
[HttpGet("/api/v1/favourites"), Scope("read:favourites")]
public async Task<IActionResult> Favourites(CancellationToken token)
{
var favourites = await Page.From(Params, Limit()).Fetch(_dbEntities.Favourites.Match(f => f.AccountId == MyId), f => f.ID, token);
var ids = favourites.Select(f => f.PostId).ToList();
var posts = (await _dbEntities.Posts.Match(p => ids.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token)).ToDictionary(p => p.ID);
var visible = new List<PostEntity>();
foreach (var id in ids.Where(posts.ContainsKey))
if (await VisibilityPolicy.CanSee(posts[id], MyId, token))
visible.Add(posts[id]);
Link("/api/v1/favourites", favourites.LastOrDefault()?.ID, favourites.FirstOrDefault()?.ID);
return Json(await _mapper.Statuses(visible, MyId, token));
}
[HttpGet("/api/v1/conversations"), Scope("read:statuses")]
public async Task<IActionResult> Conversations(CancellationToken token)
{
var groups = await _dbEntities.DmGroups.Match(g => !g.DeletionAt.HasValue && g.Members.Any(m => !m.IsForeign && m.AvatarId == MyId))
.Sort(g => g.UpdatedAt, Order.Descending).Limit(Limit()).ExecuteAsync(token);
var conversations = new List<Conversation>();
foreach (var group in groups)
{
var last = await _dbEntities.Posts.Match(p => p.ConversationId == group.ID && !p.DeletedAt.HasValue).Sort(p => p.ID, Order.Descending).ExecuteFirstAsync(token);
if (last == default)
continue;
var others = new List<string>();
foreach (var member in group.Members.Where(m => !(m.AvatarId == MyId && !m.IsForeign)))
others.Add(member.IsForeign
? (await _dbEntities.ForeignAvatars.Match(f => f.ActorURI == member.AvatarId).ExecuteFirstAsync(token))?.ID
: member.AvatarId);
var accounts = await _mapper.Accounts(others, token);
conversations.Add(new Conversation
{
Id = group.ID,
Accounts = accounts.Values.ToList(),
LastStatus = await _mapper.Status(last, MyId, token)
});
}
return Json(conversations);
}
[HttpPost("/api/v1/conversations/{id}/read"), Scope("write:conversations")]
public async Task<IActionResult> ReadConversation(string id, CancellationToken token)
{
var group = await _dbEntities.DmGroups.Match(g => g.ID == id && g.Members.Any(m => !m.IsForeign && m.AvatarId == MyId)).ExecuteFirstAsync(token);
if (group == default)
return NotFoundError();
var last = await _dbEntities.Posts.Match(p => p.ConversationId == group.ID && !p.DeletedAt.HasValue).Sort(p => p.ID, Order.Descending).ExecuteFirstAsync(token);
return Json(new Conversation { Id = group.ID, LastStatus = last == default ? default : await _mapper.Status(last, MyId, token) });
}
[HttpGet("/api/v1/markers"), Scope("read:statuses")]
public async Task<IActionResult> Markers(CancellationToken token)
{
var wanted = Params.List("timeline");
var markers = await DB.Default.Find<MarkerEntity>().Match(m => m.AvatarId == MyId).ExecuteAsync(token);
return Json(markers.Where(m => wanted.Count == 0 || wanted.Contains(m.Timeline))
.ToDictionary(m => m.Timeline, m => new Entities.Marker { LastReadId = m.LastReadId, Version = m.Version, UpdatedAt = MastodonJson.Time(m.UpdatedAt) }));
}
[HttpPost("/api/v1/markers"), Scope("write:statuses")]
public async Task<IActionResult> SaveMarkers(CancellationToken token)
{
var result = new Dictionary<string, Entities.Marker>();
foreach (var timeline in new[] { "home", "notifications" })
{
var lastRead = Params.Get($"{timeline}[last_read_id]");
if (string.IsNullOrEmpty(lastRead))
continue;
var marker = await DB.Default.UpdateAndGet<MarkerEntity>()
.Match(m => m.AvatarId == MyId && m.Timeline == timeline)
.Modify(m => m.LastReadId, lastRead)
.Modify(m => m.UpdatedAt, DateTime.UtcNow)
.Modify(b => b.Inc(m => m.Version, 1))
.Option(o => o.IsUpsert = true)
.ExecuteAsync(token);
result[timeline] = new Entities.Marker { LastReadId = marker.LastReadId, Version = marker.Version, UpdatedAt = MastodonJson.Time(marker.UpdatedAt) };
}
return Json(result);
}
async Task<IActionResult> Respond(Find<PostEntity, PostEntity> query, string path, CancellationToken token)
{
var posts = await Page.From(Params, Limit()).Fetch(query, p => p.ID, token);
Link(path, posts.LastOrDefault()?.ID, posts.FirstOrDefault()?.ID);
return Json(await _mapper.Statuses(posts, MyId, token));
}
}
public class NotificationsController : MastodonController
{
static readonly Dictionary<NotificationType, string> Names = new()
{
[NotificationType.Mention] = "mention",
[NotificationType.Follow] = "follow",
[NotificationType.FollowRequest] = "follow_request",
[NotificationType.Favourite] = "favourite",
[NotificationType.Reblog] = "reblog",
[NotificationType.Update] = "update"
};
readonly MastodonMapper _mapper;
readonly DbEntities _dbEntities;
public NotificationsController(MastodonMapper mapper, DbEntities dbEntities)
{
_mapper = mapper;
_dbEntities = dbEntities;
}
[HttpGet("/api/v1/notifications"), Scope("read:notifications")]
public async Task<IActionResult> List(CancellationToken token)
{
var types = Params.List("types").Select(Parse).Where(t => t.HasValue).Select(t => t.Value).ToList();
var excluded = Params.List("exclude_types").Select(Parse).Where(t => t.HasValue).Select(t => t.Value).ToList();
var query = _dbEntities.Notifications.Match(n => n.AvatarId == MyId);
if (types.Count > 0)
query.Match(n => types.Contains(n.Type));
if (excluded.Count > 0)
query.Match(n => !excluded.Contains(n.Type));
if (Params.Get("account_id") is { } accountId)
query.Match(n => n.FromAccountId == accountId);
var notifications = await Page.From(Params, Limit(15, 30)).Fetch(query, n => n.ID, token);
Link("/api/v1/notifications", notifications.LastOrDefault()?.ID, notifications.FirstOrDefault()?.ID);
return Json(await Map(notifications, token));
}
[HttpGet("/api/v1/notifications/{id}"), Scope("read:notifications")]
public async Task<IActionResult> Get(string id, CancellationToken token)
{
var notification = await _dbEntities.Notifications.Match(n => n.ID == id && n.AvatarId == MyId).ExecuteFirstAsync(token);
return notification == default ? NotFoundError() : Json((await Map(new List<NotificationEntity> { notification }, token)).FirstOrDefault());
}
[HttpPost("/api/v1/notifications/clear"), Scope("write:notifications")]
public async Task<IActionResult> Clear(CancellationToken token)
{
await DB.Default.DeleteAsync<NotificationEntity>(n => n.AvatarId == MyId);
return Json(new { });
}
[HttpPost("/api/v1/notifications/{id}/dismiss"), Scope("write:notifications")]
public async Task<IActionResult> Dismiss(string id, CancellationToken token)
{
await DB.Default.DeleteAsync<NotificationEntity>(n => n.ID == id && n.AvatarId == MyId);
return Json(new { });
}
[HttpGet("/api/v1/notifications/unread_count"), Scope("read:notifications")]
public async Task<IActionResult> UnreadCount(CancellationToken token) =>
Json(new { count = await DB.Default.CountAsync<NotificationEntity>(n => n.AvatarId == MyId && !n.IsRead, token) });
async Task<List<Entities.Notification>> Map(List<NotificationEntity> notifications, CancellationToken token)
{
var accounts = await _mapper.Accounts(notifications.Select(n => n.FromAccountId), token);
var postIds = notifications.Where(n => n.PostId != default).Select(n => n.PostId).Distinct().ToList();
var posts = postIds.Count == 0
? new List<PostEntity>()
: await _dbEntities.Posts.Match(p => postIds.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token);
var statuses = (await _mapper.Statuses(posts, MyId, token)).ToDictionary(s => s.Id);
return notifications
.Where(n => accounts.ContainsKey(n.FromAccountId ?? string.Empty) && (n.PostId == default || statuses.ContainsKey(n.PostId)))
.Select(n => new Entities.Notification
{
Id = n.ID,
Type = Names[n.Type],
CreatedAt = MastodonJson.Time(n.CreatedAt),
GroupKey = $"ungrouped-{n.ID}",
Account = accounts[n.FromAccountId],
Status = n.PostId == default ? default : statuses[n.PostId]
})
.ToList();
}
static NotificationType? Parse(string name) => Names.FirstOrDefault(n => n.Value == name) is { Value: not null } pair ? pair.Key : (NotificationType?)null;
}
}
+200
View File
@@ -0,0 +1,200 @@
namespace PrivaPub.Api.Mastodon.Entities
{
public class Account
{
public string Id { get; set; }
public string Username { get; set; }
public string Acct { get; set; }
public string DisplayName { get; set; }
public bool Locked { get; set; }
public bool Bot { get; set; }
public bool? Discoverable { get; set; }
public bool Indexable { get; set; }
public bool Group { get; set; }
public string CreatedAt { get; set; }
public string Note { get; set; }
public string Url { get; set; }
public string Uri { get; set; }
public string Avatar { get; set; }
public string AvatarStatic { get; set; }
public string Header { get; set; }
public string HeaderStatic { get; set; }
public int FollowersCount { get; set; }
public int FollowingCount { get; set; }
public int StatusesCount { get; set; }
public string LastStatusAt { get; set; }
public bool? HideCollections { get; set; }
public bool Noindex { get; set; } = true;
public List<object> Emojis { get; set; } = new();
public List<object> Roles { get; set; } = new();
public List<Field> Fields { get; set; } = new();
public Source Source { get; set; }
}
public class Field
{
public string Name { get; set; }
public string Value { get; set; }
public string VerifiedAt { get; set; }
}
public class Source
{
public string Privacy { get; set; } = "public";
public bool Sensitive { get; set; }
public string Language { get; set; }
public string Note { get; set; } = string.Empty;
public List<Field> Fields { get; set; } = new();
public int FollowRequestsCount { get; set; }
public string AttributionDomains { get; set; }
}
public class Status
{
public string Id { get; set; }
public string Uri { get; set; }
public string Url { get; set; }
public Account Account { get; set; }
public string InReplyToId { get; set; }
public string InReplyToAccountId { get; set; }
public Status Reblog { get; set; }
public string Content { get; set; } = string.Empty;
public string CreatedAt { get; set; }
public string EditedAt { get; set; }
public List<object> Emojis { get; set; } = new();
public int RepliesCount { get; set; }
public int ReblogsCount { get; set; }
public int FavouritesCount { get; set; }
public bool Reblogged { get; set; }
public bool Favourited { get; set; }
public bool Muted { get; set; }
public bool Bookmarked { get; set; }
public bool Pinned { get; set; }
public bool Sensitive { get; set; }
public string SpoilerText { get; set; } = string.Empty;
public string Visibility { get; set; }
public List<MediaAttachment> MediaAttachments { get; set; } = new();
public List<StatusMention> Mentions { get; set; } = new();
public List<StatusTag> Tags { get; set; } = new();
public object Card { get; set; }
public object Poll { get; set; }
public Application Application { get; set; }
public string Language { get; set; }
public string Text { get; set; }
public List<object> Filtered { get; set; } = new();
}
public class MediaAttachment
{
public string Id { get; set; }
public string Type { get; set; }
public string Url { get; set; }
public string PreviewUrl { get; set; }
public string RemoteUrl { get; set; }
public object Meta { get; set; }
public string Description { get; set; }
public string Blurhash { get; set; }
}
public class StatusMention
{
public string Id { get; set; }
public string Username { get; set; }
public string Url { get; set; }
public string Acct { get; set; }
}
public class StatusTag
{
public string Name { get; set; }
public string Url { get; set; }
}
public class Application
{
public string Id { get; set; }
public string Name { get; set; }
public string Website { get; set; }
public List<string> Scopes { get; set; } = new();
public List<string> RedirectUris { get; set; } = new();
public string RedirectUri { get; set; }
public string ClientId { get; set; }
public string ClientSecret { get; set; }
public string VapidKey { get; set; } = string.Empty;
}
public class Relationship
{
public string Id { get; set; }
public bool Following { get; set; }
public bool ShowingReblogs { get; set; }
public bool Notifying { get; set; }
public List<string> Languages { get; set; }
public bool FollowedBy { get; set; }
public bool Blocking { get; set; }
public bool BlockedBy { get; set; }
public bool Muting { get; set; }
public bool MutingNotifications { get; set; }
public bool Requested { get; set; }
public bool RequestedBy { get; set; }
public bool DomainBlocking { get; set; }
public bool Endorsed { get; set; }
public string Note { get; set; } = string.Empty;
}
public class Notification
{
public string Id { get; set; }
public string Type { get; set; }
public string CreatedAt { get; set; }
public string GroupKey { get; set; }
public Account Account { get; set; }
public Status Status { get; set; }
}
public class Context
{
public List<Status> Ancestors { get; set; } = new();
public List<Status> Descendants { get; set; } = new();
}
public class StatusEdit
{
public string Content { get; set; }
public string SpoilerText { get; set; }
public bool Sensitive { get; set; }
public string CreatedAt { get; set; }
public Account Account { get; set; }
public List<MediaAttachment> MediaAttachments { get; set; } = new();
public List<object> Emojis { get; set; } = new();
}
public class StatusSource
{
public string Id { get; set; }
public string Text { get; set; }
public string SpoilerText { get; set; }
}
public class SearchResults
{
public List<Account> Accounts { get; set; } = new();
public List<Status> Statuses { get; set; } = new();
public List<object> Hashtags { get; set; } = new();
}
public class Conversation
{
public string Id { get; set; }
public bool Unread { get; set; }
public List<Account> Accounts { get; set; } = new();
public Status LastStatus { get; set; }
}
public class Marker
{
public string LastReadId { get; set; }
public int Version { get; set; }
public string UpdatedAt { get; set; }
}
}
@@ -0,0 +1,105 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using MongoDB.Entities;
using OpenIddict.Abstractions;
using OpenIddict.Validation.AspNetCore;
using PrivaPub.Api.Mastodon.Auth;
using PrivaPub.Federation.Actors;
using PrivaPub.Models.Federation;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using static OpenIddict.Abstractions.OpenIddictConstants;
namespace PrivaPub.Api.Mastodon.Infrastructure
{
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
public sealed class ScopeAttribute : Attribute
{
public ScopeAttribute(string scope, bool requiresUser = true)
{
Scope = scope;
RequiresUser = requiresUser;
}
public string Scope { get; }
public bool RequiresUser { get; }
}
[ApiController, Authorize(AuthenticationSchemes = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme)]
public abstract class MastodonController : ControllerBase, IAsyncActionFilter
{
MastodonParams _params;
protected LocalActor Me { get; private set; }
protected string MyId => Me?.Id;
protected MastodonParams Params => _params;
[NonAction]
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
_params = await MastodonParams.Read(Request, HttpContext.RequestAborted);
var scope = context.ActionDescriptor.EndpointMetadata.OfType<ScopeAttribute>().LastOrDefault();
if (User.Identity?.IsAuthenticated == true)
{
if (scope != default && !MastodonScopes.Grants(User.GetScopes(), scope.Scope))
{
context.Result = Error(StatusCodes.Status403Forbidden, "This action is outside the authorized scopes");
return;
}
Me = await Resolve(User.GetClaim(Claims.Subject), HttpContext.RequestAborted);
}
if (scope?.RequiresUser == true && Me == default)
{
context.Result = Error(StatusCodes.Status401Unauthorized, "This method requires an authenticated user");
return;
}
await next();
}
async Task<LocalActor> Resolve(string avatarId, CancellationToken token)
{
if (string.IsNullOrEmpty(avatarId))
return default;
var services = HttpContext.RequestServices;
var db = services.GetRequiredService<DbEntities>();
var link = await db.RootToAvatars.Match(r => r.AvatarId == avatarId).ExecuteFirstAsync(token);
if (link == default)
return default;
var root = await db.RootUsers.MatchID(link.RootId).ExecuteFirstAsync(token);
if (root is not { IsBanned: false, DeletedAt: null })
return default;
return await services.GetRequiredService<ILocalActorService>().FindById(LocalActorKind.Person, avatarId, token);
}
protected JsonResult Json(object value, int status = StatusCodes.Status200OK) =>
new(value, MastodonJson.Options) { StatusCode = status };
protected static JsonResult Error(int status, string message) =>
new(new { error = message }, MastodonJson.Options) { StatusCode = status };
protected JsonResult NotFoundError() => Error(StatusCodes.Status404NotFound, "Record not found");
protected void Link(string path, string maxId, string minId)
{
var links = new List<string>();
var baseUrl = $"{Request.Scheme}://{Request.Host}{path}";
var query = Request.Query.Where(q => q.Key is not ("max_id" or "min_id" or "since_id")).Select(q => $"{q.Key}={Uri.EscapeDataString(q.Value.ToString())}").ToList();
string With(string key, string value) => baseUrl + "?" + string.Join("&", query.Append($"{key}={value}"));
if (maxId != default)
links.Add($"<{With("max_id", maxId)}>; rel=\"next\"");
if (minId != default)
links.Add($"<{With("min_id", minId)}>; rel=\"prev\"");
if (links.Count > 0)
Response.Headers["Link"] = string.Join(", ", links);
}
protected int Limit(int fallback = 20, int max = 40) => Math.Clamp(Params.Int("limit") ?? fallback, 1, max);
}
}
@@ -0,0 +1,24 @@
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace PrivaPub.Api.Mastodon.Infrastructure
{
public static class MastodonJson
{
public static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DictionaryKeyPolicy = default,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) }
};
public static string Time(DateTime value) =>
DateTime.SpecifyKind(value, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ss.fffZ", System.Globalization.CultureInfo.InvariantCulture);
public static string Day(DateTime value) =>
DateTime.SpecifyKind(value, DateTimeKind.Utc).Date.ToString("yyyy-MM-ddT00:00:00.000Z", System.Globalization.CultureInfo.InvariantCulture);
}
}
@@ -0,0 +1,109 @@
using Microsoft.AspNetCore.Http.Features;
using System.Text.Json;
namespace PrivaPub.Api.Mastodon.Infrastructure
{
public sealed class MastodonParams
{
readonly Dictionary<string, List<string>> _values;
MastodonParams(Dictionary<string, List<string>> values)
{
_values = values;
}
public static async Task<MastodonParams> Read(HttpRequest request, CancellationToken token)
{
var values = new Dictionary<string, List<string>>(StringComparer.Ordinal);
foreach (var pair in request.Query)
foreach (var value in pair.Value)
Add(values, pair.Key, value);
if (request.HasFormContentType)
{
var form = await request.ReadFormAsync(token);
foreach (var pair in form)
foreach (var value in pair.Value)
Add(values, pair.Key, value);
}
else if (request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true && request.ContentLength != 0)
{
try
{
using var document = await JsonDocument.ParseAsync(request.Body, cancellationToken: token);
Flatten(values, default, document.RootElement);
}
catch (JsonException)
{
}
}
return new MastodonParams(values);
}
public static MastodonParams From(IEnumerable<KeyValuePair<string, string>> pairs)
{
var values = new Dictionary<string, List<string>>(StringComparer.Ordinal);
foreach (var pair in pairs)
Add(values, pair.Key, pair.Value);
return new MastodonParams(values);
}
public bool Has(string name) => _values.ContainsKey(name);
public string Get(string name) => _values.TryGetValue(name, out var list) && list.Count > 0 ? list[^1] : default;
public IReadOnlyList<string> List(string name) => _values.TryGetValue(name, out var list) ? list : (IReadOnlyList<string>)Array.Empty<string>();
public bool? Bool(string name) => Get(name)?.ToLowerInvariant() switch
{
"true" or "1" or "on" or "yes" => true,
"false" or "0" or "off" or "no" or "" => false,
_ => (bool?)null
};
public int? Int(string name) => int.TryParse(Get(name), out var value) ? value : (int?)null;
static void Add(Dictionary<string, List<string>> values, string key, string value)
{
key = key.EndsWith("[]", StringComparison.Ordinal) ? key[..^2] : key;
if (!values.TryGetValue(key, out var list))
values[key] = list = new List<string>();
list.Add(value);
}
static void Flatten(Dictionary<string, List<string>> values, string prefix, JsonElement element)
{
switch (element.ValueKind)
{
case JsonValueKind.Object:
foreach (var property in element.EnumerateObject())
Flatten(values, prefix == default ? property.Name : $"{prefix}[{property.Name}]", property.Value);
break;
case JsonValueKind.Array:
foreach (var item in element.EnumerateArray())
{
if (item.ValueKind is JsonValueKind.Object or JsonValueKind.Array)
Flatten(values, prefix + "[]", item);
else
Add(values, prefix, Scalar(item));
}
break;
case JsonValueKind.Null or JsonValueKind.Undefined:
break;
default:
if (prefix != default)
Add(values, prefix, Scalar(element));
break;
}
}
static string Scalar(JsonElement element) => element.ValueKind switch
{
JsonValueKind.String => element.GetString(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
_ => element.GetRawText()
};
}
}
@@ -0,0 +1,30 @@
using MongoDB.Driver;
using MongoDB.Entities;
using System.Linq.Expressions;
namespace PrivaPub.Api.Mastodon.Infrastructure
{
public sealed record Page(string MaxId, string SinceId, string MinId, int Limit)
{
public static Page From(MastodonParams parameters, int limit) =>
new(parameters.Get("max_id"), parameters.Get("since_id"), parameters.Get("min_id"), limit);
public async Task<List<T>> Fetch<T>(Find<T, T> query, Expression<Func<T, string>> id, CancellationToken token) where T : IEntity
{
var field = new ExpressionFieldDefinition<T, string>(id);
if (!string.IsNullOrEmpty(MaxId))
query.Match(Builders<T>.Filter.Lt(field, MaxId));
if (!string.IsNullOrEmpty(SinceId))
query.Match(Builders<T>.Filter.Gt(field, SinceId));
if (!string.IsNullOrEmpty(MinId))
query.Match(Builders<T>.Filter.Gt(field, MinId));
var ascending = !string.IsNullOrEmpty(MinId);
var sortField = new ExpressionFieldDefinition<T>(Expression.Lambda<Func<T, object>>(Expression.Convert(id.Body, typeof(object)), id.Parameters));
var items = await query.Sort(s => ascending ? s.Ascending(sortField) : s.Descending(sortField)).Limit(Limit).ExecuteAsync(token);
if (ascending)
items.Reverse();
return items;
}
}
}
@@ -0,0 +1,55 @@
using PrivaPub.Api.Mastodon.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.StaticServices;
namespace PrivaPub.Api.Mastodon.Mappers
{
public class AccountSearch
{
readonly MastodonMapper _mapper;
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IRemoteActorService _remoteActors;
public AccountSearch(MastodonMapper mapper, DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors)
{
_mapper = mapper;
_dbEntities = dbEntities;
_localActors = localActors;
_remoteActors = remoteActors;
}
public async Task<List<Account>> Find(string q, bool resolve, int limit, CancellationToken token)
{
q = q?.Trim().TrimStart('@');
if (string.IsNullOrEmpty(q))
return new List<Account>();
var results = new List<Account>();
var parts = q.Split('@');
var localDomain = new Uri(_localActors.BaseAddress).Authority;
if (parts.Length == 2 && !parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(parts[0]))
{
var userName = parts[0];
var domain = parts[1].ToLowerInvariant();
var known = await _dbEntities.ForeignAvatars.Match(f => f.UserName == userName && f.Domain == domain).ExecuteFirstAsync(token);
if (known == default && resolve)
{
var actorUri = await _remoteActors.ResolveHandle(q, token);
known = actorUri == default ? default : await _remoteActors.GetActor(actorUri, refresh: false, token);
}
if (known != default)
results.Add(_mapper.Foreign(known));
return results;
}
var prefix = parts[0].ToLowerInvariant();
foreach (var avatar in await _dbEntities.Avatars.Match(a => a.UserName.StartsWith(prefix) && !a.DeletionAt.HasValue && a.Settings.IsDiscoverable != false)
.Limit(limit).ExecuteAsync(token))
results.Add(await _mapper.Local(_localActors.FromAvatar(avatar), false, token));
foreach (var foreign in await _dbEntities.ForeignAvatars.Match(f => f.UserName.StartsWith(parts[0]) && f.IsDiscoverable)
.Limit(Math.Max(0, limit - results.Count)).ExecuteAsync(token))
results.Add(_mapper.Foreign(foreign));
return results;
}
}
}
@@ -0,0 +1,256 @@
using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Entities;
using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Net;
using GroupEntity = PrivaPub.Models.Group.Group;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Api.Mastodon.Mappers
{
public class MastodonMapper
{
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
public MastodonMapper(DbEntities dbEntities, ILocalActorService localActors)
{
_dbEntities = dbEntities;
_localActors = localActors;
}
string MissingAvatar => $"{_localActors.BaseAddress}/media/missing-avatar.png";
string MissingHeader => $"{_localActors.BaseAddress}/media/missing-header.png";
public async Task<Account> Local(LocalActor actor, bool withSource, CancellationToken token)
{
var followers = await DB.Default.CountAsync<Follower>(f => f.LocalActorId == actor.Id && f.LocalActorKind == actor.Kind && f.IsAccepted, token);
var following = actor.Kind == LocalActorKind.Person
? await DB.Default.CountAsync<Following>(f => f.AvatarId == actor.Id && f.State == FollowState.Accepted, token)
: 0;
var latest = await _dbEntities.Posts
.Match(p => (actor.Kind == LocalActorKind.Group ? p.GroupId == actor.Id : p.GroupUserId == actor.Id) && !p.IsFederatedCopy
&& !p.DeletedAt.HasValue && p.Visibility != PostVisibility.Direct && p.ReblogOfPostId == null)
.Sort(p => p.ID, Order.Descending)
.Limit(1)
.ExecuteFirstAsync(token);
var statuses = latest == default
? 0
: await DB.Default.CountAsync<PostEntity>(p => (actor.Kind == LocalActorKind.Group ? p.GroupId == actor.Id : p.GroupUserId == actor.Id)
&& !p.IsFederatedCopy && !p.DeletedAt.HasValue && p.Visibility != PostVisibility.Direct && p.ReblogOfPostId == null, token);
var account = new Account
{
Id = actor.Id,
Username = actor.UserName,
Acct = actor.UserName,
DisplayName = actor.Name ?? actor.UserName,
Locked = actor.ManuallyApprovesFollowers,
Bot = actor.IsBot,
Discoverable = actor.Discoverable,
Indexable = actor.Indexable,
Noindex = !actor.Indexable,
HideCollections = actor.Settings.HideCollections,
Group = actor.Kind == LocalActorKind.Group,
CreatedAt = MastodonJson.Day(actor.Published),
Note = ActivityPubRenderer.Html(actor.Summary),
Url = actor.HtmlUrl,
Uri = actor.Uri,
Avatar = actor.PictureURL ?? MissingAvatar,
AvatarStatic = actor.PictureURL ?? MissingAvatar,
Header = actor.ThumbnailURL ?? MissingHeader,
HeaderStatic = actor.ThumbnailURL ?? MissingHeader,
FollowersCount = (int)followers,
FollowingCount = (int)following,
StatusesCount = (int)statuses,
LastStatusAt = latest == default ? default : latest.CreationDate.ToString("yyyy-MM-dd"),
Fields = actor.Fields.Select(f => new Field { Name = f.Key, Value = ActivityPubRenderer.FieldValue(f.Value) }).ToList()
};
if (withSource)
account.Source = new Source
{
Privacy = actor.Settings.DefaultVisibility ?? "public",
Sensitive = actor.Settings.DefaultSensitive,
Language = actor.Settings.DefaultLanguage,
Note = actor.Summary ?? string.Empty,
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)
};
return account;
}
public Account Foreign(ForeignAvatar foreign) => new()
{
Id = foreign.ID,
Username = foreign.UserName,
Acct = $"{foreign.UserName}@{foreign.Domain}",
DisplayName = foreign.Name ?? foreign.UserName ?? string.Empty,
Bot = foreign.AvatarType is AvatarType.Service or AvatarType.Application,
Group = foreign.AvatarType == AvatarType.Group,
Discoverable = foreign.IsDiscoverable,
CreatedAt = MastodonJson.Day(foreign.CreatedAt),
Note = foreign.Biography ?? string.Empty,
Url = foreign.Url ?? foreign.ActorURI,
Uri = foreign.ActorURI,
Avatar = foreign.PictureURL ?? MissingAvatar,
AvatarStatic = foreign.PictureURL ?? MissingAvatar,
Header = foreign.ThumbnailURL ?? MissingHeader,
HeaderStatic = foreign.ThumbnailURL ?? MissingHeader
};
public async Task<Account> Account(string id, CancellationToken token) =>
(await Accounts(new[] { id }, token)).GetValueOrDefault(id);
public async Task<Dictionary<string, Account>> Accounts(IEnumerable<string> ids, CancellationToken token)
{
var wanted = ids.Where(id => !string.IsNullOrEmpty(id)).Distinct().ToList();
var accounts = new Dictionary<string, Account>();
if (wanted.Count == 0)
return accounts;
foreach (var avatar in await _dbEntities.Avatars.Match(a => wanted.Contains(a.ID) && !a.DeletionAt.HasValue).ExecuteAsync(token))
accounts[avatar.ID] = await Local(_localActors.FromAvatar(avatar), false, token);
foreach (var group in await _dbEntities.Groups.Match(g => wanted.Contains(g.ID) && !g.DeletionAt.HasValue).ExecuteAsync(token))
accounts[group.ID] = await Local(_localActors.FromGroup(group), false, token);
foreach (var foreign in await _dbEntities.ForeignAvatars.Match(f => wanted.Contains(f.ID)).ExecuteAsync(token))
accounts[foreign.ID] = Foreign(foreign);
return accounts;
}
public async Task<Status> Status(PostEntity post, string viewerId, CancellationToken token) =>
(await Statuses(new[] { post }, viewerId, token)).FirstOrDefault();
public async Task<List<Status>> Statuses(IReadOnlyCollection<PostEntity> posts, string viewerId, CancellationToken token)
{
var originalIds = posts.Where(p => p.ReblogOfPostId != default).Select(p => p.ReblogOfPostId).Distinct().ToList();
var originals = originalIds.Count == 0
? new Dictionary<string, PostEntity>()
: (await _dbEntities.Posts.Match(p => originalIds.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token)).ToDictionary(p => p.ID);
var all = posts.Concat(originals.Values).ToList();
var mentionUris = all.SelectMany(p => p.Mentions).Where(m => !m.IsLocal).Select(m => m.ActorURI).Distinct().ToList();
var mentionAccounts = mentionUris.Count == 0
? new Dictionary<string, ForeignAvatar>()
: (await _dbEntities.ForeignAvatars.Match(f => mentionUris.Contains(f.ActorURI)).ExecuteAsync(token)).ToDictionary(f => f.ActorURI);
var accounts = await Accounts(all.Select(AuthorOf), token);
var ids = all.Select(p => p.ID).ToList();
var favourited = viewerId == default
? new HashSet<string>()
: (await _dbEntities.Favourites.Match(f => f.AccountId == viewerId && ids.Contains(f.PostId)).ExecuteAsync(token)).Select(f => f.PostId).ToHashSet();
var reblogged = viewerId == default
? new HashSet<string>()
: (await _dbEntities.Posts.Match(p => p.AuthorAccountId == viewerId && ids.Contains(p.ReblogOfPostId) && !p.DeletedAt.HasValue).ExecuteAsync(token))
.Select(p => p.ReblogOfPostId).ToHashSet();
Status Map(PostEntity post)
{
if (!accounts.TryGetValue(AuthorOf(post), out var account))
return default;
var status = new Status
{
Id = post.ID,
Uri = post.ObjectURI,
Url = post.Url ?? post.ObjectURI,
Account = account,
InReplyToId = post.AnsweringToPostId,
InReplyToAccountId = post.InReplyToAccountId,
Content = Content(post),
CreatedAt = MastodonJson.Time(post.CreationDate),
EditedAt = post.EditedAt.HasValue ? MastodonJson.Time(post.EditedAt.Value) : default,
RepliesCount = post.RepliesCount,
ReblogsCount = post.ReblogsCount,
FavouritesCount = post.FavouritesCount,
Favourited = favourited.Contains(post.ID),
Reblogged = reblogged.Contains(post.ID),
Sensitive = post.HasContentWarning,
SpoilerText = post.SpoilerText ?? (post.HasContentWarning ? post.Title ?? ActivityPubRenderer.ContentWarning : string.Empty),
Visibility = Visibility(post.Visibility),
Language = post.Language,
MediaAttachments = post.Media.Select(Media).ToList(),
Mentions = post.Mentions.Select(m => Mention(m, mentionAccounts)).Where(m => m != default).ToList(),
Tags = post.Tags.Select(t => new StatusTag { Name = t, Url = ActivityPubRenderer.TagUrl(_localActors.BaseAddress, t) }).ToList()
};
return status;
}
var mapped = new List<Status>();
foreach (var post in posts)
{
var status = Map(post);
if (status == default)
continue;
if (post.ReblogOfPostId != default)
{
if (!originals.TryGetValue(post.ReblogOfPostId, out var original) || Map(original) is not { } inner)
continue;
status.Reblog = inner;
status.Content = string.Empty;
status.Reblogged = reblogged.Contains(original.ID);
}
mapped.Add(status);
}
return mapped;
}
public static string AuthorOf(PostEntity post) => post.AuthorAccountId ?? post.GroupUserId;
public static string Content(PostEntity post)
{
var content = post.ContentHtml ?? ActivityPubRenderer.Html(post.Text);
return string.IsNullOrEmpty(post.Title) || post.IsFederatedCopy
? content ?? string.Empty
: $"<p><strong>{WebUtility.HtmlEncode(post.Title)}</strong></p>{content}";
}
public static string Visibility(PostVisibility visibility) => visibility switch
{
PostVisibility.Unlisted => "unlisted",
PostVisibility.FollowersOnly or PostVisibility.Circle => "private",
PostVisibility.Direct => "direct",
_ => "public"
};
StatusMention Mention(PostMention mention, IReadOnlyDictionary<string, ForeignAvatar> foreign)
{
if (mention.IsLocal)
{
var handle = mention.Handle?.TrimStart('@').Split('@')[0];
return new StatusMention { Id = mention.AccountId, Username = handle, Acct = handle, Url = mention.ActorURI };
}
if (!foreign.TryGetValue(mention.ActorURI, out var account))
return default;
return new StatusMention { Id = account.ID, Username = account.UserName, Acct = $"{account.UserName}@{account.Domain}", Url = account.Url ?? account.ActorURI };
}
static MediaAttachment Media(PostMedia media) => new()
{
Id = media.Id.ToString("N"),
Type = media.ContentType switch
{
{ } type when type.StartsWith("image/gif") => "gifv",
{ } type when type.StartsWith("image/") => "image",
{ } type when type.StartsWith("video/") => "video",
{ } type when type.StartsWith("audio/") => "audio",
_ => "unknown"
},
Url = media.URL ?? media.RemoteURL,
PreviewUrl = media.URL ?? media.RemoteURL,
RemoteUrl = media.RemoteURL,
Description = media.Description,
Blurhash = media.Blurhash,
Meta = media.Width.HasValue && media.Height.HasValue
? new { original = new { width = media.Width, height = media.Height, aspect = (double)media.Width / media.Height.Value } }
: default
};
}
}
@@ -32,6 +32,10 @@ namespace PrivaPub.Controllers.ClientToServer
public async Task<IActionResult> Insert(InsertPostForm form, CancellationToken token) => public async Task<IActionResult> Insert(InsertPostForm form, CancellationToken token) =>
!ModelState.IsValid ? Invalid() : Answer(await _postsService.InsertPost(User.GetUserId(), form, token)); !ModelState.IsValid ? Invalid() : Answer(await _postsService.InsertPost(User.GetUserId(), form, token));
[HttpPost, Route("/clientapi/post/update")]
public async Task<IActionResult> Update(UpdatePostForm form, CancellationToken token) =>
!ModelState.IsValid ? Invalid() : Answer(await _postsService.UpdatePost(User.GetUserId(), form, token));
[HttpPost, Route("/clientapi/post/delete")] [HttpPost, Route("/clientapi/post/delete")]
public async Task<IActionResult> Delete(DeletePostForm form, CancellationToken token) => public async Task<IActionResult> Delete(DeletePostForm form, CancellationToken token) =>
!ModelState.IsValid ? Invalid() : Answer(await _postsService.DeletePost(User.GetUserId(), form, token)); !ModelState.IsValid ? Invalid() : Answer(await _postsService.DeletePost(User.GetUserId(), form, token));
@@ -0,0 +1,43 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.Social;
using PrivaPub.Domain.Social;
using PrivaPub.Extensions;
using PrivaPub.Resources;
namespace PrivaPub.Controllers.ClientToServer
{
[ApiController,
Route("clientapi/social"),
Authorize(Policy = Policies.IsUser)]
public class SocialController : ControllerBase
{
readonly IFollowService _follows;
readonly IStringLocalizer _localizer;
public SocialController(IFollowService follows, IStringLocalizer<GenericRes> localizer)
{
_follows = follows;
_localizer = localizer;
}
[HttpPost, Route("/clientapi/follow")]
public async Task<IActionResult> Follow(FollowForm form, CancellationToken token) =>
!ModelState.IsValid ? Invalid() : Answer(await _follows.Follow(User.GetUserId(), form, token));
[HttpPost, Route("/clientapi/unfollow")]
public async Task<IActionResult> Unfollow(FollowForm form, CancellationToken token) =>
!ModelState.IsValid ? Invalid() : Answer(await _follows.Unfollow(User.GetUserId(), form, token));
[HttpGet, Route("/clientapi/following")]
public async Task<IActionResult> Following([FromQuery] string avatarId, CancellationToken token) =>
Answer(await _follows.Followings(User.GetUserId(), avatarId, token));
IActionResult Invalid() => BadRequest(new WebResult().Invalidate(_localizer["Invalid model."]));
IActionResult Answer(WebResult result) => result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result);
}
}
@@ -0,0 +1,38 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PrivaPub.ClientModels;
using PrivaPub.Domain.Timelines;
using PrivaPub.Extensions;
namespace PrivaPub.Controllers.ClientToServer
{
[ApiController,
Route("clientapi/timeline"),
Authorize(Policy = Policies.IsUser)]
public class TimelineController : ControllerBase
{
readonly ITimelineService _timelines;
public TimelineController(ITimelineService timelines)
{
_timelines = timelines;
}
[HttpGet, Route("/clientapi/timeline/home")]
public async Task<IActionResult> Home([FromQuery] string avatarId, [FromQuery(Name = "max_id")] string maxId, [FromQuery] int limit = 20,
CancellationToken token = default) =>
Answer(await _timelines.Home(User.GetUserId(), avatarId, maxId, limit, token));
[HttpGet, Route("/clientapi/notifications")]
public async Task<IActionResult> Notifications([FromQuery] string avatarId, [FromQuery(Name = "max_id")] string maxId, [FromQuery] int limit = 20,
CancellationToken token = default) =>
Answer(await _timelines.Notifications(User.GetUserId(), avatarId, maxId, limit, token));
[HttpPost, Route("/clientapi/notifications/read")]
public async Task<IActionResult> Read([FromQuery] string avatarId, CancellationToken token) =>
Answer(await _timelines.MarkNotificationsRead(User.GetUserId(), avatarId, token));
IActionResult Answer(WebResult result) => result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result);
}
}
+299
View File
@@ -0,0 +1,299 @@
using Microsoft.Extensions.Localization;
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.Social;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Group;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.Resources;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using GroupEntity = PrivaPub.Models.Group.Group;
namespace PrivaPub.Domain.Social
{
public interface IFollowService
{
Task<WebResult> Follow(string rootUserId, FollowForm form, CancellationToken token);
Task<WebResult> Unfollow(string rootUserId, FollowForm form, CancellationToken token);
Task<WebResult> Followings(string rootUserId, string avatarId, CancellationToken token);
Task<Following> FollowAs(LocalActor follower, string target, bool showReblogs, CancellationToken token);
Task UnfollowAs(LocalActor follower, string target, CancellationToken token);
Task<bool> Decide(LocalActor me, string followerAccountId, bool accept, CancellationToken token);
}
public class FollowService : IFollowService
{
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IRemoteActorService _remoteActors;
readonly IDeliveryService _delivery;
readonly IStringLocalizer<GenericRes> _localizer;
readonly ILogger<FollowService> _logger;
public FollowService(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
IStringLocalizer<GenericRes> localizer, ILogger<FollowService> logger)
{
_dbEntities = dbEntities;
_localActors = localActors;
_remoteActors = remoteActors;
_delivery = delivery;
_localizer = localizer;
_logger = logger;
}
public async Task<WebResult> Follow(string rootUserId, FollowForm form, CancellationToken token)
{
var result = new WebResult();
try
{
var follower = await OwnedAvatar(rootUserId, form.AvatarId, token);
if (follower == default)
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
var following = await FollowAs(follower, form.Target, true, token);
if (following == default)
return result.Invalidate(_localizer["Account not found."], StatusCodes.Status404NotFound);
result.Data = ToView(following);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, $"{nameof(FollowService)}.{nameof(Follow)}");
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
}
}
public async Task<WebResult> Unfollow(string rootUserId, FollowForm form, CancellationToken token)
{
var result = new WebResult();
try
{
var follower = await OwnedAvatar(rootUserId, form.AvatarId, token);
if (follower == default)
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
await UnfollowAs(follower, form.Target, token);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, $"{nameof(FollowService)}.{nameof(Unfollow)}");
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
}
}
public async Task<Following> FollowAs(LocalActor follower, string target, bool showReblogs, CancellationToken token)
{
var (local, remote) = await ResolveTarget(target, token);
if (local == default && remote == default)
return default;
if (local != default && (local.Id == follower.Id || !local.IsFederated || local.Kind == LocalActorKind.Application))
return default;
var targetUri = local?.Uri ?? remote.ActorURI;
var existing = await _dbEntities.Followings.Match(f => f.AvatarId == follower.Id && f.TargetActorURI == targetUri).ExecuteFirstAsync(token);
if (existing != default)
{
if (existing.ShowReblogs != showReblogs)
await DB.Default.Update<Following>().MatchID(existing.ID).Modify(f => f.ShowReblogs, showReblogs).ExecuteAsync(token);
existing.ShowReblogs = showReblogs;
return existing;
}
var following = new Following
{
AvatarId = follower.Id,
TargetActorURI = targetUri,
TargetAccountId = local?.Id ?? remote.ID,
TargetIsLocal = local != default,
TargetInboxURL = local?.Inbox ?? remote.InboxURL,
ShowReblogs = showReblogs,
State = local is { ManuallyApprovesFollowers: false } ? FollowState.Accepted : FollowState.Requested
};
following.ID = (string)following.GenerateNewID();
following.FollowActivityURI = follower.ActivityUri($"follow-{following.ID}");
try
{
await DB.Default.SaveAsync(following, token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return await _dbEntities.Followings.Match(f => f.AvatarId == follower.Id && f.TargetActorURI == targetUri).ExecuteFirstAsync(token);
}
if (local != default)
await FollowLocally(follower, local, following, token);
else
await _delivery.Enqueue(follower, new[] { remote.InboxURL }, FollowActivity(follower, following), token);
return following;
}
public async Task UnfollowAs(LocalActor follower, string target, CancellationToken token)
{
var (local, remote) = await ResolveTarget(target, token);
var targetUri = local?.Uri ?? remote?.ActorURI ?? target;
var following = await _dbEntities.Followings.Match(f => f.AvatarId == follower.Id && f.TargetActorURI == targetUri).ExecuteFirstAsync(token);
if (following == default)
return;
await DB.Default.DeleteAsync<Following>(following.ID);
if (following.TargetIsLocal)
{
await DB.Default.DeleteAsync<Follower>(f => f.LocalActorId == following.TargetAccountId && f.ActorURI == follower.Uri);
await DB.Default.Update<GroupEntity>().MatchID(following.TargetAccountId)
.Modify(b => b.PullFilter(g => g.Members, m => !m.IsForeign && m.AvatarId == follower.Id && m.Role == GroupRole.Member))
.ExecuteAsync(token);
return;
}
var undo = new JsonObject
{
["@context"] = ActivityPubRenderer.ActivityStreams,
["id"] = follower.ActivityUri($"undo-follow-{following.ID}"),
["type"] = "Undo",
["actor"] = follower.Uri,
["object"] = FollowActivity(follower, following)
};
await _delivery.Enqueue(follower, new[] { following.TargetInboxURL }, undo, token);
}
public async Task<bool> Decide(LocalActor me, string followerAccountId, bool accept, CancellationToken token)
{
var localFollower = await _localActors.FindById(LocalActorKind.Person, followerAccountId, token);
var remoteFollower = localFollower == default ? await _dbEntities.ForeignAvatars.MatchID(followerAccountId).ExecuteFirstAsync(token) : default;
var followerUri = localFollower?.Uri ?? remoteFollower?.ActorURI;
if (followerUri == default)
return false;
var request = await _dbEntities.Followers.Match(f => f.LocalActorId == me.Id && f.LocalActorKind == me.Kind && f.ActorURI == followerUri && !f.IsAccepted)
.ExecuteFirstAsync(token);
if (request == default)
return false;
if (accept)
await DB.Default.Update<Follower>().MatchID(request.ID).Modify(f => f.IsAccepted, true).ExecuteAsync(token);
else
await DB.Default.DeleteAsync<Follower>(request.ID);
if (localFollower != default)
{
if (accept)
await DB.Default.Update<Following>().Match(f => f.AvatarId == localFollower.Id && f.TargetActorURI == me.Uri)
.Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token);
else
await DB.Default.DeleteAsync<Following>(f => f.AvatarId == localFollower.Id && f.TargetActorURI == me.Uri);
}
else
{
var follow = new JsonObject
{
["id"] = request.FollowActivityURI,
["type"] = "Follow",
["actor"] = followerUri,
["object"] = me.Uri
};
var answer = accept
? ActivityPubRenderer.Accept(me, follow, $"accept-{request.ID}-{DateTime.UtcNow.Ticks}")
: new JsonObject
{
["@context"] = ActivityPubRenderer.ActivityStreams,
["id"] = me.ActivityUri($"reject-{request.ID}-{DateTime.UtcNow.Ticks}"),
["type"] = "Reject",
["actor"] = me.Uri,
["object"] = follow
};
await _delivery.Enqueue(me, new[] { request.InboxURL }, answer, token);
}
if (accept)
await Notifications.Add(me.Id, NotificationType.Follow, followerAccountId, followerUri, default, token);
return true;
}
public async Task<WebResult> Followings(string rootUserId, string avatarId, CancellationToken token)
{
var result = new WebResult();
var follower = await OwnedAvatar(rootUserId, avatarId, token);
if (follower == default)
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
result.Data = (await _dbEntities.Followings.Match(f => f.AvatarId == follower.Id).Sort(f => f.ID, Order.Descending).ExecuteAsync(token))
.Select(ToView).ToList();
return result;
}
async Task FollowLocally(LocalActor follower, LocalActor target, Following following, CancellationToken token)
{
await DB.Default.Update<Follower>()
.Match(f => f.LocalActorId == target.Id && f.LocalActorKind == target.Kind && f.ActorURI == follower.Uri)
.Modify(f => f.InboxURL, follower.Inbox)
.Modify(f => f.SharedInboxURL, follower.SharedInbox)
.Modify(f => f.FollowActivityURI, following.FollowActivityURI)
.Modify(f => f.IsAccepted, following.State == FollowState.Accepted)
.Modify(b => b.SetOnInsert(f => f.CreationDate, DateTime.UtcNow))
.Option(o => o.IsUpsert = true)
.ExecuteAsync(token);
if (target.Kind == LocalActorKind.Group && following.State == FollowState.Accepted)
await DB.Default.Update<GroupEntity>()
.Match(g => g.ID == target.Id && !g.Members.Any(m => !m.IsForeign && m.AvatarId == follower.Id))
.Modify(b => b.Push(g => g.Members, new GroupMember { AvatarId = follower.Id }))
.ExecuteAsync(token);
if (target.Kind == LocalActorKind.Person)
await Notifications.Add(target.Id, following.State == FollowState.Accepted ? NotificationType.Follow : NotificationType.FollowRequest,
follower.Id, follower.Uri, default, token);
}
async Task<(LocalActor Local, ForeignAvatar Remote)> ResolveTarget(string target, CancellationToken token)
{
target = target?.Trim();
if (string.IsNullOrEmpty(target))
return default;
if (target.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || target.StartsWith("http://", StringComparison.OrdinalIgnoreCase))
{
var byUri = await _localActors.FindByUri(target, token);
return byUri != default ? (byUri, default) : (default, await _remoteActors.GetActor(target, refresh: false, token));
}
var parts = target.TrimStart('@').Split('@');
var localDomain = new Uri(_localActors.BaseAddress).Authority;
if (parts.Length == 1 || parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase))
return (await _localActors.FindByUserName(parts[0], token), default);
var actorUri = await _remoteActors.ResolveHandle($"{parts[0]}@{parts[1]}", token);
return (default, actorUri == default ? default : await _remoteActors.GetActor(actorUri, refresh: false, token));
}
static JsonObject FollowActivity(LocalActor follower, Following following) => new()
{
["@context"] = ActivityPubRenderer.ActivityStreams,
["id"] = following.FollowActivityURI,
["type"] = "Follow",
["actor"] = follower.Uri,
["object"] = following.TargetActorURI
};
async Task<LocalActor> OwnedAvatar(string rootUserId, string avatarId, CancellationToken token)
{
if (string.IsNullOrEmpty(rootUserId) || string.IsNullOrEmpty(avatarId))
return default;
if (!await _dbEntities.RootToAvatars.Match(ra => ra.RootId == rootUserId && ra.AvatarId == avatarId).ExecuteAnyAsync(token))
return default;
return await _localActors.FindById(LocalActorKind.Person, avatarId, token);
}
static ViewFollowing ToView(Following following) => new()
{
Id = following.ID,
TargetActorURI = following.TargetActorURI,
TargetAccountId = following.TargetAccountId,
TargetIsLocal = following.TargetIsLocal,
State = following.State.ToString(),
CreatedAt = following.CreatedAt
};
}
}
+32
View File
@@ -0,0 +1,32 @@
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Models.Social;
namespace PrivaPub.Domain.Social
{
public static class Notifications
{
public static async Task Add(string avatarId, NotificationType type, string fromAccountId, string fromActorUri, string postId,
CancellationToken token)
{
if (string.IsNullOrEmpty(avatarId) || avatarId == fromAccountId)
return;
try
{
await DB.Default.SaveAsync(new Notification
{
AvatarId = avatarId,
Type = type,
FromAccountId = fromAccountId,
FromActorURI = fromActorUri,
PostId = postId,
DedupeKey = $"{type}|{avatarId}|{fromActorUri}|{postId}"
}, token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
}
}
}
}
+489
View File
@@ -0,0 +1,489 @@
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Domain.Content;
using PrivaPub.Domain.Privacy;
using PrivaPub.Domain.Social;
using PrivaPub.Domain.Timelines;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Group;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Domain.Statuses
{
public sealed class StatusDraft
{
public string Text { get; init; }
public bool PlainText { get; init; }
public string Title { get; init; }
public string SpoilerText { get; init; }
public bool Sensitive { get; init; }
public PostVisibility Visibility { get; init; } = PostVisibility.Public;
public string InReplyTo { get; init; }
public string GroupId { get; init; }
public string Language { get; init; }
public string ConversationId { get; init; }
public IReadOnlyList<string> Recipients { get; init; } = Array.Empty<string>();
}
public sealed record StatusOutcome(PostEntity Post, int Status = StatusCodes.Status200OK, string Error = default)
{
public bool Ok => Error == default;
public static StatusOutcome Fail(int status, string error) => new(default, status, error);
}
public interface IStatusService
{
Task<StatusOutcome> Publish(LocalActor author, StatusDraft draft, CancellationToken token);
Task<StatusOutcome> Edit(LocalActor author, string postId, StatusDraft draft, CancellationToken token);
Task<StatusOutcome> Remove(LocalActor author, string postId, CancellationToken token);
Task<StatusOutcome> Favourite(LocalActor me, string postId, bool on, CancellationToken token);
Task<StatusOutcome> Reblog(LocalActor me, string postId, bool on, PostVisibility visibility, CancellationToken token);
}
public class StatusService : IStatusService
{
const int MaxRevisions = 20;
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IRemoteActorService _remoteActors;
readonly IDeliveryService _delivery;
readonly IContentRenderer _content;
readonly IOutboxPublisher _outbox;
readonly IFanout _fanout;
public StatusService(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
IContentRenderer content, IOutboxPublisher outbox, IFanout fanout)
{
_dbEntities = dbEntities;
_localActors = localActors;
_remoteActors = remoteActors;
_delivery = delivery;
_content = content;
_outbox = outbox;
_fanout = fanout;
}
public async Task<StatusOutcome> Publish(LocalActor author, StatusDraft draft, CancellationToken token)
{
if (string.IsNullOrWhiteSpace(draft.Text))
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Text can't be blank");
LocalActor group = default;
if (!string.IsNullOrEmpty(draft.GroupId))
{
var groupEntity = await _dbEntities.Groups.MatchID(draft.GroupId).ExecuteFirstAsync(token);
if (groupEntity == default || groupEntity.DeletionAt.HasValue || !groupEntity.Members.Any(m => !m.IsForeign && m.AvatarId == author.Id))
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Group not found");
group = _localActors.FromGroup(groupEntity);
}
var parent = await Parent(draft.InReplyTo, token);
if (!string.IsNullOrEmpty(draft.InReplyTo) && parent == default && !IsRemoteUri(draft.InReplyTo))
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
if (parent != default && !await VisibilityPolicy.CanSee(parent, author.Id, token))
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
var rendered = draft.PlainText ? await _content.PlainText(draft.Text, token) : await _content.Markdown(draft.Text, token);
var isLocalOnly = group is { IsFederated: false };
var visibility = isLocalOnly ? PostVisibility.Circle : draft.Visibility is PostVisibility.Circle or PostVisibility.LocalGeo ? PostVisibility.Public : draft.Visibility;
var post = new PostEntity
{
GroupUserId = author.Id,
AuthorAccountId = author.Id,
GroupId = group?.Id,
Visibility = visibility,
Title = Clean(draft.Title),
SpoilerText = Clean(draft.SpoilerText),
HasContentWarning = draft.Sensitive || Clean(draft.SpoilerText) != default,
Text = draft.Text,
ContentHtml = rendered.Html,
ContentFormat = draft.PlainText ? ContentFormat.Plain : ContentFormat.Markdown,
Language = Clean(draft.Language),
Mentions = rendered.Mentions.Select(ToMention).ToList(),
Tags = rendered.Tags.ToList(),
AnsweringToPostId = parent?.ID,
InReplyToURI = parent?.ObjectURI ?? (IsRemoteUri(draft.InReplyTo) ? draft.InReplyTo : default),
InReplyToAccountId = parent?.AuthorAccountId ?? parent?.GroupUserId,
IsLocalOnly = isLocalOnly,
ActorURI = author.Uri
};
post.ID = (string)post.GenerateNewID();
post.ObjectURI = author.PostUri(post.ID);
post.Url = author.PostHtmlUrl(post.ID);
JsonObject create = default;
if (visibility == PostVisibility.Direct)
{
var conversation = await Conversation(author, draft, rendered.Mentions, token);
if (!conversation.Ok)
return StatusOutcome.Fail(conversation.Status, conversation.Error);
var (dmGroup, recipients) = (conversation.Group, conversation.Recipients);
post.ConversationId = dmGroup.ID;
post.ContextURI = dmGroup.ConversationURI;
post.Mentions = recipients.Select(r => new PostMention { ActorURI = r.Uri, Handle = "@" + r.Handle, IsLocal = r.LocalId != default, AccountId = r.LocalId ?? r.ForeignId })
.Concat(post.Mentions)
.DistinctBy(m => m.ActorURI)
.ToList();
var note = ActivityPubRenderer.DirectNote(post, author, recipients.Select(r => (r.Uri, r.Handle)).ToList(), dmGroup.ConversationURI);
create = ActivityPubRenderer.Create(author, note, $"create-{post.ID}");
post.To = Strings(note["to"]);
await DB.Default.Update<DmGroup>().MatchID(dmGroup.ID).Modify(g => g.UpdatedAt, DateTime.UtcNow).ExecuteAsync(token);
}
else if (!isLocalOnly)
{
var note = ActivityPubRenderer.Note(post, author, group, post.InReplyToURI);
create = ActivityPubRenderer.Create(author, note, $"create-{post.ID}");
post.To = Strings(note["to"]);
post.Cc = Strings(note["cc"]);
}
post.ActivityURI = create?["id"]?.GetValue<string>();
await DB.Default.SaveAsync(post, token);
if (parent != default)
await DB.Default.Update<PostEntity>().MatchID(parent.ID).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token);
await _fanout.Distribute(post, token);
if (create != default)
await _outbox.Publish(author, post, create, token);
if (group is { IsFederated: true } && visibility is PostVisibility.Public or PostVisibility.Unlisted)
await _delivery.EnqueueToFollowers(group, ActivityPubRenderer.Announce(group, post.ObjectURI, $"announce-{post.ID}"), token);
return new StatusOutcome(post);
}
public async Task<StatusOutcome> Edit(LocalActor author, string postId, StatusDraft draft, CancellationToken token)
{
var post = await Own(author, postId, token);
if (post == default)
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
if (string.IsNullOrWhiteSpace(draft.Text))
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Text can't be blank");
post.Revisions.Add(new PostRevision
{
Title = post.Title,
SpoilerText = post.SpoilerText,
ContentHtml = post.ContentHtml,
HasContentWarning = post.HasContentWarning,
EditedAt = post.EditedAt ?? post.CreationDate
});
if (post.Revisions.Count > MaxRevisions)
post.Revisions.RemoveRange(0, post.Revisions.Count - MaxRevisions);
var plain = draft.PlainText || post.ContentFormat == ContentFormat.Plain;
var rendered = plain ? await _content.PlainText(draft.Text, token) : await _content.Markdown(draft.Text, token);
post.Title = draft.Title == default ? post.Title : Clean(draft.Title);
post.SpoilerText = Clean(draft.SpoilerText);
post.HasContentWarning = draft.Sensitive || post.SpoilerText != default;
post.Text = draft.Text;
post.ContentHtml = rendered.Html;
post.Language = Clean(draft.Language) ?? post.Language;
post.Mentions = post.Visibility == PostVisibility.Direct
? post.Mentions.Concat(rendered.Mentions.Select(ToMention)).DistinctBy(m => m.ActorURI).ToList()
: rendered.Mentions.Select(ToMention).ToList();
post.Tags = rendered.Tags.ToList();
post.EditedAt = DateTime.UtcNow;
post.UpdateDate = post.EditedAt;
await DB.Default.SaveAsync(post, token);
if (!post.IsLocalOnly)
{
var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
var note = post.Visibility == PostVisibility.Direct
? ActivityPubRenderer.DirectNote(post, author, Array.Empty<(string, string)>(), post.ContextURI)
: ActivityPubRenderer.Note(post, author, group, post.InReplyToURI);
note["to"] = new JsonArray(post.To.Select(t => (JsonNode)t).ToArray());
note["cc"] = new JsonArray(post.Cc.Select(c => (JsonNode)c).ToArray());
var update = new JsonObject
{
["@context"] = ActivityPubRenderer.Context(),
["id"] = author.ActivityUri($"update-{post.ID}-{new DateTimeOffset(post.EditedAt.Value).ToUnixTimeSeconds()}"),
["type"] = "Update",
["actor"] = author.Uri,
["to"] = note["to"]!.DeepClone(),
["cc"] = note["cc"]!.DeepClone(),
["object"] = note
};
await _outbox.Publish(author, post, update, token);
}
return new StatusOutcome(post);
}
public async Task<StatusOutcome> Remove(LocalActor author, string postId, CancellationToken token)
{
var post = await _dbEntities.Posts.Match(p => p.ID == postId && p.GroupUserId == author.Id && !p.IsFederatedCopy).ExecuteFirstAsync(token);
if (post == default || post.DeletedAt.HasValue)
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
if (post.ReblogOfPostId != default)
return await Reblog(author, post.ReblogOfPostId, false, post.Visibility, token);
var audience = await _outbox.Audience(author, post, token);
await DB.Default.Update<PostEntity>().MatchID(post.ID)
.Modify(p => p.DeletedAt, DateTime.UtcNow)
.Modify(p => p.Text, null)
.Modify(p => p.ContentHtml, null)
.Modify(p => p.Title, null)
.Modify(p => p.SpoilerText, null)
.Modify(p => p.Media, new List<PostMedia>())
.Modify(p => p.Revisions, new List<PostRevision>())
.ExecuteAsync(token);
await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == post.ID || e.ReblogOfPostId == post.ID);
if (!string.IsNullOrEmpty(post.AnsweringToPostId))
await DB.Default.Update<PostEntity>().MatchID(post.AnsweringToPostId).Modify(b => b.Inc(p => p.RepliesCount, -1)).ExecuteAsync(token);
if (audience.Count > 0)
{
var delete = ActivityPubRenderer.Delete(author, post.ObjectURI, $"delete-{post.ID}",
new JsonArray(post.To.Select(t => (JsonNode)t).ToArray()), new JsonArray(post.Cc.Select(c => (JsonNode)c).ToArray()));
await _delivery.Enqueue(author, audience, delete, token);
}
return new StatusOutcome(post);
}
public async Task<StatusOutcome> Favourite(LocalActor me, string postId, bool on, CancellationToken token)
{
var post = await Visible(me, postId, token);
if (post == default)
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
var activityId = me.ActivityUri($"like-{post.ID}");
if (on)
{
try
{
await DB.Default.SaveAsync(new Favourite { AccountId = me.Id, ActorURI = me.Uri, PostId = post.ID, ActivityURI = activityId }, token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return new StatusOutcome(post);
}
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(b => b.Inc(p => p.FavouritesCount, 1)).ExecuteAsync(token);
post.FavouritesCount++;
if (!post.IsFederatedCopy)
await Notifications.Add(post.GroupUserId, NotificationType.Favourite, me.Id, me.Uri, post.ID, token);
}
else
{
var removed = await DB.Default.DeleteAsync<Favourite>(f => f.AccountId == me.Id && f.PostId == post.ID);
if (removed.DeletedCount == 0)
return new StatusOutcome(post);
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(b => b.Inc(p => p.FavouritesCount, -1)).ExecuteAsync(token);
post.FavouritesCount--;
}
if (post.IsFederatedCopy && await AuthorInbox(post, token) is { } inbox)
{
var like = new JsonObject
{
["@context"] = ActivityPubRenderer.ActivityStreams,
["id"] = activityId,
["type"] = "Like",
["actor"] = me.Uri,
["object"] = post.ObjectURI
};
await _delivery.Enqueue(me, new[] { inbox }, on ? like : Undo(me, like, $"undo-like-{post.ID}-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}"), token);
}
return new StatusOutcome(post);
}
public async Task<StatusOutcome> Reblog(LocalActor me, string postId, bool on, PostVisibility visibility, CancellationToken token)
{
var original = await Visible(me, postId, token);
if (original?.ReblogOfPostId != default)
original = await Visible(me, original.ReblogOfPostId, token);
if (original == default)
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
if (original.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted))
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: This post can't be boosted");
var existing = await _dbEntities.Posts.Match(p => p.ReblogOfPostId == original.ID && p.AuthorAccountId == me.Id && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
var announceId = me.ActivityUri($"announce-{original.ID}");
var announce = new JsonObject
{
["@context"] = ActivityPubRenderer.ActivityStreams,
["id"] = announceId,
["type"] = "Announce",
["actor"] = me.Uri,
["published"] = ActivityPubRenderer.Timestamp(DateTime.UtcNow),
["to"] = new JsonArray(visibility == PostVisibility.Unlisted ? me.Followers : ActivityPubRenderer.Public),
["cc"] = new JsonArray(visibility == PostVisibility.Unlisted ? ActivityPubRenderer.Public : me.Followers, original.ActorURI),
["object"] = original.ObjectURI
};
if (on)
{
if (existing != default)
return new StatusOutcome(existing);
var reblog = new PostEntity
{
GroupUserId = me.Id,
AuthorAccountId = me.Id,
ReblogOfPostId = original.ID,
Visibility = visibility == PostVisibility.Unlisted ? PostVisibility.Unlisted : PostVisibility.Public,
ObjectURI = announceId,
ActivityURI = announceId,
ActorURI = me.Uri,
To = Strings(announce["to"]),
Cc = Strings(announce["cc"])
};
try
{
await DB.Default.SaveAsync(reblog, token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return new StatusOutcome(await _dbEntities.Posts.Match(p => p.ObjectURI == announceId).ExecuteFirstAsync(token));
}
await DB.Default.Update<PostEntity>().MatchID(original.ID).Modify(b => b.Inc(p => p.ReblogsCount, 1)).ExecuteAsync(token);
if (!original.IsFederatedCopy)
await Notifications.Add(original.GroupUserId, NotificationType.Reblog, me.Id, me.Uri, original.ID, token);
await _fanout.Distribute(reblog, token);
await _delivery.EnqueueToFollowers(me, announce, token, await AuthorInbox(original, token) is { } inbox ? new[] { inbox } : default);
return new StatusOutcome(reblog);
}
if (existing == default)
return new StatusOutcome(original);
await DB.Default.DeleteAsync<PostEntity>(existing.ID);
await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == existing.ID);
await DB.Default.Update<PostEntity>().MatchID(original.ID).Modify(b => b.Inc(p => p.ReblogsCount, -1)).ExecuteAsync(token);
await _delivery.EnqueueToFollowers(me, Undo(me, announce, $"undo-announce-{original.ID}-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}"), token,
await AuthorInbox(original, token) is { } authorInbox ? new[] { authorInbox } : default);
return new StatusOutcome(original);
}
sealed record Recipient(string Uri, string Handle, string LocalId, string ForeignId, string Inbox);
sealed record ConversationResult(DmGroup Group, IReadOnlyList<Recipient> Recipients, int Status = StatusCodes.Status200OK, string Error = default)
{
public bool Ok => Error == default;
}
async Task<ConversationResult> Conversation(LocalActor author, StatusDraft draft, IReadOnlyList<ResolvedMention> mentioned, CancellationToken token)
{
DmGroup dmGroup = default;
if (!string.IsNullOrEmpty(draft.ConversationId))
{
dmGroup = await _dbEntities.DmGroups.MatchID(draft.ConversationId).ExecuteFirstAsync(token);
if (dmGroup == default || !dmGroup.Members.Any(m => !m.IsForeign && m.AvatarId == author.Id))
return new(default, default, StatusCodes.Status404NotFound, "Conversation not found");
}
else
{
var members = new List<GroupMember> { new() { AvatarId = author.Id } };
foreach (var handle in draft.Recipients.Distinct(StringComparer.OrdinalIgnoreCase))
{
var member = await ResolveRecipient(handle, token);
if (member == default)
return new(default, default, StatusCodes.Status404NotFound, $"Recipient '{handle}' not found");
if (members.All(m => m.AvatarId != member.AvatarId))
members.Add(member);
}
foreach (var mention in mentioned.Where(m => m.AccountId != author.Id))
{
var member = mention.IsLocal ? new GroupMember { AvatarId = mention.AccountId } : new GroupMember { AvatarId = mention.ActorUri, IsForeign = true };
if (members.All(m => m.AvatarId != member.AvatarId))
members.Add(member);
}
if (members.Count < 2)
return new(default, default, StatusCodes.Status422UnprocessableEntity, "Validation failed: A direct message needs at least one recipient");
var key = DmGroup.KeyOf(members);
dmGroup = await _dbEntities.DmGroups.Match(g => g.ParticipantsKey == key && !g.DeletionAt.HasValue).ExecuteFirstAsync(token);
if (dmGroup == default)
{
dmGroup = new DmGroup { Members = members, ParticipantsKey = key };
dmGroup.ID = (string)dmGroup.GenerateNewID();
dmGroup.ConversationURI = author.ConversationUri(dmGroup.ID);
await DB.Default.SaveAsync(dmGroup, token);
}
}
var recipients = new List<Recipient>();
foreach (var member in dmGroup.Members.Where(m => !(m.AvatarId == author.Id && !m.IsForeign)))
{
if (member.IsForeign)
{
var foreign = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == member.AvatarId).ExecuteFirstAsync(token);
if (foreign != default)
recipients.Add(new Recipient(foreign.ActorURI, $"{foreign.UserName}@{foreign.Domain}", default, foreign.ID, foreign.InboxURL));
}
else if (await _localActors.FindById(LocalActorKind.Person, member.AvatarId, token) is { } local)
recipients.Add(new Recipient(local.Uri, local.Handle, local.Id, default, default));
}
return new(dmGroup, recipients);
}
async Task<GroupMember> ResolveRecipient(string recipient, CancellationToken token)
{
var handle = recipient.Trim().TrimStart('@');
if (!handle.Contains('@') || handle.EndsWith("@" + new Uri(_localActors.BaseAddress).Authority, StringComparison.OrdinalIgnoreCase))
{
var local = await _localActors.FindByUserName(handle.Split('@')[0], token);
return local is { Kind: LocalActorKind.Person } ? new GroupMember { AvatarId = local.Id } : default;
}
var actorUri = await _remoteActors.ResolveHandle(handle, token);
var foreign = actorUri == default ? default : await _remoteActors.GetActor(actorUri, refresh: false, token);
return foreign == default ? default : new GroupMember { AvatarId = foreign.ActorURI, IsForeign = true };
}
async Task<PostEntity> Parent(string reference, CancellationToken token)
{
if (string.IsNullOrEmpty(reference))
return default;
return IsRemoteUri(reference)
? await _dbEntities.Posts.Match(p => p.ObjectURI == reference && !p.DeletedAt.HasValue).ExecuteFirstAsync(token)
: await _dbEntities.Posts.Match(p => p.ID == reference && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
}
async Task<PostEntity> Own(LocalActor author, string postId, CancellationToken token) =>
await _dbEntities.Posts.Match(p => p.ID == postId && p.GroupUserId == author.Id && !p.IsFederatedCopy && !p.DeletedAt.HasValue && p.ReblogOfPostId == null)
.ExecuteFirstAsync(token);
async Task<PostEntity> Visible(LocalActor me, string postId, CancellationToken token)
{
var post = string.IsNullOrEmpty(postId) ? default : await _dbEntities.Posts.Match(p => p.ID == postId && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
return post != default && await VisibilityPolicy.CanSee(post, me.Id, token) ? post : default;
}
async Task<string> AuthorInbox(PostEntity post, CancellationToken token)
{
if (!post.IsFederatedCopy || string.IsNullOrEmpty(post.AuthorAccountId))
return default;
return (await _dbEntities.ForeignAvatars.MatchID(post.AuthorAccountId).ExecuteFirstAsync(token))?.InboxURL;
}
static JsonObject Undo(LocalActor actor, JsonObject inner, string activityId) => new()
{
["@context"] = ActivityPubRenderer.ActivityStreams,
["id"] = actor.ActivityUri(activityId),
["type"] = "Undo",
["actor"] = actor.Uri,
["object"] = inner.DeepClone()
};
static PostMention ToMention(ResolvedMention mention) => new()
{
ActorURI = mention.ActorUri,
Handle = "@" + mention.Handle,
IsLocal = mention.IsLocal,
AccountId = mention.AccountId
};
static bool IsRemoteUri(string reference) =>
reference != default && (reference.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || reference.StartsWith("http://", StringComparison.OrdinalIgnoreCase));
static string Clean(string value) => string.IsNullOrWhiteSpace(value) ? default : value.Trim();
static List<string> Strings(JsonNode node) =>
node is JsonArray array ? array.Select(n => n?.GetValue<string>()).Where(s => s != default).ToList() : new List<string>();
}
}
+93
View File
@@ -0,0 +1,93 @@
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Domain.Social;
using PrivaPub.Models.Group;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.StaticServices;
using GroupEntity = PrivaPub.Models.Group.Group;
namespace PrivaPub.Domain.Timelines
{
public interface IFanout
{
Task Distribute(Post post, CancellationToken token);
}
public class Fanout : IFanout
{
readonly DbEntities _dbEntities;
public Fanout(DbEntities dbEntities)
{
_dbEntities = dbEntities;
}
public async Task Distribute(Post post, CancellationToken token)
{
var recipients = new HashSet<string>(StringComparer.Ordinal);
if (!post.IsFederatedCopy && !string.IsNullOrEmpty(post.GroupUserId))
recipients.Add(post.GroupUserId);
switch (post.Visibility)
{
case PostVisibility.Public or PostVisibility.Unlisted or PostVisibility.FollowersOnly:
var followers = post.IsFederatedCopy
? await _dbEntities.Followings.Match(f => f.TargetActorURI == post.ActorURI && f.State == FollowState.Accepted).ExecuteAsync(token)
: await _dbEntities.Followings.Match(f => f.TargetAccountId == post.GroupUserId && f.TargetIsLocal && f.State == FollowState.Accepted).ExecuteAsync(token);
foreach (var following in followers)
if (await Shows(following, post, token))
recipients.Add(following.AvatarId);
break;
case PostVisibility.Direct when !string.IsNullOrEmpty(post.ConversationId):
var conversation = await _dbEntities.DmGroups.MatchID(post.ConversationId).ExecuteFirstAsync(token);
foreach (var member in conversation?.Members.Where(m => !m.IsForeign) ?? Enumerable.Empty<GroupMember>())
recipients.Add(member.AvatarId);
break;
case PostVisibility.Circle when !string.IsNullOrEmpty(post.GroupId):
var circle = await DB.Default.Find<GroupEntity>().MatchID(post.GroupId).ExecuteFirstAsync(token);
foreach (var member in circle?.Members.Where(m => !m.IsForeign) ?? Enumerable.Empty<GroupMember>())
recipients.Add(member.AvatarId);
break;
}
foreach (var avatarId in recipients)
{
try
{
await DB.Default.SaveAsync(new TimelineEntry
{
AvatarId = avatarId,
PostId = post.ID,
AuthorAccountId = post.AuthorAccountId ?? post.GroupUserId,
ReblogOfPostId = post.ReblogOfPostId
}, token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
}
}
if (string.IsNullOrEmpty(post.ReblogOfPostId))
foreach (var mention in post.Mentions.Where(m => m.IsLocal && !string.IsNullOrEmpty(m.AccountId)))
await Notifications.Add(mention.AccountId, NotificationType.Mention, post.AuthorAccountId ?? post.GroupUserId, post.ActorURI, post.ID, token);
}
async Task<bool> Shows(Following following, Post post, CancellationToken token)
{
if (!string.IsNullOrEmpty(post.ReblogOfPostId))
return following.ShowReblogs;
var repliedTo = post.InReplyToAccountId;
var author = post.AuthorAccountId ?? post.GroupUserId;
if (string.IsNullOrEmpty(post.InReplyToURI) || repliedTo == author || repliedTo == following.AvatarId)
return true;
if (string.IsNullOrEmpty(repliedTo))
return false;
return await _dbEntities.Followings
.Match(f => f.AvatarId == following.AvatarId && f.TargetAccountId == repliedTo && f.State == FollowState.Accepted)
.ExecuteAnyAsync(token);
}
}
}
@@ -0,0 +1,119 @@
using Microsoft.Extensions.Localization;
using MongoDB.Entities;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.Post;
using PrivaPub.ClientModels.Social;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Resources;
using PrivaPub.StaticServices;
namespace PrivaPub.Domain.Timelines
{
public interface ITimelineService
{
Task<WebResult> Home(string rootUserId, string avatarId, string maxId, int limit, CancellationToken token);
Task<WebResult> Notifications(string rootUserId, string avatarId, string maxId, int limit, CancellationToken token);
Task<WebResult> MarkNotificationsRead(string rootUserId, string avatarId, CancellationToken token);
}
public class TimelineService : ITimelineService
{
const int MaxLimit = 40;
readonly DbEntities _dbEntities;
readonly IStringLocalizer<GenericRes> _localizer;
public TimelineService(DbEntities dbEntities, IStringLocalizer<GenericRes> localizer)
{
_dbEntities = dbEntities;
_localizer = localizer;
}
public async Task<WebResult> Home(string rootUserId, string avatarId, string maxId, int limit, CancellationToken token)
{
var result = new WebResult();
if (!await Owns(rootUserId, avatarId, token))
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
var query = _dbEntities.TimelineEntries.Match(e => e.AvatarId == avatarId);
if (!string.IsNullOrEmpty(maxId))
query.Match(f => f.Lt(e => e.PostId, maxId));
var entries = await query.Sort(e => e.PostId, Order.Descending).Limit(Math.Clamp(limit, 1, MaxLimit)).ExecuteAsync(token);
var ids = entries.Select(e => e.PostId).Concat(entries.Where(e => e.ReblogOfPostId != default).Select(e => e.ReblogOfPostId)).Distinct().ToList();
var posts = ids.Count == 0
? new Dictionary<string, Post>()
: (await _dbEntities.Posts.Match(p => ids.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token)).ToDictionary(p => p.ID);
result.Data = entries
.Where(e => posts.ContainsKey(e.PostId) && (e.ReblogOfPostId == default || posts.ContainsKey(e.ReblogOfPostId)))
.Select(e =>
{
var view = ToView(posts[e.PostId]);
if (e.ReblogOfPostId != default)
view.ReblogOf = ToView(posts[e.ReblogOfPostId]);
return view;
})
.ToList();
return result;
}
public async Task<WebResult> Notifications(string rootUserId, string avatarId, string maxId, int limit, CancellationToken token)
{
var result = new WebResult();
if (!await Owns(rootUserId, avatarId, token))
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
var query = _dbEntities.Notifications.Match(n => n.AvatarId == avatarId);
if (!string.IsNullOrEmpty(maxId))
query.Match(f => f.Lt(n => n.ID, maxId));
result.Data = (await query.Sort(n => n.ID, Order.Descending).Limit(Math.Clamp(limit, 1, MaxLimit)).ExecuteAsync(token))
.Select(n => new ViewNotification
{
Id = n.ID,
Type = n.Type.ToString(),
FromAccountId = n.FromAccountId,
FromActorURI = n.FromActorURI,
PostId = n.PostId,
IsRead = n.IsRead,
CreatedAt = n.CreatedAt
})
.ToList();
return result;
}
public async Task<WebResult> MarkNotificationsRead(string rootUserId, string avatarId, CancellationToken token)
{
var result = new WebResult();
if (!await Owns(rootUserId, avatarId, token))
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
await DB.Default.Update<Notification>().Match(n => n.AvatarId == avatarId && !n.IsRead).Modify(n => n.IsRead, true).ExecuteAsync(token);
return result;
}
async Task<bool> Owns(string rootUserId, string avatarId, CancellationToken token) =>
!string.IsNullOrEmpty(rootUserId) && !string.IsNullOrEmpty(avatarId)
&& await _dbEntities.RootToAvatars.Match(ra => ra.RootId == rootUserId && ra.AvatarId == avatarId).ExecuteAnyAsync(token);
static ViewPost ToView(Post post) => new()
{
Id = post.ID,
ObjectURI = post.ObjectURI,
AuthorAvatarId = post.IsFederatedCopy ? default : post.GroupUserId,
AuthorActorURI = post.ActorURI,
GroupId = post.GroupId,
DmGroupId = post.ConversationId,
Visibility = post.Visibility.ToString(),
AnsweringToPostId = post.AnsweringToPostId,
Title = post.Title,
Text = post.Text,
ContentHtml = post.ContentHtml,
HasContentWarning = post.HasContentWarning,
IsFederatedCopy = post.IsFederatedCopy,
CreationDate = post.CreationDate
};
}
}
@@ -29,6 +29,9 @@ namespace PrivaPub.Federation.Actors
public bool Discoverable { get; init; } = true; public bool Discoverable { get; init; } = true;
public bool ManuallyApprovesFollowers { get; init; } public bool ManuallyApprovesFollowers { get; init; }
public bool IsFederated { get; init; } = true; public bool IsFederated { get; init; } = true;
public bool IsBot { get; init; }
public bool Indexable { get; init; }
public AvatarSettings Settings { get; init; } = new();
public DateTime Published { get; init; } public DateTime Published { get; init; }
public string BaseAddress { get; init; } public string BaseAddress { get; init; }
public IReadOnlyDictionary<string, string> Fields { get; init; } = new Dictionary<string, string>(); public IReadOnlyDictionary<string, string> Fields { get; init; } = new Dictionary<string, string>();
@@ -195,6 +198,11 @@ namespace PrivaPub.Federation.Actors
PublicKeyPem = avatar.PublicKey, PublicKeyPem = avatar.PublicKey,
Published = avatar.CreatedAt, Published = avatar.CreatedAt,
Fields = avatar.Fields ?? new Dictionary<string, string>(), Fields = avatar.Fields ?? new Dictionary<string, string>(),
ManuallyApprovesFollowers = avatar.Settings?.IsLocked == true,
Discoverable = avatar.Settings?.IsDiscoverable != false,
IsBot = avatar.Settings?.IsBot == true,
Indexable = avatar.Settings?.IsIndexable == true,
Settings = avatar.Settings ?? new AvatarSettings(),
BaseAddress = BaseAddress BaseAddress = BaseAddress
}; };
@@ -80,6 +80,12 @@ namespace PrivaPub.Federation.Controllers
items.Add(ActivityPubRenderer.Announce(local, post.ObjectURI, $"announce-{post.ID}")); items.Add(ActivityPubRenderer.Announce(local, post.ObjectURI, $"announce-{post.ID}"));
continue; continue;
} }
if (post.ReblogOfPostId != default)
{
if (await AnnounceFor(post, token) is { } announce)
items.Add(announce);
continue;
}
items.Add(await CreateFor(post, local, token)); items.Add(await CreateFor(post, local, token));
} }
@@ -111,7 +117,7 @@ namespace PrivaPub.Federation.Controllers
{ {
var (local, post) = await PublicPost(actor, postId, token); var (local, post) = await PublicPost(actor, postId, token);
if (post == default) if (post == default)
return NotFound(); return await Tombstone(actor, postId, token) ?? NotFound();
if (WantsHtml()) if (WantsHtml())
return Redirect(local.PostHtmlUrl(post.ID)); return Redirect(local.PostHtmlUrl(post.ID));
@@ -124,6 +130,14 @@ namespace PrivaPub.Federation.Controllers
[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)
{ {
if (activityId.StartsWith("announce-", StringComparison.Ordinal))
{
var booster = await _localActors.FindByUserName(actor, token);
var uri = booster?.ActivityUri(activityId);
var reblog = uri == default ? default : await _dbEntities.Posts.Match(p => p.ObjectURI == uri && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
var rendered = reblog == default ? default : await AnnounceFor(reblog, token);
return rendered == default ? NotFound() : Activity(rendered);
}
if (!activityId.StartsWith("create-", StringComparison.Ordinal)) if (!activityId.StartsWith("create-", StringComparison.Ordinal))
return NotFound(); return NotFound();
var (local, post) = await PublicPost(actor, activityId["create-".Length..], token); var (local, post) = await PublicPost(actor, activityId["create-".Length..], token);
@@ -153,12 +167,52 @@ namespace PrivaPub.Federation.Controllers
if (local is not { IsFederated: true, Kind: LocalActorKind.Person }) if (local is not { IsFederated: true, Kind: LocalActorKind.Person })
return default; return default;
var post = await _dbEntities.Posts var post = await _dbEntities.Posts
.Match(p => p.ID == postId && p.GroupUserId == local.Id && !p.IsFederatedCopy) .Match(p => p.ID == postId && p.GroupUserId == local.Id && !p.IsFederatedCopy && p.ReblogOfPostId == null)
.Match(VisibilityPolicy.IsPublic) .Match(VisibilityPolicy.IsPublic)
.ExecuteFirstAsync(token); .ExecuteFirstAsync(token);
return (local, post); return (local, post);
} }
async Task<IActionResult> Tombstone(string actor, string postId, CancellationToken token)
{
var local = await _localActors.FindByUserName(actor, token);
if (local is not { IsFederated: true, Kind: LocalActorKind.Person })
return default;
var deleted = await _dbEntities.Posts
.Match(p => p.ID == postId && p.GroupUserId == local.Id && !p.IsFederatedCopy && p.DeletedAt.HasValue
&& (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted))
.ExecuteFirstAsync(token);
if (deleted == default)
return default;
var tombstone = new JsonObject
{
["@context"] = ActivityPubRenderer.ActivityStreams,
["id"] = local.PostUri(deleted.ID),
["type"] = "Tombstone",
["formerType"] = "Note",
["deleted"] = ActivityPubRenderer.Timestamp(deleted.DeletedAt.Value)
};
return new ContentResult { Content = tombstone.ToJsonString(), ContentType = ActivityContentType, StatusCode = StatusCodes.Status410Gone };
}
async Task<JsonObject> AnnounceFor(PostEntity reblog, CancellationToken token)
{
var original = await _dbEntities.Posts.MatchID(reblog.ReblogOfPostId).ExecuteFirstAsync(token);
if (original == default || original.DeletedAt.HasValue)
return default;
return new JsonObject
{
["@context"] = ActivityPubRenderer.ActivityStreams,
["id"] = reblog.ObjectURI,
["type"] = "Announce",
["actor"] = reblog.ActorURI,
["published"] = ActivityPubRenderer.Timestamp(reblog.CreationDate),
["to"] = new JsonArray(reblog.To.Select(t => (JsonNode)t).ToArray()),
["cc"] = new JsonArray(reblog.Cc.Select(c => (JsonNode)c).ToArray()),
["object"] = original.ObjectURI
};
}
async Task<JsonObject> CreateFor(PostEntity post, LocalActor author, CancellationToken token) async Task<JsonObject> CreateFor(PostEntity post, LocalActor author, CancellationToken token)
{ {
var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token); var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
@@ -85,7 +85,7 @@ namespace PrivaPub.Federation.Controllers
public async Task<IActionResult> NodeInfo(string version, CancellationToken token) public async Task<IActionResult> NodeInfo(string version, CancellationToken token)
{ {
var users = await DB.Default.CountAsync<Avatar>(a => !a.DeletionAt.HasValue, token); var users = await DB.Default.CountAsync<Avatar>(a => !a.DeletionAt.HasValue, token);
var posts = await DB.Default.CountAsync<PostEntity>(f => f.Where(p => !p.IsFederatedCopy) & f.Where(VisibilityPolicy.IsPublic), token); var posts = await DB.Default.CountAsync<PostEntity>(f => f.Where(p => !p.IsFederatedCopy && p.ReblogOfPostId == null) & f.Where(VisibilityPolicy.IsPublic), token);
var software = new JsonObject { ["name"] = "privapub", ["version"] = BuildInfo.Ref }; var software = new JsonObject { ["name"] = "privapub", ["version"] = BuildInfo.Ref };
if (version == "2.1") if (version == "2.1")
{ {
@@ -0,0 +1,67 @@
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using static PrivaPub.Federation.Objects.ActivityJson;
namespace PrivaPub.Federation.Inbox.Handlers
{
public class AcceptHandler : IActivityHandler
{
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
public AcceptHandler(DbEntities dbEntities, ILocalActorService localActors)
{
_dbEntities = dbEntities;
_localActors = localActors;
}
public virtual string Type => "Accept";
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
{
var following = await FindFollowing(activity["object"], actor, _dbEntities, _localActors, token);
if (following == default)
return;
await Apply(following, token);
}
protected virtual Task Apply(Following following, CancellationToken token) =>
DB.Default.Update<Following>().MatchID(following.ID).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token);
public static async Task<Following> FindFollowing(JsonNode follow, ForeignAvatar target, DbEntities dbEntities, ILocalActorService localActors,
CancellationToken token)
{
if (follow is JsonObject && Value(follow, "type") is { } type && type != "Follow")
return default;
var followId = Id(follow);
var byId = followId == default
? default
: await dbEntities.Followings.Match(f => f.FollowActivityURI == followId && f.TargetActorURI == target.ActorURI).ExecuteFirstAsync(token);
if (byId != default || follow is not JsonObject)
return byId;
var follower = await localActors.FindByUri(Id(follow["actor"]), token);
return follower == default
? default
: await dbEntities.Followings.Match(f => f.AvatarId == follower.Id && f.TargetActorURI == target.ActorURI).ExecuteFirstAsync(token);
}
}
public class RejectHandler : AcceptHandler
{
public RejectHandler(DbEntities dbEntities, ILocalActorService localActors) : base(dbEntities, localActors)
{
}
public override string Type => "Reject";
protected override Task Apply(Following following, CancellationToken token) => DB.Default.DeleteAsync<Following>(following.ID);
}
}
@@ -0,0 +1,106 @@
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Domain.Social;
using PrivaPub.Domain.Timelines;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Objects;
using PrivaPub.Infrastructure.Ids;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Globalization;
using System.Text.Json.Nodes;
using static PrivaPub.Federation.Objects.ActivityJson;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Federation.Inbox.Handlers
{
public class AnnounceHandler : IActivityHandler
{
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IRemotePosts _remotePosts;
readonly IFanout _fanout;
public AnnounceHandler(DbEntities dbEntities, ILocalActorService localActors, IRemotePosts remotePosts, IFanout fanout)
{
_dbEntities = dbEntities;
_localActors = localActors;
_remotePosts = remotePosts;
_fanout = fanout;
}
public string Type => "Announce";
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
{
var inner = activity["object"];
if (inner is JsonObject && Value(inner, "type") is "Create" or "Update" or "Delete" or "Like" or "Dislike" or "Undo" or "Add" or "Remove" or "Block")
return;
var objectUri = Id(inner);
var announceId = Id(activity);
if (objectUri == default || announceId == default)
return;
if (await _dbEntities.Posts.Match(p => p.ObjectURI == announceId).ExecuteAnyAsync(token))
return;
var isLocal = objectUri.StartsWith(_localActors.BaseAddress + "/", StringComparison.OrdinalIgnoreCase);
var original = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token);
var followed = await _dbEntities.Followings.Match(f => f.TargetActorURI == actor.ActorURI && f.State == FollowState.Accepted).ExecuteAnyAsync(token);
if (original is not { IsFederatedCopy: false } && !followed)
return;
if (original == default && !isLocal)
original = await _remotePosts.StoreContext(objectUri, 0, token);
if (original == default || original.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted))
return;
var to = Strings(activity["to"]);
var cc = Strings(activity["cc"]);
var published = DateTimeOffset.TryParse(Value(activity, "published"), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var at)
? at.UtcDateTime
: DateTime.UtcNow;
var reblog = new PostEntity
{
ID = PrivacyIds.At(published),
ObjectURI = announceId,
ActivityURI = announceId,
ActorURI = actor.ActorURI,
AuthorAccountId = actor.ID,
ReblogOfPostId = original.ID,
Visibility = Addressing.Classify(to, cc, actor.FollowersURL),
To = to,
Cc = cc,
IsFederatedCopy = true,
CreationDate = published,
UpdateDate = published
};
if (reblog.Visibility == PostVisibility.Direct)
return;
try
{
await DB.Default.SaveAsync(reblog, token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return;
}
await DB.Default.Update<PostEntity>().MatchID(original.ID).Modify(b => b.Inc(p => p.ReblogsCount, 1)).ExecuteAsync(token);
if (!original.IsFederatedCopy)
await Notifications.Add(original.GroupUserId, NotificationType.Reblog, actor.ID, actor.ActorURI, original.ID, token);
await _fanout.Distribute(reblog, token);
}
static List<string> Strings(JsonNode node) => node switch
{
JsonArray array => array.Select(Id).Where(id => id != default).ToList(),
JsonNode single when Id(single) is { } id => new List<string> { id },
_ => new List<string>()
};
}
}
@@ -1,6 +1,7 @@
using MongoDB.Driver; using MongoDB.Driver;
using MongoDB.Entities; using MongoDB.Entities;
using PrivaPub.Domain.Timelines;
using PrivaPub.Federation.Actors; using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Moderation; using PrivaPub.Federation.Moderation;
using PrivaPub.Federation.Objects; using PrivaPub.Federation.Objects;
@@ -10,6 +11,7 @@ using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Federation; using PrivaPub.Models.Federation;
using PrivaPub.Models.Group; using PrivaPub.Models.Group;
using PrivaPub.Models.Post; using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Models.User; using PrivaPub.Models.User;
using PrivaPub.StaticServices; using PrivaPub.StaticServices;
@@ -29,10 +31,14 @@ namespace PrivaPub.Federation.Inbox.Handlers
readonly IRemoteActorService _remoteActors; readonly IRemoteActorService _remoteActors;
readonly IDeliveryService _delivery; readonly IDeliveryService _delivery;
readonly IDomainBlocks _domainBlocks; readonly IDomainBlocks _domainBlocks;
readonly IFanout _fanout;
readonly IRemotePosts _remotePosts;
public CreateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery, public CreateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
IDomainBlocks domainBlocks) IDomainBlocks domainBlocks, IFanout fanout, IRemotePosts remotePosts)
{ {
_fanout = fanout;
_remotePosts = remotePosts;
_dbEntities = dbEntities; _dbEntities = dbEntities;
_localActors = localActors; _localActors = localActors;
_remoteActors = remoteActors; _remoteActors = remoteActors;
@@ -86,21 +92,13 @@ namespace PrivaPub.Federation.Inbox.Handlers
group = default; group = default;
var repliesToLocal = parent is { IsFederatedCopy: false } && visibility != PostVisibility.Direct; var repliesToLocal = parent is { IsFederatedCopy: false } && visibility != PostVisibility.Direct;
if (visibility == PostVisibility.Direct ? persons.Count == 0 : group == default && persons.Count == 0 && !repliesToLocal) var followed = visibility != PostVisibility.Direct && await _dbEntities.Followings
.Match(f => f.TargetActorURI == author.ActorURI && f.State == FollowState.Accepted)
.ExecuteAnyAsync(token);
if (visibility == PostVisibility.Direct ? persons.Count == 0 : group == default && persons.Count == 0 && !repliesToLocal && !followed)
return; return;
if (parent == default && visibility != PostVisibility.Direct)
var mentions = new List<PostMention>(); parent = await _remotePosts.Parent(note.InReplyTo, 1, token);
foreach (var mention in note.Mentions)
{
var local = await _localActors.FindByUri(mention.ActorURI, token);
mentions.Add(new PostMention
{
ActorURI = mention.ActorURI,
Handle = mention.Handle,
IsLocal = local != default,
AccountId = local?.Id
});
}
DmGroup conversation = default; DmGroup conversation = default;
if (visibility == PostVisibility.Direct) if (visibility == PostVisibility.Direct)
@@ -112,39 +110,10 @@ namespace PrivaPub.Federation.Inbox.Handlers
conversation = await FindOrCreateConversation(participants, Origin.Same(note.Context, author.ActorURI) ? note.Context : default, token); conversation = await FindOrCreateConversation(participants, Origin.Same(note.Context, author.ActorURI) ? note.Context : default, token);
} }
var post = new PostEntity var post = await _remotePosts.Build(note, author, visibility, to, cc, parent, token);
{ post.ActivityURI = Id(activity);
ID = PrivacyIds.At(note.Published), post.GroupId = group?.Id;
ObjectURI = note.Id, post.ConversationId = conversation?.ID;
ActivityURI = Id(activity),
ActorURI = author.ActorURI,
AuthorAccountId = author.ID,
Visibility = visibility,
To = to,
Cc = cc,
Url = note.Url,
ContextURI = note.Context,
QuoteURI = note.QuoteUri,
GroupId = group?.Id,
ConversationId = conversation?.ID,
Title = note.Title,
SpoilerText = note.SpoilerText,
HasContentWarning = note.Sensitive,
Text = note.ContentHtml,
ContentHtml = note.ContentHtml,
ContentFormat = ContentFormat.Html,
Language = note.Language,
Mentions = mentions,
Tags = note.Tags.ToList(),
Media = _domainBlocks.Find(new Uri(author.ActorURI).Host)?.RejectMedia == true ? new() : note.Attachments.ToList(),
InReplyToURI = note.InReplyTo,
AnsweringToPostId = parent?.ID,
InReplyToAccountId = parent?.AuthorAccountId ?? parent?.GroupUserId,
IsFederatedCopy = true,
CreationDate = note.Published,
UpdateDate = note.Updated ?? note.Published,
EditedAt = note.Updated
};
try try
{ {
await DB.Default.SaveAsync(post, token); await DB.Default.SaveAsync(post, token);
@@ -156,6 +125,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
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);
await _fanout.Distribute(post, token);
if (conversation != default) if (conversation != default)
await DB.Default.Update<DmGroup>().MatchID(conversation.ID).Modify(g => g.UpdatedAt, DateTime.UtcNow).ExecuteAsync(token); await DB.Default.Update<DmGroup>().MatchID(conversation.ID).Modify(g => g.UpdatedAt, DateTime.UtcNow).ExecuteAsync(token);
if (group != default) if (group != default)
@@ -7,6 +7,7 @@ using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Federation; using PrivaPub.Models.Federation;
using PrivaPub.Models.Group; using PrivaPub.Models.Group;
using PrivaPub.Models.Post; using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Models.User; using PrivaPub.Models.User;
using PrivaPub.StaticServices; using PrivaPub.StaticServices;
@@ -56,11 +57,19 @@ namespace PrivaPub.Federation.Inbox.Handlers
if (follower.LocalActorKind == LocalActorKind.Group) if (follower.LocalActorKind == LocalActorKind.Group)
await RemoveForeignMember(follower.LocalActorId, actor.ActorURI, token); await RemoveForeignMember(follower.LocalActorId, actor.ActorURI, token);
} }
await DB.Default.DeleteAsync<Following>(f => f.TargetActorURI == actor.ActorURI);
await DB.Default.DeleteAsync<TimelineEntry>(e => e.AuthorAccountId == actor.ID);
return; return;
} }
await DB.Default.DeleteAsync<PostEntity>(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI); var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI).ExecuteFirstAsync(token);
return; if (post == default)
} return;
await DB.Default.DeleteAsync<PostEntity>(post.ID);
await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == post.ID || e.ReblogOfPostId == post.ID);
await DB.Default.DeleteAsync<PostEntity>(p => p.ReblogOfPostId == post.ID);
if (!string.IsNullOrEmpty(post.AnsweringToPostId))
await DB.Default.Update<PostEntity>().MatchID(post.AnsweringToPostId).Modify(b => b.Inc(p => p.RepliesCount, -1)).ExecuteAsync(token);
}
} }
} }
@@ -1,6 +1,8 @@
using MongoDB.Entities; using MongoDB.Entities;
using PrivaPub.Domain.Social;
using PrivaPub.Federation.Actors; using PrivaPub.Federation.Actors;
using PrivaPub.Models.Social;
using PrivaPub.Federation.Objects; using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Outbox; using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Rendering; using PrivaPub.Federation.Rendering;
@@ -60,6 +62,9 @@ namespace PrivaPub.Federation.Inbox.Handlers
record.IsAccepted = existing?.IsAccepted == true || !target.ManuallyApprovesFollowers; record.IsAccepted = existing?.IsAccepted == true || !target.ManuallyApprovesFollowers;
await DB.Default.SaveAsync(record, token); await DB.Default.SaveAsync(record, token);
if (target.Kind == LocalActorKind.Person)
await Notifications.Add(target.Id, record.IsAccepted ? NotificationType.Follow : NotificationType.FollowRequest,
follower.ID, follower.ActorURI, default, token);
if (!record.IsAccepted) if (!record.IsAccepted)
return; return;
@@ -0,0 +1,56 @@
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Domain.Social;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using static PrivaPub.Federation.Objects.ActivityJson;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Federation.Inbox.Handlers
{
public class LikeHandler : IActivityHandler
{
readonly DbEntities _dbEntities;
public LikeHandler(DbEntities dbEntities)
{
_dbEntities = dbEntities;
}
public string Type => "Like";
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
{
var objectUri = Id(activity["object"]);
var post = objectUri == default
? default
: await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token);
if (post == default || !MaySee(post, actor))
return;
try
{
await DB.Default.SaveAsync(new Favourite { AccountId = actor.ID, ActorURI = actor.ActorURI, PostId = post.ID, ActivityURI = Id(activity) }, token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return;
}
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(b => b.Inc(p => p.FavouritesCount, 1)).ExecuteAsync(token);
if (!post.IsFederatedCopy)
await Notifications.Add(post.GroupUserId, NotificationType.Favourite, actor.ID, actor.ActorURI, post.ID, token);
}
public static bool MaySee(PostEntity post, ForeignAvatar actor) =>
post.Visibility is PostVisibility.Public or PostVisibility.Unlisted
|| post.Visibility is PostVisibility.FollowersOnly or PostVisibility.Direct
&& (post.To.Contains(actor.ActorURI) || post.Cc.Contains(actor.ActorURI) || post.Mentions.Any(m => m.ActorURI == actor.ActorURI));
}
}
@@ -1,12 +1,8 @@
using MongoDB.Entities; using MongoDB.Entities;
using PrivaPub.Federation.Actors; using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Federation; using PrivaPub.Models.Federation;
using PrivaPub.Models.Group; using PrivaPub.Models.Social;
using PrivaPub.Models.Post;
using PrivaPub.Models.User; using PrivaPub.Models.User;
using PrivaPub.StaticServices; using PrivaPub.StaticServices;
@@ -23,38 +19,37 @@ namespace PrivaPub.Federation.Inbox.Handlers
{ {
readonly DbEntities _dbEntities; readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors; readonly ILocalActorService _localActors;
readonly IRemoteActorService _remoteActors;
readonly IDeliveryService _delivery;
public UndoHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery) public UndoHandler(DbEntities dbEntities, ILocalActorService localActors)
{ {
_dbEntities = dbEntities; _dbEntities = dbEntities;
_localActors = localActors; _localActors = localActors;
_remoteActors = remoteActors;
_delivery = delivery;
} }
public string Type => "Undo"; public string Type => "Undo";
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token) public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
{ {
var undo = activity; var inner = activity["object"];
var inner = undo["object"];
var innerType = inner is JsonObject ? Value(inner, "type") : default; var innerType = inner is JsonObject ? Value(inner, "type") : default;
var innerId = Id(inner); var innerId = Id(inner);
if (inner is JsonObject && Id(inner["actor"]) != actor.ActorURI) if (innerType is null or "Follow")
return; await UndoFollow(inner, innerId, actor, token);
if (innerType is not (null or "Follow")) if (innerType is null or "Like")
return; await UndoLike(inner, innerId, actor, token);
if (innerType is null or "Announce")
await UndoAnnounce(innerId, actor, token);
}
async Task UndoFollow(JsonNode inner, string innerId, ForeignAvatar actor, CancellationToken token)
{
var followers = await _dbEntities.Followers.Match(f => f.ActorURI == actor.ActorURI).ExecuteAsync(token); var followers = await _dbEntities.Followers.Match(f => f.ActorURI == actor.ActorURI).ExecuteAsync(token);
var targetUri = inner is JsonObject ? Id(inner["object"]) : default; var targetUri = inner is JsonObject ? Id(inner["object"]) : default;
var target = targetUri == default ? default : await _localActors.FindByUri(targetUri, token);
foreach (var follower in followers) foreach (var follower in followers)
{ {
var matchesActivity = innerId != default && follower.FollowActivityURI == innerId; var matchesActivity = innerId != default && follower.FollowActivityURI == innerId;
var target = targetUri == default ? default : await _localActors.FindByUri(targetUri, token);
var matchesTarget = target != default && target.Id == follower.LocalActorId && target.Kind == follower.LocalActorKind; var matchesTarget = target != default && target.Id == follower.LocalActorId && target.Kind == follower.LocalActorKind;
if (!matchesActivity && !matchesTarget) if (!matchesActivity && !matchesTarget)
continue; continue;
@@ -63,7 +58,38 @@ namespace PrivaPub.Federation.Inbox.Handlers
if (follower.LocalActorKind == LocalActorKind.Group) if (follower.LocalActorKind == LocalActorKind.Group)
await RemoveForeignMember(follower.LocalActorId, actor.ActorURI, token); await RemoveForeignMember(follower.LocalActorId, actor.ActorURI, token);
} }
return; }
}
async Task UndoLike(JsonNode inner, string innerId, ForeignAvatar actor, CancellationToken token)
{
var favourite = innerId == default
? default
: await _dbEntities.Favourites.Match(f => f.ActivityURI == innerId && f.ActorURI == actor.ActorURI).ExecuteFirstAsync(token);
if (favourite == default && inner is JsonObject && Id(inner["object"]) is { } objectUri)
{
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteFirstAsync(token);
favourite = post == default
? default
: await _dbEntities.Favourites.Match(f => f.PostId == post.ID && f.ActorURI == actor.ActorURI).ExecuteFirstAsync(token);
}
if (favourite == default)
return;
await DB.Default.DeleteAsync<Favourite>(favourite.ID);
await DB.Default.Update<PostEntity>().MatchID(favourite.PostId).Modify(b => b.Inc(p => p.FavouritesCount, -1)).ExecuteAsync(token);
}
async Task UndoAnnounce(string innerId, ForeignAvatar actor, CancellationToken token)
{
if (innerId == default)
return;
var reblog = await _dbEntities.Posts
.Match(p => p.ObjectURI == innerId && p.ActorURI == actor.ActorURI && p.ReblogOfPostId != null)
.ExecuteFirstAsync(token);
if (reblog == default)
return;
await DB.Default.DeleteAsync<PostEntity>(reblog.ID);
await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == reblog.ID);
await DB.Default.Update<PostEntity>().MatchID(reblog.ReblogOfPostId).Modify(b => b.Inc(p => p.ReblogsCount, -1)).ExecuteAsync(token);
}
} }
} }
+173
View File
@@ -0,0 +1,173 @@
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Moderation;
using PrivaPub.Federation.Objects;
using PrivaPub.Infrastructure.Ids;
using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.Post;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Text.Json;
using System.Text.Json.Nodes;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Federation.Inbox
{
public sealed record AncestorsPayload(string PostId, int Depth);
public interface IRemotePosts
{
Task<PostEntity> Build(NoteDocument note, ForeignAvatar author, PostVisibility visibility, IReadOnlyList<string> to, IReadOnlyList<string> cc,
PostEntity parent, CancellationToken token);
Task<PostEntity> Parent(string inReplyTo, int depth, CancellationToken token);
Task<PostEntity> StoreContext(string objectUri, int depth, CancellationToken token);
}
public class RemotePosts : IRemotePosts
{
public const int MaxDepth = 10;
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IRemoteActorService _remoteActors;
readonly IDomainBlocks _domainBlocks;
readonly IJobQueue _queue;
public RemotePosts(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDomainBlocks domainBlocks,
IJobQueue queue)
{
_dbEntities = dbEntities;
_localActors = localActors;
_remoteActors = remoteActors;
_domainBlocks = domainBlocks;
_queue = queue;
}
public async Task<PostEntity> Build(NoteDocument note, ForeignAvatar author, PostVisibility visibility, IReadOnlyList<string> to,
IReadOnlyList<string> cc, PostEntity parent, CancellationToken token)
{
var mentions = new List<PostMention>();
foreach (var mention in note.Mentions)
{
var local = await _localActors.FindByUri(mention.ActorURI, token);
mentions.Add(new PostMention { ActorURI = mention.ActorURI, Handle = mention.Handle, IsLocal = local != default, AccountId = local?.Id });
}
return new PostEntity
{
ID = PrivacyIds.At(note.Published),
ObjectURI = note.Id,
ActorURI = author.ActorURI,
AuthorAccountId = author.ID,
Visibility = visibility,
To = to.ToList(),
Cc = cc.ToList(),
Url = note.Url,
ContextURI = note.Context,
QuoteURI = note.QuoteUri,
Title = note.Title,
SpoilerText = note.SpoilerText,
HasContentWarning = note.Sensitive,
Text = note.ContentHtml,
ContentHtml = note.ContentHtml,
ContentFormat = ContentFormat.Html,
Language = note.Language,
Mentions = mentions,
Tags = note.Tags.ToList(),
Media = _domainBlocks.Find(new Uri(author.ActorURI).Host)?.RejectMedia == true ? new() : note.Attachments.ToList(),
InReplyToURI = note.InReplyTo,
AnsweringToPostId = parent?.ID,
InReplyToAccountId = parent?.AuthorAccountId ?? parent?.GroupUserId,
IsFederatedCopy = true,
CreationDate = note.Published,
UpdateDate = note.Updated ?? note.Published,
EditedAt = note.Updated
};
}
public async Task<PostEntity> Parent(string inReplyTo, int depth, CancellationToken token)
{
if (string.IsNullOrEmpty(inReplyTo))
return default;
var known = await _dbEntities.Posts.Match(p => p.ObjectURI == inReplyTo && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
if (known != default || inReplyTo.StartsWith(_localActors.BaseAddress + "/", StringComparison.OrdinalIgnoreCase))
return known;
return await StoreContext(inReplyTo, depth, token);
}
public async Task<PostEntity> StoreContext(string objectUri, int depth, CancellationToken token)
{
var existing = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteFirstAsync(token);
if (existing != default || depth > MaxDepth)
return existing;
using var fetched = await _remoteActors.FetchObject(objectUri, token);
var note = fetched == default ? default : NoteParser.Parse(JsonNode.Parse(fetched.Root.GetRawText()));
if (note == default || !Origin.Same(note.Id, note.AttributedTo))
return default;
var visibility = Addressing.Classify(note.To, note.Cc, default);
if (visibility is not (PostVisibility.Public or PostVisibility.Unlisted))
return default;
var author = await _remoteActors.GetActor(note.AttributedTo, refresh: false, token);
if (author == default)
return default;
var grandparent = string.IsNullOrEmpty(note.InReplyTo)
? default
: await _dbEntities.Posts.Match(p => p.ObjectURI == note.InReplyTo && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
var post = await Build(note, author, visibility, note.To, note.Cc, grandparent, token);
try
{
await DB.Default.SaveAsync(post, token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteFirstAsync(token);
}
if (grandparent == default && !string.IsNullOrEmpty(note.InReplyTo) && depth < MaxDepth)
await _queue.Enqueue(JobKind.FetchAncestors, JsonSerializer.Serialize(new AncestorsPayload(post.ID, depth + 1)), new Uri(note.InReplyTo).Host,
"ancestors|" + post.ID, token);
return post;
}
}
public class AncestorsJobHandler : IJobHandler
{
readonly DbEntities _dbEntities;
readonly IRemotePosts _remotePosts;
public AncestorsJobHandler(DbEntities dbEntities, IRemotePosts remotePosts)
{
_dbEntities = dbEntities;
_remotePosts = remotePosts;
}
public JobKind Kind => JobKind.FetchAncestors;
public int Concurrency => 1;
public int MaxAttempts => 3;
public int PerHostLimit => 1;
public async Task<JobOutcome> Handle(Job job, CancellationToken token)
{
var payload = JsonSerializer.Deserialize<AncestorsPayload>(job.Payload);
var child = await _dbEntities.Posts.MatchID(payload.PostId).ExecuteFirstAsync(token);
if (child == default || string.IsNullOrEmpty(child.InReplyToURI) || !string.IsNullOrEmpty(child.AnsweringToPostId))
return JobOutcome.Done;
var parent = await _remotePosts.Parent(child.InReplyToURI, payload.Depth, token);
if (parent == default)
return JobOutcome.Done;
await DB.Default.Update<PostEntity>().MatchID(child.ID)
.Modify(p => p.AnsweringToPostId, parent.ID)
.Modify(p => p.InReplyToAccountId, parent.AuthorAccountId ?? parent.GroupUserId)
.ExecuteAsync(token);
return JobOutcome.Done;
}
}
}
+1 -1
View File
@@ -166,7 +166,7 @@ namespace PrivaPub.Federation.Objects
: default; : default;
static int? Int(JsonNode node) => static int? Int(JsonNode node) =>
node is JsonValue value && value.TryGetValue<int>(out var number) && number is > 0 and < 100_000 ? number : default; node is JsonValue value && value.TryGetValue<int>(out var number) && number is > 0 and < 100_000 ? number : (int?)null;
static string Plain(string value, int max) static string Plain(string value, int max)
{ {
@@ -0,0 +1,95 @@
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Post;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Federation.Outbox
{
public interface IOutboxPublisher
{
Task<IReadOnlyList<string>> Audience(LocalActor author, PostEntity post, CancellationToken token);
Task Publish(LocalActor author, PostEntity post, JsonObject activity, CancellationToken token);
Task PublishProfile(LocalActor actor, CancellationToken token);
}
public class OutboxPublisher : IOutboxPublisher
{
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IDeliveryService _delivery;
public OutboxPublisher(DbEntities dbEntities, ILocalActorService localActors, IDeliveryService delivery)
{
_dbEntities = dbEntities;
_localActors = localActors;
_delivery = delivery;
}
public async Task<IReadOnlyList<string>> Audience(LocalActor author, PostEntity post, CancellationToken token)
{
if (post.Visibility is PostVisibility.Circle or PostVisibility.LocalGeo || post.IsLocalOnly)
return Array.Empty<string>();
var inboxes = new List<string>();
if (post.Visibility is PostVisibility.Public or PostVisibility.Unlisted or PostVisibility.FollowersOnly)
inboxes.AddRange(await _delivery.FollowerInboxes(author, token));
var addressed = post.Mentions.Where(m => !m.IsLocal).Select(m => m.ActorURI)
.Concat(post.Visibility == PostVisibility.Direct ? post.To.Concat(post.Cc) : Enumerable.Empty<string>())
.Where(uri => !uri.StartsWith(author.BaseAddress + "/", StringComparison.OrdinalIgnoreCase))
.Distinct(StringComparer.Ordinal)
.ToList();
foreach (var uri in addressed)
{
var actor = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == uri).ExecuteFirstAsync(token);
if (actor != default && !string.IsNullOrEmpty(actor.InboxURL))
inboxes.Add(actor.InboxURL);
}
if (post.Visibility != PostVisibility.Direct && !string.IsNullOrEmpty(post.InReplyToAccountId))
{
var parentAuthor = await _dbEntities.ForeignAvatars.MatchID(post.InReplyToAccountId).ExecuteFirstAsync(token);
if (parentAuthor != default && !string.IsNullOrEmpty(parentAuthor.InboxURL))
inboxes.Add(parentAuthor.InboxURL);
}
if (!string.IsNullOrEmpty(post.GroupId) && post.Visibility is PostVisibility.Public or PostVisibility.Unlisted)
{
var group = await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
if (group is { IsFederated: true })
inboxes.AddRange(await _delivery.FollowerInboxes(group, token));
}
return inboxes.Where(i => !string.IsNullOrEmpty(i)).Distinct(StringComparer.Ordinal).ToList();
}
public async Task Publish(LocalActor author, PostEntity post, JsonObject activity, CancellationToken token)
{
var inboxes = await Audience(author, post, token);
if (inboxes.Count > 0)
await _delivery.Enqueue(author, inboxes, activity, token);
}
public async Task PublishProfile(LocalActor actor, CancellationToken token)
{
var document = ActivityPubRenderer.Actor(actor);
document.Remove("@context");
var update = new JsonObject
{
["@context"] = ActivityPubRenderer.Context(),
["id"] = actor.ActivityUri($"update-profile-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}"),
["type"] = "Update",
["actor"] = actor.Uri,
["to"] = new JsonArray(ActivityPubRenderer.Public),
["cc"] = new JsonArray(actor.Followers),
["object"] = document
};
await _delivery.EnqueueToFollowers(actor, update, token);
}
}
}
@@ -65,6 +65,7 @@ namespace PrivaPub.Federation.Rendering
{ {
LocalActorKind.Group => "Group", LocalActorKind.Group => "Group",
LocalActorKind.Application => "Application", LocalActorKind.Application => "Application",
_ when actor.IsBot => "Service",
_ => "Person" _ => "Person"
}, },
["preferredUsername"] = actor.UserName, ["preferredUsername"] = actor.UserName,
@@ -78,7 +79,7 @@ namespace PrivaPub.Federation.Rendering
["published"] = Day(actor.Published), ["published"] = Day(actor.Published),
["manuallyApprovesFollowers"] = actor.ManuallyApprovesFollowers, ["manuallyApprovesFollowers"] = actor.ManuallyApprovesFollowers,
["discoverable"] = actor.Discoverable, ["discoverable"] = actor.Discoverable,
["indexable"] = false, ["indexable"] = actor.Indexable,
["webfinger"] = actor.Handle, ["webfinger"] = actor.Handle,
["endpoints"] = new JsonObject { ["sharedInbox"] = actor.SharedInbox }, ["endpoints"] = new JsonObject { ["sharedInbox"] = actor.SharedInbox },
["publicKey"] = new JsonObject ["publicKey"] = new JsonObject
@@ -190,7 +191,7 @@ namespace PrivaPub.Federation.Rendering
return "@" + (handle.Contains('@') ? handle : $"{handle}@{new Uri(mention.ActorURI).Authority}"); return "@" + (handle.Contains('@') ? handle : $"{handle}@{new Uri(mention.ActorURI).Authority}");
} }
static string FieldValue(string value) public static string FieldValue(string value)
{ {
var encoded = WebUtility.HtmlEncode(value ?? string.Empty); var encoded = WebUtility.HtmlEncode(value ?? string.Empty);
return Uri.TryCreate(value, UriKind.Absolute, out var link) && link.Scheme is "https" or "http" return Uri.TryCreate(value, UriKind.Absolute, out var link) && link.Scheme is "https" or "http"
+28
View File
@@ -6,6 +6,7 @@ using PrivaPub.Models.Federation;
using PrivaPub.Models.Group; using PrivaPub.Models.Group;
using PrivaPub.Models.Jobs; using PrivaPub.Models.Jobs;
using PrivaPub.Models.Post; using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Models.User; using PrivaPub.Models.User;
namespace PrivaPub.Infrastructure.Data namespace PrivaPub.Infrastructure.Data
@@ -59,6 +60,33 @@ namespace PrivaPub.Infrastructure.Data
.Key(j => j.FinishedAt, KeyType.Ascending) .Key(j => j.FinishedAt, KeyType.Ascending)
.Option(o => o.ExpireAfter = TimeSpan.FromDays(7)) .Option(o => o.ExpireAfter = TimeSpan.FromDays(7))
.CreateAsync(token); .CreateAsync(token);
await DB.Default.Index<Following>()
.Key(f => f.AvatarId, KeyType.Ascending)
.Key(f => f.TargetActorURI, KeyType.Ascending)
.Option(o => o.Unique = true)
.CreateAsync(token);
await Plain<Following>(token, f => f.TargetActorURI, f => f.State);
await Plain<Following>(token, f => f.TargetAccountId, f => f.State);
await Plain<Following>(token, f => f.FollowActivityURI);
await DB.Default.Index<TimelineEntry>()
.Key(e => e.AvatarId, KeyType.Ascending)
.Key(e => e.PostId, KeyType.Ascending)
.Option(o => o.Unique = true)
.CreateAsync(token);
await Plain<TimelineEntry>(token, e => e.PostId);
await Unique<Notification>(n => n.DedupeKey, Builders<Notification>.Filter.Type(n => n.DedupeKey, BsonType.String), token);
await Plain<Notification>(token, n => n.AvatarId, n => n.ID);
await DB.Default.Index<Favourite>()
.Key(f => f.AccountId, KeyType.Ascending)
.Key(f => f.PostId, KeyType.Ascending)
.Option(o => o.Unique = true)
.CreateAsync(token);
await Plain<Favourite>(token, f => f.ActivityURI);
await DB.Default.Index<Marker>()
.Key(m => m.AvatarId, KeyType.Ascending)
.Key(m => m.Timeline, KeyType.Ascending)
.Option(o => o.Unique = true)
.CreateAsync(token);
await Unique<DomainBlock>(b => b.Domain, Builders<DomainBlock>.Filter.Type(b => b.Domain, BsonType.String), token); await Unique<DomainBlock>(b => b.Domain, Builders<DomainBlock>.Filter.Type(b => b.Domain, BsonType.String), token);
await Unique<RemoteInstance>(i => i.Host, Builders<RemoteInstance>.Filter.Type(i => i.Host, BsonType.String), token); await Unique<RemoteInstance>(i => i.Host, Builders<RemoteInstance>.Filter.Type(i => i.Host, BsonType.String), token);
} }
+26 -4
View File
@@ -1,6 +1,8 @@
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.ResponseCompression; using Microsoft.AspNetCore.ResponseCompression;
using OpenIddict.Validation.AspNetCore;
using PrivaPub.ClientModels; using PrivaPub.ClientModels;
using PrivaPub.Extensions; using PrivaPub.Extensions;
using PrivaPub.Models; using PrivaPub.Models;
@@ -17,6 +19,9 @@ using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Moderation; using PrivaPub.Federation.Moderation;
using PrivaPub.Federation.Inbox.Handlers; using PrivaPub.Federation.Inbox.Handlers;
using PrivaPub.Domain.Content; using PrivaPub.Domain.Content;
using PrivaPub.Domain.Social;
using PrivaPub.Domain.Statuses;
using PrivaPub.Domain.Timelines;
using PrivaPub.Infrastructure.Http; using PrivaPub.Infrastructure.Http;
using PrivaPub.Infrastructure.Jobs; using PrivaPub.Infrastructure.Jobs;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -25,6 +30,8 @@ namespace PrivaPub.Middleware
{ {
public static class PrivaPubConfigurations public static class PrivaPubConfigurations
{ {
const string SchemeSelector = "PrivaPub";
public static IServiceCollection PrivaPubAppSettingsConfiguration(this IServiceCollection service, IConfiguration configuration) public static IServiceCollection PrivaPubAppSettingsConfiguration(this IServiceCollection service, IConfiguration configuration)
{ {
return service return service
@@ -56,13 +63,21 @@ namespace PrivaPub.Middleware
.AddSingleton<ILocalActorService, LocalActorService>() .AddSingleton<ILocalActorService, LocalActorService>()
.AddSingleton<IRemoteActorService, RemoteActorService>() .AddSingleton<IRemoteActorService, RemoteActorService>()
.AddSingleton<IDeliveryService, DeliveryService>() .AddSingleton<IDeliveryService, DeliveryService>()
.AddSingleton<IOutboxPublisher, OutboxPublisher>()
.AddSingleton<IFanout, Fanout>()
.AddSingleton<IInboxReceiver, InboxReceiver>() .AddSingleton<IInboxReceiver, InboxReceiver>()
.AddSingleton<IActivityHandler, FollowHandler>() .AddSingleton<IActivityHandler, FollowHandler>()
.AddSingleton<IActivityHandler, AcceptHandler>()
.AddSingleton<IActivityHandler, RejectHandler>()
.AddSingleton<IActivityHandler, UndoHandler>() .AddSingleton<IActivityHandler, UndoHandler>()
.AddSingleton<IActivityHandler, LikeHandler>()
.AddSingleton<IActivityHandler, AnnounceHandler>()
.AddSingleton<IActivityHandler, CreateHandler>() .AddSingleton<IActivityHandler, CreateHandler>()
.AddSingleton<IActivityHandler, DeleteHandler>() .AddSingleton<IActivityHandler, DeleteHandler>()
.AddSingleton<IActivityHandler, UpdateHandler>() .AddSingleton<IActivityHandler, UpdateHandler>()
.AddSingleton<IJobHandler, InboxProcessor>() .AddSingleton<IJobHandler, InboxProcessor>()
.AddSingleton<IRemotePosts, RemotePosts>()
.AddSingleton<IJobHandler, AncestorsJobHandler>()
.AddSingleton<IJobQueue, JobQueue>() .AddSingleton<IJobQueue, JobQueue>()
.AddSingleton<IHostCircuitBreaker, HostCircuitBreaker>() .AddSingleton<IHostCircuitBreaker, HostCircuitBreaker>()
.AddSingleton<IJobHandler, DeliveryJobHandler>() .AddSingleton<IJobHandler, DeliveryJobHandler>()
@@ -79,10 +94,14 @@ namespace PrivaPub.Middleware
}) })
.AddAuthentication(options => .AddAuthentication(options =>
{ {
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultAuthenticateScheme = SchemeSelector;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = SchemeSelector;
options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultScheme = SchemeSelector;
}) })
.AddPolicyScheme(SchemeSelector, SchemeSelector, options => options.ForwardDefaultSelector = context =>
context.Request.Path.StartsWithSegments("/api")
? OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme
: JwtBearerDefaults.AuthenticationScheme)
.AddPrivaPubAuth(configuration) .AddPrivaPubAuth(configuration)
.Services .Services
.AddSingleton<AuthTokenManager>() .AddSingleton<AuthTokenManager>()
@@ -118,6 +137,9 @@ namespace PrivaPub.Middleware
.AddTransient<IPrivateAvatarUsersService, PrivateAvatarUsersService>() .AddTransient<IPrivateAvatarUsersService, PrivateAvatarUsersService>()
.AddTransient<IGroupUsersService, GroupUsersService>() .AddTransient<IGroupUsersService, GroupUsersService>()
.AddTransient<IPostsService, PostsService>() .AddTransient<IPostsService, PostsService>()
.AddTransient<IStatusService, StatusService>()
.AddTransient<IFollowService, FollowService>()
.AddTransient<ITimelineService, TimelineService>()
.AddSingleton<AppConfigurationService>() .AddSingleton<AppConfigurationService>()
.AddHttpContextAccessor() .AddHttpContextAccessor()
.AddMemoryCache() .AddMemoryCache()
@@ -167,7 +189,7 @@ namespace PrivaPub.Middleware
configure.AllowAnyMethod() configure.AllowAnyMethod()
.AllowAnyHeader() .AllowAnyHeader()
.AllowAnyOrigin() .AllowAnyOrigin()
.AllowAnyMethod() .WithExposedHeaders("Link", "X-RateLimit-Remaining", "X-RateLimit-Reset")
.DisallowCredentials(); .DisallowCredentials();
}); });
}); });
+2 -1
View File
@@ -21,7 +21,8 @@ namespace PrivaPub.Models.Jobs
public enum JobKind public enum JobKind
{ {
Deliver, Deliver,
ProcessInbox ProcessInbox,
FetchAncestors
} }
public enum JobState public enum JobState
+2 -1
View File
@@ -3,6 +3,7 @@ namespace PrivaPub.Models.Post
public enum ContentFormat public enum ContentFormat
{ {
Markdown, Markdown,
Html Html,
Plain
} }
} }
+13
View File
@@ -0,0 +1,13 @@
using MongoDB.Entities;
namespace PrivaPub.Models.Social
{
public class Favourite : Entity
{
public string AccountId { get; set; }//Avatar.ID or ForeignAvatar.ID
public string ActorURI { get; set; }
public string PostId { get; set; }
public string ActivityURI { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
}
+23
View File
@@ -0,0 +1,23 @@
using MongoDB.Entities;
namespace PrivaPub.Models.Social
{
public class Following : Entity
{
public string AvatarId { get; set; }//the local follower
public string TargetActorURI { get; set; }
public string TargetAccountId { get; set; }//Avatar.ID, Group.ID or ForeignAvatar.ID
public bool TargetIsLocal { get; set; }
public string TargetInboxURL { get; set; }
public FollowState State { get; set; }
public string FollowActivityURI { get; set; }
public bool ShowReblogs { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
public enum FollowState
{
Requested,
Accepted
}
}
+13
View File
@@ -0,0 +1,13 @@
using MongoDB.Entities;
namespace PrivaPub.Models.Social
{
public class Marker : Entity
{
public string AvatarId { get; set; }
public string Timeline { get; set; }
public string LastReadId { get; set; }
public int Version { get; set; }
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
}
+26
View File
@@ -0,0 +1,26 @@
using MongoDB.Entities;
namespace PrivaPub.Models.Social
{
public class Notification : Entity
{
public string AvatarId { get; set; }
public NotificationType Type { get; set; }
public string FromAccountId { get; set; }
public string FromActorURI { get; set; }
public string PostId { get; set; }
public string DedupeKey { get; set; }
public bool IsRead { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
public enum NotificationType
{
Mention,
Follow,
FollowRequest,
Favourite,
Reblog,
Update
}
}
+12
View File
@@ -0,0 +1,12 @@
using MongoDB.Entities;
namespace PrivaPub.Models.Social
{
public class TimelineEntry : Entity
{
public string AvatarId { get; set; }
public string PostId { get; set; }//the post shown: a reblog row's id for a reblog
public string AuthorAccountId { get; set; }
public string ReblogOfPostId { get; set; }
}
}
+9
View File
@@ -93,6 +93,15 @@ namespace PrivaPub.Models.User
public bool ThemeIsDarkMode { get; set; } = false; public bool ThemeIsDarkMode { get; set; } = false;
public bool ThemeIsDarkGray { get; set; } = false; public bool ThemeIsDarkGray { get; set; } = false;
public bool ThemeIsLightGray { get; set; } = true; public bool ThemeIsLightGray { get; set; } = true;
public bool IsLocked { get; set; }
public bool IsBot { get; set; }
public bool IsDiscoverable { get; set; } = true;
public bool IsIndexable { get; set; }
public bool HideCollections { get; set; } = true;
public string DefaultVisibility { get; set; } = "public";
public bool DefaultSensitive { get; set; }
public string DefaultLanguage { get; set; }
} }
public enum AvatarServer public enum AvatarServer
+2
View File
@@ -12,6 +12,8 @@
<PackageReference Include="Markdig" Version="1.4.0" /> <PackageReference Include="Markdig" Version="1.4.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
<PackageReference Include="MongoDB.Entities" Version="25.1.0" /> <PackageReference Include="MongoDB.Entities" Version="25.1.0" />
<PackageReference Include="OpenIddict.AspNetCore" Version="7.7.1" />
<PackageReference Include="OpenIddict.MongoDb" Version="7.7.1" />
<PackageReference Include="PasswordGenerator" Version="3.0.0" /> <PackageReference Include="PasswordGenerator" Version="3.0.0" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" /> <PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.MongoDB" Version="7.3.0" /> <PackageReference Include="Serilog.Sinks.MongoDB" Version="7.3.0" />
+4
View File
@@ -11,6 +11,7 @@ using Serilog;
using PrivaPub.Data; using PrivaPub.Data;
using PrivaPub.Extensions; using PrivaPub.Extensions;
using PrivaPub.Api.Mastodon.Auth;
using PrivaPub.Infrastructure; using PrivaPub.Infrastructure;
using PrivaPub.Infrastructure.Cli; using PrivaPub.Infrastructure.Cli;
using PrivaPub.Infrastructure.Data; using PrivaPub.Infrastructure.Data;
@@ -54,6 +55,9 @@ try
.PrivaPubFederationConfiguration(builder.Configuration) .PrivaPubFederationConfiguration(builder.Configuration)
.PrivaPubCORSConfiguration() .PrivaPubCORSConfiguration()
.PrivaPubRateLimiting() .PrivaPubRateLimiting()
.PrivaPubOAuth(builder.Environment)
.AddScoped<PrivaPub.Api.Mastodon.Mappers.MastodonMapper>()
.AddScoped<PrivaPub.Api.Mastodon.Mappers.AccountSearch>()
.PrivaPubMiddlewareConfiguration(); .PrivaPubMiddlewareConfiguration();
} }
catch (Exception ex) catch (Exception ex)
@@ -10,6 +10,7 @@ using PrivaPub.Models.User;
using PrivaPub.Resources; using PrivaPub.Resources;
using PrivaPub.StaticServices; using PrivaPub.StaticServices;
using PrivaPub.Federation.Actors; using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Outbox;
namespace PrivaPub.Services.ClientToServer.Private namespace PrivaPub.Services.ClientToServer.Private
{ {
@@ -23,16 +24,19 @@ namespace PrivaPub.Services.ClientToServer.Private
public class PrivateAvatarUsersService : IPrivateAvatarUsersService public class PrivateAvatarUsersService : IPrivateAvatarUsersService
{ {
readonly ILocalActorService _localActors; readonly ILocalActorService _localActors;
readonly IOutboxPublisher _outbox;
readonly DbEntities _dbEntities; readonly DbEntities _dbEntities;
readonly IStringLocalizer<GenericRes> _localizer; readonly IStringLocalizer<GenericRes> _localizer;
readonly ILogger<PrivateAvatarUsersService> _logger; readonly ILogger<PrivateAvatarUsersService> _logger;
public PrivateAvatarUsersService(ILocalActorService localActors, public PrivateAvatarUsersService(ILocalActorService localActors,
IOutboxPublisher outbox,
DbEntities dbEntities, DbEntities dbEntities,
IStringLocalizer<GenericRes> localizer, IStringLocalizer<GenericRes> localizer,
ILogger<PrivateAvatarUsersService> logger) ILogger<PrivateAvatarUsersService> logger)
{ {
_localActors = localActors; _localActors = localActors;
_outbox = outbox;
_dbEntities = dbEntities; _dbEntities = dbEntities;
_localizer = localizer; _localizer = localizer;
_logger = logger; _logger = logger;
@@ -115,6 +119,7 @@ namespace PrivaPub.Services.ClientToServer.Private
avatar.Settings = ToSettings(form.Settings); avatar.Settings = ToSettings(form.Settings);
avatar.UpdatedAt = DateTime.UtcNow; avatar.UpdatedAt = DateTime.UtcNow;
await DB.Default.SaveAsync(avatar); await DB.Default.SaveAsync(avatar);
await _outbox.PublishProfile(_localActors.FromAvatar(avatar), default);
result.Data = ToView(avatar); result.Data = ToView(avatar);
return result; return result;
+2 -2
View File
@@ -19,7 +19,7 @@ namespace PrivaPub.Services
try try
{ {
var localizer = context.HttpContext.RequestServices.GetRequiredService<IStringLocalizer<GenericRes>>(); var localizer = context.HttpContext.RequestServices.GetRequiredService<IStringLocalizer<GenericRes>>();
var webResult = new WebResult().Invalidate(localizer["Unauthorized: {0}", context.Exception], StatusCodes.Status401Unauthorized); var webResult = new WebResult().Invalidate(localizer["Unauthorized."], StatusCodes.Status401Unauthorized);
context.Response.ContentType = contentType; context.Response.ContentType = contentType;
await context.Response.BodyWriter.WriteAsync(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(webResult))); await context.Response.BodyWriter.WriteAsync(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(webResult)));
} }
@@ -35,7 +35,7 @@ namespace PrivaPub.Services
try try
{ {
var localizer = context.HttpContext.RequestServices.GetRequiredService<IStringLocalizer<GenericRes>>(); var localizer = context.HttpContext.RequestServices.GetRequiredService<IStringLocalizer<GenericRes>>();
var webResult = new WebResult().Invalidate(localizer["Forbidden: {0}", context.Result.None ? "N/A" : context.Result.Failure?.Message ?? "N/A"], StatusCodes.Status403Forbidden); var webResult = new WebResult().Invalidate(localizer["Forbidden."], StatusCodes.Status403Forbidden);
context.Response.ContentType = contentType; context.Response.ContentType = contentType;
await context.Response.BodyWriter.WriteAsync(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(webResult))); await context.Response.BodyWriter.WriteAsync(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(webResult)));
} }
+70 -250
View File
@@ -7,13 +7,15 @@ using PrivaPub.ClientModels.Post;
using PrivaPub.Models.Federation; using PrivaPub.Models.Federation;
using PrivaPub.Models.Group; using PrivaPub.Models.Group;
using PrivaPub.Models.Post; using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Resources; using PrivaPub.Resources;
using PrivaPub.StaticServices; using PrivaPub.StaticServices;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using PostEntity = PrivaPub.Models.Post.Post; using PostEntity = PrivaPub.Models.Post.Post;
using PrivaPub.Domain.Content; using PrivaPub.Domain.Statuses;
using PrivaPub.Domain.Timelines;
using PrivaPub.Federation.Actors; using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Rendering; using PrivaPub.Federation.Rendering;
using PrivaPub.Federation.Outbox; using PrivaPub.Federation.Outbox;
@@ -24,6 +26,7 @@ namespace PrivaPub.Services
{ {
Task<WebResult> InsertPost(string rootUserId, InsertPostForm form, CancellationToken token); Task<WebResult> InsertPost(string rootUserId, InsertPostForm form, CancellationToken token);
Task<WebResult> DeletePost(string rootUserId, DeletePostForm form, CancellationToken token); Task<WebResult> DeletePost(string rootUserId, DeletePostForm form, CancellationToken token);
Task<WebResult> UpdatePost(string rootUserId, UpdatePostForm form, CancellationToken token);
Task<WebResult> GetPosts(string rootUserId, string avatarId, string groupId, CancellationToken token); Task<WebResult> GetPosts(string rootUserId, string avatarId, string groupId, CancellationToken token);
Task<WebResult> InsertDm(string rootUserId, InsertDmForm form, CancellationToken token); Task<WebResult> InsertDm(string rootUserId, InsertDmForm form, CancellationToken token);
Task<WebResult> GetDms(string rootUserId, string avatarId, string dmGroupId, CancellationToken token); Task<WebResult> GetDms(string rootUserId, string avatarId, string dmGroupId, CancellationToken token);
@@ -35,159 +38,62 @@ namespace PrivaPub.Services
readonly DbEntities _dbEntities; readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors; readonly ILocalActorService _localActors;
readonly IRemoteActorService _remoteActors; readonly IStatusService _statuses;
readonly IDeliveryService _delivery;
readonly IContentRenderer _content;
readonly IStringLocalizer<GenericRes> _localizer; readonly IStringLocalizer<GenericRes> _localizer;
readonly ILogger<PostsService> _logger; readonly ILogger<PostsService> _logger;
public PostsService(DbEntities dbEntities, public PostsService(DbEntities dbEntities,
ILocalActorService localActors, ILocalActorService localActors,
IRemoteActorService remoteActors, IStatusService statuses,
IDeliveryService delivery,
IContentRenderer content,
IStringLocalizer<GenericRes> localizer, IStringLocalizer<GenericRes> localizer,
ILogger<PostsService> logger) ILogger<PostsService> logger)
{ {
_dbEntities = dbEntities; _dbEntities = dbEntities;
_localActors = localActors; _localActors = localActors;
_remoteActors = remoteActors; _statuses = statuses;
_delivery = delivery;
_content = content;
_localizer = localizer; _localizer = localizer;
_logger = logger; _logger = logger;
} }
public async Task<WebResult> InsertPost(string rootUserId, InsertPostForm form, CancellationToken token) public async Task<WebResult> InsertPost(string rootUserId, InsertPostForm form, CancellationToken token)
{ {
var result = new WebResult(); var author = await OwnedAvatar(rootUserId, form.AvatarId, token);
try if (author == default)
return new WebResult().Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
return await Answer(() => _statuses.Publish(author, new StatusDraft
{ {
var author = await OwnedAvatar(rootUserId, form.AvatarId, token); Text = form.Text,
if (author == default) Title = form.Title,
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound); SpoilerText = form.SpoilerText,
Sensitive = form.HasContentWarning,
LocalActor group = default; Visibility = LocalVisibility(form.Visibility),
var isLocalOnly = false; InReplyTo = form.AnsweringToPostId,
if (!string.IsNullOrEmpty(form.GroupId)) GroupId = form.GroupId
{ }, token), nameof(InsertPost));
var groupEntity = await _dbEntities.Groups.MatchID(form.GroupId).ExecuteFirstAsync(token);
if (groupEntity == default || groupEntity.DeletionAt.HasValue
|| !groupEntity.Members.Any(m => !m.IsForeign && m.AvatarId == form.AvatarId))
return result.Invalidate(_localizer["Group not found."], StatusCodes.Status404NotFound);
group = _localActors.FromGroup(groupEntity);
isLocalOnly = !group.IsFederated;
}
var parent = await Parent(form.AnsweringToPostId, token);
var rendered = await _content.Markdown(form.Text, token);
var post = new PostEntity
{
GroupUserId = author.Id,
AuthorAccountId = author.Id,
GroupId = group?.Id,
Visibility = isLocalOnly ? PostVisibility.Circle : PostVisibility.Public,
Title = form.Title,
Text = form.Text,
ContentHtml = rendered.Html,
ContentFormat = ContentFormat.Markdown,
Mentions = rendered.Mentions.Select(ToMention).ToList(),
Tags = rendered.Tags.ToList(),
HasContentWarning = form.HasContentWarning,
AnsweringToPostId = parent?.ID,
InReplyToURI = parent?.ObjectURI ?? RemoteUri(form.AnsweringToPostId),
InReplyToAccountId = parent?.AuthorAccountId ?? parent?.GroupUserId,
IsLocalOnly = isLocalOnly,
ActorURI = author.Uri
};
post.ID = (string)post.GenerateNewID();
post.ObjectURI = author.PostUri(post.ID);
post.Url = author.PostHtmlUrl(post.ID);
if (isLocalOnly)
{
await DB.Default.SaveAsync(post, token);
result.Data = ToView(post);
return result;
}
var note = ActivityPubRenderer.Note(post, author, group, post.InReplyToURI);
var create = ActivityPubRenderer.Create(author, note, $"create-{post.ID}");
post.ActivityURI = create["id"]!.GetValue<string>();
post.To = Strings(note["to"]);
post.Cc = Strings(note["cc"]);
await DB.Default.SaveAsync(post, token);
if (parent != default)
await DB.Default.Update<PostEntity>().MatchID(parent.ID).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token);
var addressedInboxes = rendered.Mentions.Where(m => !m.IsLocal).Select(m => m.Inbox).ToList();
if (parent is { IsFederatedCopy: true } && !string.IsNullOrEmpty(parent.AuthorAccountId))
addressedInboxes.Add((await _dbEntities.ForeignAvatars.MatchID(parent.AuthorAccountId).ExecuteFirstAsync(token))?.InboxURL);
await _delivery.EnqueueToFollowers(author, create, token, addressedInboxes);
if (group != default)
await _delivery.EnqueueToFollowers(group, ActivityPubRenderer.Announce(group, post.ObjectURI, $"announce-{post.ID}"), token);
result.Data = ToView(post);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, $"{nameof(PostsService)}.{nameof(InsertPost)}");
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
}
} }
public async Task<WebResult> DeletePost(string rootUserId, DeletePostForm form, CancellationToken token) public async Task<WebResult> DeletePost(string rootUserId, DeletePostForm form, CancellationToken token)
{ {
var result = new WebResult(); var author = await OwnedAvatar(rootUserId, form.AvatarId, token);
try if (author == default)
return new WebResult().Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
var result = await Answer(() => _statuses.Remove(author, form.PostId, token), nameof(DeletePost));
result.Data = default;
return result;
}
public async Task<WebResult> UpdatePost(string rootUserId, UpdatePostForm form, CancellationToken token)
{
var author = await OwnedAvatar(rootUserId, form.AvatarId, token);
if (author == default)
return new WebResult().Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
return await Answer(() => _statuses.Edit(author, form.PostId, new StatusDraft
{ {
var author = await OwnedAvatar(rootUserId, form.AvatarId, token); Text = form.Text,
if (author == default) Title = form.Title,
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound); SpoilerText = form.SpoilerText,
Sensitive = form.HasContentWarning
var post = await _dbEntities.Posts }, token), nameof(UpdatePost));
.Match(p => p.ID == form.PostId && p.GroupUserId == author.Id && !p.IsFederatedCopy)
.ExecuteFirstAsync(token);
if (post == default)
return result.Invalidate(_localizer["Post not found."], StatusCodes.Status404NotFound);
await DB.Default.DeleteAsync<PostEntity>(post.ID);
if (post.IsLocalOnly)
return result;
if (post.Visibility == PostVisibility.Direct)
{
var recipients = new List<string>();
foreach (var uri in post.To.Concat(post.Cc).Distinct())
{
var foreign = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == uri).ExecuteFirstAsync(token);
if (foreign != default)
recipients.Add(foreign.InboxURL);
}
var directDelete = ActivityPubRenderer.Delete(author, post.ObjectURI, $"delete-{post.ID}",
new JsonArray(post.To.Select(t => (JsonNode)t).ToArray()), new JsonArray());
await _delivery.Enqueue(author, recipients, directDelete, token);
return result;
}
var delete = ActivityPubRenderer.Delete(author, post.ObjectURI, $"delete-{post.ID}",
new JsonArray(ActivityPubRenderer.Public), new JsonArray(author.Followers));
var extraInboxes = Enumerable.Empty<string>();
if (!string.IsNullOrEmpty(post.GroupId))
{
var group = await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
if (group != default)
extraInboxes = await _delivery.FollowerInboxes(group, token);
}
await _delivery.EnqueueToFollowers(author, delete, token, extraInboxes);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, $"{nameof(PostsService)}.{nameof(DeletePost)}");
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
}
} }
public async Task<WebResult> GetPosts(string rootUserId, string avatarId, string groupId, CancellationToken token) public async Task<WebResult> GetPosts(string rootUserId, string avatarId, string groupId, CancellationToken token)
@@ -225,96 +131,19 @@ namespace PrivaPub.Services
public async Task<WebResult> InsertDm(string rootUserId, InsertDmForm form, CancellationToken token) public async Task<WebResult> InsertDm(string rootUserId, InsertDmForm form, CancellationToken token)
{ {
var result = new WebResult(); var author = await OwnedAvatar(rootUserId, form.AvatarId, token);
try if (author == default)
return new WebResult().Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
if (string.IsNullOrEmpty(form.DmGroupId) && form.Recipients.Count == 0)
return new WebResult().Invalidate(_localizer["At least one recipient is required."]);
return await Answer(() => _statuses.Publish(author, new StatusDraft
{ {
var author = await OwnedAvatar(rootUserId, form.AvatarId, token); Text = form.Text,
if (author == default) Sensitive = form.HasContentWarning,
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound); Visibility = PostVisibility.Direct,
ConversationId = form.DmGroupId,
DmGroup dmGroup; Recipients = form.Recipients
if (!string.IsNullOrEmpty(form.DmGroupId)) }, token), nameof(InsertDm));
{
dmGroup = await _dbEntities.DmGroups.MatchID(form.DmGroupId).ExecuteFirstAsync(token);
if (dmGroup == default || !dmGroup.Members.Any(m => !m.IsForeign && m.AvatarId == author.Id))
return result.Invalidate(_localizer["Conversation not found."], StatusCodes.Status404NotFound);
}
else
{
if (form.Recipients.Count == 0)
return result.Invalidate(_localizer["At least one recipient is required."]);
var members = new List<GroupMember> { new() { AvatarId = author.Id } };
foreach (var recipient in form.Recipients.Distinct(StringComparer.OrdinalIgnoreCase))
{
var member = await ResolveRecipient(recipient, token);
if (member == default)
return result.Invalidate(_localizer["Recipient '{0}' not found.", recipient], StatusCodes.Status404NotFound);
if (members.All(m => m.AvatarId != member.AvatarId))
members.Add(member);
}
var key = DmGroup.KeyOf(members);
dmGroup = await _dbEntities.DmGroups.Match(g => g.ParticipantsKey == key && !g.DeletionAt.HasValue).ExecuteFirstAsync(token);
if (dmGroup == default)
{
dmGroup = new DmGroup { Members = members, ParticipantsKey = key };
dmGroup.ID = (string)dmGroup.GenerateNewID();
dmGroup.ConversationURI = author.ConversationUri(dmGroup.ID);
await DB.Default.SaveAsync(dmGroup, token);
}
}
var dm = new PostEntity
{
GroupUserId = author.Id,
AuthorAccountId = author.Id,
ConversationId = dmGroup.ID,
Visibility = PostVisibility.Direct,
ContextURI = dmGroup.ConversationURI,
Text = form.Text,
ContentHtml = (await _content.Markdown(form.Text, token)).Html,
ContentFormat = ContentFormat.Markdown,
HasContentWarning = form.HasContentWarning,
ActorURI = author.Uri
};
dm.ID = (string)dm.GenerateNewID();
dm.ObjectURI = author.PostUri(dm.ID);
dm.Url = author.PostHtmlUrl(dm.ID);
var remote = new List<(string Uri, string Handle)>();
var inboxes = new List<string>();
foreach (var member in dmGroup.Members.Where(m => m.IsForeign))
{
var foreign = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == member.AvatarId).ExecuteFirstAsync(token);
if (foreign == default)
continue;
remote.Add((foreign.ActorURI, $"{foreign.UserName}@{foreign.Domain}"));
inboxes.Add(foreign.InboxURL);
}
foreach (var member in dmGroup.Members.Where(m => !m.IsForeign && m.AvatarId != author.Id))
{
var local = await _localActors.FindById(LocalActorKind.Person, member.AvatarId, token);
if (local != default)
remote.Add((local.Uri, local.Handle));
}
var directNote = ActivityPubRenderer.DirectNote(dm, author, remote, dmGroup.ConversationURI);
var directCreate = ActivityPubRenderer.Create(author, directNote, $"create-{dm.ID}");
dm.ActivityURI = directCreate["id"]!.GetValue<string>();
dm.To = Strings(directNote["to"]);
dm.Mentions = remote.Select(r => new PostMention { ActorURI = r.Uri, Handle = "@" + r.Handle, IsLocal = r.Uri.StartsWith(author.BaseAddress + "/") }).ToList();
await DB.Default.SaveAsync(dm, token);
await DB.Default.Update<DmGroup>().MatchID(dmGroup.ID).Modify(g => g.UpdatedAt, DateTime.UtcNow).ExecuteAsync(token);
if (inboxes.Count > 0)
await _delivery.Enqueue(author, inboxes, directCreate, token);
result.Data = ToView(dm);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, $"{nameof(PostsService)}.{nameof(InsertDm)}");
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
}
} }
public async Task<WebResult> GetDms(string rootUserId, string avatarId, string dmGroupId, CancellationToken token) public async Task<WebResult> GetDms(string rootUserId, string avatarId, string dmGroupId, CancellationToken token)
@@ -362,44 +191,35 @@ namespace PrivaPub.Services
} }
} }
async Task<GroupMember> ResolveRecipient(string recipient, CancellationToken token)
async Task<WebResult> Answer(Func<Task<StatusOutcome>> action, string operation)
{ {
var handle = recipient.Trim().TrimStart('@'); var result = new WebResult();
if (!handle.Contains('@') || handle.EndsWith("@" + new Uri(_localActors.BaseAddress).Authority, StringComparison.OrdinalIgnoreCase)) try
{ {
var local = await _localActors.FindByUserName(handle.Split('@')[0], token); var outcome = await action();
return local is { Kind: LocalActorKind.Person } ? new GroupMember { AvatarId = local.Id } : default; if (!outcome.Ok)
return result.Invalidate(_localizer[outcome.Error], outcome.Status);
result.Data = ToView(outcome.Post);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "{Service}.{Operation}", nameof(PostsService), operation);
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
} }
var actorUri = await _remoteActors.ResolveHandle(handle, token);
if (actorUri == default)
return default;
var foreign = await _remoteActors.GetActor(actorUri, refresh: false, token);
return foreign == default ? default : new GroupMember { AvatarId = foreign.ActorURI, IsForeign = true };
} }
async Task<PostEntity> Parent(string answeringTo, CancellationToken token) static PostVisibility LocalVisibility(string requested) => requested?.ToLowerInvariant() switch
{ {
if (string.IsNullOrEmpty(answeringTo)) "unlisted" => PostVisibility.Unlisted,
return default; "followersonly" or "private" => PostVisibility.FollowersOnly,
return answeringTo.StartsWith("https://", StringComparison.OrdinalIgnoreCase) _ => PostVisibility.Public
? await _dbEntities.Posts.Match(p => p.ObjectURI == answeringTo).ExecuteFirstAsync(token)
: await _dbEntities.Posts.MatchID(answeringTo).ExecuteFirstAsync(token);
}
static PostMention ToMention(ResolvedMention mention) => new()
{
ActorURI = mention.ActorUri,
Handle = "@" + mention.Handle,
IsLocal = mention.IsLocal,
AccountId = mention.AccountId
}; };
static string RemoteUri(string answeringTo) =>
answeringTo != default && answeringTo.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ? answeringTo : default;
static List<string> Strings(JsonNode node) =>
node is JsonArray array ? array.Select(n => n?.GetValue<string>()).Where(s => s != default).ToList() : new List<string>();
async Task<LocalActor> OwnedAvatar(string rootUserId, string avatarId, CancellationToken token) async Task<LocalActor> OwnedAvatar(string rootUserId, string avatarId, CancellationToken token)
{ {
+6
View File
@@ -5,6 +5,7 @@ using PrivaPub.Models.Data;
using PrivaPub.Models.Federation; using PrivaPub.Models.Federation;
using PrivaPub.Models.Group; using PrivaPub.Models.Group;
using PrivaPub.Models.Post; using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Models.User; using PrivaPub.Models.User;
namespace PrivaPub.StaticServices namespace PrivaPub.StaticServices
@@ -32,5 +33,10 @@ namespace PrivaPub.StaticServices
public Find<Follower> Followers { get { return DB.Default.Find<Follower>(); } } public Find<Follower> Followers { get { return DB.Default.Find<Follower>(); } }
public Find<Delivery> Deliveries { get { return DB.Default.Find<Delivery>(); } } public Find<Delivery> Deliveries { get { return DB.Default.Find<Delivery>(); } }
public Find<InstanceActor> InstanceActors { get { return DB.Default.Find<InstanceActor>(); } } public Find<InstanceActor> InstanceActors { get { return DB.Default.Find<InstanceActor>(); } }
public Find<Following> Followings { get { return DB.Default.Find<Following>(); } }
public Find<TimelineEntry> TimelineEntries { get { return DB.Default.Find<TimelineEntry>(); } }
public Find<Notification> Notifications { get { return DB.Default.Find<Notification>(); } }
public Find<Favourite> Favourites { get { return DB.Default.Find<Favourite>(); } }
} }
} }
+29
View File
@@ -0,0 +1,29 @@
@page "/oauth/authorize"
@model PrivaPub.Web.Pages.OAuth.AuthorizeModel
@{
ViewData["Title"] = "Authorize";
}
<header><h1>Authorize @Model.ApplicationName</h1></header>
<p>It asks for: <strong>@string.Join(", ", Model.RequestedScopes)</strong>.</p>
@if (Model.Avatars.Count == 0)
{
<p role="alert">This login has no persona yet. Create one first.</p>
}
else
{
<form method="post">
@foreach (var parameter in Model.Parameters)
{
<input type="hidden" name="@parameter.Key" value="@parameter.Value">
}
<fieldset>
<legend>Act as</legend>
@foreach (var avatar in Model.Avatars)
{
<p><label><input type="radio" name="avatarId" value="@avatar.Id" required @(avatar == Model.Avatars[0] ? "checked" : "")> @avatar.Name <span class="meta">@@@avatar.Handle</span></label></p>
}
</fieldset>
<p class="meta">Each persona is a separate account to the application. Choosing one does not reveal the others.</p>
<p><button type="submit" name="decision" value="allow">Allow</button> <button type="submit" name="decision" value="deny">Deny</button></p>
</form>
}
+18
View File
@@ -0,0 +1,18 @@
@page "/oauth/login"
@model PrivaPub.Web.Pages.OAuth.LoginModel
@{
ViewData["Title"] = "Sign in";
}
<header><h1>Sign in to PrivaPub</h1></header>
<p class="meta">Sign in with your private login. You will then choose which persona the application acts as; the
application never sees the login.</p>
<form method="post">
<input type="hidden" name="ReturnUrl" value="@Model.ReturnUrl">
<p><label>Username<br><input name="UserName" autocomplete="username" required autofocus></label></p>
<p><label>Password<br><input name="Password" type="password" autocomplete="current-password" required></label></p>
@if (Model.Error != default)
{
<p role="alert">@Model.Error</p>
}
<p><button type="submit">Sign in</button></p>
</form>
+165
View File
@@ -0,0 +1,165 @@
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.IdentityModel.Tokens;
using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore;
using PrivaPub.Api.Mastodon.Auth;
using PrivaPub.ClientModels;
using PrivaPub.Federation.Actors;
using PrivaPub.Infrastructure;
using PrivaPub.Models.Federation;
using PrivaPub.Models.User;
using PrivaPub.Services;
using PrivaPub.StaticServices;
using System.Security.Claims;
using static OpenIddict.Abstractions.OpenIddictConstants;
namespace PrivaPub.Web.Pages.OAuth
{
[EnableRateLimiting(RateLimiting.Accounts)]
public class LoginModel : PageModel
{
readonly IRootUsersService _users;
public LoginModel(IRootUsersService users)
{
_users = users;
}
[BindProperty(SupportsGet = true)]
public string ReturnUrl { get; set; }
public string Error { get; private set; }
public void OnGet() => Harden();
public async Task<IActionResult> OnPostAsync([FromForm] string userName, [FromForm] string password)
{
Harden();
if (string.IsNullOrEmpty(userName) || string.IsNullOrEmpty(password))
{
Error = "Enter your username and password.";
return Page();
}
var result = await _users.LoginAsync(new LoginForm { UserName = userName, Password = password });
if (!result.IsValid)
{
Error = "That username and password do not match.";
return Page();
}
var (root, _) = ((RootUser, ClientModels.User.ViewUserSettings))result.Data;
var identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, root.ID) }, OAuthSetup.LoginScheme);
await HttpContext.SignInAsync(OAuthSetup.LoginScheme, new ClaimsPrincipal(identity));
var target = ReturnUrl?.StartsWith("/oauth/authorize?", StringComparison.Ordinal) == true ? ReturnUrl : "/oauth/authorize";
return LocalRedirect(target + (target.Contains('?') ? "&" : "?") + "signed_in=1");
}
void Harden()
{
Response.Headers["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'";
Response.Headers["Cache-Control"] = "no-store";
}
}
[IgnoreAntiforgeryToken]
public class AuthorizeModel : PageModel
{
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly OpenIddict.Abstractions.IOpenIddictApplicationManager _applications;
public AuthorizeModel(DbEntities dbEntities, ILocalActorService localActors, OpenIddict.Abstractions.IOpenIddictApplicationManager applications)
{
_dbEntities = dbEntities;
_localActors = localActors;
_applications = applications;
}
public string ApplicationName { get; private set; }
public IReadOnlyList<string> RequestedScopes { get; private set; } = Array.Empty<string>();
public IReadOnlyList<LocalActor> Avatars { get; private set; } = Array.Empty<LocalActor>();
public IReadOnlyList<KeyValuePair<string, string>> Parameters { get; private set; } = Array.Empty<KeyValuePair<string, string>>();
public async Task<IActionResult> OnGetAsync(CancellationToken token) => await Show(token);
public async Task<IActionResult> OnPostAsync([FromForm] string avatarId, [FromForm] string decision, CancellationToken token)
{
var request = HttpContext.GetOpenIddictServerRequest();
var rootId = await SignedInRoot();
if (request == default || rootId == default)
return await Show(token);
if (decision != "allow")
return Forbid(new AuthenticationProperties(new Dictionary<string, string>
{
[OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.AccessDenied,
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user denied the request."
}), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
var owns = !string.IsNullOrEmpty(avatarId)
&& await _dbEntities.RootToAvatars.Match(r => r.RootId == rootId && r.AvatarId == avatarId).ExecuteAnyAsync(token);
var avatar = owns ? await _localActors.FindById(LocalActorKind.Person, avatarId, token) : default;
if (avatar == default)
return await Show(token);
var identity = new ClaimsIdentity(TokenValidationParameters.DefaultAuthenticationType, Claims.Name, Claims.Role);
identity.SetClaim(Claims.Subject, avatar.Id);
identity.SetClaim(Claims.Name, avatar.UserName);
identity.SetScopes(MastodonScopes.Parse(request.Scope));
identity.SetDestinations(_ => new[] { Destinations.AccessToken });
await HttpContext.SignOutAsync(OAuthSetup.LoginScheme);
return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
async Task<IActionResult> Show(CancellationToken token)
{
var request = HttpContext.GetOpenIddictServerRequest();
if (request == default)
return BadRequest();
var rootId = await SignedInRoot();
var forceLogin = string.Equals((string)request["force_login"], "true", StringComparison.OrdinalIgnoreCase)
&& Request.Query["signed_in"] != "1";
if (rootId == default || forceLogin)
{
var query = string.Join("&", Request.Query.Where(q => q.Key != "signed_in").Select(q => $"{Uri.EscapeDataString(q.Key)}={Uri.EscapeDataString(q.Value.ToString())}"));
return Redirect($"/oauth/login?returnUrl={Uri.EscapeDataString("/oauth/authorize?" + query)}");
}
var application = await _applications.FindByClientIdAsync(request.ClientId, token);
ApplicationName = application == default ? "an application" : await _applications.GetDisplayNameAsync(application, token) ?? "an application";
RequestedScopes = MastodonScopes.Parse(request.Scope);
var avatarIds = (await _dbEntities.RootToAvatars.Match(r => r.RootId == rootId).ExecuteAsync(token)).Select(r => r.AvatarId).ToList();
var avatars = new List<LocalActor>();
foreach (var id in avatarIds)
if (await _localActors.FindById(LocalActorKind.Person, id, token) is { } avatar)
avatars.Add(avatar);
Avatars = avatars;
Parameters = request.GetParameters()
.Where(p => p.Key is not ("avatarId" or "decision" or "signed_in"))
.Select(p => new KeyValuePair<string, string>(p.Key, (string)p.Value))
.ToList();
Response.Headers["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'";
Response.Headers["Cache-Control"] = "no-store";
return Page();
}
async Task<string> SignedInRoot()
{
var login = await HttpContext.AuthenticateAsync(OAuthSetup.LoginScheme);
var rootId = login.Succeeded ? login.Principal.FindFirstValue(ClaimTypes.NameIdentifier) : default;
if (rootId == default)
return default;
var root = await _dbEntities.RootUsers.MatchID(rootId).ExecuteFirstAsync();
return root is { IsBanned: false, DeletedAt: null } ? rootId : default;
}
}
}
+2 -1
View File
@@ -72,6 +72,7 @@ namespace PrivaPub.Web.Pages
? _dbEntities.Posts.Match(p => p.GroupId == Actor.Id) ? _dbEntities.Posts.Match(p => p.GroupId == Actor.Id)
: _dbEntities.Posts.Match(p => p.GroupUserId == Actor.Id && !p.IsFederatedCopy)) : _dbEntities.Posts.Match(p => p.GroupUserId == Actor.Id && !p.IsFederatedCopy))
.Match(VisibilityPolicy.IsPublic) .Match(VisibilityPolicy.IsPublic)
.Match(p => p.ReblogOfPostId == null)
.Sort(p => p.ID, Order.Descending) .Sort(p => p.ID, Order.Descending)
.Limit(PageSize) .Limit(PageSize)
.ExecuteAsync(token); .ExecuteAsync(token);
@@ -112,7 +113,7 @@ namespace PrivaPub.Web.Pages
if (Actor is not { IsFederated: true, Kind: LocalActorKind.Person }) if (Actor is not { IsFederated: true, Kind: LocalActorKind.Person })
return NotFound(); return NotFound();
var post = await _dbEntities.Posts var post = await _dbEntities.Posts
.Match(p => p.ID == id && p.GroupUserId == Actor.Id && !p.IsFederatedCopy) .Match(p => p.ID == id && p.GroupUserId == Actor.Id && !p.IsFederatedCopy && p.ReblogOfPostId == null)
.Match(VisibilityPolicy.IsPublic) .Match(VisibilityPolicy.IsPublic)
.ExecuteFirstAsync(token); .ExecuteFirstAsync(token);
if (post == default) if (post == default)
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

+2 -2
View File
@@ -5,8 +5,8 @@ Written 2026-10-01 from the original 2023 code, the decePubClient UI, a federati
## Status ## Status
- [x] P0 Security baseline: v1.1.0, deployed and verified 2026-10-01 - [x] P0 Security baseline: v1.1.0, deployed and verified 2026-10-01
- [ ] P1.1 Infrastructure and content model - [x] P1.1 Infrastructure and content model: v1.2.0, deployed and verified 2026-10-01
- [ ] P1.2 Social graph and timelines - [x] P1.2 Social graph and timelines: v1.3.0, deployed and verified 2026-10-01
- [ ] P2 Mastodon client API - [ ] P2 Mastodon client API
- [ ] P3 Social features - [ ] P3 Social features
- [ ] P4 Groups and privacy features - [ ] P4 Groups and privacy features
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Checks the Mastodon client API the way a client first meets it.
# usage: tools/smoke/mastodon-api.sh https://privapub.thepra.dev [ACCESS_TOKEN]
set -euo pipefail
BASE="${1:?base url}"; TOKEN="${2:-}"
fail() { echo "::error::$*"; exit 1; }
json() { python3 -c "import sys,json; d=json.load(sys.stdin); $1"; }
version=$(curl -fsS "$BASE/api/v1/instance" | json "print(d['version'])") || fail "instance v1"
curl -fsS "$BASE/api/v2/instance" | json "assert d['configuration']['statuses']['max_characters'] > 0" || fail "instance v2"
curl -fsS "$BASE/.well-known/oauth-authorization-server" | json "assert d['token_endpoint'].endswith('/oauth/token')" || fail "oauth discovery"
app=$(curl -fsS -X POST "$BASE/api/v1/apps" -d 'client_name=privapub-smoke&redirect_uris=urn:ietf:wg:oauth:2.0:oob&scopes=read') || fail "app registration"
id=$(echo "$app" | json "print(d['client_id'])"); secret=$(echo "$app" | json "print(d['client_secret'])")
app_token=$(curl -fsS -X POST "$BASE/oauth/token" -d "grant_type=client_credentials&client_id=$id&client_secret=$secret&scope=read" | json "print(d['access_token'])") || fail "client credentials"
curl -fsS -H "Authorization: Bearer $app_token" "$BASE/api/v1/apps/verify_credentials" | json "assert d['name'] == 'privapub-smoke'" || fail "app verify_credentials"
code=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $app_token" "$BASE/api/v1/accounts/verify_credentials")
[ "$code" = "401" ] || fail "an app token reached a user endpoint ($code)"
curl -fsS "$BASE/api/v1/timelines/public?limit=2" | json "assert isinstance(d, list)" || fail "public timeline"
curl -fsS -X POST "$BASE/oauth/revoke" -d "token=$app_token&client_id=$id&client_secret=$secret" -o /dev/null || fail "revoke"
if [ -n "$TOKEN" ]; then
curl -fsS -H "Authorization: Bearer $TOKEN" "$BASE/api/v1/accounts/verify_credentials" | json "assert d['source'] is not None" || fail "verify_credentials"
curl -fsS -H "Authorization: Bearer $TOKEN" "$BASE/api/v1/timelines/home?limit=5" | json "assert isinstance(d, list)" || fail "home"
curl -fsS -H "Authorization: Bearer $TOKEN" "$BASE/api/v1/notifications?limit=5" | json "assert isinstance(d, list)" || fail "notifications"
fi
echo "mastodon api ok: $version"