Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a62e8a567 | ||
|
|
d80cd42a0c | ||
|
|
d2f0c7a14e | ||
|
|
0f85030744 | ||
|
|
2eb2a63f1e | ||
|
|
bf7c88ce71 | ||
|
|
611abb5857 | ||
|
|
a060204dd6 | ||
|
|
ccc3597699 | ||
|
|
034b792801 | ||
|
|
31f6015591 |
No files matched your search
@@ -14,3 +14,6 @@ jobs:
|
||||
|
||||
- name: Build
|
||||
run: dotnet build PrivaPub.sln -c Release
|
||||
|
||||
- name: Test
|
||||
run: dotnet test PrivaPub.sln -c Release --no-build
|
||||
@@ -12,6 +12,8 @@ env:
|
||||
BACKUPS: /var/backups/privapub.thepra.dev
|
||||
LOCAL_URL: http://127.0.0.1:6970
|
||||
PUBLIC_URL: https://privapub.thepra.dev
|
||||
MONGO_URI: mongodb://127.0.0.1:27022
|
||||
MONGO_DB: PrivaPub
|
||||
|
||||
jobs:
|
||||
site:
|
||||
@@ -26,6 +28,9 @@ jobs:
|
||||
echo "BUILD_REF=${GITHUB_REF_NAME:-master}" >> "$GITHUB_ENV"
|
||||
echo "BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Test
|
||||
run: dotnet test PrivaPub.sln -c Release
|
||||
|
||||
- name: Publish
|
||||
run: |
|
||||
rm -rf "$GITHUB_WORKSPACE/publish"
|
||||
@@ -52,6 +57,13 @@ jobs:
|
||||
echo "SNAPSHOT=$BACKUPS/site-$STAMP" >> "$GITHUB_ENV"
|
||||
ls -1dt "$BACKUPS"/site-* 2>/dev/null | tail -n +4 | xargs -r rm -rf || true
|
||||
|
||||
- name: Dump the database
|
||||
run: |
|
||||
DUMP="$BACKUPS/mongo-$(date +%Y%m%d-%H%M%S).archive.gz"
|
||||
mongodump --quiet --uri "$MONGO_URI" --db "$MONGO_DB" --gzip --archive="$DUMP"
|
||||
echo "::notice::database dumped to $DUMP ($(du -h "$DUMP" | cut -f1))"
|
||||
ls -1t "$BACKUPS"/mongo-*.archive.gz 2>/dev/null | tail -n +8 | xargs -r rm -f || true
|
||||
|
||||
- name: Stop, sync, start
|
||||
run: |
|
||||
sudo systemctl stop "$UNIT"
|
||||
@@ -85,4 +97,11 @@ jobs:
|
||||
[ "$code" = "200" ] || { echo "::error::the instance actor answered $code"; exit 1; }
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' "$PUBLIC_URL/.well-known/nodeinfo")
|
||||
[ "$code" = "200" ] || { echo "::error::nodeinfo answered $code"; exit 1; }
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' "$PUBLIC_URL/swagger/index.html")
|
||||
[ "$code" = "404" ] || { echo "::error::swagger answered $code in production"; exit 1; }
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/activity+json' --data '{"junk":' "$PUBLIC_URL/human-centipede")
|
||||
[ "$code" = "400" ] || { echo "::error::a junk inbox POST answered $code"; exit 1; }
|
||||
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")
|
||||
[ "$code" = "401" ] || { echo "::error::an unsigned inbox POST answered $code"; exit 1; }
|
||||
echo "::notice::serving $served"
|
||||
@@ -0,0 +1,233 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Guidance for working in this repository. `docs/ROADMAP.md` holds the owner's decisions and the phased plan to full
|
||||
ActivityPub interop; read it before changing anything federation-, privacy- or API-shaped.
|
||||
|
||||
## What this is
|
||||
|
||||
**PrivaPub** (repo name SocialPub) is a self-hosted ActivityPub server in C#, live at https://privapub.thepra.dev.
|
||||
It is meant as a Pleroma-like microblogging server for privacy-minded people and small communities, federating with
|
||||
Mastodon, GoToSocial, Pleroma/Akkoma, Misskey and Lemmy.
|
||||
|
||||
Its defining idea: **one private login owns several public personas.**
|
||||
|
||||
- **`RootUser` is the private login.** Username, password, optional email (used only for recovery), policies.
|
||||
- **`Avatar` is a public persona.** Each one is an ActivityPub actor with its own RSA keys at `/peasants/{username}`.
|
||||
Root and avatar are linked only through `RootToAvatar`.
|
||||
- **Personas must stay unlinkable** to other users and servers; only the instance admin can see the link.
|
||||
- **`Group` is an ActivityPub Group actor.** It has members, an invitation code and an optional password. Groups are
|
||||
becoming two kinds: a public **community** (FEP-1b12, Lemmy-compatible) and a private, invitation-only **circle**.
|
||||
- **`DmGroup` is a direct-message conversation.**
|
||||
- **`privapub` is the instance actor** (type Application). It signs fetches no persona should be tied to.
|
||||
|
||||
Privacy features in the model:
|
||||
- location-ranged posts (`Post.Location`, `RangeKm`), to become local-only and never federated;
|
||||
- contacts a persona can share (`Avatar.SharedPersonalContacts`);
|
||||
- private notes (`RootUserNote`, `PersonalNote`).
|
||||
|
||||
The intended client is the Mastodon client API (Tusky, Elk, Phanpy, Ivory), where each avatar logs in as its own
|
||||
account. The private `/clientapi` covers what Mastodon can't express. `thepra/decePubClient`
|
||||
(https://decepub.thepra.dev) is the owner's own Pleroma-FE-like PWA. It still shows mock data and is not wired to the
|
||||
server.
|
||||
|
||||
## Route names are deliberate
|
||||
|
||||
The odd names are the owner's and part of the project's character. **Never "fix" them to conventional ones.** A new
|
||||
actor-scoped route gets a name in the same spirit, agreed with the owner, and a name is frozen once it has federated.
|
||||
|
||||
| Thing | Route |
|
||||
|---|---|
|
||||
| Actor | `/peasants/{name}` (`/users/{name}` 301-redirects here) |
|
||||
| Inbox | `/peasants/{name}/mouth` |
|
||||
| Outbox | `/peasants/{name}/anus` |
|
||||
| Shared inbox | `/human-centipede` (also `/peasants/{name}/human-centipede`) |
|
||||
| Token refresh | `/clientapi/user/sniff/again` |
|
||||
|
||||
These are agreed for the roadmap and replace the conventional names still in the code today:
|
||||
|
||||
| Thing | Route | Replaces |
|
||||
|---|---|---|
|
||||
| Followers | `/groupies` | `/followers` |
|
||||
| Following | `/stalking` | `/following` |
|
||||
| Notes | `/scribbles/{id}` | `/posts/{id}` |
|
||||
| Activities | `/grunts/{id}` | `/activities/{id}` |
|
||||
| Replies, likes, shares | `/gossip`, `/drool`, `/echoes` | |
|
||||
| Featured | `/trophies` | |
|
||||
| Featured tags | `/tattoos` | |
|
||||
| Group members | `/flock` | |
|
||||
| Group moderators | `/wardens` | |
|
||||
| DM context | `/whispers/{id}` | |
|
||||
|
||||
Routes other software looks up by name stay conventional:
|
||||
- `/.well-known/*` and `/nodeinfo/*`;
|
||||
- `/api/v1/*` and `/oauth/*`;
|
||||
- `/@{name}` pages;
|
||||
- `/media/*`.
|
||||
|
||||
## Repo map
|
||||
|
||||
```
|
||||
PrivaPub.sln
|
||||
PrivaPub/ ASP.NET Core Web API, net10.0
|
||||
Program.cs host, Mongo init (GUIDs Standard), pipeline, /build.json
|
||||
Middleware/SocialPubConfigurations.cs every DI registration (auth, federation, services, swagger, CORS)
|
||||
Controllers/ClientToServer/ /clientapi/*: RootUser (signup/login/invitations/recovery), PrivateAvatar,
|
||||
Group, Post (posts + DMs), Admin, Data
|
||||
Infrastructure/
|
||||
Http/ FederationHttp + SafeHttpHandlerFactory + IpRangeGuard: the only way out
|
||||
Data/ Indexes (created at start), EntityMaps.Warm, Migrations/_NNN_*.cs
|
||||
Cli/ AdminCommands (`PrivaPub admin promote|demote <root>`)
|
||||
RateLimiting.cs accounts (per client address) and inbox (per sending origin) policies
|
||||
Federation/
|
||||
Controllers/ PeasantsController (actor, outbox, followers, following, posts, inboxes),
|
||||
WellKnownController (webfinger, nodeinfo), UsersController (redirect)
|
||||
Actors/ LocalActorService (LocalActor, Keys, ReservedName), RemoteActorService
|
||||
(authoritative fetch, key verification, WebFinger), ActorDocument (parser)
|
||||
Objects/ Origin (same-origin rules), ContentSanitizer (HtmlSanitizer, Mastodon allowlist)
|
||||
Signing/ HttpSignatures (draft-cavage sign/verify)
|
||||
Inbox/ InboxService (Follow/Undo/Create/Delete/Update)
|
||||
Outbox/ DeliveryService + DeliveryWorker
|
||||
Rendering/ ActivityPubRenderer (JsonObject builders)
|
||||
Services/ RootUsersService, GroupUsersService, PostsService, AppConfigurationService, …
|
||||
Models/ Mongo entities: User/, Group/, Post/, Federation/, AppConfiguration
|
||||
StaticServices/ DbEntities (Find<T> accessors), AuthTokenManager (JWT), PasswordHasher
|
||||
Data/InitDb.cs first-run seeding (languages)
|
||||
PrivaPub.ClientModels/ DTOs + validation resources shared with clients
|
||||
PrivaPub.Tests/ xUnit v3; Support/ has a fake two-origin peer and a throwaway-database fixture
|
||||
deploy/ nginx vhost, systemd units (privapub, privapub-mongod), max/setup.sh
|
||||
.gitea/workflows/ build.yml (push) · deploy.yml (tag v*)
|
||||
docs/ROADMAP.md decisions + phased plan
|
||||
```
|
||||
|
||||
The roadmap moves code towards `Infrastructure/`, `Federation/`, `Domain/`, `Api/{ClientApi,Mastodon}` and `Web/`,
|
||||
one pure-move commit at a time.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
dotnet build PrivaPub.sln -c Release
|
||||
dotnet test PrivaPub.sln # unit tests; integration tests skip
|
||||
PRIVAPUB_TEST_MONGOD=1 dotnet test PrivaPub.sln # all of them, against mongod on 127.0.0.1:27017
|
||||
# (PRIVAPUB_TEST_MONGO overrides; a fresh database per run, dropped after)
|
||||
cd PrivaPub && ASPNETCORE_ENVIRONMENT=Development \
|
||||
Kestrel__Endpoints__Http__Url=http://127.0.0.1:6970 Kestrel__Endpoints__Http__Protocols=Http1AndHttp2 \
|
||||
AppConfiguration__BackendBaseAddress=http://127.0.0.1:6970 MongoSettings__Database=PrivaPubTest \
|
||||
dotnet run # needs a local mongod on 27017; swagger at /swagger
|
||||
```
|
||||
|
||||
Development config (`appsettings.Development.json`) binds HTTPS 7195 with HTTP/2 only; the overrides above make it
|
||||
curl-able. Outbound fetches only go to https DNS names resolving to public addresses; a test network (Pasture) sets
|
||||
`Federation__AllowPrivateNetworks=true` and `Federation__AllowPlainHttp=true`, which startup refuses in Production.
|
||||
|
||||
Promoting an admin on the box (signing up as "admin" grants nothing):
|
||||
|
||||
```bash
|
||||
cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin promote <root>
|
||||
```
|
||||
|
||||
## Federation invariants
|
||||
|
||||
1. **Every outbound request goes through `IFederationHttp`.** Its handler resolves the name itself and connects only
|
||||
to public addresses; redirects are followed by hand (three at most, each re-checked); bodies are capped at 1 MB;
|
||||
only JSON media types are read; a refused URL is not asked again for five minutes. Never create another
|
||||
`HttpClient` for federation.
|
||||
2. **Every fetch is signed by the instance actor** (`privapub`), never by a persona; deliveries are signed by the acting
|
||||
avatar or group. Both are draft-cavage rsa-sha256 over `(request-target) host date` (+ `digest` on bodies).
|
||||
3. **A remote document is believed only from its own address.** `RemoteActorService.FetchObject` requires the
|
||||
document's `id` to be the URL it was served from (a same-origin alias is followed once). A key is accepted only if
|
||||
the actor lists it, its `owner` is the actor and it shares the actor's origin.
|
||||
4. **Inbound inboxes verify everything before acting:**
|
||||
- the signature covers `(request-target)`, `host`, `digest` and `date` or `(created)`;
|
||||
- the Digest matches the body and the date is at most an hour old and fifteen minutes ahead;
|
||||
- the signature verifies against the key owner's key, and the activity's `actor` is the key owner;
|
||||
- the activity's `id`, and any object it creates, updates or deletes, is on the actor's origin; a cross-origin
|
||||
object is refetched from its own origin.
|
||||
5. **Status codes:**
|
||||
- bad or missing signature: **401**;
|
||||
- malformed or forged body: **400**;
|
||||
- accepted: **202**;
|
||||
- over the rate limit: **429**;
|
||||
- **never 500** from `/peasants` or an inbox. Peers retry or "double-knock" based on these codes.
|
||||
6. **Actor documents:**
|
||||
- served as `application/activity+json`;
|
||||
- `publicKey` is an SPKI PEM at `{actor}#main-key`, with `owner` equal to the actor's id;
|
||||
- the shared inbox is advertised in `endpoints.sharedInbox`.
|
||||
7. **Remote HTML is sanitized before it is stored** (`ContentSanitizer`); `Post.ContentHtml` is what is shown,
|
||||
`ContentFormat` says what `Text` holds. Remote names are plain text.
|
||||
8. **Circles never federate.** A circle's actor, collections, WebFinger and inbox answer 404, and its posts are
|
||||
`IsLocalOnly`. Only communities are Group actors.
|
||||
9. **A DM joins a conversation only by `DmGroup.ParticipantsKey`**, the exact set of its participants; a remote
|
||||
`context` decides nothing.
|
||||
10. **Durable delivery:** activities go out through the delivery queue, never inline in a request.
|
||||
|
||||
## Privacy invariants
|
||||
|
||||
- **No root id in federation output, NodeInfo or logs, no IP next to an identity in logs, and no `ex.Message` to a
|
||||
client** ("Something went wrong." instead).
|
||||
- **Never derive a public name from the root username.** Invitation sign-up takes `AvatarUserName` and refuses one
|
||||
equal to the login.
|
||||
- **One username space:** personas, groups and the instance reserve their name in `ReservedName` (unique index) before
|
||||
they are saved; `LocalActorService.TryReserveUserName` is the only way to claim one.
|
||||
- **Per-avatar state stays per avatar:** blocks, mutes, notifications, follows. Nothing may relate sibling avatars.
|
||||
- **Location-ranged posts never federate.**
|
||||
|
||||
## Data
|
||||
|
||||
- MongoDB through **MongoDB.Entities 25.1**, instance API: `DB.Default.Find<T>()`, `.SaveAsync(e)`, `.Update<T>()`,
|
||||
`.DeleteAsync<T>()`, `.CountAsync<T>()`. `DbEntities` wraps the finds.
|
||||
- **Pass no cancellation token to `DeleteAsync` outside a transaction;** v25 throws if you do.
|
||||
- **Collection name = class name, so never rename an entity class.**
|
||||
- `Entity.ID` is a 24-character lowercase hex string; `GenerateNewID()` returns `object`, so cast it.
|
||||
- New fields must be additive: a deploy rollback restores the binary, not the database.
|
||||
- **Startup order:** `EntityMaps.Warm()` (every entity's class map, one at a time; two mapped at once throw "An item
|
||||
with the same key has already been added" and stay broken), then `MigrateAsync` (`Infrastructure/Data/Migrations`,
|
||||
`_NNN_` order, each runs once), then `Indexes.Create()`. A new entity needs nothing; a new unique index needs a
|
||||
dedupe migration before it.
|
||||
- Production runs its **own mongod** on 127.0.0.1:27022 (unit `privapub-mongod`, no auth, data in
|
||||
`/var/lib/privapub/mongo`). The box's shared mongod needs credentials nobody here has.
|
||||
|
||||
## Code style
|
||||
|
||||
- Tabs, block-scoped namespaces, Allman braces.
|
||||
- New services use `readonly _camel` fields and constructor injection. Older services use PascalCase fields; leave
|
||||
them.
|
||||
- Services return `WebResult` (`PrivaPub.ClientModels/WebResult.cs`): `result.Invalidate(localizer[...], status)`.
|
||||
Controllers turn it into a status code.
|
||||
- Localised strings go through `IStringLocalizer<GenericRes>`.
|
||||
- Every `Display`/`ErrorMessage` resource key must exist in `FieldsNameResource`/`ErrorsResource`, including the
|
||||
`Designer.cs`, which the CLI build doesn't regenerate. A missing key throws at validation time.
|
||||
- ActivityPub output is built with `System.Text.Json.Nodes` in `ActivityPubRenderer`, not typed models. Inbound
|
||||
documents are read through `InboxService.Id`/`Value`, which handle string, object and array.
|
||||
- Libraries chosen for the roadmap: HtmlSanitizer, Markdig (`DisableHtml`), NSign (RFC 9421 inbound), OpenIddict +
|
||||
OpenIddict.MongoDb, NetVips, Blurhash.Core, FFMpegCore. No ImageSharp (licence key enforced), no MassTransit.
|
||||
|
||||
## Testing
|
||||
|
||||
`PrivaPub.Tests` (xUnit v3). Unit tests need nothing; tests marked `Category=Integration` need a mongod and skip
|
||||
without `PRIVAPUB_TEST_MONGOD=1`. CI runs the unit tests only (the box's mongods are production).
|
||||
|
||||
- `Support/Peer` is an in-process HTTP server answering on two origins (`127.0.0.1` and `localhost`), so origin rules
|
||||
can be tested; `Support/RemoteActor` signs real deliveries with its own key.
|
||||
- Inbox scenarios go through `InboxService.Receive` with a signed request, not through the private handlers.
|
||||
|
||||
Beyond the tests, verify by building, running locally, and exercising:
|
||||
- the client API (sign up, create an avatar, a group, a post);
|
||||
- the ActivityPub endpoints with curl and `Accept: application/activity+json`.
|
||||
|
||||
Interop is checked against real servers: Fediverse Pasture with podman on the workstation, verify.funfedi.dev, and
|
||||
the owner's GoToSocial at social.arasaka.software. **Ask before acting from the owner's GoToSocial account.**
|
||||
|
||||
## Deploy
|
||||
|
||||
- **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`,
|
||||
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).
|
||||
- **The box:** Max (`nuvola.xyz`). Unit `privapub` runs as www-data from `/var/www/privapub.thepra.dev` with
|
||||
`ASPNETCORE_ENVIRONMENT=Production`.
|
||||
- **One-time root setup:** `deploy/max/setup.sh`, run through `../arasaka.software/tools/max/run.sh`.
|
||||
- **Config:** `appsettings.Production.json` is committed and deployed, **secrets included, by the owner's convention**
|
||||
(the same as arasaka.software). A hand edit on the box is lost at the next deploy.
|
||||
- **Secrets drift:** `AppConfigurationService` copies `AppConfiguration` into Mongo on first boot and reads the stored
|
||||
copy afterwards, so changing those keys needs a Mongo update as well.
|
||||
- **Logs:** `journalctl -u privapub`, plus the `logs` database on the private mongod.
|
||||
@@ -24,6 +24,7 @@ namespace PrivaPub.ClientModels.Group
|
||||
public string Description { get; set; }
|
||||
|
||||
public string InvitationPassword { get; set; }
|
||||
public bool IsCommunity { get; set; }
|
||||
public bool IsDiscoverable { get; set; } = true;
|
||||
public bool ManuallyApprovesMembers { get; set; }
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ namespace PrivaPub.ClientModels.Group
|
||||
public string Description { get; set; }
|
||||
public string Url { get; set; }
|
||||
public string Handle { get; set; }
|
||||
public bool IsCommunity { get; set; }
|
||||
public bool IsDiscoverable { get; set; }
|
||||
public bool ManuallyApprovesMembers { get; set; }
|
||||
public bool IsOwner { get; set; }
|
||||
|
||||
@@ -10,5 +10,12 @@ namespace PrivaPub.ClientModels
|
||||
StringLength(Constants.GroupInvitationLength, MinimumLength = Constants.GroupInvitationLength,
|
||||
ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string InvitationCode { get; set; }
|
||||
|
||||
[StringLength(32, MinimumLength = 3, ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
RegularExpression("^[a-z0-9_]+$", ErrorMessageResourceName = "EmptySpacesNotAllowed", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string AvatarUserName { get; set; }
|
||||
|
||||
[StringLength(64, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string AvatarName { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ namespace PrivaPub.ClientModels.Post
|
||||
public string AnsweringToPostId { get; set; }
|
||||
public string Title { get; set; }
|
||||
public string Text { get; set; }
|
||||
public string ContentHtml { get; set; }
|
||||
public bool HasContentWarning { get; set; }
|
||||
public bool IsFederatedCopy { get; set; }
|
||||
public DateTime CreationDate { get; set; }
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
using PrivaPub.Federation.Objects;
|
||||
|
||||
namespace PrivaPub.Tests.Federation
|
||||
{
|
||||
public class ContentSanitizerTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("<script>alert(1)</script><p>hi</p>", "<p>hi</p>")]
|
||||
[InlineData("<p onclick=\"alert(1)\">hi</p>", "<p>hi</p>")]
|
||||
[InlineData("<img src=x onerror=alert(1)>", "")]
|
||||
[InlineData("<a href=\"javascript:alert(1)\">x</a>", "<a rel=\"nofollow noopener noreferrer\" target=\"_blank\">x</a>")]
|
||||
[InlineData("<a href=\"data:text/html,<script>alert(1)</script>\">x</a>", "<a rel=\"nofollow noopener noreferrer\" target=\"_blank\">x</a>")]
|
||||
[InlineData("<a href=\"/relative\">x</a>", "<a rel=\"nofollow noopener noreferrer\" target=\"_blank\">x</a>")]
|
||||
[InlineData("<iframe src=\"https://evil.example\"></iframe>text", "text")]
|
||||
[InlineData("<style>body{display:none}</style><p>x</p>", "<p>x</p>")]
|
||||
[InlineData("<svg><script>alert(1)</script></svg>", "")]
|
||||
[InlineData("<p style=\"position:fixed\">x</p>", "<p>x</p>")]
|
||||
[InlineData("<form action=\"https://evil.example\"><input name=p></form>ok", "ok")]
|
||||
[InlineData("<p>a<!-- comment -->b</p>", "<p>ab</p>")]
|
||||
[InlineData("<div><p>kept</p></div>", "<p>kept</p>")]
|
||||
[InlineData("<h2>Title</h2>", "<p><strong>Title</strong></p>")]
|
||||
[InlineData("<span class=\"evil big\">x</span>", "<span>x</span>")]
|
||||
public void Removes_what_is_not_allowed(string input, string expected) =>
|
||||
Assert.Equal(expected, ContentSanitizer.Html(input));
|
||||
|
||||
[Fact]
|
||||
public void Keeps_mastodon_mention_and_hashtag_markup()
|
||||
{
|
||||
var html = "<p><span class=\"h-card\" translate=\"no\"><a href=\"https://m.example/@alice\" class=\"u-url mention\">@<span>alice</span></a></span> "
|
||||
+ "<a href=\"https://m.example/tags/fedi\" class=\"mention hashtag\" rel=\"tag\">#<span>fedi</span></a> "
|
||||
+ "<a href=\"https://example.org/a-long-link\"><span class=\"invisible\">https://</span><span class=\"ellipsis\">example.org/a-long</span></a></p>";
|
||||
|
||||
var sanitized = ContentSanitizer.Html(html);
|
||||
|
||||
Assert.Contains("class=\"h-card\"", sanitized);
|
||||
Assert.Contains("class=\"u-url mention\"", sanitized);
|
||||
Assert.Contains("class=\"mention hashtag\"", sanitized);
|
||||
Assert.Contains("class=\"invisible\"", sanitized);
|
||||
Assert.Contains("class=\"ellipsis\"", sanitized);
|
||||
Assert.Contains("href=\"https://m.example/@alice\"", sanitized);
|
||||
Assert.DoesNotContain("rel=\"tag\"", sanitized);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData(null)]
|
||||
public void Empty_input_is_empty(string input) =>
|
||||
Assert.Equal(string.Empty, ContentSanitizer.Html(input));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Signing;
|
||||
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace PrivaPub.Tests.Federation
|
||||
{
|
||||
public class HttpSignaturesTests
|
||||
{
|
||||
const string Host = "privapub.test";
|
||||
const string KeyId = "https://mastodon.example/users/alice#main-key";
|
||||
static readonly DateTimeOffset Now = new(2026, 10, 1, 12, 0, 0, TimeSpan.Zero);
|
||||
static readonly byte[] Body = Encoding.UTF8.GetBytes("""{"type":"Follow","actor":"https://mastodon.example/users/alice"}""");
|
||||
|
||||
readonly RSA _key = RSA.Create(2048);
|
||||
|
||||
string PublicKey => _key.ExportSubjectPublicKeyInfoPem();
|
||||
|
||||
DefaultHttpContext MastodonRequest(string signedTarget = "/peasants/bob/mouth", DateTimeOffset? signedAt = default,
|
||||
string headers = "(request-target) host date digest content-type", byte[] signedBody = default, string extra = "")
|
||||
{
|
||||
var date = (signedAt ?? Now).ToString("r", CultureInfo.InvariantCulture);
|
||||
var digest = HttpSignatures.Digest(signedBody ?? Body);
|
||||
var values = new Dictionary<string, string>
|
||||
{
|
||||
["(request-target)"] = $"post {signedTarget}",
|
||||
["host"] = Host,
|
||||
["date"] = date,
|
||||
["digest"] = digest,
|
||||
["content-type"] = "application/activity+json"
|
||||
};
|
||||
var signingString = string.Join("\n", headers.Split(' ').Select(h => $"{h}: {values[h]}"));
|
||||
var signature = Convert.ToBase64String(_key.SignData(Encoding.UTF8.GetBytes(signingString), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
|
||||
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Method = "POST";
|
||||
context.Request.Host = new HostString(Host);
|
||||
context.Request.Path = "/peasants/bob/mouth";
|
||||
context.Features.Get<IHttpRequestFeature>().RawTarget = "/peasants/bob/mouth";
|
||||
context.Request.Headers["Date"] = date;
|
||||
context.Request.Headers["Digest"] = digest;
|
||||
context.Request.Headers["Content-Type"] = "application/activity+json";
|
||||
context.Request.Headers["Signature"] = $"keyId=\"{KeyId}\",algorithm=\"rsa-sha256\",headers=\"{headers}\",signature=\"{signature}\"{extra}";
|
||||
return context;
|
||||
}
|
||||
|
||||
string Check(HttpContext context, byte[] body = default)
|
||||
{
|
||||
var parameters = HttpSignatures.Parse(context.Request.Headers["Signature"].ToString());
|
||||
var problem = HttpSignatures.CheckRequest(context.Request, parameters, body ?? Body, Now);
|
||||
if (problem != default)
|
||||
return problem;
|
||||
return HttpSignatures.Verify(PublicKey, HttpSignatures.SigningString(context.Request, parameters), parameters.Signature)
|
||||
? default
|
||||
: "does not verify";
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Accepts_a_mastodon_style_delivery() =>
|
||||
Assert.Null(Check(MastodonRequest()));
|
||||
|
||||
[Fact]
|
||||
public void Refuses_a_tampered_body() =>
|
||||
Assert.Equal("the digest does not match the body", Check(MastodonRequest(), Encoding.UTF8.GetBytes("""{"type":"Delete"}""")));
|
||||
|
||||
[Fact]
|
||||
public void Refuses_a_signature_for_another_inbox() =>
|
||||
Assert.Equal("does not verify", Check(MastodonRequest(signedTarget: "/peasants/carol/mouth")));
|
||||
|
||||
[Fact]
|
||||
public void Refuses_a_signature_older_than_an_hour() =>
|
||||
Assert.Equal("the Date header is outside the allowed window", Check(MastodonRequest(signedAt: Now.AddMinutes(-61))));
|
||||
|
||||
[Fact]
|
||||
public void Accepts_a_clock_slightly_ahead() =>
|
||||
Assert.Null(Check(MastodonRequest(signedAt: Now.AddMinutes(10))));
|
||||
|
||||
[Fact]
|
||||
public void Refuses_a_clock_far_ahead() =>
|
||||
Assert.Equal("the Date header is outside the allowed window", Check(MastodonRequest(signedAt: Now.AddMinutes(20))));
|
||||
|
||||
[Fact]
|
||||
public void Refuses_a_signature_without_the_request_target() =>
|
||||
Assert.Equal("(request-target) is not signed", Check(MastodonRequest(headers: "host date digest")));
|
||||
|
||||
[Fact]
|
||||
public void Refuses_a_signature_without_the_host() =>
|
||||
Assert.Equal("host is not signed", Check(MastodonRequest(headers: "(request-target) date digest")));
|
||||
|
||||
[Fact]
|
||||
public void Refuses_a_body_whose_digest_is_not_signed() =>
|
||||
Assert.Equal("the digest is not signed", Check(MastodonRequest(headers: "(request-target) host date")));
|
||||
|
||||
[Fact]
|
||||
public void Refuses_an_expired_signature() =>
|
||||
Assert.Equal("the signature has expired",
|
||||
Check(MastodonRequest(extra: $",expires=\"{Now.AddMinutes(-20).ToUnixTimeSeconds()}\"")));
|
||||
|
||||
[Fact]
|
||||
public void Signs_with_the_raw_request_target()
|
||||
{
|
||||
var context = MastodonRequest(signedTarget: "/peasants/b%6Fb/mouth");
|
||||
context.Features.Get<IHttpRequestFeature>().RawTarget = "/peasants/b%6Fb/mouth";
|
||||
|
||||
Assert.Null(Check(context));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verifies_its_own_outbound_signature()
|
||||
{
|
||||
var (privateKey, publicKey) = Keys.NewKeyPair();
|
||||
var signer = new LocalActor { UserName = "bob", BaseAddress = "https://privapub.test", PrivateKeyPem = privateKey };
|
||||
using var outbound = new HttpRequestMessage(HttpMethod.Post, "https://mastodon.example/users/alice/inbox");
|
||||
HttpSignatures.Sign(outbound, signer, Body);
|
||||
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Method = "POST";
|
||||
context.Request.Host = new HostString("mastodon.example");
|
||||
context.Request.Path = "/users/alice/inbox";
|
||||
context.Features.Get<IHttpRequestFeature>().RawTarget = "/users/alice/inbox";
|
||||
foreach (var header in outbound.Headers)
|
||||
context.Request.Headers[header.Key] = string.Join(", ", header.Value);
|
||||
var parameters = HttpSignatures.Parse(context.Request.Headers["Signature"].ToString());
|
||||
|
||||
Assert.Equal("https://privapub.test/peasants/bob#main-key", parameters.KeyId);
|
||||
Assert.Null(HttpSignatures.CheckRequest(context.Request, parameters, Body));
|
||||
Assert.True(HttpSignatures.Verify(publicKey, HttpSignatures.SigningString(context.Request, parameters), parameters.Signature));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Inbox;
|
||||
using PrivaPub.Federation.Outbox;
|
||||
using PrivaPub.Models;
|
||||
using PrivaPub.Models.Group;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
using PrivaPub.Tests.Support;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using GroupEntity = PrivaPub.Models.Group.Group;
|
||||
|
||||
namespace PrivaPub.Tests.Federation
|
||||
{
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class InboxScenarioTests : IAsyncLifetime
|
||||
{
|
||||
const string Host = "privapub.test";
|
||||
const string Base = "https://" + Host;
|
||||
|
||||
Peer _peer;
|
||||
LocalActorService _local;
|
||||
InboxService _inbox;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||
_peer = await Peer.Start();
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
_local = new LocalActorService(new DbEntities(), new StaticOptions<AppConfiguration>(new AppConfiguration { BackendBaseAddress = Base }));
|
||||
var remote = new RemoteActorService(Peer.Http(cache), _local, cache, new DbEntities());
|
||||
_inbox = new InboxService(new DbEntities(), _local, remote, new DeliveryService(new DbEntities()), NullLogger<InboxService>.Instance);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_peer != default)
|
||||
await _peer.DisposeAsync();
|
||||
}
|
||||
|
||||
async Task<LocalActor> LocalAvatar(string name)
|
||||
{
|
||||
var (privateKey, publicKey) = Keys.NewKeyPair();
|
||||
var avatar = new Avatar { UserName = $"{name}{Guid.NewGuid():N}"[..20], PrivateKey = privateKey, PublicKey = publicKey };
|
||||
await DB.Default.SaveAsync(avatar, TestContext.Current.CancellationToken);
|
||||
return _local.FromAvatar(avatar);
|
||||
}
|
||||
|
||||
static JsonObject DirectCreate(RemoteActor author, string to, string context = default, string objectOrigin = default, string attributedTo = default)
|
||||
{
|
||||
var id = $"{objectOrigin ?? Origin(author.Id)}/notes/{Guid.NewGuid():N}";
|
||||
var note = new JsonObject
|
||||
{
|
||||
["id"] = id,
|
||||
["type"] = "Note",
|
||||
["attributedTo"] = attributedTo ?? author.Id,
|
||||
["content"] = "<p>psst</p>",
|
||||
["to"] = new JsonArray(to),
|
||||
["cc"] = new JsonArray()
|
||||
};
|
||||
if (context != default)
|
||||
note["context"] = context;
|
||||
return new JsonObject
|
||||
{
|
||||
["id"] = $"{Origin(author.Id)}/activities/{Guid.NewGuid():N}",
|
||||
["type"] = "Create",
|
||||
["actor"] = author.Id,
|
||||
["to"] = new JsonArray(to),
|
||||
["object"] = note
|
||||
};
|
||||
}
|
||||
|
||||
static string Origin(string uri) => new Uri(uri).GetLeftPart(UriPartial.Authority);
|
||||
|
||||
[Fact]
|
||||
public async Task A_context_cannot_pull_a_stranger_into_an_existing_conversation()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var alice = await LocalAvatar("alice");
|
||||
var bob = new RemoteActor(_peer, "bob");
|
||||
var mallory = new RemoteActor(_peer, "mallory");
|
||||
var context = $"{_peer.A}/contexts/{Guid.NewGuid():N}";
|
||||
|
||||
var first = await _inbox.Receive(bob.Post(Host, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri, context)), alice, token);
|
||||
var injected = await _inbox.Receive(mallory.Post(Host, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri, context)), alice, token);
|
||||
|
||||
Assert.Equal(202, first.StatusCode);
|
||||
Assert.Equal(202, injected.StatusCode);
|
||||
var bobDm = await DB.Default.Find<DmPost>().Match(p => p.ActorURI == bob.Id).ExecuteSingleAsync(token);
|
||||
var malloryDm = await DB.Default.Find<DmPost>().Match(p => p.ActorURI == mallory.Id).ExecuteSingleAsync(token);
|
||||
Assert.NotEqual(bobDm.GroupId, malloryDm.GroupId);
|
||||
var bobConversation = await DB.Default.Find<DmGroup>().OneAsync(bobDm.GroupId, token);
|
||||
Assert.DoesNotContain(bobConversation.Members, m => m.AvatarId == mallory.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Replies_between_the_same_people_land_in_the_same_conversation()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var alice = await LocalAvatar("alice");
|
||||
var bob = new RemoteActor(_peer, "bob");
|
||||
|
||||
await _inbox.Receive(bob.Post(Host, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri)), alice, token);
|
||||
await _inbox.Receive(bob.Post(Host, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri)), alice, token);
|
||||
|
||||
var dms = await DB.Default.Find<DmPost>().Match(p => p.ActorURI == bob.Id).ExecuteAsync(token);
|
||||
Assert.Equal(2, dms.Count);
|
||||
Assert.Single(dms.Select(d => d.GroupId).Distinct());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_activity_id_on_another_origin_is_refused()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var alice = await LocalAvatar("alice");
|
||||
var mallory = new RemoteActor(_peer, "mallory");
|
||||
var create = DirectCreate(mallory, alice.Uri);
|
||||
create["id"] = $"{_peer.B}/activities/{Guid.NewGuid():N}";
|
||||
|
||||
var result = await _inbox.Receive(mallory.Post(Host, $"/peasants/{alice.UserName}/mouth", create), alice, token);
|
||||
|
||||
Assert.Equal(400, result.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_note_put_in_someone_elses_mouth_is_refused()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var alice = await LocalAvatar("alice");
|
||||
var mallory = new RemoteActor(_peer, "mallory");
|
||||
var victim = new RemoteActor(_peer, "victim");
|
||||
|
||||
var result = await _inbox.Receive(mallory.Post(Host, $"/peasants/{alice.UserName}/mouth",
|
||||
DirectCreate(mallory, alice.Uri, attributedTo: victim.Id)), alice, token);
|
||||
|
||||
Assert.Equal(400, result.StatusCode);
|
||||
Assert.False(await DB.Default.Find<DmPost>().Match(p => p.ActorURI == victim.Id).ExecuteAnyAsync(token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_cross_origin_object_is_fetched_from_its_origin_before_it_is_believed()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var alice = await LocalAvatar("alice");
|
||||
var mallory = new RemoteActor(_peer, "mallory");
|
||||
|
||||
var result = await _inbox.Receive(mallory.Post(Host, $"/peasants/{alice.UserName}/mouth",
|
||||
DirectCreate(mallory, alice.Uri, objectOrigin: _peer.B)), alice, token);
|
||||
|
||||
Assert.Equal(202, result.StatusCode);
|
||||
Assert.False(await DB.Default.Find<DmPost>().Match(p => p.ActorURI == mallory.Id).ExecuteAnyAsync(token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_bad_signature_is_a_401()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var alice = await LocalAvatar("alice");
|
||||
var mallory = new RemoteActor(_peer, "mallory");
|
||||
var request = mallory.Post(Host, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri));
|
||||
request.Headers["Signature"] = request.Headers["Signature"].ToString().Replace("signature=\"", "signature=\"AAAA");
|
||||
|
||||
Assert.Equal(401, (await _inbox.Receive(request, alice, token)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Junk_is_a_400_never_a_500()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var alice = await LocalAvatar("alice");
|
||||
var mallory = new RemoteActor(_peer, "mallory");
|
||||
|
||||
foreach (var junk in new JsonNode[] { new JsonArray(1, 2), JsonValue.Create("x"), new JsonObject { ["type"] = "Create" } })
|
||||
Assert.Equal(400, (await _inbox.Receive(mallory.Post(Host, $"/peasants/{alice.UserName}/mouth", junk), alice, token)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_circle_is_not_a_federated_actor()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (privateKey, publicKey) = Keys.NewKeyPair();
|
||||
var circle = new GroupEntity { UserName = $"circle{Guid.NewGuid():N}"[..20], PrivateKey = privateKey, PublicKey = publicKey };
|
||||
await DB.Default.SaveAsync(circle, token);
|
||||
var bob = new RemoteActor(_peer, "bob");
|
||||
var actor = _local.FromGroup(circle);
|
||||
var follow = new JsonObject
|
||||
{
|
||||
["id"] = $"{bob.Id}/follows/{Guid.NewGuid():N}",
|
||||
["type"] = "Follow",
|
||||
["actor"] = bob.Id,
|
||||
["object"] = actor.Uri
|
||||
};
|
||||
|
||||
Assert.False(actor.IsFederated);
|
||||
Assert.Equal(404, (await _inbox.Receive(bob.Post(Host, "/human-centipede", follow), default, token)).StatusCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Models;
|
||||
using PrivaPub.StaticServices;
|
||||
using PrivaPub.Tests.Support;
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace PrivaPub.Tests.Federation
|
||||
{
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class RemoteActorServiceTests : IAsyncLifetime
|
||||
{
|
||||
Peer _peer;
|
||||
RemoteActorService _service;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||
_peer = await Peer.Start();
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var local = new LocalActorService(new DbEntities(),
|
||||
new StaticOptions<AppConfiguration>(new AppConfiguration { BackendBaseAddress = "https://privapub.test" }));
|
||||
_service = new RemoteActorService(Peer.Http(cache), local, cache, new DbEntities());
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_peer != default)
|
||||
await _peer.DisposeAsync();
|
||||
}
|
||||
|
||||
static string Actor(string id, string keyId = default, string owner = default, string pem = default, string type = "Person") =>
|
||||
new JsonObject
|
||||
{
|
||||
["id"] = id,
|
||||
["type"] = type,
|
||||
["preferredUsername"] = id[(id.LastIndexOf('/') + 1)..],
|
||||
["inbox"] = id + "/inbox",
|
||||
["publicKey"] = new JsonObject
|
||||
{
|
||||
["id"] = keyId ?? id + "#main-key",
|
||||
["owner"] = owner ?? id,
|
||||
["publicKeyPem"] = pem ?? Keys.NewKeyPair().PublicKeyPem
|
||||
}
|
||||
}.ToJsonString();
|
||||
|
||||
string Unique(string name) => $"/users/{name}{Guid.NewGuid():N}";
|
||||
|
||||
[Fact]
|
||||
public async Task Accepts_an_actor_whose_document_names_its_own_address()
|
||||
{
|
||||
var path = Unique("alice");
|
||||
_peer.Serve(path, Actor("{A}" + path));
|
||||
|
||||
var actor = await _service.GetActorByKeyId($"{_peer.A}{path}#main-key", refresh: false, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(actor);
|
||||
Assert.Equal(_peer.A + path, actor.ActorURI);
|
||||
Assert.Equal($"{_peer.A}{path}#main-key", actor.PublicKeyId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refuses_a_document_that_claims_another_origin()
|
||||
{
|
||||
var victim = Unique("bob");
|
||||
var mallory = Unique("mallory");
|
||||
_peer.Serve(mallory, Actor("{B}" + victim));
|
||||
|
||||
var actor = await _service.GetActor(_peer.A + mallory, refresh: false, TestContext.Current.CancellationToken);
|
||||
var byKey = await _service.GetActorByKeyId($"{_peer.B}{victim}#main-key", refresh: false, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Null(actor);
|
||||
Assert.Null(byKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refuses_a_key_owned_by_someone_else()
|
||||
{
|
||||
var eve = Unique("eve");
|
||||
_peer.Serve(eve, Actor("{A}" + eve, owner: "{A}/users/alice"));
|
||||
|
||||
Assert.Null(await _service.GetActorByKeyId($"{_peer.A}{eve}#main-key", refresh: false, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refuses_a_key_document_whose_owner_is_on_another_origin()
|
||||
{
|
||||
var alice = Unique("alice");
|
||||
var key = $"/keys/{Guid.NewGuid():N}";
|
||||
_peer.Serve(alice, Actor("{B}" + alice));
|
||||
_peer.Serve(key, new JsonObject { ["id"] = "{A}" + key, ["owner"] = "{B}" + alice, ["publicKeyPem"] = Keys.NewKeyPair().PublicKeyPem }.ToJsonString());
|
||||
|
||||
Assert.Null(await _service.GetActorByKeyId(_peer.A + key, refresh: false, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refuses_a_key_the_actor_does_not_list()
|
||||
{
|
||||
var alice = Unique("alice");
|
||||
_peer.Serve(alice, Actor("{A}" + alice));
|
||||
|
||||
Assert.Null(await _service.GetActorByKeyId($"{_peer.A}{alice}#other-key", refresh: false, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Follows_a_same_origin_alias_to_the_actor_it_names()
|
||||
{
|
||||
var alice = Unique("alice");
|
||||
var alias = $"/@alias{Guid.NewGuid():N}";
|
||||
_peer.Serve(alice, Actor("{A}" + alice));
|
||||
_peer.Serve(alias, Actor("{A}" + alice));
|
||||
|
||||
var actor = await _service.GetActor(_peer.A + alias, refresh: false, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(actor);
|
||||
Assert.Equal(_peer.A + alice, actor.ActorURI);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolves_a_gotosocial_style_key_address()
|
||||
{
|
||||
var gts = Unique("gts");
|
||||
var keyPath = gts + "/main-key";
|
||||
var pem = Keys.NewKeyPair().PublicKeyPem;
|
||||
_peer.Serve(gts, Actor("{A}" + gts, keyId: "{A}" + keyPath, pem: pem));
|
||||
_peer.Serve(keyPath, Actor("{A}" + gts, keyId: "{A}" + keyPath, pem: pem));
|
||||
|
||||
var actor = await _service.GetActorByKeyId(_peer.A + keyPath, refresh: false, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(actor);
|
||||
Assert.Equal(_peer.A + gts, actor.ActorURI);
|
||||
Assert.Equal(pem, actor.PublicKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_rotated_key_replaces_the_stored_one_but_only_once_per_interval()
|
||||
{
|
||||
var alice = Unique("alice");
|
||||
var keyId = $"{_peer.A}{alice}#main-key";
|
||||
var first = Keys.NewKeyPair().PublicKeyPem;
|
||||
var second = Keys.NewKeyPair().PublicKeyPem;
|
||||
var third = Keys.NewKeyPair().PublicKeyPem;
|
||||
_peer.Serve(alice, Actor("{A}" + alice, pem: first));
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
|
||||
Assert.Equal(first, (await _service.GetActorByKeyId(keyId, refresh: false, token)).PublicKey);
|
||||
_peer.Serve(alice, Actor("{A}" + alice, pem: second));
|
||||
Assert.Equal(first, (await _service.GetActorByKeyId(keyId, refresh: true, token)).PublicKey);
|
||||
|
||||
var fresh = new RemoteActorService(Peer.Http(), new LocalActorService(new DbEntities(),
|
||||
new StaticOptions<AppConfiguration>(new AppConfiguration { BackendBaseAddress = "https://privapub.test" })),
|
||||
new MemoryCache(new MemoryCacheOptions()), new DbEntities());
|
||||
Assert.Equal(second, (await fresh.GetActorByKeyId(keyId, refresh: true, token)).PublicKey);
|
||||
_peer.Serve(alice, Actor("{A}" + alice, pem: third));
|
||||
Assert.Equal(second, (await fresh.GetActorByKeyId(keyId, refresh: true, token)).PublicKey);
|
||||
Assert.Single(await new DbEntities().ForeignAvatars.Match(a => a.ActorURI == _peer.A + alice).ExecuteAsync(token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Signs_every_fetch()
|
||||
{
|
||||
var alice = Unique("alice");
|
||||
_peer.Serve(alice, Actor("{A}" + alice));
|
||||
|
||||
await _service.GetActor(_peer.A + alice, refresh: false, TestContext.Current.CancellationToken);
|
||||
|
||||
var request = Assert.Single(_peer.Requests, r => r.Path == alice);
|
||||
Assert.Contains("keyId=\"https://privapub.test/peasants/privapub#main-key\"", request.Signature);
|
||||
}
|
||||
}
|
||||
|
||||
public class ActorDocumentTests
|
||||
{
|
||||
[Fact]
|
||||
public void Reads_a_mastodon_actor()
|
||||
{
|
||||
var document = ActorDocument.Parse(JsonDocument.Parse("""
|
||||
{
|
||||
"@context": ["https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1"],
|
||||
"id": "https://mastodon.example/users/alice",
|
||||
"type": "Person",
|
||||
"preferredUsername": "alice",
|
||||
"name": "Alice",
|
||||
"inbox": "https://mastodon.example/users/alice/inbox",
|
||||
"outbox": "https://mastodon.example/users/alice/outbox",
|
||||
"url": "https://mastodon.example/@alice",
|
||||
"discoverable": false,
|
||||
"endpoints": { "sharedInbox": "https://mastodon.example/inbox" },
|
||||
"icon": { "type": "Image", "url": "https://files.mastodon.example/a.png" },
|
||||
"publicKey": {
|
||||
"id": "https://mastodon.example/users/alice#main-key",
|
||||
"owner": "https://mastodon.example/users/alice",
|
||||
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIB\n-----END PUBLIC KEY-----\n"
|
||||
}
|
||||
}
|
||||
""").RootElement);
|
||||
|
||||
Assert.Equal("alice", document.PreferredUsername);
|
||||
Assert.Equal("https://mastodon.example/inbox", document.SharedInbox);
|
||||
Assert.Equal("https://files.mastodon.example/a.png", document.Icon);
|
||||
Assert.False(document.Discoverable);
|
||||
Assert.NotNull(document.Key("https://mastodon.example/users/alice#main-key"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Keeps_only_keys_owned_by_the_actor_on_its_origin()
|
||||
{
|
||||
var document = ActorDocument.Parse(JsonDocument.Parse("""
|
||||
{
|
||||
"id": "https://a.example/users/alice",
|
||||
"type": "Person",
|
||||
"publicKey": [
|
||||
{ "id": "https://a.example/users/alice#main-key", "owner": "https://a.example/users/alice", "publicKeyPem": "x" },
|
||||
{ "id": "https://b.example/keys/1", "owner": "https://a.example/users/alice", "publicKeyPem": "y" },
|
||||
{ "id": "https://a.example/users/alice#other", "owner": "https://a.example/users/bob", "publicKeyPem": "z" },
|
||||
{ "id": "https://a.example/users/alice#unowned", "publicKeyPem": "w" }
|
||||
]
|
||||
}
|
||||
""").RootElement);
|
||||
|
||||
Assert.Equal("https://a.example/users/alice#main-key", Assert.Single(document.Keys).Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Refuses_an_inbox_on_another_origin()
|
||||
{
|
||||
var document = ActorDocument.Parse(JsonDocument.Parse("""
|
||||
{ "id": "https://a.example/users/alice", "type": "Person", "inbox": "https://b.example/inbox" }
|
||||
""").RootElement);
|
||||
|
||||
Assert.Null(document.Inbox);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Note")]
|
||||
[InlineData("Tombstone")]
|
||||
[InlineData("Collection")]
|
||||
public void Refuses_non_actor_types(string type) =>
|
||||
Assert.Null(ActorDocument.Parse(JsonDocument.Parse($$"""{ "id": "https://a.example/x", "type": "{{type}}" }""").RootElement));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
using PrivaPub.Infrastructure.Http;
|
||||
|
||||
namespace PrivaPub.Tests.Infrastructure
|
||||
{
|
||||
public sealed class FederationHttpTests : IAsyncLifetime
|
||||
{
|
||||
WebApplication _peer;
|
||||
string _base;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
var builder = WebApplication.CreateSlimBuilder();
|
||||
builder.WebHost.UseUrls("http://127.0.0.1:0");
|
||||
_peer = builder.Build();
|
||||
_peer.MapGet("/actor", () => Results.Text("{\"id\":\"x\"}", "application/activity+json"));
|
||||
_peer.MapGet("/html", () => Results.Text("<html></html>", "text/html"));
|
||||
_peer.MapGet("/big", () => Results.Text("{\"a\":\"" + new string('a', FederationHttp.MaxResponseBytes) + "\"}", "application/activity+json"));
|
||||
_peer.MapGet("/hop/{n:int}", (int n) => Results.Redirect(n == 0 ? "/actor" : $"/hop/{n - 1}"));
|
||||
_peer.MapGet("/gone", () => Results.StatusCode(410));
|
||||
await _peer.StartAsync();
|
||||
_base = _peer.Urls.First();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync() => await _peer.DisposeAsync();
|
||||
|
||||
static FederationHttp Client(bool allowTestNetwork = true)
|
||||
{
|
||||
var options = new FederationOptions { AllowPrivateNetworks = allowTestNetwork, AllowPlainHttp = allowTestNetwork };
|
||||
var services = new ServiceCollection();
|
||||
services.AddHttpClient(FederationHttp.ClientName)
|
||||
.ConfigurePrimaryHttpMessageHandler(() => SafeHttpHandlerFactory.Create(options));
|
||||
var provider = services.BuildServiceProvider();
|
||||
return new FederationHttp(provider.GetRequiredService<IHttpClientFactory>(), new MemoryCache(new MemoryCacheOptions()),
|
||||
new StaticOptionsMonitor(options), NullLogger<FederationHttp>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reads_a_json_document()
|
||||
{
|
||||
using var fetched = await Client().GetJson($"{_base}/actor", "application/activity+json", default, TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(fetched);
|
||||
Assert.Equal("x", fetched.Root.GetProperty("id").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Follows_up_to_three_redirects_and_reports_the_final_url()
|
||||
{
|
||||
using var fetched = await Client().GetJson($"{_base}/hop/2", "application/activity+json", default, TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(fetched);
|
||||
Assert.Equal($"{_base}/actor", fetched.FinalUri.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refuses_a_fourth_redirect() =>
|
||||
Assert.Null(await Client().GetJson($"{_base}/hop/3", "application/activity+json", default, TestContext.Current.CancellationToken));
|
||||
|
||||
[Fact]
|
||||
public async Task Refuses_html() =>
|
||||
Assert.Null(await Client().GetJson($"{_base}/html", "application/activity+json", default, TestContext.Current.CancellationToken));
|
||||
|
||||
[Fact]
|
||||
public async Task Refuses_a_body_over_the_limit() =>
|
||||
Assert.Null(await Client().GetJson($"{_base}/big", "application/activity+json", default, TestContext.Current.CancellationToken));
|
||||
|
||||
[Fact]
|
||||
public async Task Refuses_an_error_status() =>
|
||||
Assert.Null(await Client().GetJson($"{_base}/gone", "application/activity+json", default, TestContext.Current.CancellationToken));
|
||||
|
||||
[Fact]
|
||||
public async Task Refuses_a_private_network_in_production_mode() =>
|
||||
Assert.Null(await Client(allowTestNetwork: false).GetJson($"{_base}/actor", "application/activity+json", default, TestContext.Current.CancellationToken));
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://mastodon.social/users/Gargron", true)]
|
||||
[InlineData("http://mastodon.social/users/Gargron", false)]
|
||||
[InlineData("https://user:pass@mastodon.social/", false)]
|
||||
[InlineData("https://localhost/", false)]
|
||||
[InlineData("https://printer.local/", false)]
|
||||
[InlineData("https://metadata.google.internal/", false)]
|
||||
[InlineData("https://127.0.0.1/", false)]
|
||||
[InlineData("https://[::1]/", false)]
|
||||
[InlineData("ftp://example.org/", false)]
|
||||
public void IsAllowed_takes_https_dns_names_only(string url, bool allowed) =>
|
||||
Assert.Equal(allowed, Client(allowTestNetwork: false).IsAllowed(new Uri(url)));
|
||||
|
||||
sealed class StaticOptionsMonitor : IOptionsMonitor<FederationOptions>
|
||||
{
|
||||
public StaticOptionsMonitor(FederationOptions value) => CurrentValue = value;
|
||||
public FederationOptions CurrentValue { get; }
|
||||
public FederationOptions Get(string name) => CurrentValue;
|
||||
public IDisposable OnChange(Action<FederationOptions, string> listener) => default;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Infrastructure.Data;
|
||||
using PrivaPub.Infrastructure.Data.Migrations;
|
||||
using PrivaPub.Models;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
using PrivaPub.Tests.Support;
|
||||
|
||||
namespace PrivaPub.Tests.Infrastructure
|
||||
{
|
||||
[Trait("Category", "Integration")]
|
||||
public class IndexTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Duplicates_are_removed_and_the_unique_indexes_then_hold()
|
||||
{
|
||||
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var actorUri = $"https://r.example/users/{Guid.NewGuid():N}";
|
||||
var localId = Guid.NewGuid().ToString("N")[..24];
|
||||
await DB.Default.SaveAsync(new[]
|
||||
{
|
||||
new ForeignAvatar { ActorURI = actorUri, Name = "old", UpdatedAt = DateTime.UtcNow.AddDays(-2) },
|
||||
new ForeignAvatar { ActorURI = actorUri, Name = "new", UpdatedAt = DateTime.UtcNow }
|
||||
}, token);
|
||||
await DB.Default.SaveAsync(new[]
|
||||
{
|
||||
new Follower { LocalActorId = localId, ActorURI = actorUri, IsAccepted = false },
|
||||
new Follower { LocalActorId = localId, ActorURI = actorUri, IsAccepted = true }
|
||||
}, token);
|
||||
|
||||
await new _001_deduplicate_before_unique_indexes().UpgradeAsync();
|
||||
await Indexes.Create(token);
|
||||
|
||||
Assert.Equal("new", (await DB.Default.Find<ForeignAvatar>().Match(a => a.ActorURI == actorUri).ExecuteSingleAsync(token)).Name);
|
||||
Assert.True((await DB.Default.Find<Follower>().Match(f => f.LocalActorId == localId).ExecuteSingleAsync(token)).IsAccepted);
|
||||
var duplicate = await Assert.ThrowsAsync<MongoWriteException>(async () =>
|
||||
await DB.Default.SaveAsync(new ForeignAvatar { ActorURI = actorUri }, token));
|
||||
Assert.Equal(ServerErrorCategory.DuplicateKey, duplicate.WriteError.Category);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_username_is_reserved_once_across_personas_and_groups()
|
||||
{
|
||||
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
await Indexes.Create(token);
|
||||
var local = new LocalActorService(new DbEntities(), new StaticOptions<AppConfiguration>(new AppConfiguration { BackendBaseAddress = "https://privapub.test" }));
|
||||
var name = $"n{Guid.NewGuid():N}"[..20];
|
||||
|
||||
Assert.True(await local.TryReserveUserName(name, LocalActorKind.Person, "a", token));
|
||||
Assert.False(await local.TryReserveUserName(name.ToUpperInvariant(), LocalActorKind.Group, "b", token));
|
||||
Assert.True(await local.IsUserNameTaken(name, token));
|
||||
Assert.False(await local.TryReserveUserName("admin", LocalActorKind.Person, "c", token));
|
||||
Assert.False(await local.TryReserveUserName(LocalActorService.InstanceUserName, LocalActorKind.Person, "c", token));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using System.Net;
|
||||
|
||||
using PrivaPub.Infrastructure.Http;
|
||||
|
||||
namespace PrivaPub.Tests.Infrastructure
|
||||
{
|
||||
public class IpRangeGuardTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("1.1.1.1")]
|
||||
[InlineData("8.8.8.8")]
|
||||
[InlineData("93.184.215.14")]
|
||||
[InlineData("172.15.255.255")]
|
||||
[InlineData("172.32.0.1")]
|
||||
[InlineData("100.63.255.255")]
|
||||
[InlineData("100.128.0.0")]
|
||||
[InlineData("2a01:4f8::1")]
|
||||
[InlineData("2606:4700:4700::1111")]
|
||||
[InlineData("::ffff:1.1.1.1")]
|
||||
public void Public_addresses_are_allowed(string address) =>
|
||||
Assert.True(IpRangeGuard.IsPublic(IPAddress.Parse(address)));
|
||||
|
||||
[Theory]
|
||||
[InlineData("0.0.0.0")]
|
||||
[InlineData("10.1.2.3")]
|
||||
[InlineData("100.64.0.1")]
|
||||
[InlineData("100.127.255.255")]
|
||||
[InlineData("127.0.0.1")]
|
||||
[InlineData("127.255.255.254")]
|
||||
[InlineData("169.254.169.254")]
|
||||
[InlineData("172.16.0.1")]
|
||||
[InlineData("172.31.255.255")]
|
||||
[InlineData("192.0.0.170")]
|
||||
[InlineData("192.0.2.1")]
|
||||
[InlineData("192.88.99.1")]
|
||||
[InlineData("192.168.1.1")]
|
||||
[InlineData("198.18.0.1")]
|
||||
[InlineData("198.51.100.7")]
|
||||
[InlineData("203.0.113.9")]
|
||||
[InlineData("224.0.0.251")]
|
||||
[InlineData("239.255.255.250")]
|
||||
[InlineData("240.0.0.1")]
|
||||
[InlineData("255.255.255.255")]
|
||||
[InlineData("::")]
|
||||
[InlineData("::1")]
|
||||
[InlineData("::127.0.0.1")]
|
||||
[InlineData("::ffff:127.0.0.1")]
|
||||
[InlineData("::ffff:10.0.0.1")]
|
||||
[InlineData("::ffff:169.254.169.254")]
|
||||
[InlineData("64:ff9b::7f00:1")]
|
||||
[InlineData("64:ff9b::808:808")]
|
||||
[InlineData("64:ff9b:1::1")]
|
||||
[InlineData("100::1")]
|
||||
[InlineData("2001::1")]
|
||||
[InlineData("2001:0:4136:e378:8000:63bf:3fff:fdd2")]
|
||||
[InlineData("2001:db8::1")]
|
||||
[InlineData("2001:10::1")]
|
||||
[InlineData("2002:7f00:1::1")]
|
||||
[InlineData("2002:c0a8:101::1")]
|
||||
[InlineData("3fff::1")]
|
||||
[InlineData("fc00::1")]
|
||||
[InlineData("fd12:3456:789a::1")]
|
||||
[InlineData("fe80::1")]
|
||||
[InlineData("fec0::1")]
|
||||
[InlineData("ff02::1")]
|
||||
public void Private_and_special_addresses_are_refused(string address) =>
|
||||
Assert.False(IpRangeGuard.IsPublic(IPAddress.Parse(address)));
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_refuses_a_name_that_resolves_to_loopback()
|
||||
{
|
||||
await Assert.ThrowsAsync<BlockedDestinationException>(async () =>
|
||||
await SafeHttpHandlerFactory.Connect(new DnsEndPoint("localhost", 443), allowPrivateNetworks: false, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("127.0.0.1")]
|
||||
[InlineData("[::1]")]
|
||||
[InlineData("169.254.169.254")]
|
||||
public async Task Connect_refuses_a_private_literal(string host)
|
||||
{
|
||||
await Assert.ThrowsAsync<BlockedDestinationException>(async () =>
|
||||
await SafeHttpHandlerFactory.Connect(new DnsEndPoint(host, 443), allowPrivateNetworks: false, TestContext.Current.CancellationToken));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Infrastructure.Data.Migrations;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Tests.Support;
|
||||
|
||||
namespace PrivaPub.Tests.Infrastructure
|
||||
{
|
||||
[Trait("Category", "Integration")]
|
||||
public class MigrationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Stored_remote_content_is_sanitized_and_local_content_rendered()
|
||||
{
|
||||
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var remote = new Post { IsFederatedCopy = true, Text = "<p>hi<script>alert(1)</script></p>", ObjectURI = $"https://r.example/{Guid.NewGuid():N}" };
|
||||
var local = new Post { Text = "**bold** <b>raw</b>", ObjectURI = $"https://privapub.test/{Guid.NewGuid():N}" };
|
||||
await DB.Default.SaveAsync(new[] { remote, local }, token);
|
||||
|
||||
await new _002_sanitize_stored_content().UpgradeAsync();
|
||||
|
||||
var migratedRemote = await DB.Default.Find<Post>().OneAsync(remote.ID, token);
|
||||
var migratedLocal = await DB.Default.Find<Post>().OneAsync(local.ID, token);
|
||||
Assert.Equal("<p>hi</p>", migratedRemote.Text);
|
||||
Assert.Equal("<p>hi</p>", migratedRemote.ContentHtml);
|
||||
Assert.Equal(ContentFormat.Html, migratedRemote.ContentFormat);
|
||||
Assert.Equal("**bold** <b>raw</b>", migratedLocal.Text);
|
||||
Assert.Equal("<p><strong>bold</strong> <b>raw</b></p>", migratedLocal.ContentHtml);
|
||||
Assert.Equal(ContentFormat.Markdown, migratedLocal.ContentFormat);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>disable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<OutputType>Exe</OutputType>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
|
||||
<PackageReference Include="xunit.v3" Version="3.2.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\PrivaPub\PrivaPub.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="Fixtures\**\*" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,43 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization;
|
||||
using MongoDB.Bson.Serialization.Serializers;
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Infrastructure.Data;
|
||||
|
||||
[assembly: AssemblyFixture(typeof(PrivaPub.Tests.Support.MongoFixture))]
|
||||
|
||||
namespace PrivaPub.Tests.Support
|
||||
{
|
||||
public sealed class MongoFixture : IAsyncLifetime
|
||||
{
|
||||
public const string Skip = "set PRIVAPUB_TEST_MONGOD=1 (and optionally PRIVAPUB_TEST_MONGO) to run the tests that need a mongod";
|
||||
|
||||
public static bool Enabled => Environment.GetEnvironmentVariable("PRIVAPUB_TEST_MONGOD") == "1";
|
||||
|
||||
public string Database { get; } = $"PrivaPubTests_{Guid.NewGuid():N}";
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
if (!Enabled)
|
||||
return;
|
||||
try
|
||||
{
|
||||
BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard));
|
||||
}
|
||||
catch (BsonSerializationException)
|
||||
{
|
||||
}
|
||||
var connection = Environment.GetEnvironmentVariable("PRIVAPUB_TEST_MONGO") ?? "mongodb://127.0.0.1:27017";
|
||||
await DB.InitAsync(Database, MongoClientSettings.FromConnectionString(connection));
|
||||
EntityMaps.Warm();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Enabled)
|
||||
await DB.Default.Database().Client.DropDatabaseAsync(Database);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
using PrivaPub.Infrastructure.Http;
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace PrivaPub.Tests.Support
|
||||
{
|
||||
public sealed class Peer : IAsyncDisposable
|
||||
{
|
||||
readonly WebApplication _app;
|
||||
readonly ConcurrentDictionary<string, string> _documents = new();
|
||||
|
||||
public int Port { get; }
|
||||
public string A => $"http://127.0.0.1:{Port}";
|
||||
public string B => $"http://localhost:{Port}";
|
||||
public ConcurrentQueue<HttpRequestRecord> Requests { get; } = new();
|
||||
|
||||
Peer(WebApplication app, int port)
|
||||
{
|
||||
_app = app;
|
||||
Port = port;
|
||||
}
|
||||
|
||||
public static async Task<Peer> Start()
|
||||
{
|
||||
var builder = WebApplication.CreateSlimBuilder();
|
||||
builder.WebHost.UseUrls("http://127.0.0.1:0");
|
||||
var app = builder.Build();
|
||||
Peer peer = default;
|
||||
app.Run(async context =>
|
||||
{
|
||||
peer.Requests.Enqueue(new(context.Request.Method, context.Request.Path, context.Request.Headers["Signature"].ToString()));
|
||||
var key = context.Request.Path.Value;
|
||||
if (!peer._documents.TryGetValue(key, out var document))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
}
|
||||
context.Response.ContentType = "application/activity+json";
|
||||
await context.Response.WriteAsync(document.Replace("{A}", peer.A).Replace("{B}", peer.B));
|
||||
});
|
||||
await app.StartAsync();
|
||||
peer = new Peer(app, new Uri(app.Urls.First()).Port);
|
||||
return peer;
|
||||
}
|
||||
|
||||
public void Serve(string path, string json) => _documents[path] = json;
|
||||
|
||||
public static FederationHttp Http(IMemoryCache cache = default)
|
||||
{
|
||||
var options = new FederationOptions { AllowPrivateNetworks = true, AllowPlainHttp = true };
|
||||
var services = new ServiceCollection();
|
||||
services.AddHttpClient(FederationHttp.ClientName)
|
||||
.ConfigurePrimaryHttpMessageHandler(() => SafeHttpHandlerFactory.Create(options));
|
||||
return new FederationHttp(services.BuildServiceProvider().GetRequiredService<IHttpClientFactory>(),
|
||||
cache ?? new MemoryCache(new MemoryCacheOptions()), new StaticOptions<FederationOptions>(options),
|
||||
NullLogger<FederationHttp>.Instance);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync() => await _app.DisposeAsync();
|
||||
}
|
||||
|
||||
public sealed record HttpRequestRecord(string Method, string Path, string Signature);
|
||||
|
||||
public sealed class StaticOptions<T> : IOptionsMonitor<T>
|
||||
{
|
||||
public StaticOptions(T value) => CurrentValue = value;
|
||||
public T CurrentValue { get; }
|
||||
public T Get(string name) => CurrentValue;
|
||||
public IDisposable OnChange(Action<T, string> listener) => default;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
|
||||
using PrivaPub.Federation.Signing;
|
||||
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace PrivaPub.Tests.Support
|
||||
{
|
||||
public sealed class RemoteActor
|
||||
{
|
||||
readonly RSA _key = RSA.Create(2048);
|
||||
|
||||
public RemoteActor(Peer peer, string name, string origin = default)
|
||||
{
|
||||
Name = $"{name}{Guid.NewGuid():N}"[..20];
|
||||
Id = $"{origin ?? peer.A}/users/{Name}";
|
||||
peer.Serve($"/users/{Name}", Document().ToJsonString());
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
public string Id { get; }
|
||||
public string KeyId => Id + "#main-key";
|
||||
|
||||
public JsonObject Document() => new()
|
||||
{
|
||||
["id"] = Id,
|
||||
["type"] = "Person",
|
||||
["preferredUsername"] = Name,
|
||||
["inbox"] = Id + "/inbox",
|
||||
["followers"] = Id + "/followers",
|
||||
["publicKey"] = new JsonObject
|
||||
{
|
||||
["id"] = KeyId,
|
||||
["owner"] = Id,
|
||||
["publicKeyPem"] = _key.ExportSubjectPublicKeyInfoPem()
|
||||
}
|
||||
};
|
||||
|
||||
public HttpRequest Post(string host, string path, JsonNode activity)
|
||||
{
|
||||
var body = Encoding.UTF8.GetBytes(activity.ToJsonString());
|
||||
var date = DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture);
|
||||
var digest = HttpSignatures.Digest(body);
|
||||
var signingString = $"(request-target): post {path}\nhost: {host}\ndate: {date}\ndigest: {digest}";
|
||||
var signature = Convert.ToBase64String(_key.SignData(Encoding.UTF8.GetBytes(signingString), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
|
||||
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Method = "POST";
|
||||
context.Request.Host = new HostString(host);
|
||||
context.Request.Path = path;
|
||||
context.Features.Get<IHttpRequestFeature>().RawTarget = path;
|
||||
context.Request.Headers["Date"] = date;
|
||||
context.Request.Headers["Digest"] = digest;
|
||||
context.Request.Headers["Content-Type"] = "application/activity+json";
|
||||
context.Request.Headers["Signature"] = $"keyId=\"{KeyId}\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest\",signature=\"{signature}\"";
|
||||
context.Request.Body = new MemoryStream(body);
|
||||
context.Request.ContentLength = body.Length;
|
||||
return context.Request;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,20 +7,54 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PrivaPub", "PrivaPub\PrivaP
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PrivaPub.ClientModels", "PrivaPub.ClientModels\PrivaPub.ClientModels.csproj", "{5E63C68C-0E60-4418-A53A-FF4EF0007080}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PrivaPub.Tests", "PrivaPub.Tests\PrivaPub.Tests.csproj", "{BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Release|x64.Build.0 = Release|Any CPU
|
||||
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Release|x86.Build.0 = Release|Any CPU
|
||||
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Release|x64.Build.0 = Release|Any CPU
|
||||
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Release|x86.Build.0 = Release|Any CPU
|
||||
{BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Release|x64.Build.0 = Release|Any CPU
|
||||
{BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{BA20AFE4-A348-4BDF-A2F4-36E0710D61A2}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
@@ -45,7 +45,7 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"{nameof(User)}.{nameof(RemoveUsers)}()");
|
||||
return BadRequest(result.Invalidate(ex.Message));
|
||||
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"{nameof(User)}.{nameof(BanUsers)}()");
|
||||
return BadRequest(result.Invalidate(ex.Message));
|
||||
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"{nameof(User)}.{nameof(UnbanUsers)}()");
|
||||
return BadRequest(result.Invalidate(ex.Message));
|
||||
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
// catch (Exception ex)
|
||||
// {
|
||||
// Logger.LogError(ex, $"{nameof(User)}.{nameof(GetUsers)}()");
|
||||
// return BadRequest(result.Invalidate(ex.Message));
|
||||
// return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
||||
// }
|
||||
//}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"{nameof(DataController)}.{nameof(Languages)}()");
|
||||
return BadRequest(result.Invalidate(ex.Message, exception: ex));
|
||||
return BadRequest(result.Invalidate("Something went wrong.", exception: ex));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"{nameof(PrivateAvatarController)}.{nameof(InsertAvatar)}()");
|
||||
return BadRequest(result.Invalidate(ex.Message));
|
||||
return BadRequest(result.Invalidate(_localizer["Something went wrong."]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"{nameof(PrivateAvatarController)}.{nameof(UpdateAvatar)}()");
|
||||
return BadRequest(result.Invalidate(ex.Message));
|
||||
return BadRequest(result.Invalidate(_localizer["Something went wrong."]));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
@@ -10,6 +11,8 @@ using PrivaPub.ClientModels.Group;
|
||||
using PrivaPub.ClientModels.User;
|
||||
using PrivaPub.ClientModels.User.Avatar;
|
||||
using PrivaPub.Extensions;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Infrastructure;
|
||||
using PrivaPub.Models;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.Resources;
|
||||
@@ -28,6 +31,7 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
readonly IRootUsersService UsersService;
|
||||
readonly IGroupUsersService GroupUsersService;
|
||||
readonly IPrivateAvatarUsersService AvatarUsersService;
|
||||
readonly ILocalActorService LocalActors;
|
||||
readonly AuthTokenManager AuthTokenManager;
|
||||
readonly IOptionsMonitor<AppConfiguration> AppConfiguration;
|
||||
readonly ILogger<RootUserController> Logger;
|
||||
@@ -36,6 +40,7 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
public RootUserController(IRootUsersService usersService,
|
||||
IGroupUsersService groupUsersService,
|
||||
IPrivateAvatarUsersService avatarUsersService,
|
||||
ILocalActorService localActors,
|
||||
AuthTokenManager authTokenManager,
|
||||
IOptionsMonitor<AppConfiguration> appConfiguration,
|
||||
IStringLocalizer<GenericRes> localizer,
|
||||
@@ -44,6 +49,7 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
UsersService = usersService;
|
||||
GroupUsersService = groupUsersService;
|
||||
AvatarUsersService = avatarUsersService;
|
||||
LocalActors = localActors;
|
||||
AuthTokenManager = authTokenManager;
|
||||
AppConfiguration = appConfiguration;
|
||||
Localizer = localizer;
|
||||
@@ -52,7 +58,7 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
|
||||
#region User endpoints
|
||||
|
||||
[HttpPost, Route("/clientapi/user/signup"), Authorize(Policy = Policies.IsUser), AllowAnonymous]
|
||||
[HttpPost, Route("/clientapi/user/signup"), EnableRateLimiting(RateLimiting.Accounts), Authorize(Policy = Policies.IsUser), AllowAnonymous]
|
||||
public async Task<IActionResult> SignUp(LoginForm signUpForm)
|
||||
{
|
||||
if (User.Identity?.IsAuthenticated ?? false) return Redirect("/");
|
||||
@@ -67,18 +73,16 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
|
||||
(var user, var userSettings) = ((RootUser, ViewUserSettings))result.Data;
|
||||
var jwtUser = AuthTokenManager.GenerateToken(user, userSettings);
|
||||
Logger.LogInformation(
|
||||
$"{nameof(SignUp)}();IP:[{HttpContext.Connection?.RemoteIpAddress}];\nUser-Agent:[{Request.Headers["User-Agent"]}];\nUserId:[{user.ID}]");
|
||||
return Ok(jwtUser);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"{nameof(User)}.{nameof(SignUp)}()");
|
||||
return BadRequest(result.Invalidate(ex.Message));
|
||||
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost, Route("/clientapi/user/login"), Authorize(Policy = Policies.IsUser), AllowAnonymous]
|
||||
[HttpPost, Route("/clientapi/user/login"), EnableRateLimiting(RateLimiting.Accounts), Authorize(Policy = Policies.IsUser), AllowAnonymous]
|
||||
public async Task<IActionResult> Login(LoginForm loginForm)
|
||||
{
|
||||
if (User.Identity?.IsAuthenticated ?? false) return Redirect("/discussions");
|
||||
@@ -94,19 +98,17 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
var (user, userSettings) =
|
||||
((RootUser, ViewUserSettings))result.Data;
|
||||
var jwtUser = AuthTokenManager.GenerateToken(user, userSettings);
|
||||
Logger.LogInformation(
|
||||
$"{nameof(Login)}();IP:[{HttpContext.Connection?.RemoteIpAddress}];\nUser-Agent:[{Request.Headers["User-Agent"]}];\nUserId:[{user.ID}]");
|
||||
|
||||
return Ok(jwtUser);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"{nameof(User)}.{nameof(Login)}()");
|
||||
return BadRequest(result.Invalidate(ex.Message));
|
||||
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost, Route("/clientapi/user/invitation/signup"), AllowAnonymous]
|
||||
[HttpPost, Route("/clientapi/user/invitation/signup"), EnableRateLimiting(RateLimiting.Accounts), AllowAnonymous]
|
||||
public async Task<IActionResult> InvitationSignUp(InvitationLoginForm signUpForm, CancellationToken token)
|
||||
{
|
||||
var result = new WebResult();
|
||||
@@ -118,6 +120,11 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
if (!invitation.IsValid)
|
||||
return StatusCode(invitation.StatusCode, invitation);
|
||||
|
||||
var avatarUserName = signUpForm.AvatarUserName?.Trim().ToLowerInvariant();
|
||||
var personaProblem = await PersonaProblem(avatarUserName, signUpForm.UserName, token);
|
||||
if (personaProblem != default)
|
||||
return BadRequest(result.Invalidate(personaProblem));
|
||||
|
||||
result = await UsersService.SignUpAsync(signUpForm, signUpForm.InvitationCode);
|
||||
if (!result.IsValid)
|
||||
return StatusCode(result.StatusCode, result);
|
||||
@@ -126,8 +133,8 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
var avatar = await AvatarUsersService.InsertAvatar(new InsertAvatarForm
|
||||
{
|
||||
RootId = user.ID,
|
||||
Name = user.UserName,
|
||||
UserName = user.UserName,
|
||||
Name = string.IsNullOrWhiteSpace(signUpForm.AvatarName) ? avatarUserName : signUpForm.AvatarName.Trim(),
|
||||
UserName = avatarUserName,
|
||||
Biography = string.Empty
|
||||
});
|
||||
if (!avatar.IsValid)
|
||||
@@ -142,18 +149,16 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
if (!joined.IsValid)
|
||||
return StatusCode(joined.StatusCode, joined);
|
||||
|
||||
Logger.LogInformation(
|
||||
$"{nameof(InvitationSignUp)}();IP:[{HttpContext.Connection?.RemoteIpAddress}];\nUser-Agent:[{Request.Headers["User-Agent"]}];\nUserId:[{user.ID}]");
|
||||
return Ok(AuthTokenManager.GenerateToken(user, userSettings));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"{nameof(User)}.{nameof(InvitationSignUp)}()");
|
||||
return BadRequest(result.Invalidate(ex.Message));
|
||||
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost, Route("/clientapi/user/invitation/login"), AllowAnonymous]
|
||||
[HttpPost, Route("/clientapi/user/invitation/login"), EnableRateLimiting(RateLimiting.Accounts), AllowAnonymous]
|
||||
public async Task<IActionResult> InvitationLogin(InvitationLoginForm loginForm, CancellationToken token)
|
||||
{
|
||||
var result = new WebResult();
|
||||
@@ -171,14 +176,20 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
var (user, userSettings) = ((RootUser, ViewUserSettings))result.Data;
|
||||
|
||||
var avatars = await AvatarUsersService.GetRootAvatars(user.ID, token);
|
||||
var avatar = (avatars.Data as List<ViewAvatar>)?.FirstOrDefault();
|
||||
var avatarUserName = loginForm.AvatarUserName?.Trim().ToLowerInvariant();
|
||||
var avatar = string.IsNullOrEmpty(avatarUserName)
|
||||
? (avatars.Data as List<ViewAvatar>)?.FirstOrDefault()
|
||||
: (avatars.Data as List<ViewAvatar>)?.FirstOrDefault(a => a.UserName == avatarUserName);
|
||||
if (avatar == default)
|
||||
{
|
||||
var personaProblem = await PersonaProblem(avatarUserName, user.UserName, token);
|
||||
if (personaProblem != default)
|
||||
return BadRequest(new WebResult().Invalidate(personaProblem));
|
||||
var inserted = await AvatarUsersService.InsertAvatar(new InsertAvatarForm
|
||||
{
|
||||
RootId = user.ID,
|
||||
Name = user.UserName,
|
||||
UserName = user.UserName,
|
||||
Name = string.IsNullOrWhiteSpace(loginForm.AvatarName) ? avatarUserName : loginForm.AvatarName.Trim(),
|
||||
UserName = avatarUserName,
|
||||
Biography = string.Empty
|
||||
});
|
||||
if (!inserted.IsValid)
|
||||
@@ -195,17 +206,26 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
if (!joined.IsValid)
|
||||
return StatusCode(joined.StatusCode, joined);
|
||||
|
||||
Logger.LogInformation(
|
||||
$"{nameof(InvitationLogin)}();IP:[{HttpContext.Connection?.RemoteIpAddress}];\nUser-Agent:[{Request.Headers["User-Agent"]}];\nUserId:[{user.ID}]");
|
||||
return Ok(AuthTokenManager.GenerateToken(user, userSettings));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"{nameof(User)}.{nameof(InvitationLogin)}()");
|
||||
return BadRequest(result.Invalidate(ex.Message));
|
||||
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
||||
}
|
||||
}
|
||||
|
||||
async Task<string> PersonaProblem(string avatarUserName, string rootUserName, CancellationToken token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(avatarUserName))
|
||||
return Localizer["Choose a username for your persona."];
|
||||
if (string.Equals(avatarUserName, rootUserName?.Trim(), StringComparison.OrdinalIgnoreCase))
|
||||
return Localizer["Your persona's username must differ from your login."];
|
||||
if (await LocalActors.IsUserNameTaken(avatarUserName, token))
|
||||
return Localizer["The username '{0}' is already take.", avatarUserName];
|
||||
return default;
|
||||
}
|
||||
|
||||
[HttpGet, Route("/clientapi/user/logout"), Authorize(Policy = Policies.IsUser)]
|
||||
public IActionResult Logout()
|
||||
{
|
||||
@@ -217,7 +237,7 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"{nameof(User)}.{nameof(Logout)}()");
|
||||
return BadRequest(result.Invalidate(ex.Message));
|
||||
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,7 +258,7 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"{nameof(User)}.{nameof(UpdateUser)}()");
|
||||
return BadRequest(result.Invalidate(ex.Message));
|
||||
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,7 +279,7 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"{nameof(User)}.{nameof(UpdateUserSettings)}()");
|
||||
return BadRequest(result.Invalidate(ex.Message));
|
||||
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,7 +300,7 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"{nameof(User)}.{nameof(UpdatePassword)}()");
|
||||
return BadRequest(result.Invalidate(ex.Message));
|
||||
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,7 +319,7 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
// catch (Exception ex)
|
||||
// {
|
||||
// Logger.LogError(ex, $"{nameof(User)}.{nameof(GetUser)}()");
|
||||
// return BadRequest(result.Invalidate(ex.Message));
|
||||
// return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
||||
// }
|
||||
//}
|
||||
|
||||
@@ -317,11 +337,11 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"{nameof(User)}.{nameof(GetUserSettings)}()");
|
||||
return BadRequest(result.Invalidate(ex.Message));
|
||||
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost, Route("/clientapi/user/recover/password"), AllowAnonymous]
|
||||
[HttpPost, Route("/clientapi/user/recover/password"), EnableRateLimiting(RateLimiting.Accounts), AllowAnonymous]
|
||||
public async Task<IActionResult> RecoverPassword(PasswordRecoveryForm passwordRecoveryForm)
|
||||
{
|
||||
var result = new WebResult();
|
||||
@@ -341,11 +361,11 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"{nameof(User)}.{nameof(RecoverPassword)}()");
|
||||
return BadRequest(result.Invalidate(ex.Message));
|
||||
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost, Route("/clientapi/user/recover/valid"), AllowAnonymous]
|
||||
[HttpPost, Route("/clientapi/user/recover/valid"), EnableRateLimiting(RateLimiting.Accounts), AllowAnonymous]
|
||||
public async Task<IActionResult> IsValidRecoveryCode(
|
||||
[FromBody,
|
||||
Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
@@ -364,11 +384,11 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"{nameof(User)}.{nameof(IsValidRecoveryCode)}()");
|
||||
return BadRequest(result.Invalidate(ex.Message));
|
||||
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost, Route("/clientapi/user/recover/update/password"), AllowAnonymous]
|
||||
[HttpPost, Route("/clientapi/user/recover/update/password"), EnableRateLimiting(RateLimiting.Accounts), AllowAnonymous]
|
||||
public async Task<IActionResult> ChangePassword(NewPasswordForm newPasswordForm)
|
||||
{
|
||||
var result = new WebResult();
|
||||
@@ -385,7 +405,7 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"{nameof(User)}.{nameof(ChangePassword)}()");
|
||||
return BadRequest(result.Invalidate(ex.Message));
|
||||
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,7 +426,7 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
// catch (Exception ex)
|
||||
// {
|
||||
// Logger.LogError(ex, $"{nameof(User)}.{nameof(RemoveSelf)}()");
|
||||
// return BadRequest(result.Invalidate(ex.Message));
|
||||
// return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
||||
// }
|
||||
//}
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Text.Json;
|
||||
|
||||
using PrivaPub.Federation.Objects;
|
||||
|
||||
namespace PrivaPub.Federation.Actors
|
||||
{
|
||||
public sealed record ActorKey(string Id, string Pem);
|
||||
|
||||
public sealed class ActorDocument
|
||||
{
|
||||
static readonly string[] ActorTypes = { "Person", "Service", "Application", "Group", "Organization" };
|
||||
|
||||
public string Id { get; init; }
|
||||
public string Type { get; init; }
|
||||
public string PreferredUsername { get; init; }
|
||||
public string Name { get; init; }
|
||||
public string Summary { get; init; }
|
||||
public string Url { get; init; }
|
||||
public string Inbox { get; init; }
|
||||
public string Outbox { get; init; }
|
||||
public string SharedInbox { get; init; }
|
||||
public string Icon { get; init; }
|
||||
public bool Discoverable { get; init; } = true;
|
||||
public IReadOnlyList<ActorKey> Keys { get; init; } = Array.Empty<ActorKey>();
|
||||
|
||||
public ActorKey Key(string keyId) => Keys.FirstOrDefault(k => k.Id == keyId);
|
||||
|
||||
public static ActorDocument Parse(JsonElement root)
|
||||
{
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
return default;
|
||||
var id = RemoteActorService.Text(root, "id");
|
||||
var type = RemoteActorService.Text(root, "type");
|
||||
if (Origin.Of(id) == default || !ActorTypes.Contains(type))
|
||||
return default;
|
||||
|
||||
var inbox = RemoteActorService.Text(root, "inbox");
|
||||
return new ActorDocument
|
||||
{
|
||||
Id = id,
|
||||
Type = type,
|
||||
PreferredUsername = RemoteActorService.Text(root, "preferredUsername"),
|
||||
Name = RemoteActorService.Text(root, "name"),
|
||||
Summary = RemoteActorService.Text(root, "summary"),
|
||||
Url = RemoteActorService.Text(root, "url") ?? id,
|
||||
Inbox = Origin.Same(inbox, id) ? inbox : default,
|
||||
Outbox = RemoteActorService.Text(root, "outbox"),
|
||||
SharedInbox = root.TryGetProperty("endpoints", out var endpoints) ? RemoteActorService.Text(endpoints, "sharedInbox") : default,
|
||||
Icon = root.TryGetProperty("icon", out var icon) ? RemoteActorService.Text(icon, "url") : default,
|
||||
Discoverable = !root.TryGetProperty("discoverable", out var discoverable) || discoverable.ValueKind != JsonValueKind.False,
|
||||
Keys = ParseKeys(root, id)
|
||||
};
|
||||
}
|
||||
|
||||
static List<ActorKey> ParseKeys(JsonElement root, string actorId)
|
||||
{
|
||||
var keys = new List<ActorKey>();
|
||||
if (!root.TryGetProperty("publicKey", out var publicKey))
|
||||
return keys;
|
||||
var candidates = publicKey.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Object => new[] { publicKey },
|
||||
JsonValueKind.Array => publicKey.EnumerateArray().Where(k => k.ValueKind == JsonValueKind.Object).ToArray(),
|
||||
_ => Array.Empty<JsonElement>()
|
||||
};
|
||||
foreach (var key in candidates)
|
||||
{
|
||||
var keyId = RemoteActorService.Text(key, "id");
|
||||
var owner = RemoteActorService.Text(key, "owner");
|
||||
var pem = RemoteActorService.Text(key, "publicKeyPem");
|
||||
if (string.IsNullOrEmpty(pem) || owner != actorId || !Origin.Same(keyId, actorId))
|
||||
continue;
|
||||
keys.Add(new ActorKey(keyId, pem));
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
-11
@@ -1,9 +1,11 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Models;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Group;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
@@ -11,7 +13,7 @@ using System.Security.Cryptography;
|
||||
|
||||
using GroupEntity = PrivaPub.Models.Group.Group;
|
||||
|
||||
namespace PrivaPub.Services.Federation
|
||||
namespace PrivaPub.Federation.Actors
|
||||
{
|
||||
public class LocalActor
|
||||
{
|
||||
@@ -26,6 +28,7 @@ namespace PrivaPub.Services.Federation
|
||||
public string PublicKeyPem { get; init; }
|
||||
public bool Discoverable { get; init; } = true;
|
||||
public bool ManuallyApprovesFollowers { get; init; }
|
||||
public bool IsFederated { get; init; } = true;
|
||||
public DateTime Published { get; init; }
|
||||
public string BaseAddress { get; init; }
|
||||
|
||||
@@ -50,6 +53,7 @@ namespace PrivaPub.Services.Federation
|
||||
Task<LocalActor> FindByUri(string actorUri, CancellationToken token);
|
||||
Task<LocalActor> GetInstanceActor(CancellationToken token);
|
||||
Task<bool> IsUserNameTaken(string userName, CancellationToken token);
|
||||
Task<bool> TryReserveUserName(string userName, LocalActorKind kind, string ownerId, CancellationToken token);
|
||||
LocalActor FromAvatar(Avatar avatar);
|
||||
LocalActor FromGroup(GroupEntity group);
|
||||
}
|
||||
@@ -58,8 +62,15 @@ namespace PrivaPub.Services.Federation
|
||||
{
|
||||
public const string InstanceUserName = "privapub";
|
||||
|
||||
static readonly HashSet<string> ReservedByInstance = new(StringComparer.Ordinal)
|
||||
{
|
||||
InstanceUserName, "admin", "administrator", "root", "system", "support", "help", "moderator", "mod",
|
||||
"abuse", "postmaster", "webmaster", "hostmaster", "security", "noreply", "no_reply", "null", "undefined"
|
||||
};
|
||||
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly IOptionsMonitor<AppConfiguration> _appConfiguration;
|
||||
InstanceActor _instanceActor;
|
||||
|
||||
public LocalActorService(DbEntities dbEntities, IOptionsMonitor<AppConfiguration> appConfiguration)
|
||||
{
|
||||
@@ -115,13 +126,7 @@ namespace PrivaPub.Services.Federation
|
||||
|
||||
public async Task<LocalActor> GetInstanceActor(CancellationToken token)
|
||||
{
|
||||
var instance = await _dbEntities.InstanceActors.ExecuteFirstAsync(token);
|
||||
if (instance == default)
|
||||
{
|
||||
var (privateKey, publicKey) = Keys.NewKeyPair();
|
||||
instance = new InstanceActor { PrivateKey = privateKey, PublicKey = publicKey };
|
||||
await DB.Default.SaveAsync(instance, token);
|
||||
}
|
||||
var instance = _instanceActor ??= await LoadInstanceActor(token);
|
||||
|
||||
return new LocalActor
|
||||
{
|
||||
@@ -138,14 +143,39 @@ namespace PrivaPub.Services.Federation
|
||||
};
|
||||
}
|
||||
|
||||
async Task<InstanceActor> LoadInstanceActor(CancellationToken token)
|
||||
{
|
||||
var instance = await _dbEntities.InstanceActors.Sort(i => i.CreationDate, Order.Ascending).ExecuteFirstAsync(token);
|
||||
if (instance != default)
|
||||
return instance;
|
||||
var (privateKey, publicKey) = Keys.NewKeyPair();
|
||||
instance = new InstanceActor { PrivateKey = privateKey, PublicKey = publicKey };
|
||||
await DB.Default.SaveAsync(instance, token);
|
||||
return instance;
|
||||
}
|
||||
|
||||
public async Task<bool> IsUserNameTaken(string userName, CancellationToken token)
|
||||
{
|
||||
userName = userName?.ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(userName) || userName == InstanceUserName)
|
||||
if (string.IsNullOrEmpty(userName) || ReservedByInstance.Contains(userName))
|
||||
return true;
|
||||
if (await _dbEntities.Avatars.Match(a => a.UserName == userName).ExecuteAnyAsync(token))
|
||||
return await DB.Default.Find<ReservedName>().Match(r => r.Name == userName).ExecuteAnyAsync(token);
|
||||
}
|
||||
|
||||
public async Task<bool> TryReserveUserName(string userName, LocalActorKind kind, string ownerId, CancellationToken token)
|
||||
{
|
||||
userName = userName?.ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(userName) || ReservedByInstance.Contains(userName))
|
||||
return false;
|
||||
try
|
||||
{
|
||||
await DB.Default.SaveAsync(new ReservedName { Name = userName, OwnerKind = kind, OwnerId = ownerId }, token);
|
||||
return true;
|
||||
return await _dbEntities.Groups.Match(g => g.UserName == userName).ExecuteAnyAsync(token);
|
||||
}
|
||||
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public LocalActor FromAvatar(Avatar avatar) => new()
|
||||
@@ -176,6 +206,7 @@ namespace PrivaPub.Services.Federation
|
||||
PublicKeyPem = group.PublicKey,
|
||||
Discoverable = group.IsDiscoverable,
|
||||
ManuallyApprovesFollowers = group.ManuallyApprovesMembers,
|
||||
IsFederated = group.Kind == GroupKind.Community,
|
||||
Published = group.CreationDate,
|
||||
BaseAddress = BaseAddress
|
||||
};
|
||||
@@ -0,0 +1,197 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Text.Json;
|
||||
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
using PrivaPub.Federation.Objects;
|
||||
using PrivaPub.Federation.Signing;
|
||||
using PrivaPub.Infrastructure.Http;
|
||||
|
||||
namespace PrivaPub.Federation.Actors
|
||||
{
|
||||
public interface IRemoteActorService
|
||||
{
|
||||
Task<FetchedJson> FetchObject(string uri, CancellationToken token);
|
||||
Task<ForeignAvatar> GetActor(string actorUri, bool refresh, CancellationToken token);
|
||||
Task<ForeignAvatar> GetActorByKeyId(string keyId, bool refresh, CancellationToken token);
|
||||
Task<string> ResolveHandle(string handle, CancellationToken token);
|
||||
}
|
||||
|
||||
public class RemoteActorService : IRemoteActorService
|
||||
{
|
||||
public const string ActivityJson = "application/activity+json";
|
||||
const string Accept = "application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"";
|
||||
static readonly TimeSpan CacheLifetime = TimeSpan.FromDays(1);
|
||||
static readonly TimeSpan RefetchInterval = TimeSpan.FromMinutes(5);
|
||||
|
||||
readonly IFederationHttp _http;
|
||||
readonly ILocalActorService _localActors;
|
||||
readonly IMemoryCache _cache;
|
||||
readonly DbEntities _dbEntities;
|
||||
|
||||
public RemoteActorService(IFederationHttp http, ILocalActorService localActors, IMemoryCache cache, DbEntities dbEntities)
|
||||
{
|
||||
_http = http;
|
||||
_localActors = localActors;
|
||||
_cache = cache;
|
||||
_dbEntities = dbEntities;
|
||||
}
|
||||
|
||||
public async Task<FetchedJson> FetchObject(string uri, CancellationToken token)
|
||||
{
|
||||
var signer = await _localActors.GetInstanceActor(token);
|
||||
var fetched = await Get(uri, signer, token);
|
||||
if (fetched == default)
|
||||
return default;
|
||||
|
||||
var id = Text(fetched.Root, "id");
|
||||
if (Origin.IsDocumentAt(id, fetched.FinalUri))
|
||||
return fetched;
|
||||
|
||||
var finalUri = fetched.FinalUri;
|
||||
fetched.Dispose();
|
||||
if (!Origin.Same(id, finalUri.AbsoluteUri))
|
||||
return default;
|
||||
|
||||
var named = await Get(id, signer, token);
|
||||
if (named != default && Origin.IsDocumentAt(Text(named.Root, "id"), named.FinalUri))
|
||||
return named;
|
||||
named?.Dispose();
|
||||
return default;
|
||||
}
|
||||
|
||||
public async Task<ForeignAvatar> GetActor(string actorUri, bool refresh, CancellationToken token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(actorUri))
|
||||
return default;
|
||||
actorUri = StripFragment(actorUri);
|
||||
|
||||
var cached = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == actorUri).ExecuteFirstAsync(token);
|
||||
if (cached != default && !refresh && DateTime.UtcNow - cached.UpdatedAt < CacheLifetime)
|
||||
return cached;
|
||||
if (!MayFetch(actorUri))
|
||||
return cached;
|
||||
|
||||
using var fetched = await FetchObject(actorUri, token);
|
||||
var actor = fetched == default ? default : ActorDocument.Parse(fetched.Root);
|
||||
if (actor == default)
|
||||
return cached;
|
||||
|
||||
var key = cached == default ? default : actor.Key(cached.PublicKeyId);
|
||||
return await Upsert(actor, key ?? actor.Keys.FirstOrDefault(), token);
|
||||
}
|
||||
|
||||
public async Task<ForeignAvatar> GetActorByKeyId(string keyId, bool refresh, CancellationToken token)
|
||||
{
|
||||
if (Origin.Of(keyId) == default)
|
||||
return default;
|
||||
|
||||
var cached = await _dbEntities.ForeignAvatars.Match(a => a.PublicKeyId == keyId).ExecuteFirstAsync(token);
|
||||
if (cached != default && !string.IsNullOrEmpty(cached.PublicKey) && !refresh)
|
||||
return cached;
|
||||
if (!MayFetch(keyId))
|
||||
return cached;
|
||||
|
||||
using var fetched = await FetchObject(StripFragment(keyId), token);
|
||||
if (fetched == default)
|
||||
return cached;
|
||||
|
||||
var actor = ActorDocument.Parse(fetched.Root);
|
||||
if (actor == default)
|
||||
{
|
||||
var owner = Text(fetched.Root, "owner");
|
||||
if (Text(fetched.Root, "id") != keyId || !Origin.Same(owner, keyId))
|
||||
return default;
|
||||
using var ownerDocument = await FetchObject(owner, token);
|
||||
actor = ownerDocument == default ? default : ActorDocument.Parse(ownerDocument.Root);
|
||||
}
|
||||
|
||||
var key = actor?.Key(keyId);
|
||||
if (key == default)
|
||||
return default;
|
||||
return await Upsert(actor, key, token);
|
||||
}
|
||||
|
||||
public async Task<string> ResolveHandle(string handle, CancellationToken token)
|
||||
{
|
||||
var parts = handle?.TrimStart('@').Split('@');
|
||||
if (parts is not { Length: 2 } || string.IsNullOrEmpty(parts[0]) || string.IsNullOrEmpty(parts[1]))
|
||||
return default;
|
||||
|
||||
var url = $"https://{parts[1]}/.well-known/webfinger?resource={Uri.EscapeDataString($"acct:{parts[0]}@{parts[1]}")}";
|
||||
using var fetched = await _http.GetJson(url, "application/jrd+json, application/json", sign: default, token);
|
||||
if (fetched == default)
|
||||
return default;
|
||||
var document = fetched.Document;
|
||||
if (!document.RootElement.TryGetProperty("links", out var links) || links.ValueKind != JsonValueKind.Array)
|
||||
return default;
|
||||
|
||||
foreach (var link in links.EnumerateArray())
|
||||
{
|
||||
var type = Text(link, "type") ?? string.Empty;
|
||||
if (Text(link, "rel") == "self" && (type.Contains("activity+json") || type.Contains("ld+json")))
|
||||
return Text(link, "href");
|
||||
}
|
||||
return default;
|
||||
}
|
||||
|
||||
async Task<FetchedJson> Get(string uri, LocalActor signer, CancellationToken token) =>
|
||||
await _http.GetJson(uri, Accept, request => HttpSignatures.Sign(request, signer, body: null), token);
|
||||
|
||||
bool MayFetch(string uri)
|
||||
{
|
||||
var key = "remote-actor:fetched:" + uri;
|
||||
if (_cache.TryGetValue(key, out _))
|
||||
return false;
|
||||
_cache.Set(key, true, RefetchInterval);
|
||||
return true;
|
||||
}
|
||||
|
||||
static async Task<ForeignAvatar> Upsert(ActorDocument actor, ActorKey key, CancellationToken token)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
return await DB.Default.UpdateAndGet<ForeignAvatar>()
|
||||
.Match(a => a.ActorURI == actor.Id)
|
||||
.Modify(a => a.UserName, actor.PreferredUsername)
|
||||
.Modify(a => a.Name, actor.Name)
|
||||
.Modify(a => a.Biography, ContentSanitizer.Html(actor.Summary))
|
||||
.Modify(a => a.Url, actor.Url)
|
||||
.Modify(a => a.Domain, new Uri(actor.Id).Authority)
|
||||
.Modify(a => a.InboxURL, actor.Inbox)
|
||||
.Modify(a => a.OutboxURL, actor.Outbox)
|
||||
.Modify(a => a.SharedInboxURL, actor.SharedInbox)
|
||||
.Modify(a => a.PictureURL, actor.Icon)
|
||||
.Modify(a => a.IsDiscoverable, actor.Discoverable)
|
||||
.Modify(a => a.AvatarType, Enum.TryParse<AvatarType>(actor.Type, out var type) ? type : AvatarType.Person)
|
||||
.Modify(a => a.PublicKeyId, key?.Id)
|
||||
.Modify(a => a.PublicKey, key?.Pem)
|
||||
.Modify(a => a.UpdatedAt, now)
|
||||
.Modify(b => b.SetOnInsert(a => a.CreatedAt, now))
|
||||
.Option(o => o.IsUpsert = true)
|
||||
.ExecuteAsync(token);
|
||||
}
|
||||
|
||||
public static string StripFragment(string uri)
|
||||
{
|
||||
var hash = uri.IndexOf('#');
|
||||
return hash < 0 ? uri : uri[..hash];
|
||||
}
|
||||
|
||||
public static string Text(JsonElement element, string property)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty(property, out var value))
|
||||
return default;
|
||||
return value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => value.GetString(),
|
||||
JsonValueKind.Object => Text(value, "id") ?? Text(value, "href"),
|
||||
JsonValueKind.Array => value.EnumerateArray().Select(v => v.ValueKind == JsonValueKind.String ? v.GetString() : Text(v, "id")).FirstOrDefault(v => v != null),
|
||||
_ => default
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
-15
@@ -1,16 +1,20 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Services.Federation;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Federation.Inbox;
|
||||
using PrivaPub.Infrastructure;
|
||||
|
||||
namespace PrivaPub.Controllers.ServerToServer
|
||||
namespace PrivaPub.Federation.Controllers
|
||||
{
|
||||
[ApiController, Route("peasants")]
|
||||
public class PeasantsController : ControllerBase
|
||||
@@ -36,23 +40,23 @@ namespace PrivaPub.Controllers.ServerToServer
|
||||
public async Task<IActionResult> GetActor(string actor, CancellationToken token)
|
||||
{
|
||||
var local = await _localActors.FindByUserName(actor, token);
|
||||
return local == default ? NotFound() : Activity(ActivityPubRenderer.Actor(local));
|
||||
return local is not { IsFederated: true } ? NotFound() : Activity(ActivityPubRenderer.Actor(local));
|
||||
}
|
||||
|
||||
[HttpGet, Route("{actor}/anus")]
|
||||
public async Task<IActionResult> Outbox(string actor, CancellationToken token)
|
||||
{
|
||||
var local = await _localActors.FindByUserName(actor, token);
|
||||
if (local == default)
|
||||
if (local is not { IsFederated: true })
|
||||
return NotFound();
|
||||
|
||||
var total = local.Kind == LocalActorKind.Group
|
||||
? await DB.Default.CountAsync<PostEntity>(p => p.GroupId == local.Id, token)
|
||||
: await DB.Default.CountAsync<PostEntity>(p => p.GroupUserId == local.Id && !p.IsFederatedCopy, token);
|
||||
? await DB.Default.CountAsync<PostEntity>(p => p.GroupId == local.Id && !p.IsLocalOnly, token)
|
||||
: await DB.Default.CountAsync<PostEntity>(p => p.GroupUserId == local.Id && !p.IsFederatedCopy && !p.IsLocalOnly, token);
|
||||
|
||||
var latest = await (local.Kind == LocalActorKind.Group
|
||||
? _dbEntities.Posts.Match(p => p.GroupId == local.Id)
|
||||
: _dbEntities.Posts.Match(p => p.GroupUserId == local.Id && !p.IsFederatedCopy))
|
||||
? _dbEntities.Posts.Match(p => p.GroupId == local.Id && !p.IsLocalOnly)
|
||||
: _dbEntities.Posts.Match(p => p.GroupUserId == local.Id && !p.IsFederatedCopy && !p.IsLocalOnly))
|
||||
.Sort(p => p.CreationDate, Order.Descending)
|
||||
.Limit(OutboxSize)
|
||||
.ExecuteAsync(token);
|
||||
@@ -77,7 +81,7 @@ namespace PrivaPub.Controllers.ServerToServer
|
||||
public async Task<IActionResult> Followers(string actor, CancellationToken token)
|
||||
{
|
||||
var local = await _localActors.FindByUserName(actor, token);
|
||||
if (local == default)
|
||||
if (local is not { IsFederated: true })
|
||||
return NotFound();
|
||||
var count = await DB.Default.CountAsync<Follower>(
|
||||
f => f.LocalActorId == local.Id && f.LocalActorKind == local.Kind && f.IsAccepted, token);
|
||||
@@ -88,7 +92,7 @@ namespace PrivaPub.Controllers.ServerToServer
|
||||
public async Task<IActionResult> Following(string actor, CancellationToken token)
|
||||
{
|
||||
var local = await _localActors.FindByUserName(actor, token);
|
||||
return local == default ? NotFound() : Activity(ActivityPubRenderer.OrderedCollection(local.Following, 0, Enumerable.Empty<JsonNode>()));
|
||||
return local is not { IsFederated: true } ? NotFound() : Activity(ActivityPubRenderer.OrderedCollection(local.Following, 0, Enumerable.Empty<JsonNode>()));
|
||||
}
|
||||
|
||||
[HttpGet, Route("{actor}/posts/{postId}")]
|
||||
@@ -99,7 +103,7 @@ namespace PrivaPub.Controllers.ServerToServer
|
||||
return NotFound();
|
||||
|
||||
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.IsLocalOnly)
|
||||
.ExecuteFirstAsync(token);
|
||||
if (post == default)
|
||||
return NotFound();
|
||||
@@ -111,20 +115,20 @@ namespace PrivaPub.Controllers.ServerToServer
|
||||
return Activity(note);
|
||||
}
|
||||
|
||||
[HttpPost, Route("{actor}/mouth")]
|
||||
[HttpPost, Route("{actor}/mouth"), EnableRateLimiting(RateLimiting.Inbox)]
|
||||
public async Task<IActionResult> Inbox(string actor, CancellationToken token)
|
||||
{
|
||||
var local = await _localActors.FindByUserName(actor, token);
|
||||
if (local == default)
|
||||
if (local is not { IsFederated: true })
|
||||
return NotFound();
|
||||
return Answer(await _inbox.Receive(Request, local, token));
|
||||
}
|
||||
|
||||
[HttpPost, Route("{actor}/human-centipede")]
|
||||
[HttpPost, Route("{actor}/human-centipede"), EnableRateLimiting(RateLimiting.Inbox)]
|
||||
public async Task<IActionResult> ActorSharedInbox(string actor, CancellationToken token) =>
|
||||
Answer(await _inbox.Receive(Request, default, token));
|
||||
|
||||
[HttpPost, Route("/human-centipede")]
|
||||
[HttpPost, Route("/human-centipede"), EnableRateLimiting(RateLimiting.Inbox)]
|
||||
public async Task<IActionResult> SharedInbox(CancellationToken token) =>
|
||||
Answer(await _inbox.Receive(Request, default, token));
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace PrivaPub.Controllers.ServerToServer
|
||||
namespace PrivaPub.Federation.Controllers
|
||||
{
|
||||
[ApiController, Route("users")]
|
||||
public class UsersController : ControllerBase
|
||||
+3
-3
@@ -4,14 +4,14 @@ using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.Services.Federation;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
using PrivaPub.Federation.Actors;
|
||||
|
||||
namespace PrivaPub.Controllers.ServerToServer
|
||||
namespace PrivaPub.Federation.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
public class WellKnownController : ControllerBase
|
||||
@@ -43,7 +43,7 @@ namespace PrivaPub.Controllers.ServerToServer
|
||||
else
|
||||
actor = await _localActors.FindByUri(resource, token);
|
||||
|
||||
if (actor == default)
|
||||
if (actor is not { IsFederated: true })
|
||||
return NotFound();
|
||||
|
||||
var document = new JsonObject
|
||||
+51
-36
@@ -2,6 +2,7 @@ using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Group;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
@@ -11,8 +12,13 @@ using System.Text.Json.Nodes;
|
||||
using DmPostEntity = PrivaPub.Models.Post.DmPost;
|
||||
using GroupEntity = PrivaPub.Models.Group.Group;
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Signing;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Federation.Objects;
|
||||
using PrivaPub.Federation.Outbox;
|
||||
|
||||
namespace PrivaPub.Services.Federation
|
||||
namespace PrivaPub.Federation.Inbox
|
||||
{
|
||||
public sealed record InboxResult(int StatusCode, string Error = default);
|
||||
|
||||
@@ -77,14 +83,13 @@ namespace PrivaPub.Services.Federation
|
||||
if (requestProblem != default)
|
||||
return new(StatusCodes.Status401Unauthorized, requestProblem);
|
||||
|
||||
var signAs = recipient ?? await _localActors.GetInstanceActor(token);
|
||||
var signingString = HttpSignatures.SigningString(request, parameters);
|
||||
var keyOwner = await _remoteActors.GetActorByKeyId(parameters.KeyId, signAs, refresh: false, token);
|
||||
var keyOwner = await _remoteActors.GetActorByKeyId(parameters.KeyId, refresh: false, token);
|
||||
if (keyOwner == default && type == "Delete" && Id(activity["object"]) == actorUri)
|
||||
return new(StatusCodes.Status202Accepted);
|
||||
if (keyOwner == default || !HttpSignatures.Verify(keyOwner.PublicKey, signingString, parameters.Signature))
|
||||
{
|
||||
keyOwner = await _remoteActors.GetActorByKeyId(parameters.KeyId, signAs, refresh: true, token);
|
||||
keyOwner = await _remoteActors.GetActorByKeyId(parameters.KeyId, refresh: true, token);
|
||||
if (keyOwner == default || !HttpSignatures.Verify(keyOwner.PublicKey, signingString, parameters.Signature))
|
||||
return new(StatusCodes.Status401Unauthorized, "the signature does not verify");
|
||||
}
|
||||
@@ -92,15 +97,19 @@ namespace PrivaPub.Services.Federation
|
||||
if (!string.Equals(keyOwner.ActorURI, actorUri, StringComparison.Ordinal))
|
||||
return new(StatusCodes.Status401Unauthorized, "the activity's actor is not the key's owner");
|
||||
|
||||
var activityId = Id(activity);
|
||||
if (activityId != default && !Origin.Same(activityId, actorUri))
|
||||
return new(StatusCodes.Status400BadRequest, "the activity's id is not on its actor's origin");
|
||||
|
||||
_logger.LogInformation("Inbox {Recipient}: {Type} from {Actor}", recipient?.Handle ?? "shared", type, actorUri);
|
||||
|
||||
return type switch
|
||||
{
|
||||
"Follow" => await Follow(activity, keyOwner, token),
|
||||
"Undo" => await Undo(activity, keyOwner, token),
|
||||
"Create" => await Create(activity, keyOwner, signAs, token),
|
||||
"Create" => await Create(activity, keyOwner, token),
|
||||
"Delete" => await Delete(activity, keyOwner, token),
|
||||
"Update" => await Update(activity, keyOwner, signAs, token),
|
||||
"Update" => await Update(activity, keyOwner, token),
|
||||
_ => new(StatusCodes.Status202Accepted)
|
||||
};
|
||||
}
|
||||
@@ -108,7 +117,7 @@ namespace PrivaPub.Services.Federation
|
||||
async Task<InboxResult> Follow(JsonNode follow, ForeignAvatar follower, CancellationToken token)
|
||||
{
|
||||
var target = await _localActors.FindByUri(Id(follow["object"]), token);
|
||||
if (target == default || target.Kind == LocalActorKind.Application)
|
||||
if (target is not { IsFederated: true } || target.Kind == LocalActorKind.Application)
|
||||
return new(StatusCodes.Status404NotFound, "no such local actor");
|
||||
|
||||
var existing = await _dbEntities.Followers
|
||||
@@ -143,6 +152,8 @@ namespace PrivaPub.Services.Federation
|
||||
var innerType = inner is JsonObject ? Value(inner, "type") : default;
|
||||
var innerId = Id(inner);
|
||||
|
||||
if (inner is JsonObject && Id(inner["actor"]) != actor.ActorURI)
|
||||
return new(StatusCodes.Status400BadRequest, "an actor can only undo its own activities");
|
||||
if (innerType is not (null or "Follow"))
|
||||
return new(StatusCodes.Status202Accepted);
|
||||
|
||||
@@ -163,13 +174,13 @@ namespace PrivaPub.Services.Federation
|
||||
return new(StatusCodes.Status202Accepted);
|
||||
}
|
||||
|
||||
async Task<InboxResult> Create(JsonNode create, ForeignAvatar author, LocalActor signAs, CancellationToken token)
|
||||
async Task<InboxResult> Create(JsonNode create, ForeignAvatar author, CancellationToken token)
|
||||
{
|
||||
var note = create["object"];
|
||||
if (note is JsonValue)
|
||||
if (note is not JsonObject || !Origin.Same(Id(note), author.ActorURI))
|
||||
{
|
||||
using var fetched = await _remoteActors.Fetch(Id(note), signAs, token);
|
||||
note = fetched == default ? default : JsonNode.Parse(fetched.RootElement.GetRawText());
|
||||
using var fetched = await _remoteActors.FetchObject(Id(note), token);
|
||||
note = fetched == default ? default : JsonNode.Parse(fetched.Root.GetRawText());
|
||||
}
|
||||
if (note is not JsonObject || Value(note, "type") is not ("Note" or "Article" or "Page" or "Question"))
|
||||
return new(StatusCodes.Status202Accepted);
|
||||
@@ -186,7 +197,7 @@ namespace PrivaPub.Services.Federation
|
||||
foreach (var uri in addressed.Concat(new[] { Id(note["audience"]) }).Where(u => u != default).Distinct())
|
||||
{
|
||||
var local = await _localActors.FindByUri(uri, token);
|
||||
if (local != default && localTargets.All(l => l.Id != local.Id))
|
||||
if (local is { IsFederated: true } && localTargets.All(l => l.Id != local.Id))
|
||||
localTargets.Add(local);
|
||||
}
|
||||
|
||||
@@ -201,13 +212,16 @@ namespace PrivaPub.Services.Federation
|
||||
if (await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteAnyAsync(token))
|
||||
return new(StatusCodes.Status202Accepted);
|
||||
|
||||
var html = ContentSanitizer.Html(Value(note, "content"));
|
||||
var post = new PostEntity
|
||||
{
|
||||
ObjectURI = objectUri,
|
||||
ActorURI = author.ActorURI,
|
||||
GroupId = group?.Id,
|
||||
Title = Value(note, "summary") ?? Value(note, "name"),
|
||||
Text = Value(note, "content"),
|
||||
Text = html,
|
||||
ContentHtml = html,
|
||||
ContentFormat = ContentFormat.Html,
|
||||
HasContentWarning = note["sensitive"] is JsonValue sensitive && sensitive.TryGetValue<bool>(out var s) && s,
|
||||
AnsweringToPostId = await LocalPostId(inReplyTo, token) ?? inReplyTo,
|
||||
IsFederatedCopy = true,
|
||||
@@ -229,15 +243,22 @@ namespace PrivaPub.Services.Federation
|
||||
if (await _dbEntities.DmPosts.Match(p => p.ObjectURI == objectUri).ExecuteAnyAsync(token))
|
||||
return new(StatusCodes.Status202Accepted);
|
||||
|
||||
var participants = addressed.Where(a => a != ActivityPubRenderer.Public).Append(author.ActorURI).ToList();
|
||||
var dmGroup = await FindOrCreateDmGroup(participants, Value(note, "context") ?? Value(note, "conversation"), token);
|
||||
var participants = recipients.Select(r => r.Uri)
|
||||
.Concat(addressed.Where(a => a != ActivityPubRenderer.Public && !a.EndsWith("/followers") && !a.EndsWith("/following")))
|
||||
.Append(author.ActorURI)
|
||||
.ToList();
|
||||
var context = Value(note, "context") ?? Value(note, "conversation");
|
||||
var dmGroup = await FindOrCreateDmGroup(participants, Origin.Same(context, author.ActorURI) ? context : default, token);
|
||||
var dmHtml = ContentSanitizer.Html(Value(note, "content"));
|
||||
var dm = new DmPostEntity
|
||||
{
|
||||
ObjectURI = objectUri,
|
||||
ActorURI = author.ActorURI,
|
||||
GroupId = dmGroup.ID,
|
||||
Title = Value(note, "summary"),
|
||||
Text = Value(note, "content"),
|
||||
Text = dmHtml,
|
||||
ContentHtml = dmHtml,
|
||||
ContentFormat = ContentFormat.Html,
|
||||
HasContentWarning = note["sensitive"] is JsonValue dmSensitive && dmSensitive.TryGetValue<bool>(out var ds) && ds,
|
||||
AnsweringToPostId = inReplyTo,
|
||||
IsFederatedCopy = true,
|
||||
@@ -251,7 +272,7 @@ namespace PrivaPub.Services.Federation
|
||||
async Task<InboxResult> Delete(JsonNode delete, ForeignAvatar actor, CancellationToken token)
|
||||
{
|
||||
var objectUri = Id(delete["object"]);
|
||||
if (string.IsNullOrEmpty(objectUri))
|
||||
if (!Origin.Same(objectUri, actor.ActorURI))
|
||||
return new(StatusCodes.Status202Accepted);
|
||||
|
||||
if (objectUri == actor.ActorURI)
|
||||
@@ -274,27 +295,29 @@ namespace PrivaPub.Services.Federation
|
||||
return new(StatusCodes.Status202Accepted);
|
||||
}
|
||||
|
||||
async Task<InboxResult> Update(JsonNode update, ForeignAvatar actor, LocalActor signAs, CancellationToken token)
|
||||
async Task<InboxResult> Update(JsonNode update, ForeignAvatar actor, CancellationToken token)
|
||||
{
|
||||
var inner = update["object"];
|
||||
if (Id(inner) == actor.ActorURI)
|
||||
{
|
||||
await _remoteActors.GetActor(actor.ActorURI, signAs, refresh: true, token);
|
||||
await _remoteActors.GetActor(actor.ActorURI, refresh: true, token);
|
||||
return new(StatusCodes.Status202Accepted);
|
||||
}
|
||||
if (inner is not JsonObject || Id(inner["attributedTo"]) != actor.ActorURI)
|
||||
if (inner is not JsonObject || Id(inner["attributedTo"]) != actor.ActorURI || !Origin.Same(Id(inner), actor.ActorURI))
|
||||
return new(StatusCodes.Status202Accepted);
|
||||
|
||||
var objectUri = Id(inner);
|
||||
var text = Value(inner, "content");
|
||||
var text = ContentSanitizer.Html(Value(inner, "content"));
|
||||
await DB.Default.Update<PostEntity>()
|
||||
.Match(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI)
|
||||
.Modify(p => p.Text, text)
|
||||
.Modify(p => p.ContentHtml, text)
|
||||
.Modify(p => p.UpdateDate, DateTime.UtcNow)
|
||||
.ExecuteAsync(token);
|
||||
await DB.Default.Update<DmPostEntity>()
|
||||
.Match(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI)
|
||||
.Modify(p => p.Text, text)
|
||||
.Modify(p => p.ContentHtml, text)
|
||||
.Modify(p => p.UpdateDate, DateTime.UtcNow)
|
||||
.ExecuteAsync(token);
|
||||
return new(StatusCodes.Status202Accepted);
|
||||
@@ -330,27 +353,19 @@ namespace PrivaPub.Services.Federation
|
||||
foreach (var uri in participantUris.Distinct(StringComparer.Ordinal))
|
||||
{
|
||||
var local = await _localActors.FindByUri(uri, token);
|
||||
members.Add(local != default && local.Kind == LocalActorKind.Person
|
||||
var member = local != default && local.Kind == LocalActorKind.Person
|
||||
? new GroupMember { AvatarId = local.Id }
|
||||
: new GroupMember { AvatarId = uri, IsForeign = true });
|
||||
: new GroupMember { AvatarId = uri, IsForeign = true };
|
||||
if (members.All(m => m.AvatarId != member.AvatarId || m.IsForeign != member.IsForeign))
|
||||
members.Add(member);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(context))
|
||||
{
|
||||
var byContext = await _dbEntities.DmGroups.Match(g => g.ConversationURI == context).ExecuteFirstAsync(token);
|
||||
if (byContext != default)
|
||||
return byContext;
|
||||
}
|
||||
|
||||
var keys = members.Select(m => m.AvatarId).OrderBy(k => k, StringComparer.Ordinal).ToList();
|
||||
var candidates = await _dbEntities.DmGroups
|
||||
.Match(g => !g.DeletionAt.HasValue && g.Members.Count == keys.Count)
|
||||
.ExecuteAsync(token);
|
||||
var match = candidates.FirstOrDefault(g => g.Members.Select(m => m.AvatarId).OrderBy(k => k, StringComparer.Ordinal).SequenceEqual(keys));
|
||||
var key = DmGroup.KeyOf(members);
|
||||
var match = await _dbEntities.DmGroups.Match(g => g.ParticipantsKey == key && !g.DeletionAt.HasValue).ExecuteFirstAsync(token);
|
||||
if (match != default)
|
||||
return match;
|
||||
|
||||
var dmGroup = new DmGroup { Members = members, ConversationURI = context };
|
||||
var dmGroup = new DmGroup { Members = members, ConversationURI = context, ParticipantsKey = key };
|
||||
await DB.Default.SaveAsync(dmGroup, token);
|
||||
return dmGroup;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using AngleSharp.Css.Dom;
|
||||
using AngleSharp.Dom;
|
||||
|
||||
using Ganss.Xss;
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace PrivaPub.Federation.Objects
|
||||
{
|
||||
public static partial class ContentSanitizer
|
||||
{
|
||||
static readonly HtmlSanitizer Sanitizer = Build();
|
||||
|
||||
public static string Html(string html) =>
|
||||
string.IsNullOrWhiteSpace(html) ? string.Empty : Sanitizer.Sanitize(html).Trim();
|
||||
|
||||
static HtmlSanitizer Build()
|
||||
{
|
||||
var sanitizer = new HtmlSanitizer(new HtmlSanitizerOptions
|
||||
{
|
||||
AllowedTags = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"p", "br", "span", "a", "abbr", "del", "s", "pre", "blockquote", "code", "b", "strong", "u", "i", "em",
|
||||
"sub", "sup", "ul", "ol", "li", "ruby", "rt", "rp", "h1", "h2", "h3", "h4", "h5", "h6"
|
||||
},
|
||||
AllowedAttributes = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"href", "rel", "class", "translate", "start", "reversed", "value", "title"
|
||||
},
|
||||
AllowedCssProperties = new HashSet<string>(),
|
||||
AllowedAtRules = new HashSet<CssRuleType>(),
|
||||
AllowedSchemes = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"http", "https", "dat", "dweb", "ipfs", "ipns", "ssb", "gopher", "xmpp", "magnet", "gemini"
|
||||
},
|
||||
UriAttributes = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "href" }
|
||||
})
|
||||
{
|
||||
KeepChildNodes = true
|
||||
};
|
||||
foreach (var allowed in new[] { "mention", "hashtag", "ellipsis", "invisible" })
|
||||
sanitizer.AllowedClasses.Add(allowed);
|
||||
sanitizer.RemovingCssClass += (_, e) => e.Cancel = MicroformatClass().IsMatch(e.CssClass);
|
||||
sanitizer.RemovingTag += (_, e) =>
|
||||
{
|
||||
if (e.Tag.LocalName is "script" or "style" or "template" or "iframe" or "object" or "embed" or "noscript" or "svg" or "math")
|
||||
e.Tag.InnerHtml = string.Empty;
|
||||
};
|
||||
sanitizer.PostProcessNode += (_, e) =>
|
||||
{
|
||||
if (e.Node is not IElement element)
|
||||
return;
|
||||
switch (element.LocalName)
|
||||
{
|
||||
case "a":
|
||||
if (!SchemePrefix().IsMatch(element.GetAttribute("href") ?? string.Empty))
|
||||
element.RemoveAttribute("href");
|
||||
element.SetAttribute("rel", "nofollow noopener noreferrer");
|
||||
element.SetAttribute("target", "_blank");
|
||||
break;
|
||||
case "h1" or "h2" or "h3" or "h4" or "h5" or "h6":
|
||||
var paragraph = e.Document.CreateElement("p");
|
||||
var strong = e.Document.CreateElement("strong");
|
||||
while (element.FirstChild != default)
|
||||
strong.AppendChild(element.FirstChild);
|
||||
paragraph.AppendChild(strong);
|
||||
e.ReplacementNodes.Add(paragraph);
|
||||
break;
|
||||
}
|
||||
};
|
||||
return sanitizer;
|
||||
}
|
||||
|
||||
[GeneratedRegex("^[a-z][a-z0-9+.-]*:", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex SchemePrefix();
|
||||
|
||||
[GeneratedRegex("^(h|p|u|dt|e)-[a-z0-9-]+$")]
|
||||
private static partial Regex MicroformatClass();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace PrivaPub.Federation.Objects
|
||||
{
|
||||
public static class Origin
|
||||
{
|
||||
public static string Of(string uri) =>
|
||||
Uri.TryCreate(uri, UriKind.Absolute, out var parsed) ? Of(parsed) : default;
|
||||
|
||||
public static string Of(Uri uri) =>
|
||||
uri is { IsAbsoluteUri: true } && (uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp)
|
||||
? $"{uri.Scheme}://{uri.IdnHost.ToLowerInvariant()}:{uri.Port}"
|
||||
: default;
|
||||
|
||||
public static bool Same(string first, string second)
|
||||
{
|
||||
var origin = Of(first);
|
||||
return origin != default && origin == Of(second);
|
||||
}
|
||||
|
||||
public static bool IsDocumentAt(string id, Uri location) =>
|
||||
Uri.TryCreate(id, UriKind.Absolute, out var parsed)
|
||||
&& location is { IsAbsoluteUri: true }
|
||||
&& Uri.Compare(parsed, location, UriComponents.HttpRequestUrl, UriFormat.UriEscaped, StringComparison.Ordinal) == 0;
|
||||
}
|
||||
}
|
||||
+10
-7
@@ -7,8 +7,11 @@ using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Signing;
|
||||
using PrivaPub.Infrastructure.Http;
|
||||
|
||||
namespace PrivaPub.Services.Federation
|
||||
namespace PrivaPub.Federation.Outbox
|
||||
{
|
||||
public interface IDeliveryService
|
||||
{
|
||||
@@ -67,13 +70,13 @@ namespace PrivaPub.Services.Federation
|
||||
static readonly TimeSpan Poll = TimeSpan.FromSeconds(3);
|
||||
|
||||
readonly IServiceProvider _services;
|
||||
readonly IHttpClientFactory _httpClientFactory;
|
||||
readonly IFederationHttp _http;
|
||||
readonly ILogger<DeliveryWorker> _logger;
|
||||
|
||||
public DeliveryWorker(IServiceProvider services, IHttpClientFactory httpClientFactory, ILogger<DeliveryWorker> logger)
|
||||
public DeliveryWorker(IServiceProvider services, IFederationHttp http, ILogger<DeliveryWorker> logger)
|
||||
{
|
||||
_services = services;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_http = http;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -129,7 +132,7 @@ namespace PrivaPub.Services.Federation
|
||||
delivery.Attempts++;
|
||||
try
|
||||
{
|
||||
if (!Uri.TryCreate(delivery.InboxURL, UriKind.Absolute, out var inbox) || !RemoteActorService.IsFetchable(inbox))
|
||||
if (!Uri.TryCreate(delivery.InboxURL, UriKind.Absolute, out var inbox) || !_http.IsAllowed(inbox))
|
||||
{
|
||||
delivery.AbandonedAt = DateTime.UtcNow;
|
||||
delivery.LastError = "not a deliverable inbox";
|
||||
@@ -144,7 +147,7 @@ namespace PrivaPub.Services.Federation
|
||||
request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(RemoteActorService.ActivityJson);
|
||||
HttpSignatures.Sign(request, signer, body);
|
||||
|
||||
using var response = await _httpClientFactory.CreateClient(RemoteActorService.HttpClientName).SendAsync(request, token);
|
||||
using var response = await _http.Send(request, token);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
delivery.DeliveredAt = DateTime.UtcNow;
|
||||
@@ -160,7 +163,7 @@ namespace PrivaPub.Services.Federation
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException && !token.IsCancellationRequested)
|
||||
catch (Exception ex) when (ex is HttpRequestException or BlockedDestinationException or TaskCanceledException && !token.IsCancellationRequested)
|
||||
{
|
||||
delivery.LastError = ex.Message;
|
||||
}
|
||||
+2
-1
@@ -7,8 +7,9 @@ using System.Text.Json.Nodes;
|
||||
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
using DmPostEntity = PrivaPub.Models.Post.DmPost;
|
||||
using PrivaPub.Federation.Actors;
|
||||
|
||||
namespace PrivaPub.Services.Federation
|
||||
namespace PrivaPub.Federation.Rendering
|
||||
{
|
||||
public static class ActivityPubRenderer
|
||||
{
|
||||
+46
-11
@@ -1,15 +1,19 @@
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using PrivaPub.Federation.Actors;
|
||||
|
||||
namespace PrivaPub.Services.Federation
|
||||
namespace PrivaPub.Federation.Signing
|
||||
{
|
||||
public sealed record SignatureParameters(string KeyId, string Algorithm, string[] Headers, byte[] Signature,
|
||||
string Created, string Expires);
|
||||
|
||||
public static class HttpSignatures
|
||||
{
|
||||
static readonly TimeSpan AllowedClockSkew = TimeSpan.FromHours(12);
|
||||
public static readonly TimeSpan MaxAge = TimeSpan.FromHours(1);
|
||||
public static readonly TimeSpan MaxClockSkew = TimeSpan.FromMinutes(15);
|
||||
|
||||
public static string Digest(byte[] body) => "SHA-256=" + Convert.ToBase64String(SHA256.HashData(body));
|
||||
|
||||
@@ -104,7 +108,7 @@ namespace PrivaPub.Services.Federation
|
||||
switch (header)
|
||||
{
|
||||
case "(request-target)":
|
||||
lines.Add($"(request-target): {request.Method.ToLowerInvariant()} {request.PathBase}{request.Path}{request.QueryString}");
|
||||
lines.Add($"(request-target): {request.Method.ToLowerInvariant()} {RequestTarget(request)}");
|
||||
break;
|
||||
case "(created)":
|
||||
lines.Add($"(created): {parameters.Created}");
|
||||
@@ -125,10 +129,17 @@ namespace PrivaPub.Services.Federation
|
||||
return string.Join("\n", lines);
|
||||
}
|
||||
|
||||
public static string CheckRequest(HttpRequest request, SignatureParameters parameters, byte[] body)
|
||||
public static string CheckRequest(HttpRequest request, SignatureParameters parameters, byte[] body) =>
|
||||
CheckRequest(request, parameters, body, DateTimeOffset.UtcNow);
|
||||
|
||||
public static string CheckRequest(HttpRequest request, SignatureParameters parameters, byte[] body, DateTimeOffset now)
|
||||
{
|
||||
if (parameters.Algorithm is not ("rsa-sha256" or "hs2019"))
|
||||
return $"unsupported signature algorithm '{parameters.Algorithm}'";
|
||||
if (!parameters.Headers.Contains("(request-target)"))
|
||||
return "(request-target) is not signed";
|
||||
if (!parameters.Headers.Contains("host"))
|
||||
return "host is not signed";
|
||||
|
||||
if (body is { Length: > 0 })
|
||||
{
|
||||
@@ -141,24 +152,48 @@ namespace PrivaPub.Services.Federation
|
||||
return "the digest does not match the body";
|
||||
}
|
||||
|
||||
if (parameters.Headers.Contains("date"))
|
||||
var signsDate = parameters.Headers.Contains("date");
|
||||
var signsCreated = parameters.Headers.Contains("(created)");
|
||||
if (!signsDate && !signsCreated)
|
||||
return "neither date nor (created) is signed";
|
||||
|
||||
if (signsDate)
|
||||
{
|
||||
if (!DateTimeOffset.TryParse(request.Headers["Date"].ToString(), CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal, out var date))
|
||||
return "unreadable Date header";
|
||||
if ((DateTimeOffset.UtcNow - date).Duration() > AllowedClockSkew)
|
||||
if (!IsFresh(date, now))
|
||||
return "the Date header is outside the allowed window";
|
||||
}
|
||||
else if (!parameters.Headers.Contains("(created)"))
|
||||
return "neither date nor (created) is signed";
|
||||
|
||||
if (!string.IsNullOrEmpty(parameters.Expires) && long.TryParse(parameters.Expires, out var expires)
|
||||
&& DateTimeOffset.FromUnixTimeSeconds(expires) < DateTimeOffset.UtcNow - AllowedClockSkew)
|
||||
return "the signature has expired";
|
||||
if (signsCreated)
|
||||
{
|
||||
if (!long.TryParse(parameters.Created, NumberStyles.Integer, CultureInfo.InvariantCulture, out var created))
|
||||
return "unreadable (created)";
|
||||
if (!IsFresh(DateTimeOffset.FromUnixTimeSeconds(created), now))
|
||||
return "(created) is outside the allowed window";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(parameters.Expires) || parameters.Headers.Contains("(expires)"))
|
||||
{
|
||||
if (!long.TryParse(parameters.Expires, NumberStyles.Integer, CultureInfo.InvariantCulture, out var expires))
|
||||
return "unreadable (expires)";
|
||||
if (DateTimeOffset.FromUnixTimeSeconds(expires) < now - MaxClockSkew)
|
||||
return "the signature has expired";
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
static bool IsFresh(DateTimeOffset signedAt, DateTimeOffset now) =>
|
||||
signedAt >= now - MaxAge && signedAt <= now + MaxClockSkew;
|
||||
|
||||
static string RequestTarget(HttpRequest request)
|
||||
{
|
||||
var raw = request.HttpContext.Features.Get<IHttpRequestFeature>()?.RawTarget;
|
||||
return string.IsNullOrEmpty(raw) || raw[0] != '/' ? $"{request.PathBase}{request.Path}{request.QueryString}" : raw;
|
||||
}
|
||||
|
||||
public static bool Verify(string publicKeyPem, string signingString, byte[] signature)
|
||||
{
|
||||
if (string.IsNullOrEmpty(publicKeyPem) || signingString == null)
|
||||
@@ -0,0 +1,40 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.ClientModels;
|
||||
using PrivaPub.Models.User;
|
||||
|
||||
namespace PrivaPub.Infrastructure.Cli
|
||||
{
|
||||
public static class AdminCommands
|
||||
{
|
||||
const string Usage = "usage: PrivaPub admin promote|demote <root username>";
|
||||
|
||||
public static async Task<int> Run(string[] args)
|
||||
{
|
||||
if (args is not [("promote" or "demote") and var verb, var userName])
|
||||
{
|
||||
Console.Error.WriteLine(Usage);
|
||||
return 2;
|
||||
}
|
||||
|
||||
userName = userName.ToLowerInvariant();
|
||||
var user = await DB.Default.Find<RootUser>().Match(u => u.UserName == userName).ExecuteFirstAsync();
|
||||
if (user == default)
|
||||
{
|
||||
Console.Error.WriteLine($"no root user '{userName}'");
|
||||
return 1;
|
||||
}
|
||||
|
||||
user.Policies.RemoveAll(p => p is Policies.IsAdmin or Policies.IsModerator);
|
||||
if (verb == "promote")
|
||||
user.Policies.AddRange(new[] { Policies.IsAdmin, Policies.IsModerator });
|
||||
if (!user.Policies.Contains(Policies.IsUser))
|
||||
user.Policies.Add(Policies.IsUser);
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await DB.Default.SaveAsync(user);
|
||||
|
||||
Console.WriteLine($"{userName}: {string.Join(", ", user.Policies)}");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
namespace PrivaPub.Infrastructure.Data
|
||||
{
|
||||
public static class EntityMaps
|
||||
{
|
||||
public static void Warm()
|
||||
{
|
||||
var collection = typeof(DB).GetMethods()
|
||||
.Single(m => m.Name == nameof(DB.Collection) && m.IsGenericMethodDefinition && m.GetParameters().Length == 0);
|
||||
var entities = typeof(EntityMaps).Assembly.GetTypes()
|
||||
.Where(t => t is { IsClass: true, IsAbstract: false, IsGenericTypeDefinition: false } && typeof(IEntity).IsAssignableFrom(t))
|
||||
.OrderBy(t => t.FullName, StringComparer.Ordinal);
|
||||
foreach (var entity in entities)
|
||||
collection.MakeGenericMethod(entity).Invoke(DB.Default, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Group;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.User;
|
||||
|
||||
namespace PrivaPub.Infrastructure.Data
|
||||
{
|
||||
public static class Indexes
|
||||
{
|
||||
public static async Task Create(CancellationToken token = default)
|
||||
{
|
||||
await Unique<Post>(p => p.ObjectURI, Builders<Post>.Filter.Type(p => p.ObjectURI, BsonType.String), token);
|
||||
await Plain<Post>(token, p => p.GroupUserId, p => p.ID);
|
||||
await Plain<Post>(token, p => p.GroupId, p => p.ID);
|
||||
await Plain<Post>(token, p => p.ActorURI);
|
||||
|
||||
await Unique<DmPost>(p => p.ObjectURI, Builders<DmPost>.Filter.Type(p => p.ObjectURI, BsonType.String), token);
|
||||
await Plain<DmPost>(token, p => p.GroupId, p => p.ID);
|
||||
|
||||
await Unique<ForeignAvatar>(a => a.ActorURI, Builders<ForeignAvatar>.Filter.Type(a => a.ActorURI, BsonType.String), token);
|
||||
await Plain<ForeignAvatar>(token, a => a.PublicKeyId);
|
||||
|
||||
await DB.Default.Index<Follower>()
|
||||
.Key(f => f.LocalActorId, KeyType.Ascending)
|
||||
.Key(f => f.LocalActorKind, KeyType.Ascending)
|
||||
.Key(f => f.ActorURI, KeyType.Ascending)
|
||||
.Option(o => o.Unique = true)
|
||||
.CreateAsync(token);
|
||||
await Plain<Follower>(token, f => f.ActorURI);
|
||||
|
||||
await DB.Default.Index<RootToAvatar>()
|
||||
.Key(r => r.RootId, KeyType.Ascending)
|
||||
.Key(r => r.AvatarId, KeyType.Ascending)
|
||||
.Option(o => o.Unique = true)
|
||||
.CreateAsync(token);
|
||||
await Plain<RootToAvatar>(token, r => r.AvatarId);
|
||||
|
||||
await Unique<ReservedName>(r => r.Name, Builders<ReservedName>.Filter.Type(r => r.Name, BsonType.String), token);
|
||||
await Unique<RootUser>(u => u.UserName, Builders<RootUser>.Filter.Type(u => u.UserName, BsonType.String), token);
|
||||
await Plain<Avatar>(token, a => a.UserName);
|
||||
await Plain<Group>(token, g => g.UserName);
|
||||
await Plain<Group>(token, g => g.InvitationCode);
|
||||
|
||||
await Plain<DmGroup>(token, g => g.ParticipantsKey);
|
||||
|
||||
await Plain<Delivery>(token, d => d.DeliveredAt, d => d.AbandonedAt, d => d.NextAttemptAt);
|
||||
}
|
||||
|
||||
static async Task Unique<T>(System.Linq.Expressions.Expression<Func<T, object>> key, FilterDefinition<T> partial,
|
||||
CancellationToken token) where T : IEntity =>
|
||||
await DB.Default.Index<T>()
|
||||
.Key(key, KeyType.Ascending)
|
||||
.Option(o =>
|
||||
{
|
||||
o.Unique = true;
|
||||
o.PartialFilterExpression = partial;
|
||||
})
|
||||
.CreateAsync(token);
|
||||
|
||||
static async Task Plain<T>(CancellationToken token, params System.Linq.Expressions.Expression<Func<T, object>>[] keys) where T : IEntity
|
||||
{
|
||||
var index = DB.Default.Index<T>();
|
||||
foreach (var key in keys)
|
||||
index.Key(key, KeyType.Ascending);
|
||||
await index.CreateAsync(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Group;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.User;
|
||||
|
||||
namespace PrivaPub.Infrastructure.Data.Migrations
|
||||
{
|
||||
public class _001_deduplicate_before_unique_indexes : IMigration
|
||||
{
|
||||
public async Task UpgradeAsync()
|
||||
{
|
||||
var foreignAvatars = await DB.Default.Find<ForeignAvatar>().Match(a => a.ActorURI != null).ExecuteAsync();
|
||||
await DeleteAllBut(foreignAvatars.GroupBy(a => a.ActorURI).Select(g => g.OrderByDescending(a => a.UpdatedAt)), DeleteForeignAvatar);
|
||||
|
||||
var posts = await DB.Default.Find<Post>().Match(p => p.ObjectURI != null).ExecuteAsync();
|
||||
await DeleteAllBut(posts.GroupBy(p => p.ObjectURI).Select(g => g.OrderBy(p => p.ID, StringComparer.Ordinal)), DeletePost);
|
||||
|
||||
var dmPosts = await DB.Default.Find<DmPost>().Match(p => p.ObjectURI != null).ExecuteAsync();
|
||||
await DeleteAllBut(dmPosts.GroupBy(p => p.ObjectURI).Select(g => g.OrderBy(p => p.ID, StringComparer.Ordinal)), DeleteDmPost);
|
||||
|
||||
var followers = await DB.Default.Find<Follower>().ExecuteAsync();
|
||||
await DeleteAllBut(followers.GroupBy(f => (f.LocalActorId, f.LocalActorKind, f.ActorURI))
|
||||
.Select(g => g.OrderByDescending(f => f.IsAccepted).ThenBy(f => f.ID, StringComparer.Ordinal)), DeleteFollower);
|
||||
|
||||
var links = await DB.Default.Find<RootToAvatar>().ExecuteAsync();
|
||||
await DeleteAllBut(links.GroupBy(r => (r.RootId, r.AvatarId)).Select(g => g.OrderBy(r => r.ID, StringComparer.Ordinal)), DeleteLink);
|
||||
|
||||
var taken = new HashSet<string>(StringComparer.Ordinal) { LocalActorService.InstanceUserName };
|
||||
var reservations = new List<ReservedName>
|
||||
{
|
||||
new() { Name = LocalActorService.InstanceUserName, OwnerKind = LocalActorKind.Application }
|
||||
};
|
||||
foreach (var avatar in (await DB.Default.Find<Avatar>().ExecuteAsync()).OrderBy(a => a.ID, StringComparer.Ordinal))
|
||||
if (!string.IsNullOrEmpty(avatar.UserName) && taken.Add(avatar.UserName.ToLowerInvariant()))
|
||||
reservations.Add(new() { Name = avatar.UserName.ToLowerInvariant(), OwnerKind = LocalActorKind.Person, OwnerId = avatar.ID });
|
||||
foreach (var group in (await DB.Default.Find<Group>().ExecuteAsync()).OrderBy(g => g.ID, StringComparer.Ordinal))
|
||||
if (!string.IsNullOrEmpty(group.UserName) && taken.Add(group.UserName.ToLowerInvariant()))
|
||||
reservations.Add(new() { Name = group.UserName.ToLowerInvariant(), OwnerKind = LocalActorKind.Group, OwnerId = group.ID });
|
||||
await DB.Default.DeleteAsync<ReservedName>(_ => true);
|
||||
await DB.Default.SaveAsync(reservations);
|
||||
}
|
||||
|
||||
static async Task DeleteAllBut<T>(IEnumerable<IOrderedEnumerable<T>> groups, Func<T, Task> delete)
|
||||
{
|
||||
foreach (var group in groups)
|
||||
foreach (var duplicate in group.Skip(1))
|
||||
await delete(duplicate);
|
||||
}
|
||||
|
||||
static Task DeleteForeignAvatar(ForeignAvatar a) => DB.Default.DeleteAsync<ForeignAvatar>(a.ID);
|
||||
static Task DeletePost(Post p) => DB.Default.DeleteAsync<Post>(p.ID);
|
||||
static Task DeleteDmPost(DmPost p) => DB.Default.DeleteAsync<DmPost>(p.ID);
|
||||
static Task DeleteFollower(Follower f) => DB.Default.DeleteAsync<Follower>(f.ID);
|
||||
static Task DeleteLink(RootToAvatar r) => DB.Default.DeleteAsync<RootToAvatar>(r.ID);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Federation.Objects;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.User;
|
||||
|
||||
namespace PrivaPub.Infrastructure.Data.Migrations
|
||||
{
|
||||
public class _002_sanitize_stored_content : IMigration
|
||||
{
|
||||
public async Task UpgradeAsync()
|
||||
{
|
||||
foreach (var post in await DB.Default.Find<Post>().ExecuteAsync())
|
||||
{
|
||||
var html = post.IsFederatedCopy ? ContentSanitizer.Html(post.Text) : ActivityPubRenderer.Html(post.Text);
|
||||
await DB.Default.Update<Post>().MatchID(post.ID)
|
||||
.Modify(p => p.Text, post.IsFederatedCopy ? html : post.Text)
|
||||
.Modify(p => p.ContentHtml, html)
|
||||
.Modify(p => p.ContentFormat, post.IsFederatedCopy ? ContentFormat.Html : ContentFormat.Markdown)
|
||||
.ExecuteAsync();
|
||||
}
|
||||
|
||||
foreach (var post in await DB.Default.Find<DmPost>().ExecuteAsync())
|
||||
{
|
||||
var html = post.IsFederatedCopy ? ContentSanitizer.Html(post.Text) : ActivityPubRenderer.Html(post.Text);
|
||||
await DB.Default.Update<DmPost>().MatchID(post.ID)
|
||||
.Modify(p => p.Text, post.IsFederatedCopy ? html : post.Text)
|
||||
.Modify(p => p.ContentHtml, html)
|
||||
.Modify(p => p.ContentFormat, post.IsFederatedCopy ? ContentFormat.Html : ContentFormat.Markdown)
|
||||
.ExecuteAsync();
|
||||
}
|
||||
|
||||
foreach (var avatar in await DB.Default.Find<ForeignAvatar>().ExecuteAsync())
|
||||
await DB.Default.Update<ForeignAvatar>().MatchID(avatar.ID)
|
||||
.Modify(a => a.Biography, ContentSanitizer.Html(avatar.Biography))
|
||||
.ExecuteAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Models.Group;
|
||||
using PrivaPub.Models.Post;
|
||||
|
||||
namespace PrivaPub.Infrastructure.Data.Migrations
|
||||
{
|
||||
public class _003_conversation_keys_and_circles : IMigration
|
||||
{
|
||||
public async Task UpgradeAsync()
|
||||
{
|
||||
foreach (var dmGroup in await DB.Default.Find<DmGroup>().ExecuteAsync())
|
||||
await DB.Default.Update<DmGroup>().MatchID(dmGroup.ID)
|
||||
.Modify(g => g.ParticipantsKey, DmGroup.KeyOf(dmGroup.Members))
|
||||
.ExecuteAsync();
|
||||
|
||||
await DB.Default.Update<Group>()
|
||||
.Match(_ => true)
|
||||
.Modify(g => g.Kind, GroupKind.Circle)
|
||||
.ExecuteAsync();
|
||||
|
||||
await DB.Default.Update<Post>()
|
||||
.Match(p => p.GroupId != null && !p.IsFederatedCopy)
|
||||
.Modify(p => p.IsLocalOnly, true)
|
||||
.ExecuteAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace PrivaPub.Infrastructure.Http
|
||||
{
|
||||
public sealed class FetchedJson : IDisposable
|
||||
{
|
||||
public Uri FinalUri { get; init; }
|
||||
public JsonDocument Document { get; init; }
|
||||
public JsonElement Root => Document.RootElement;
|
||||
|
||||
public void Dispose() => Document?.Dispose();
|
||||
}
|
||||
|
||||
public interface IFederationHttp
|
||||
{
|
||||
bool IsAllowed(Uri target);
|
||||
Task<FetchedJson> GetJson(string url, string accept, Action<HttpRequestMessage> sign, CancellationToken token);
|
||||
Task<HttpResponseMessage> Send(HttpRequestMessage request, CancellationToken token);
|
||||
}
|
||||
|
||||
public class FederationHttp : IFederationHttp
|
||||
{
|
||||
public const string ClientName = "Federation";
|
||||
public const int MaxResponseBytes = 1024 * 1024;
|
||||
public static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(15);
|
||||
|
||||
const int MaxRedirects = 3;
|
||||
static readonly TimeSpan NegativeCacheLifetime = TimeSpan.FromMinutes(5);
|
||||
static readonly string[] JsonMediaTypes =
|
||||
{
|
||||
"application/activity+json",
|
||||
"application/ld+json",
|
||||
"application/jrd+json",
|
||||
"application/json"
|
||||
};
|
||||
|
||||
readonly IHttpClientFactory _httpClientFactory;
|
||||
readonly IMemoryCache _cache;
|
||||
readonly IOptionsMonitor<FederationOptions> _options;
|
||||
readonly ILogger<FederationHttp> _logger;
|
||||
|
||||
public FederationHttp(IHttpClientFactory httpClientFactory, IMemoryCache cache, IOptionsMonitor<FederationOptions> options,
|
||||
ILogger<FederationHttp> logger)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_cache = cache;
|
||||
_options = options;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public bool IsAllowed(Uri target)
|
||||
{
|
||||
if (target is not { IsAbsoluteUri: true } || !string.IsNullOrEmpty(target.UserInfo))
|
||||
return false;
|
||||
var options = _options.CurrentValue;
|
||||
if (target.Scheme != Uri.UriSchemeHttps && !(options.AllowPlainHttp && target.Scheme == Uri.UriSchemeHttp))
|
||||
return false;
|
||||
if (options.AllowPrivateNetworks)
|
||||
return true;
|
||||
return target.HostNameType == UriHostNameType.Dns
|
||||
&& target.Host.Contains('.')
|
||||
&& !target.Host.EndsWith(".localhost", StringComparison.OrdinalIgnoreCase)
|
||||
&& !target.Host.EndsWith(".local", StringComparison.OrdinalIgnoreCase)
|
||||
&& !target.Host.EndsWith(".internal", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public async Task<FetchedJson> GetJson(string url, string accept, Action<HttpRequestMessage> sign, CancellationToken token)
|
||||
{
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var target) || !IsAllowed(target))
|
||||
return default;
|
||||
var negativeKey = NegativeKey(target);
|
||||
if (_cache.TryGetValue(negativeKey, out _))
|
||||
return default;
|
||||
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(token);
|
||||
timeout.CancelAfter(RequestTimeout);
|
||||
try
|
||||
{
|
||||
for (var hop = 0; hop <= MaxRedirects; hop++)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, target);
|
||||
request.Headers.Accept.ParseAdd(accept);
|
||||
sign?.Invoke(request);
|
||||
|
||||
using var response = await _httpClientFactory.CreateClient(ClientName)
|
||||
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token);
|
||||
|
||||
if (IsRedirect(response.StatusCode))
|
||||
{
|
||||
var location = response.Headers.Location;
|
||||
var next = location == default ? default : location.IsAbsoluteUri ? location : new Uri(target, location);
|
||||
if (!IsAllowed(next))
|
||||
return Refuse(negativeKey, url, "a redirect to a disallowed location");
|
||||
target = next;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return Refuse(negativeKey, url, $"status {(int)response.StatusCode}");
|
||||
|
||||
var mediaType = response.Content.Headers.ContentType?.MediaType;
|
||||
if (mediaType == default || !JsonMediaTypes.Contains(mediaType, StringComparer.OrdinalIgnoreCase))
|
||||
return Refuse(negativeKey, url, $"content type '{mediaType}'");
|
||||
if (response.Content.Headers.ContentLength > MaxResponseBytes)
|
||||
return Refuse(negativeKey, url, "a body over the size limit");
|
||||
|
||||
var body = await ReadBounded(response.Content, MaxResponseBytes, timeout.Token);
|
||||
if (body == default)
|
||||
return Refuse(negativeKey, url, "a body over the size limit");
|
||||
|
||||
return new FetchedJson { FinalUri = target, Document = JsonDocument.Parse(body) };
|
||||
}
|
||||
return Refuse(negativeKey, url, "too many redirects");
|
||||
}
|
||||
catch (OperationCanceledException) when (!token.IsCancellationRequested)
|
||||
{
|
||||
return Refuse(negativeKey, url, "a timeout");
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or JsonException or BlockedDestinationException)
|
||||
{
|
||||
return Refuse(negativeKey, url, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<HttpResponseMessage> Send(HttpRequestMessage request, CancellationToken token)
|
||||
{
|
||||
if (!IsAllowed(request.RequestUri))
|
||||
throw new BlockedDestinationException(request.RequestUri?.Host);
|
||||
return await _httpClientFactory.CreateClient(ClientName).SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token);
|
||||
}
|
||||
|
||||
public static async Task<byte[]> ReadBounded(HttpContent content, int limit, CancellationToken token)
|
||||
{
|
||||
await using var stream = await content.ReadAsStreamAsync(token);
|
||||
using var buffer = new MemoryStream();
|
||||
var chunk = new byte[16 * 1024];
|
||||
int read;
|
||||
while ((read = await stream.ReadAsync(chunk, token)) > 0)
|
||||
{
|
||||
if (buffer.Length + read > limit)
|
||||
return default;
|
||||
buffer.Write(chunk, 0, read);
|
||||
}
|
||||
return buffer.ToArray();
|
||||
}
|
||||
|
||||
static bool IsRedirect(HttpStatusCode status) =>
|
||||
status is HttpStatusCode.MovedPermanently or HttpStatusCode.Found or HttpStatusCode.SeeOther
|
||||
or HttpStatusCode.TemporaryRedirect or HttpStatusCode.PermanentRedirect;
|
||||
|
||||
static string NegativeKey(Uri target) => "federation-http:refused:" + target.AbsoluteUri;
|
||||
|
||||
FetchedJson Refuse(string negativeKey, string url, string reason)
|
||||
{
|
||||
_cache.Set(negativeKey, true, NegativeCacheLifetime);
|
||||
_logger.LogInformation("GET {Url} refused: {Reason}", url, reason);
|
||||
return default;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace PrivaPub.Infrastructure.Http
|
||||
{
|
||||
public class FederationOptions
|
||||
{
|
||||
public bool AllowPrivateNetworks { get; set; }
|
||||
public bool AllowPlainHttp { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace PrivaPub.Infrastructure.Http
|
||||
{
|
||||
public static class IpRangeGuard
|
||||
{
|
||||
static readonly IPNetwork[] BlockedV4 =
|
||||
{
|
||||
IPNetwork.Parse("0.0.0.0/8"),
|
||||
IPNetwork.Parse("10.0.0.0/8"),
|
||||
IPNetwork.Parse("100.64.0.0/10"),
|
||||
IPNetwork.Parse("127.0.0.0/8"),
|
||||
IPNetwork.Parse("169.254.0.0/16"),
|
||||
IPNetwork.Parse("172.16.0.0/12"),
|
||||
IPNetwork.Parse("192.0.0.0/24"),
|
||||
IPNetwork.Parse("192.0.2.0/24"),
|
||||
IPNetwork.Parse("192.88.99.0/24"),
|
||||
IPNetwork.Parse("192.168.0.0/16"),
|
||||
IPNetwork.Parse("198.18.0.0/15"),
|
||||
IPNetwork.Parse("198.51.100.0/24"),
|
||||
IPNetwork.Parse("203.0.113.0/24"),
|
||||
IPNetwork.Parse("224.0.0.0/4"),
|
||||
IPNetwork.Parse("240.0.0.0/4")
|
||||
};
|
||||
|
||||
static readonly IPNetwork GlobalUnicastV6 = IPNetwork.Parse("2000::/3");
|
||||
|
||||
static readonly IPNetwork[] BlockedV6 =
|
||||
{
|
||||
IPNetwork.Parse("2001::/32"),//Teredo
|
||||
IPNetwork.Parse("2001:2::/48"),
|
||||
IPNetwork.Parse("2001:10::/28"),
|
||||
IPNetwork.Parse("2001:20::/28"),
|
||||
IPNetwork.Parse("2001:db8::/32"),
|
||||
IPNetwork.Parse("2002::/16"),//6to4
|
||||
IPNetwork.Parse("3fff::/20")
|
||||
};
|
||||
|
||||
public static bool IsPublic(IPAddress address)
|
||||
{
|
||||
if (address == default)
|
||||
return false;
|
||||
if (address.IsIPv4MappedToIPv6)
|
||||
address = address.MapToIPv4();
|
||||
|
||||
return address.AddressFamily switch
|
||||
{
|
||||
AddressFamily.InterNetwork => !BlockedV4.Any(range => range.Contains(address)),
|
||||
AddressFamily.InterNetworkV6 => GlobalUnicastV6.Contains(address) && !BlockedV6.Any(range => range.Contains(address)),
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace PrivaPub.Infrastructure.Http
|
||||
{
|
||||
public sealed class BlockedDestinationException : Exception
|
||||
{
|
||||
public BlockedDestinationException(string host) : base($"'{host}' does not resolve to a public address") { }
|
||||
}
|
||||
|
||||
public static class SafeHttpHandlerFactory
|
||||
{
|
||||
public static SocketsHttpHandler Create(FederationOptions options) => new()
|
||||
{
|
||||
AllowAutoRedirect = false,
|
||||
UseProxy = false,
|
||||
UseCookies = false,
|
||||
AutomaticDecompression = DecompressionMethods.All,
|
||||
ConnectTimeout = TimeSpan.FromSeconds(10),
|
||||
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
|
||||
MaxResponseHeadersLength = 64,
|
||||
ConnectCallback = (context, token) => Connect(context.DnsEndPoint, options.AllowPrivateNetworks, token)
|
||||
};
|
||||
|
||||
public static async ValueTask<Stream> Connect(DnsEndPoint endPoint, bool allowPrivateNetworks, CancellationToken token)
|
||||
{
|
||||
var addresses = await Resolve(endPoint.Host, token);
|
||||
if (addresses.Length == 0 || !allowPrivateNetworks && !addresses.All(IpRangeGuard.IsPublic))
|
||||
throw new BlockedDestinationException(endPoint.Host);
|
||||
|
||||
var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
|
||||
try
|
||||
{
|
||||
await socket.ConnectAsync(addresses, endPoint.Port, token);
|
||||
return new NetworkStream(socket, ownsSocket: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
socket.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
static async Task<IPAddress[]> Resolve(string host, CancellationToken token)
|
||||
{
|
||||
if (IPAddress.TryParse(host.Trim('[', ']'), out var literal))
|
||||
return new[] { literal };
|
||||
try
|
||||
{
|
||||
return await Dns.GetHostAddressesAsync(host, token);
|
||||
}
|
||||
catch (SocketException)
|
||||
{
|
||||
return Array.Empty<IPAddress>();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
using PrivaPub.Federation.Objects;
|
||||
using PrivaPub.Federation.Signing;
|
||||
|
||||
using System.Threading.RateLimiting;
|
||||
|
||||
namespace PrivaPub.Infrastructure
|
||||
{
|
||||
public static class RateLimiting
|
||||
{
|
||||
public const string Accounts = "accounts";
|
||||
public const string Inbox = "inbox";
|
||||
|
||||
public static IServiceCollection PrivaPubRateLimiting(this IServiceCollection service) =>
|
||||
service.AddRateLimiter(options =>
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
options.AddPolicy(Accounts, context => RateLimitPartition.GetFixedWindowLimiter(
|
||||
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||
_ => new FixedWindowRateLimiterOptions { PermitLimit = 10, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 }));
|
||||
options.AddPolicy(Inbox, context => RateLimitPartition.GetTokenBucketLimiter(
|
||||
SenderOrigin(context.Request) ?? "unsigned:" + context.Connection.RemoteIpAddress,
|
||||
_ => new TokenBucketRateLimiterOptions
|
||||
{
|
||||
TokenLimit = 300,
|
||||
TokensPerPeriod = 50,
|
||||
ReplenishmentPeriod = TimeSpan.FromSeconds(10),
|
||||
QueueLimit = 0
|
||||
}));
|
||||
});
|
||||
|
||||
static string SenderOrigin(HttpRequest request)
|
||||
{
|
||||
var signature = request.Headers["Signature"].ToString();
|
||||
return string.IsNullOrEmpty(signature) ? default : Origin.Of(HttpSignatures.Parse(signature)?.KeyId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,11 @@ using System.Text.Json.Serialization;
|
||||
using Microsoft.OpenApi;
|
||||
using PrivaPub.Services.ClientToServer.Private;
|
||||
using PrivaPub.Services.ClientToServer.Public;
|
||||
using PrivaPub.Services.Federation;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Outbox;
|
||||
using PrivaPub.Federation.Inbox;
|
||||
using PrivaPub.Infrastructure.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace PrivaPub.Middleware
|
||||
{
|
||||
@@ -30,14 +34,19 @@ namespace PrivaPub.Middleware
|
||||
//.AddHostedService<GroupsCleanerWorker>()
|
||||
//.AddHostedService<PoliciesCleanerWorker>();
|
||||
}
|
||||
public static IServiceCollection PrivaPubFederationConfiguration(this IServiceCollection service)
|
||||
public static IServiceCollection PrivaPubFederationConfiguration(this IServiceCollection service, IConfiguration configuration)
|
||||
{
|
||||
service.AddHttpClient(RemoteActorService.HttpClientName, client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(20);
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd($"PrivaPub/{BuildInfo.Ref}");
|
||||
});
|
||||
service.Configure<FederationOptions>(configuration.GetSection("Federation"));
|
||||
service.AddHttpClient(FederationHttp.ClientName, (provider, client) =>
|
||||
{
|
||||
var baseAddress = provider.GetRequiredService<IOptionsMonitor<AppConfiguration>>().CurrentValue.BackendBaseAddress?.TrimEnd('/');
|
||||
client.Timeout = FederationHttp.RequestTimeout;
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd($"PrivaPub/{BuildInfo.Ref} (+{baseAddress}/)");
|
||||
})
|
||||
.ConfigurePrimaryHttpMessageHandler(provider =>
|
||||
SafeHttpHandlerFactory.Create(provider.GetRequiredService<IOptions<FederationOptions>>().Value));
|
||||
return service
|
||||
.AddSingleton<IFederationHttp, FederationHttp>()
|
||||
.AddSingleton<ILocalActorService, LocalActorService>()
|
||||
.AddSingleton<IRemoteActorService, RemoteActorService>()
|
||||
.AddSingleton<IDeliveryService, DeliveryService>()
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace PrivaPub.Models.Group
|
||||
{
|
||||
public class DmGroup : Entity
|
||||
@@ -7,9 +10,14 @@ namespace PrivaPub.Models.Group
|
||||
public string Description { get; set; }
|
||||
public List<GroupMember> Members { get; set; } = new();
|
||||
public string ConversationURI { get; set; }//context of the federated thread
|
||||
public string ParticipantsKey { get; set; }
|
||||
|
||||
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? DeletionAt { get; set; }
|
||||
|
||||
public static string KeyOf(IEnumerable<GroupMember> members) =>
|
||||
Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join("\n",
|
||||
members.Select(m => (m.IsForeign ? "remote:" : "local:") + m.AvatarId).Distinct().Order(StringComparer.Ordinal)))));
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ namespace PrivaPub.Models.Group
|
||||
public string PrivateKey { get; set; }
|
||||
public string PublicKey { get; set; }
|
||||
|
||||
public GroupKind Kind { get; set; }
|
||||
public bool IsDiscoverable { get; set; } = true;
|
||||
public bool ManuallyApprovesMembers { get; set; }
|
||||
public string OwnerAvatarId { get; set; }
|
||||
@@ -38,6 +39,12 @@ namespace PrivaPub.Models.Group
|
||||
public DateTime JoinedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public enum GroupKind
|
||||
{
|
||||
Circle,
|
||||
Community
|
||||
}
|
||||
|
||||
public enum GroupRole
|
||||
{
|
||||
Member,
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace PrivaPub.Models.Post
|
||||
{
|
||||
public enum ContentFormat
|
||||
{
|
||||
Markdown,
|
||||
Html
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ namespace PrivaPub.Models.Post
|
||||
public string GroupId { get; set; }
|
||||
public string Title { get; set; }
|
||||
public string Text { get; set; }
|
||||
public string ContentHtml { get; set; }
|
||||
public ContentFormat ContentFormat { get; set; }
|
||||
public List<PostMedia> Media { get; set; } = new();
|
||||
public List<float> Location { get; set; } = new();
|
||||
public bool HasContentWarning { get; set; } = false;
|
||||
|
||||
@@ -9,10 +9,13 @@ namespace PrivaPub.Models.Post
|
||||
public string GroupId { get; set; }
|
||||
public string Title { get; set; }
|
||||
public string Text { get; set; }
|
||||
public string ContentHtml { get; set; }
|
||||
public ContentFormat ContentFormat { get; set; }
|
||||
public List<PostMedia> Media { get; set; } = new();
|
||||
public List<float> Location { get; set; } = new();
|
||||
public float RangeKm { get; set; } = 5.0f;
|
||||
public bool HasContentWarning { get; set; } = false;
|
||||
public bool IsLocalOnly { get; set; }
|
||||
|
||||
public bool IsFederatedCopy { get; set; }
|
||||
public string ObjectURI { get; set; }//the Note's id
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
namespace PrivaPub.Models.Post
|
||||
{
|
||||
public class PostBoost
|
||||
{
|
||||
public string PostId { get; set; }
|
||||
public string GroupUserId { get; set; }
|
||||
|
||||
public bool IsFederatedCopy { get; set; }
|
||||
|
||||
public DateTime CreationDate { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Models.Federation;
|
||||
|
||||
namespace PrivaPub.Models.User
|
||||
{
|
||||
public class ReservedName : Entity
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public LocalActorKind OwnerKind { get; set; }
|
||||
public string OwnerId { get; set; }
|
||||
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="HtmlSanitizer" Version="9.2.1039" />
|
||||
<PackageReference Include="MailKit" Version="4.18.0" />
|
||||
<PackageReference Include="Markdig" Version="1.4.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
|
||||
|
||||
+25
-3
@@ -11,6 +11,10 @@ using Serilog;
|
||||
|
||||
using PrivaPub.Data;
|
||||
using PrivaPub.Extensions;
|
||||
using PrivaPub.Infrastructure;
|
||||
using PrivaPub.Infrastructure.Cli;
|
||||
using PrivaPub.Infrastructure.Data;
|
||||
using PrivaPub.Infrastructure.Http;
|
||||
using PrivaPub.Middleware;
|
||||
using PrivaPub.Models;
|
||||
using PrivaPub.Services;
|
||||
@@ -47,8 +51,9 @@ try
|
||||
.PrivaPubOptimizationConfiguration()
|
||||
.PrivaPubDataBaseConfiguration()
|
||||
.PrivaPubServicesConfiguration()
|
||||
.PrivaPubFederationConfiguration()
|
||||
.PrivaPubFederationConfiguration(builder.Configuration)
|
||||
.PrivaPubCORSConfiguration()
|
||||
.PrivaPubRateLimiting()
|
||||
.PrivaPubMiddlewareConfiguration();
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -57,11 +62,24 @@ try
|
||||
throw;
|
||||
}
|
||||
|
||||
var federationOptions = builder.Configuration.GetSection("Federation").Get<FederationOptions>() ?? new();
|
||||
if (builder.Environment.IsProduction() && (federationOptions.AllowPrivateNetworks || federationOptions.AllowPlainHttp))
|
||||
throw new InvalidOperationException("Federation:AllowPrivateNetworks and Federation:AllowPlainHttp are for test networks and must stay off in Production.");
|
||||
|
||||
try
|
||||
{
|
||||
BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard));
|
||||
var mongoSettings = builder.Configuration.GetSection(nameof(MongoSettings)).Get<MongoSettings>();
|
||||
await DB.InitAsync(mongoSettings.Database, MongoClientSettings.FromConnectionString(mongoSettings.ConnectionString));
|
||||
EntityMaps.Warm();
|
||||
await DB.Default.MigrateAsync<Program>();
|
||||
await Indexes.Create();
|
||||
|
||||
if (args is ["admin", ..])
|
||||
{
|
||||
Environment.ExitCode = await AdminCommands.Run(args[1..]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -92,8 +110,11 @@ try
|
||||
});
|
||||
}
|
||||
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
app.UseCors("DefaultCORS");
|
||||
@@ -103,6 +124,7 @@ try
|
||||
app.UseRequestLocalization(await localizationService.Get());
|
||||
|
||||
app.UseRouting();
|
||||
app.UseRateLimiter();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
namespace PrivaPub.Services
|
||||
{
|
||||
public class ActivityPubClient : HttpClient
|
||||
{
|
||||
//readonly HttpClient client;
|
||||
|
||||
//public ActivityPubClient(HttpClient client)
|
||||
//{
|
||||
// this.client = client;
|
||||
//}
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,11 @@ using MongoDB.Entities;
|
||||
using PrivaPub.ClientModels;
|
||||
using PrivaPub.ClientModels.User;
|
||||
using PrivaPub.ClientModels.User.Avatar;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.Resources;
|
||||
using PrivaPub.Services.Federation;
|
||||
using PrivaPub.StaticServices;
|
||||
using PrivaPub.Federation.Actors;
|
||||
|
||||
namespace PrivaPub.Services.ClientToServer.Private
|
||||
{
|
||||
@@ -64,6 +65,9 @@ namespace PrivaPub.Services.ClientToServer.Private
|
||||
PublicKey = publicKey,
|
||||
Domain = _localActors.BaseAddress
|
||||
};
|
||||
newAvatar.ID = (string)newAvatar.GenerateNewID();
|
||||
if (!await _localActors.TryReserveUserName(userName, LocalActorKind.Person, newAvatar.ID, default))
|
||||
return result.Invalidate(_localizer["The username '{0}' is already take.", userName]);
|
||||
if (form.Settings is { IsDefault: false })
|
||||
newAvatar.Settings = ToSettings(form.Settings);
|
||||
|
||||
@@ -81,7 +85,7 @@ namespace PrivaPub.Services.ClientToServer.Private
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"{nameof(PrivateAvatarUsersService)}.{nameof(InsertAvatar)}");
|
||||
return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex);
|
||||
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +122,7 @@ namespace PrivaPub.Services.ClientToServer.Private
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"{nameof(PrivateAvatarUsersService)}.{nameof(UpdateAvatar)}");
|
||||
return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex);
|
||||
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +143,7 @@ namespace PrivaPub.Services.ClientToServer.Private
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"{nameof(PrivateAvatarUsersService)}.{nameof(GetRootAvatars)}");
|
||||
return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex);
|
||||
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace PrivaPub.Services.Federation
|
||||
{
|
||||
public interface IRemoteActorService
|
||||
{
|
||||
Task<JsonDocument> Fetch(string uri, LocalActor signAs, CancellationToken token);
|
||||
Task<ForeignAvatar> GetActor(string actorUri, LocalActor signAs, bool refresh, CancellationToken token);
|
||||
Task<ForeignAvatar> GetActorByKeyId(string keyId, LocalActor signAs, bool refresh, CancellationToken token);
|
||||
Task<string> ResolveHandle(string handle, CancellationToken token);
|
||||
}
|
||||
|
||||
public class RemoteActorService : IRemoteActorService
|
||||
{
|
||||
public const string HttpClientName = "ActivityPub";
|
||||
public const string ActivityJson = "application/activity+json";
|
||||
const string Accept = "application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"";
|
||||
static readonly TimeSpan CacheLifetime = TimeSpan.FromDays(1);
|
||||
|
||||
readonly IHttpClientFactory _httpClientFactory;
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly ILogger<RemoteActorService> _logger;
|
||||
|
||||
public RemoteActorService(IHttpClientFactory httpClientFactory, DbEntities dbEntities, ILogger<RemoteActorService> logger)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_dbEntities = dbEntities;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<JsonDocument> Fetch(string uri, LocalActor signAs, CancellationToken token)
|
||||
{
|
||||
if (!Uri.TryCreate(uri, UriKind.Absolute, out var target) || !IsFetchable(target))
|
||||
return default;
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, target);
|
||||
request.Headers.Accept.ParseAdd(Accept);
|
||||
if (signAs != default)
|
||||
HttpSignatures.Sign(request, signAs, body: null);
|
||||
|
||||
using var response = await _httpClientFactory.CreateClient(HttpClientName).SendAsync(request, token);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogInformation("GET {Uri} answered {Status}", uri, (int)response.StatusCode);
|
||||
return default;
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(token);
|
||||
return await JsonDocument.ParseAsync(stream, cancellationToken: token);
|
||||
}
|
||||
|
||||
public async Task<ForeignAvatar> GetActor(string actorUri, LocalActor signAs, bool refresh, CancellationToken token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(actorUri))
|
||||
return default;
|
||||
actorUri = StripFragment(actorUri);
|
||||
|
||||
var cached = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == actorUri).ExecuteFirstAsync(token);
|
||||
if (cached != default && !refresh && DateTime.UtcNow - cached.UpdatedAt < CacheLifetime)
|
||||
return cached;
|
||||
|
||||
using var document = await Fetch(actorUri, signAs, token);
|
||||
if (document == default)
|
||||
return cached;
|
||||
|
||||
return await Upsert(document.RootElement, cached, token);
|
||||
}
|
||||
|
||||
public async Task<ForeignAvatar> GetActorByKeyId(string keyId, LocalActor signAs, bool refresh, CancellationToken token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(keyId))
|
||||
return default;
|
||||
|
||||
if (!refresh)
|
||||
{
|
||||
var cached = await _dbEntities.ForeignAvatars.Match(a => a.PublicKeyId == keyId).ExecuteFirstAsync(token);
|
||||
if (cached != default && !string.IsNullOrEmpty(cached.PublicKey))
|
||||
return cached;
|
||||
}
|
||||
|
||||
using var document = await Fetch(StripFragment(keyId), signAs, token);
|
||||
if (document == default)
|
||||
return default;
|
||||
|
||||
var root = document.RootElement;
|
||||
if (root.TryGetProperty("publicKey", out _))
|
||||
{
|
||||
var actorUri = Text(root, "id");
|
||||
var existing = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == actorUri).ExecuteFirstAsync(token);
|
||||
return await Upsert(root, existing, token);
|
||||
}
|
||||
|
||||
var owner = Text(root, "owner");
|
||||
return owner == default ? default : await GetActor(owner, signAs, refresh: true, token);
|
||||
}
|
||||
|
||||
public async Task<string> ResolveHandle(string handle, CancellationToken token)
|
||||
{
|
||||
var parts = handle?.TrimStart('@').Split('@');
|
||||
if (parts is not { Length: 2 } || string.IsNullOrEmpty(parts[0]) || string.IsNullOrEmpty(parts[1]))
|
||||
return default;
|
||||
|
||||
var url = $"https://{parts[1]}/.well-known/webfinger?resource={Uri.EscapeDataString($"acct:{parts[0]}@{parts[1]}")}";
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var target) || !IsFetchable(target))
|
||||
return default;
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, target);
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/jrd+json"));
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
using var response = await _httpClientFactory.CreateClient(HttpClientName).SendAsync(request, token);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return default;
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(token);
|
||||
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: token);
|
||||
if (!document.RootElement.TryGetProperty("links", out var links) || links.ValueKind != JsonValueKind.Array)
|
||||
return default;
|
||||
|
||||
foreach (var link in links.EnumerateArray())
|
||||
{
|
||||
var type = Text(link, "type") ?? string.Empty;
|
||||
if (Text(link, "rel") == "self" && (type.Contains("activity+json") || type.Contains("ld+json")))
|
||||
return Text(link, "href");
|
||||
}
|
||||
return default;
|
||||
}
|
||||
|
||||
async Task<ForeignAvatar> Upsert(JsonElement actor, ForeignAvatar existing, CancellationToken token)
|
||||
{
|
||||
var actorUri = Text(actor, "id");
|
||||
if (string.IsNullOrEmpty(actorUri))
|
||||
return existing;
|
||||
|
||||
var avatar = existing ?? new ForeignAvatar { ActorURI = actorUri, CreatedAt = DateTime.UtcNow };
|
||||
avatar.ActorURI = actorUri;
|
||||
avatar.UserName = Text(actor, "preferredUsername");
|
||||
avatar.Name = Text(actor, "name");
|
||||
avatar.Biography = Text(actor, "summary");
|
||||
avatar.Url = Text(actor, "url") ?? actorUri;
|
||||
avatar.Domain = new Uri(actorUri).Authority;
|
||||
avatar.InboxURL = Text(actor, "inbox");
|
||||
avatar.OutboxURL = Text(actor, "outbox");
|
||||
avatar.IsDiscoverable = !actor.TryGetProperty("discoverable", out var discoverable) || discoverable.ValueKind != JsonValueKind.False;
|
||||
avatar.AvatarType = Enum.TryParse<AvatarType>(Text(actor, "type"), out var type) ? type : AvatarType.Person;
|
||||
if (actor.TryGetProperty("endpoints", out var endpoints) && endpoints.ValueKind == JsonValueKind.Object)
|
||||
avatar.SharedInboxURL = Text(endpoints, "sharedInbox");
|
||||
if (actor.TryGetProperty("publicKey", out var publicKey) && publicKey.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
avatar.PublicKeyId = Text(publicKey, "id");
|
||||
avatar.PublicKey = Text(publicKey, "publicKeyPem");
|
||||
}
|
||||
if (actor.TryGetProperty("icon", out var icon) && icon.ValueKind == JsonValueKind.Object)
|
||||
avatar.PictureURL = Text(icon, "url");
|
||||
avatar.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await DB.Default.SaveAsync(avatar, token);
|
||||
return avatar;
|
||||
}
|
||||
|
||||
public static bool IsFetchable(Uri target) =>
|
||||
target.Scheme == Uri.UriSchemeHttps
|
||||
&& target.HostNameType == UriHostNameType.Dns
|
||||
&& !target.IsLoopback
|
||||
&& !target.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase)
|
||||
&& target.Host.Contains('.');
|
||||
|
||||
public static string StripFragment(string uri)
|
||||
{
|
||||
var hash = uri.IndexOf('#');
|
||||
return hash < 0 ? uri : uri[..hash];
|
||||
}
|
||||
|
||||
public static string Text(JsonElement element, string property)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty(property, out var value))
|
||||
return default;
|
||||
return value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => value.GetString(),
|
||||
JsonValueKind.Object => Text(value, "id") ?? Text(value, "href"),
|
||||
JsonValueKind.Array => value.EnumerateArray().Select(v => v.ValueKind == JsonValueKind.String ? v.GetString() : Text(v, "id")).FirstOrDefault(v => v != null),
|
||||
_ => default
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,12 @@ using PrivaPub.ClientModels.Group;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Group;
|
||||
using PrivaPub.Resources;
|
||||
using PrivaPub.Services.Federation;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using GroupEntity = PrivaPub.Models.Group.Group;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Federation.Outbox;
|
||||
|
||||
namespace PrivaPub.Services
|
||||
{
|
||||
@@ -67,7 +69,7 @@ namespace PrivaPub.Services
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"{nameof(GroupUsersService)}.{nameof(GetGroup)}");
|
||||
return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex);
|
||||
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +90,7 @@ namespace PrivaPub.Services
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"{nameof(GroupUsersService)}.{nameof(GetGroups)}");
|
||||
return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex);
|
||||
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +115,7 @@ namespace PrivaPub.Services
|
||||
Domain = _localActors.BaseAddress,
|
||||
PrivateKey = privateKey,
|
||||
PublicKey = publicKey,
|
||||
Kind = form.IsCommunity ? GroupKind.Community : GroupKind.Circle,
|
||||
IsDiscoverable = form.IsDiscoverable,
|
||||
ManuallyApprovesMembers = form.ManuallyApprovesMembers,
|
||||
OwnerAvatarId = form.AvatarId,
|
||||
@@ -120,6 +123,9 @@ namespace PrivaPub.Services
|
||||
HashedInvitationPassword = string.IsNullOrEmpty(form.InvitationPassword) ? default : _passwordHasher.Hash(form.InvitationPassword),
|
||||
Members = new() { new GroupMember { AvatarId = form.AvatarId, Role = GroupRole.Owner } }
|
||||
};
|
||||
group.ID = (string)group.GenerateNewID();
|
||||
if (!await _localActors.TryReserveUserName(userName, LocalActorKind.Group, group.ID, token))
|
||||
return result.Invalidate(_localizer["The username '{0}' is already take.", userName]);
|
||||
var actor = _localActors.FromGroup(group);
|
||||
group.Url = actor.Uri;
|
||||
group.InboxURL = actor.Inbox;
|
||||
@@ -132,7 +138,7 @@ namespace PrivaPub.Services
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"{nameof(GroupUsersService)}.{nameof(InsertGroup)}");
|
||||
return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex);
|
||||
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,7 +174,7 @@ namespace PrivaPub.Services
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"{nameof(GroupUsersService)}.{nameof(UpdateGroup)}");
|
||||
return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex);
|
||||
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,7 +214,7 @@ namespace PrivaPub.Services
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"{nameof(GroupUsersService)}.{nameof(JoinGroup)}");
|
||||
return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex);
|
||||
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,7 +240,7 @@ namespace PrivaPub.Services
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"{nameof(GroupUsersService)}.{nameof(LeaveGroup)}");
|
||||
return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex);
|
||||
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,7 +282,7 @@ namespace PrivaPub.Services
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"{nameof(GroupUsersService)}.{nameof(ApproveMember)}");
|
||||
return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex);
|
||||
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,6 +330,7 @@ namespace PrivaPub.Services
|
||||
Description = group.Description,
|
||||
Url = actor.Uri,
|
||||
Handle = actor.Handle,
|
||||
IsCommunity = group.Kind == GroupKind.Community,
|
||||
IsDiscoverable = group.IsDiscoverable,
|
||||
ManuallyApprovesMembers = group.ManuallyApprovesMembers,
|
||||
IsOwner = group.OwnerAvatarId == avatarId,
|
||||
|
||||
@@ -6,14 +6,17 @@ using PrivaPub.ClientModels;
|
||||
using PrivaPub.ClientModels.Post;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Group;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Resources;
|
||||
using PrivaPub.Services.Federation;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using DmPostEntity = PrivaPub.Models.Post.DmPost;
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Federation.Outbox;
|
||||
|
||||
namespace PrivaPub.Services
|
||||
{
|
||||
@@ -62,6 +65,7 @@ namespace PrivaPub.Services
|
||||
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
|
||||
|
||||
LocalActor group = default;
|
||||
var isLocalOnly = false;
|
||||
if (!string.IsNullOrEmpty(form.GroupId))
|
||||
{
|
||||
var groupEntity = await _dbEntities.Groups.MatchID(form.GroupId).ExecuteFirstAsync(token);
|
||||
@@ -69,6 +73,7 @@ namespace PrivaPub.Services
|
||||
|| !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 post = new PostEntity
|
||||
@@ -77,14 +82,23 @@ namespace PrivaPub.Services
|
||||
GroupId = group?.Id,
|
||||
Title = form.Title,
|
||||
Text = form.Text,
|
||||
ContentHtml = ActivityPubRenderer.Html(form.Text),
|
||||
ContentFormat = ContentFormat.Markdown,
|
||||
HasContentWarning = form.HasContentWarning,
|
||||
AnsweringToPostId = form.AnsweringToPostId,
|
||||
IsLocalOnly = isLocalOnly,
|
||||
ActorURI = author.Uri
|
||||
};
|
||||
post.ID = (string)post.GenerateNewID();
|
||||
post.ObjectURI = author.PostUri(post.ID);
|
||||
await DB.Default.SaveAsync(post, token);
|
||||
|
||||
if (isLocalOnly)
|
||||
{
|
||||
result.Data = ToView(post);
|
||||
return result;
|
||||
}
|
||||
|
||||
var inReplyTo = await ReplyTarget(form.AnsweringToPostId, token);
|
||||
var note = ActivityPubRenderer.Note(post, author, group, inReplyTo);
|
||||
var create = ActivityPubRenderer.Create(author, note, $"create-{post.ID}");
|
||||
@@ -98,7 +112,7 @@ namespace PrivaPub.Services
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"{nameof(PostsService)}.{nameof(InsertPost)}");
|
||||
return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex);
|
||||
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +132,8 @@ namespace PrivaPub.Services
|
||||
return result.Invalidate(_localizer["Post not found."], StatusCodes.Status404NotFound);
|
||||
|
||||
await DB.Default.DeleteAsync<PostEntity>(post.ID);
|
||||
if (post.IsLocalOnly)
|
||||
return result;
|
||||
|
||||
var delete = ActivityPubRenderer.Delete(author, post.ObjectURI, $"delete-{post.ID}",
|
||||
new JsonArray(ActivityPubRenderer.Public), new JsonArray(author.Followers));
|
||||
@@ -134,7 +150,7 @@ namespace PrivaPub.Services
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"{nameof(PostsService)}.{nameof(DeletePost)}");
|
||||
return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex);
|
||||
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,7 +183,7 @@ namespace PrivaPub.Services
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"{nameof(PostsService)}.{nameof(GetPosts)}");
|
||||
return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex);
|
||||
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,10 +216,15 @@ namespace PrivaPub.Services
|
||||
if (members.All(m => m.AvatarId != member.AvatarId))
|
||||
members.Add(member);
|
||||
}
|
||||
dmGroup = new DmGroup { Members = members };
|
||||
dmGroup.ID = (string)dmGroup.GenerateNewID();
|
||||
dmGroup.ConversationURI = $"{author.Uri}/conversations/{dmGroup.ID}";
|
||||
await DB.Default.SaveAsync(dmGroup, token);
|
||||
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.Uri}/conversations/{dmGroup.ID}";
|
||||
await DB.Default.SaveAsync(dmGroup, token);
|
||||
}
|
||||
}
|
||||
|
||||
var dm = new DmPostEntity
|
||||
@@ -211,6 +232,8 @@ namespace PrivaPub.Services
|
||||
GroupUserId = author.Id,
|
||||
GroupId = dmGroup.ID,
|
||||
Text = form.Text,
|
||||
ContentHtml = ActivityPubRenderer.Html(form.Text),
|
||||
ContentFormat = ContentFormat.Markdown,
|
||||
HasContentWarning = form.HasContentWarning,
|
||||
ActorURI = author.Uri
|
||||
};
|
||||
@@ -249,7 +272,7 @@ namespace PrivaPub.Services
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"{nameof(PostsService)}.{nameof(InsertDm)}");
|
||||
return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex);
|
||||
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,7 +317,7 @@ namespace PrivaPub.Services
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"{nameof(PostsService)}.{nameof(GetDms)}");
|
||||
return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex);
|
||||
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,7 +333,7 @@ namespace PrivaPub.Services
|
||||
var actorUri = await _remoteActors.ResolveHandle(handle, token);
|
||||
if (actorUri == default)
|
||||
return default;
|
||||
var foreign = await _remoteActors.GetActor(actorUri, await _localActors.GetInstanceActor(token), refresh: false, token);
|
||||
var foreign = await _remoteActors.GetActor(actorUri, refresh: false, token);
|
||||
return foreign == default ? default : new GroupMember { AvatarId = foreign.ActorURI, IsForeign = true };
|
||||
}
|
||||
|
||||
@@ -343,6 +366,7 @@ namespace PrivaPub.Services
|
||||
AnsweringToPostId = post.AnsweringToPostId,
|
||||
Title = post.Title,
|
||||
Text = post.Text,
|
||||
ContentHtml = post.ContentHtml,
|
||||
HasContentWarning = post.HasContentWarning,
|
||||
IsFederatedCopy = post.IsFederatedCopy,
|
||||
CreationDate = post.CreationDate
|
||||
@@ -358,6 +382,7 @@ namespace PrivaPub.Services
|
||||
AnsweringToPostId = post.AnsweringToPostId,
|
||||
Title = post.Title,
|
||||
Text = post.Text,
|
||||
ContentHtml = post.ContentHtml,
|
||||
HasContentWarning = post.HasContentWarning,
|
||||
IsFederatedCopy = post.IsFederatedCopy,
|
||||
CreationDate = post.CreationDate
|
||||
|
||||
@@ -64,13 +64,6 @@ namespace PrivaPub.Services
|
||||
UserName = signUpForm.UserName,
|
||||
HashedPassword = signUpPasswordHashed
|
||||
};
|
||||
if (signUpForm.UserName == "admin")
|
||||
{
|
||||
newUser.Policies.Clear();
|
||||
newUser.Policies.Add(Policies.IsAdmin);
|
||||
newUser.Policies.Add(Policies.IsUser);
|
||||
newUser.Policies.Add(Policies.IsModerator);
|
||||
}
|
||||
|
||||
var cultureLanguage = CultureInfo.CurrentCulture.TwoLetterISOLanguageName;
|
||||
var language = await DbEntities.Languages.Match(l => l.International2Code == cultureLanguage).ExecuteFirstAsync();
|
||||
@@ -111,7 +104,7 @@ namespace PrivaPub.Services
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(SignUpAsync)}()");
|
||||
return result.Invalidate(ex.Message, exception: ex);
|
||||
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +158,7 @@ namespace PrivaPub.Services
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(LoginAsync)}()");
|
||||
return result.Invalidate(ex.Message, exception: ex);
|
||||
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,7 +185,7 @@ namespace PrivaPub.Services
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(UpdateUserAsync)}()");
|
||||
return result.Invalidate(ex.Message, exception: ex);
|
||||
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,7 +223,7 @@ namespace PrivaPub.Services
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(UpdateUserSettingsAsync)}()");
|
||||
return result.Invalidate(ex.Message, exception: ex);
|
||||
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,7 +252,7 @@ namespace PrivaPub.Services
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(UpdateUserPasswordAsync)}()");
|
||||
return result.Invalidate(ex.Message, exception: ex);
|
||||
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,7 +284,7 @@ namespace PrivaPub.Services
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(RemoveUserAsync)}()");
|
||||
return result.Invalidate(ex.Message, exception: ex);
|
||||
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,7 +303,7 @@ namespace PrivaPub.Services
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(BanUserAsync)}()");
|
||||
return result.Invalidate(ex.Message, exception: ex);
|
||||
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,7 +322,7 @@ namespace PrivaPub.Services
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(UnbanUserAsync)}()");
|
||||
return result.Invalidate(ex.Message, exception: ex);
|
||||
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,7 +350,7 @@ namespace PrivaPub.Services
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(GetUserSettingsAsync)}()");
|
||||
return result.Invalidate(ex.Message, exception: ex);
|
||||
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,30 +441,30 @@ namespace PrivaPub.Services
|
||||
{
|
||||
var toParsed = await smtpClient.VerifyAsync(user.Email);
|
||||
if (toParsed == null)
|
||||
return result.Invalidate($"Invalid email of {user.Email}.", (int)SmtpStatusCode.MailboxUnavailable);
|
||||
return result.Invalidate(Localizer["Invalid email."], (int)SmtpStatusCode.MailboxUnavailable);
|
||||
}
|
||||
catch (OperationCanceledException ex)
|
||||
{
|
||||
Logger.LogWarning(
|
||||
$"SMTP operation canceled exception at email verification of {user.Email}. Exception=[{ex.Message}]");
|
||||
"SMTP operation canceled at email verification: {Error}", ex.Message);
|
||||
}
|
||||
catch (SmtpCommandException ex)
|
||||
{
|
||||
Logger.LogWarning(
|
||||
$"SMTP command exception at email verification of {user.Email}. Exception=[{ex.Message}]");
|
||||
"SMTP command exception at email verification: {Error}", ex.Message);
|
||||
}
|
||||
catch (SmtpProtocolException ex)
|
||||
{
|
||||
Logger.LogWarning(
|
||||
$"SMTP protocol exception at email verification of {user.Email}. Exception=[{ex.Message}]");
|
||||
"SMTP protocol exception at email verification: {Error}", ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning($"General exception at email verification of {user.Email}. Exception=[{ex.Message}]");
|
||||
Logger.LogWarning("Exception at email verification: {Error}", ex.Message);
|
||||
}
|
||||
|
||||
var message = new MimeMessage();
|
||||
message.From.Add(new MailboxAddress(Localizer["Eugene - collAnon support"], AppConfigurationService.AppConfiguration.EmailConfiguration.SmtpUsername));
|
||||
message.From.Add(new MailboxAddress("PrivaPub", AppConfigurationService.AppConfiguration.EmailConfiguration.SmtpUsername));
|
||||
message.To.Add(MailboxAddress.Parse(user.Email));
|
||||
message.Subject = Localizer["PrivaPub - Password recovery link"];
|
||||
message.Body = new TextPart("plain")
|
||||
@@ -501,7 +494,7 @@ Eugene from collAnon, following is the password recovery link:
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(SetupAndSendRecoveryEmail)}()");
|
||||
return result.Invalidate(ex.Message, exception: ex);
|
||||
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,7 +512,7 @@ Eugene from collAnon, following is the password recovery link:
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return result.Invalidate(ex.Message, exception: ex);
|
||||
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -555,7 +548,7 @@ Eugene from collAnon, following is the password recovery link:
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(ChangePassword)}()");
|
||||
return result.Invalidate(ex.Message, exception: ex);
|
||||
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,2 +1,13 @@
|
||||
# SocialPub
|
||||
# PrivaPub
|
||||
|
||||
A self-hosted ActivityPub server in C# (.NET 10, MongoDB) where one private login owns several unlinkable public
|
||||
personas. It federates with Mastodon, GoToSocial, Pleroma/Akkoma, Misskey and Lemmy.
|
||||
|
||||
- Live: https://privapub.thepra.dev
|
||||
- Client: [decePubClient](https://git.thepra.dev/thepra/decePubClient), https://decepub.thepra.dev
|
||||
- Architecture, conventions and deploy: [CLAUDE.md](CLAUDE.md)
|
||||
- Decisions and the plan to full ActivityPub interop: [docs/ROADMAP.md](docs/ROADMAP.md)
|
||||
|
||||
```bash
|
||||
dotnet build PrivaPub.sln -c Release
|
||||
```
|
||||
+461
@@ -0,0 +1,461 @@
|
||||
# PrivaPub roadmap
|
||||
|
||||
Written 2026-10-01 from the original 2023 code, the decePubClient UI, a federation gap audit of commit `075c222`, research on .NET ActivityPub libraries, and the owner's decisions. Each phase ends in a tagged deploy plus verification; tick phases off here as they land.
|
||||
|
||||
## Status
|
||||
|
||||
- [ ] P0 Security baseline
|
||||
- [ ] P1.1 Infrastructure and content model
|
||||
- [ ] P1.2 Social graph and timelines
|
||||
- [ ] P2 Mastodon client API
|
||||
- [ ] P3 Social features
|
||||
- [ ] P4 Groups and privacy features
|
||||
- [ ] P5 FEPs and polish
|
||||
|
||||
## Intent
|
||||
|
||||
It is a self-hosted, Pleroma-like microblogging server for privacy-minded individuals and small communities. It
|
||||
federates with Mastodon, Pleroma/Akkoma, Misskey and GoToSocial (`AvatarServer` enum). Its distinguishing idea is
|
||||
**one private login (RootUser) owning several public personas (Avatars), each a separate actor with its own keys**.
|
||||
The rename SocialPub → PrivaPub happened in the commit that added the private avatar service.
|
||||
|
||||
Its privacy additions on top of the usual fediverse features:
|
||||
- **Location-ranged posts:** `Post.Location` and `RangeKm` (5 km default), currently unused.
|
||||
- **Contacts a persona chooses to share:** `Avatar.SharedPersonalContacts`, plus a root contact book (`ContactItem`).
|
||||
- **Private notes:** about other accounts (`RootUserNote`) and on one's own avatar (`PersonalNote`).
|
||||
- **Invitation-gated groups and DM groups.**
|
||||
- **Signup without email:** email is only for password recovery.
|
||||
|
||||
The client (decePubClient) is a near copy of **Pleroma-FE plus Pleroma's admin panel**: a visibility picker, a
|
||||
subject line, Plain/HTML/Markdown content types, media with alt text, boosts and likes, threads, mutes and blocks, data
|
||||
import/export, and admin sections for users, reports, emoji, MRF policies and uploads. Today it shows mock data and
|
||||
calls nothing.
|
||||
|
||||
Leftovers of the collAnon template, not intent:
|
||||
- the discussion and confrontation resources;
|
||||
- collAnon's invitation flow;
|
||||
- QR scanning;
|
||||
- the "collAnon support" mail sender.
|
||||
|
||||
The finishing pass reinterpreted the owner's never-written `Group` and `DmGroup` as an ActivityPub Group actor with an
|
||||
invitation code and a direct-message conversation. The owner confirmed it on 2026-10-01, with groups split into communities
|
||||
and circles (see Owner decisions).
|
||||
|
||||
**Status at HEAD:**
|
||||
|
||||
| Status | Features |
|
||||
|---|---|
|
||||
| Works | Accounts and JWT, several avatars, per-avatar actors and keys, posts, replies, CW, delete, DMs, groups and invitations, WebFinger, NodeInfo, signed inbox for Follow/Undo/Create/Delete/Update, delivery queue |
|
||||
| Missing | Following remote accounts (no outgoing Follow), home/local/federated timelines, notifications, likes, boosts, visibility other than public, media, polls, edits by the author, profile Update federation, mutes, blocks, reports (Flag), Move, custom emoji, the location and contact features, admin and moderation, and a client that talks to the server |
|
||||
|
||||
**Persona separation is breached in three places today:**
|
||||
- invitation signup names the avatar after the root username;
|
||||
- logs pair root IDs with IP addresses;
|
||||
- NodeInfo counts avatars as users.
|
||||
|
||||
## Where it stood
|
||||
|
||||
**Works:**
|
||||
- WebFinger, including `acct:` for groups
|
||||
- NodeInfo 2.0
|
||||
- Person, Group and Application actors with SPKI keys
|
||||
- Outbound cavage signatures (every fetch is signed, so authorized-fetch servers work)
|
||||
- Inbound cavage verification, with key refetch and a key-owner check
|
||||
- Shared inbox, durable delivery with backoff
|
||||
- Follow with auto-Accept or manual approval for groups, and Undo
|
||||
- Inbound public, group and DM Creates
|
||||
- Delete and Update restricted to the author
|
||||
- DMs both ways, with Mention tags
|
||||
|
||||
**Critical (security):**
|
||||
- **S1, actor/key cache poisoning:** `RemoteActorService.Upsert` and `GetActorByKeyId` accept any document under its
|
||||
own `id`, with no origin, `publicKey.id` or `owner` checks. Any remote actor can be impersonated.
|
||||
- **S2:** no check that an object's `id` host matches its actor's.
|
||||
- **S3, SSRF:** only string checks. Names resolving to private addresses, redirects and rebinding all get through, and
|
||||
a request can trigger it before authentication.
|
||||
- **S4:** unbounded fetch size, and 500s on unexpected content.
|
||||
- **S5:** remote HTML is stored raw, with no format flag.
|
||||
- **S6:** loose signature freshness.
|
||||
- **S8:** DM conversation injection.
|
||||
- **S9:** "private" groups federate as Public.
|
||||
- **S14:** the "admin" username grants admin, and Swagger is open in production.
|
||||
|
||||
**Blockers:**
|
||||
- **O1:** no outgoing Follow, so no home timeline.
|
||||
- **O2, O3:** replies and mentions aren't delivered to their targets.
|
||||
- **I1:** inbound replies and mentions are stored but invisible, and there are no notifications.
|
||||
- **G1:** groups Announce the object URI instead of the activity, so Lemmy and other FEP-1b12 software see empty
|
||||
communities.
|
||||
- **K7:** no media, timelines, likes or boosts for local users.
|
||||
|
||||
**Important:**
|
||||
- **Data:** no indexes or unique constraints, upsert races (K1-K4).
|
||||
- **Inbox:** processing runs synchronously with no idempotency store (I9, I10).
|
||||
- **Delivery:** one serial worker, and a single poisoned row can stall the queue (L1-L3).
|
||||
- **Objects:**
|
||||
- Edits and profile updates don't federate (O7).
|
||||
- No 410 Tombstones (A7).
|
||||
- Content warning without a title (O5).
|
||||
- Titles are lost on Mastodon (O6).
|
||||
- Visibility modes are missing (O4).
|
||||
- **Interactions:** likes, boosts and reports are dropped (I2, I3).
|
||||
- **Media:** inbound attachments are dropped, and there's no media proxy (I6, S13).
|
||||
- **Actor profile:**
|
||||
- Actor `url` serves JSON to browsers (A1).
|
||||
- No `attachment` fields, Move or `alsoKnownAs` (A2, A3).
|
||||
- No locked or undiscoverable accounts (A4).
|
||||
- **Signatures:** inbound RFC 9421 and Content-Digest (H1, H2).
|
||||
|
||||
## Owner decisions (2026-10-01)
|
||||
|
||||
| Question | Decision |
|
||||
|---|---|
|
||||
| Client interface | **Mastodon client API**, so Tusky, Elk, Phanpy, Ivory and the official apps work. Each avatar is its own Mastodon account: at OAuth authorize, the logged-in RootUser picks the avatar the token is for. PrivaPub-only features (avatars, groups, contacts, range posts) stay on `/clientapi`. Moving decePubClient onto the Mastodon API is out of scope. |
|
||||
| Personas | **Unlinkable to other users and servers.** The admin can still see the link in the database. No root IDs next to IPs in logs, NodeInfo counts nothing that links personas, blocks, mutes and notifications are per avatar, and invitation signup no longer names the avatar after the root username. |
|
||||
| Location-ranged posts | **Local only, never federated.** Shown to local users within the radius, with coordinates rounded on storage. |
|
||||
| Groups | **Per group, two kinds.** A *community* federates per FEP-1b12, Lemmy-compatible: it Announces the activity, uses `audience`, and accepts posts from non-followers. A *circle* is invitation-only: posts are addressed to the members collection, and objects are served only to signed requests from members. |
|
||||
|
||||
## Libraries (researched; no maintained .NET ActivityPub library exists, so Letterbook and Iceshrimp.NET both wrote their own)
|
||||
|
||||
| Area | Choice |
|
||||
|---|---|
|
||||
| AS2 / ActivityPub model | Own thin layer on `System.Text.Json.Nodes`: inbound helpers for single-or-array values, id-or-object, `type` arrays and the three Public forms; outbound builders with Mastodon's `@context`. No JSON-LD processing; fetch from the origin instead of verifying LD signatures. |
|
||||
| HTTP signatures | Keep own draft-cavage (sign and verify). Add **NSign 1.2.5** (`NSign.AspNetCore`) to verify inbound RFC 9421 and Content-Digest. Always answer a bad signature with 401, never 500. |
|
||||
| HTML | **HtmlSanitizer 9.x** with Mastodon's allowlist (tags `p br span a del pre code em strong b i u ul ol li blockquote`, attributes `href rel class`, microformat classes, a scheme allowlist, forced `rel=nofollow noopener noreferrer`) |
|
||||
| Markdown | **Markdig 1.4**: `DisableHtml`, autolinks, custom inline parsers for `@user@domain` and `#tag` emitting Mastodon's h-card and hashtag markup |
|
||||
| Media | **NetVips** (+NetVips.Native) for images: autorotate, strip EXIF/GPS, thumbnails. **Blurhash.Core**, **FFMpegCore** for video. Own `IMediaStore` (local disk now). Not ImageSharp 4, whose license key is enforced at build. |
|
||||
| OAuth | **OpenIddict 7.7 + OpenIddict.MongoDb**: authorization code with PKCE, client_credentials, `oob` redirect, dynamic apps from `POST /api/v1/apps`, reference tokens |
|
||||
| Jobs | Own Mongo job collection for both inbox processing and delivery: `FindOneAndUpdate` leases, a `Channel` wake-up, per-host circuit breaker, Mastodon's backoff (`n⁴+15+jitter`, 16 tries). No MassTransit (commercial from v9, no Mongo transport). |
|
||||
| Tests | New xUnit project with fixture JSON captured from Mastodon, GoToSocial, Misskey, Lemmy and Akkoma; interop checked with Fediverse Pasture, verify.funfedi.dev and activitypub.academy |
|
||||
|
||||
**FEPs, now:** FEP-f1d5 (NodeInfo 2.0 and 2.1), FEP-67ff (FEDERATION.md), FEP-8fcf, FEP-5feb `indexable`,
|
||||
FEP-7628 Move, FEP-2c59, FEP-044f quotes (parse, plus `interactionPolicy`), FEP-1b12 groups.
|
||||
|
||||
**FEPs, later:** FEP-521a, FEP-8b32, FEP-e232, relays (FEP-ae0c).
|
||||
|
||||
**FEPs, skipped:** FEP-c390, FEP-ef61, FEP-fb2a, FEP-844e.
|
||||
|
||||
## Route names (deliberate; part of the project's character)
|
||||
|
||||
The odd names are the owner's and stay. Every new actor-scoped route follows the same theme. The names marked *new*
|
||||
were agreed on 2026-10-01.
|
||||
|
||||
| Thing | Route | Status |
|
||||
|---|---|---|
|
||||
| Actor | `/peasants/{name}` | kept (`/users/{name}` 301-redirects here) |
|
||||
| Inbox | `/peasants/{name}/mouth` | kept |
|
||||
| Outbox | `/peasants/{name}/anus` | kept |
|
||||
| Shared inbox | `/human-centipede` (and `/peasants/{name}/human-centipede`) | kept |
|
||||
| Token refresh (client API) | `/clientapi/user/sniff/again` | kept |
|
||||
| Followers | `/peasants/{name}/groupies` | *new*, replaces `/followers` in P1 |
|
||||
| Following | `/peasants/{name}/stalking` | *new*, replaces `/following` in P1 |
|
||||
| Notes (objects) | `/peasants/{name}/scribbles/{id}` | *new*, replaces `/posts/{id}` in P1 |
|
||||
| Activities | `/peasants/{name}/grunts/{id}` | *new*, replaces `/activities/{id}` in P1 |
|
||||
| Replies, likes, shares collections | `/scribbles/{id}/gossip`, `/drool`, `/echoes` | *new* |
|
||||
| Featured (pins) | `/peasants/{name}/trophies` | *new* |
|
||||
| Featured tags | `/peasants/{name}/tattoos` | *new* |
|
||||
| Group members | `/peasants/{group}/flock` | *new* |
|
||||
| Group moderators | `/peasants/{group}/wardens` | *new* |
|
||||
| DM conversation context | `/peasants/{name}/whispers/{id}` | *new*, replaces `/conversations/{id}` |
|
||||
|
||||
Routes outside the actor namespace stay conventional, because other software looks for them by name:
|
||||
- `/.well-known/*` and `/nodeinfo/*`;
|
||||
- the Mastodon API (`/api/v1/*`, `/oauth/*`);
|
||||
- the human pages `/@{name}` and `/@{name}/{id}`;
|
||||
- `/media/*`.
|
||||
|
||||
Once a P1 deploy has federated a name, it is frozen.
|
||||
|
||||
## Target structure (moved incrementally; entity class names and URI shapes never change)
|
||||
|
||||
```
|
||||
PrivaPub/
|
||||
Infrastructure/ Http/ (IpRangeGuard, SafeHttpHandlerFactory, FederationHttp, BoundedReader), Jobs/ (JobQueue, JobWorker,
|
||||
Backoff, HostCircuitBreaker), Ids/ (PrivacyIdGenerator, PostIds), Data/ (Indexes, Migrations/_NNN_*),
|
||||
RateLimiting, ErrorHandling
|
||||
Federation/ Signing/ (DraftCavage ← Services/Federation/HttpSignatures.cs, SignedFetchAuthorizer, Rfc9421Verifier)
|
||||
Actors/ (LocalActorService, Keys, RemoteActorService, WebFingerClient, ActorParser)
|
||||
Objects/ (Origin, ObjectFetcher, NoteParser, Addressing, ContentSanitizer)
|
||||
Inbox/ (InboxReceiver, InboxProcessor, Handlers/{Follow,Accept,Reject,Undo,Create,Update,Delete,Like,Announce,Flag,Move}Handler)
|
||||
Outbox/ (AudienceResolver, OutboxPublisher, GroupDistributor) Rendering/ (ApContext, Actor/Note/Activity/CollectionRenderer)
|
||||
Controllers/ (Peasants, WellKnown, NodeInfo, Users)
|
||||
Domain/ AvatarContext, Statuses/StatusService (← Services/PostsService.cs), Timelines/FanoutService, Notifications/,
|
||||
Relationships/, Groups/GroupService (← GroupUsersService.cs), Content/ (MarkdownRenderer, PlainTextRenderer,
|
||||
MentionTagParsers), Privacy/VisibilityPolicy (single CanSee), Media/ (P3), Geo/ (P4)
|
||||
Api/ ClientApi/Controllers (← Controllers/ClientToServer, routes unchanged), Mastodon/{Controllers,Entities,Mappers,Auth,Infrastructure}
|
||||
Web/Pages/ Profile, Status (P1); OAuth/Login, ChooseAvatar, Oob (P2)
|
||||
```
|
||||
|
||||
The first refactor commit is a pure move with namespaces only. Logic changes follow in separate commits.
|
||||
|
||||
## Phases (each one ends in a tagged deploy plus verification)
|
||||
|
||||
### P0 Security baseline
|
||||
- **S1, actor verification:** `RemoteActorService`.
|
||||
- `GetActor` accepts a document only if its `id` equals the final URL, on the same origin.
|
||||
- `GetActorByKeyId` resolves the owner, then the strict actor, and requires `publicKey.id == keyId` and
|
||||
`owner == id`.
|
||||
- Upserts become atomic on a unique `ActorURI`.
|
||||
- Key refetch is limited to once per 5 minutes.
|
||||
- **S3 and S4, safe outbound HTTP:**
|
||||
- A `SocketsHttpHandler.ConnectCallback` resolves DNS and rejects loopback, private, link-local, CGNAT and ULA
|
||||
addresses, plus NAT64, 6to4, Teredo and IPv4-mapped forms. It then connects to the vetted IP.
|
||||
- Redirects are manual (at most 3, each re-checked).
|
||||
- Responses are capped at 1 MB with a content-type check and a 15 s timeout, with a negative cache for failing URLs.
|
||||
- Exceptions map to ProblemDetails: 400 or 401, never 500.
|
||||
- **S2, origin checks:** `Origin.Same`. An activity's or object's `id` must share the actor's origin, and
|
||||
cross-origin embedded objects are refetched.
|
||||
- **S5, sanitising:** `ContentSanitizer` with HtmlSanitizer. `Post.ContentHtml` and `ContentFormat` are added, and
|
||||
migration `_002` sanitises stored remote content.
|
||||
- **S6, signature freshness:** `(request-target)` and `host` are required, plus `date` or `(created)` and `digest`.
|
||||
The window is −1 h to +15 min. `(expires)` is enforced, and the raw request target is used.
|
||||
- **S8, DM injection:** a DM joins a conversation by `context` only if the author is already a member; otherwise it
|
||||
is matched on `DmGroup.ParticipantsKey`.
|
||||
- **S9, groups:** a `Group.Kind` field is added. Existing groups become Circles (migration `_003`), and Circle posts
|
||||
stay local-only until P4.
|
||||
- **S14:**
|
||||
- Delete the `"admin"` signup branch and add the CLI `admin promote`.
|
||||
- Serve Swagger only in Development.
|
||||
- Return generic error messages (no `ex.Message`).
|
||||
- **Privacy:**
|
||||
- Drop the IP, User-Agent and root-id log lines in `RootUserController`.
|
||||
- Invitation signup takes its own `AvatarUserName`.
|
||||
- All fetches are signed by the instance actor.
|
||||
- **Indexes and races:** `Infrastructure/Data/Indexes.cs`, with migration `_001` deduplicating first.
|
||||
- Unique: `ObjectURI`, `ForeignAvatar.ActorURI`, the Follower triple, RootToAvatar, and `ReservedName` (one
|
||||
username space across avatars and groups).
|
||||
- Plain: `PublicKeyId`, `(GroupUserId, _id)`, `(GroupId, _id)`.
|
||||
- **Rate limiting** on login, signup, invitation and inbox (per key host).
|
||||
- **Cleanup:** delete `Services/ActivityPubClient.cs` and `Models/Post/PostBoost.cs`.
|
||||
- **New `PrivaPub.Tests`** (xUnit v3): IpRangeGuard table, signature fixtures (a real Mastodon request plus tampering
|
||||
cases), key-poisoning, origin rules, XSS corpus, DM injection.
|
||||
- **CI:** `build.yml` runs the tests. `deploy.yml` runs the tests and a `mongodump` before swapping.
|
||||
|
||||
### P1 Interop foundations (two deploys)
|
||||
|
||||
**P1.1, infrastructure and content model:**
|
||||
- **Mongo job queue** (`Job`, `RemoteInstance`):
|
||||
- Leases via `UpdateAndGet`, `Channel` wake-up, a reaper for expired leases.
|
||||
- Workers: delivery 8 (at most 2 per host), inbox 2.
|
||||
- Backoff `n⁴+15+jitter`, 16 tries. Non-transient errors go straight to Dead.
|
||||
- Per-host circuit breaker. Dedupe on `activity.id` and `activityId|inbox`.
|
||||
- TTL on finished jobs.
|
||||
- Migration `_004` moves pending `Delivery` rows into jobs.
|
||||
- **Async inbox:** `InboxReceiver` verifies and enqueues, then answers 202 (a duplicate also gets 202).
|
||||
`InboxProcessor` dispatches to the handlers split out of `InboxService`.
|
||||
- **Merge DmPost into Post** (migration `_005`; ids kept; `Visibility=Direct`, `ConversationId`). Post gains:
|
||||
- `Visibility {Public, Unlisted, FollowersOnly, Direct, Circle, LocalGeo}`
|
||||
- `AuthorAccountId`, `To`, `Cc`, `ActivityURI`, `Url`, `ContextURI`
|
||||
- `InReplyToURI`, `InReplyToAccountId`, `ReblogOfPostId`
|
||||
- `SpoilerText`, `Language`, `Mentions[]`, `Tags[]`, `Media[]`
|
||||
- counters, `Revisions[]`, `DeletedAt`
|
||||
|
||||
`/clientapi/dm/*` moves onto Post, and every read goes through `VisibilityPolicy.CanSee`.
|
||||
- **Parsing inbound content:**
|
||||
- `NoteParser` handles Note, Article, Page and Question. It reads `content`, then `contentMap`, then
|
||||
`_misskey_content`; also `summary`, `name`, `sensitive`, the `tag` array, `attachment` (as remote media),
|
||||
`inReplyTo`, `context`, `audience`, `updated`, and the `quote` fields (stored as a URI).
|
||||
- `Addressing` classifies Public, Unlisted, FollowersOnly or Direct. Followers-only is detected by the actor's
|
||||
stored `followers` URL exactly, not by a path suffix.
|
||||
- **Rendering local text:**
|
||||
- `MarkdownRenderer` (Markdig) and `PlainTextRenderer` (for Mastodon API input).
|
||||
- Mentions resolved through WebFinger become an h-card plus a `Mention` tag; hashtags become a `Hashtag` tag.
|
||||
- **Renderers:**
|
||||
- `ApContext`: Mastodon's `@context` (toot, schema, `indexable`, `blurhash`, `focalPoint`, `featured`,
|
||||
`alsoKnownAs`, `movedTo`, `webfinger`).
|
||||
- Actor and note `url` point at HTML pages; fields become `attachment` PropertyValue; the `webfinger` property is
|
||||
added; `published` is truncated to the day.
|
||||
- A title becomes `name` and is also prepended to the content as bold text.
|
||||
- A CW without a title gets a localised "Content warning" summary.
|
||||
- Routes are renamed to the themed set (`/groupies`, `/stalking`, `/scribbles/{id}`, `/grunts/{id}`,
|
||||
`/whispers/{id}`), activity URIs resolve, and the outbox gets a `first` page.
|
||||
- **Web:**
|
||||
- Razor pages `/@{user}` and `/@{user}/{id}`, with content negotiation; `/peasants/{u}` with `text/html`
|
||||
redirects there.
|
||||
- WebFinger `profile-page` points at the HTML page.
|
||||
- NodeInfo 2.1 (avatars counted as users, `openRegistrations` from config) and `FEDERATION.md`.
|
||||
- **Moderation:** `DomainBlock` (Silence, Suspend, RejectMedia), applied in the receiver, the fetcher and delivery.
|
||||
- **IDs:** register `PrivacyIdGenerator` for Avatar and Group (day-truncated timestamp plus random bytes). Remote post
|
||||
ids are ObjectIds generated from their `published` time.
|
||||
|
||||
**P1.2, social graph and timelines:**
|
||||
- **Outgoing Follow** (`Following` entity, plus Accept, Reject and outbound Undo). A local target is handled in-process.
|
||||
- **`AudienceResolver` and `OutboxPublisher`:**
|
||||
|
||||
| Visibility | to | cc |
|
||||
|---|---|---|
|
||||
| Public | Public | followers + mentions |
|
||||
| Unlisted | followers | Public + mentions |
|
||||
| Followers-only | followers | mentions |
|
||||
| Direct | mentions | (none) |
|
||||
|
||||
Replies are also delivered to the parent's author, and group posts to the group. Mentions are delivered to personal
|
||||
inboxes.
|
||||
- **Inbound Create:**
|
||||
- The interested set is followers of the author, plus addressed or mentioned local avatars, plus the local parent's
|
||||
author, plus group members. If nobody local is interested, the post is dropped.
|
||||
- Store the post (idempotent on ObjectURI), then fan out to the **`TimelineEntry`** collection
|
||||
(`{AvatarId, PostId, AuthorAccountId, ReblogOfPostId}`, unique `(AvatarId, PostId)`, paged by PostId).
|
||||
- Mastodon's reply, mute and reblog rules apply at write time.
|
||||
- Mention notifications are written.
|
||||
- **`Notification`** entity: `{AvatarId, Type, FromAccountId, PostId, DedupeKey}`, unique on `DedupeKey`.
|
||||
- **Edits:** outbound `Update{Note}` to the same To/Cc, with revisions kept. Profile edits send `Update{Person}`.
|
||||
- **Deletes:** soft delete, a Delete sent to the stored audience, and the object then answers **410** with a
|
||||
Tombstone (a deleted actor answers 410 too).
|
||||
- **Inbound Like and Announce:** a `Favourite` entity, reblog rows with the original refetched, Undo of both,
|
||||
counters and notifications.
|
||||
- **Threads:** a `FetchAncestors` backfill job, depth at most 10.
|
||||
- **Test endpoints:** `/clientapi/timeline/home`, `/clientapi/follow` and `/clientapi/notifications`, usable before P2.
|
||||
|
||||
### P2 Mastodon client API
|
||||
- **OAuth:** OpenIddict 7.7 + OpenIddict.MongoDb.
|
||||
- Storage is `IMongoDatabase` registered as `DB.Default.Database()`.
|
||||
- Endpoints: `/oauth/authorize`, `/oauth/token`, `/oauth/revoke` and `/.well-known/oauth-authorization-server`.
|
||||
- Flows: authorization code (PKCE optional) and client_credentials.
|
||||
- Scopes: `read`, `write`, `follow`, `push` and the granular ones.
|
||||
- Tokens are non-expiring reference tokens. The signing and encryption keys are persistent, in `/etc/privapub/oidc-*.pem`.
|
||||
- The `oob` code page is supported, as is `force_login`.
|
||||
- **Authorize UX:** log in with the root password (a short-lived cookie limited to `/oauth`), choose an avatar, consent.
|
||||
- **The token's `sub` is the avatar id, and no root claim is ever in the principal.** A banned or deleted root
|
||||
invalidates its tokens.
|
||||
- `POST /api/v1/apps` creates OpenIddict applications dynamically and prunes unused ones.
|
||||
- **Plumbing:**
|
||||
- `MastodonJson`: snake_case, explicit nulls and empty arrays (Tusky breaks on missing fields).
|
||||
- A binder that merges Rails-style query, form and JSON parameters.
|
||||
- `{"error"}` responses.
|
||||
- `Link` paging on `max_id`, `since_id` and `min_id`; CORS exposes `Link`.
|
||||
- `Idempotency-Key` honoured on posting statuses.
|
||||
- **Endpoints:**
|
||||
- **Instance:** v1 and v2, advertising version `4.2.0 (compatible; PrivaPub)`.
|
||||
- **Accounts:** `verify_credentials`, `update_credentials`, `:id`, `:id/statuses`, `lookup`, `relationships`,
|
||||
`search`, followers and following, follow and unfollow, `follow_requests`.
|
||||
- **Statuses:** CRUD, edit, `context`, `history`, `source`, favourite and reblog plus their undos (outbound Like and
|
||||
Announce), `reblogged_by`, `favourited_by`.
|
||||
- **Timelines and the rest:** home, public, tag; notifications; markers; conversations; `/api/v2/search` with
|
||||
`resolve`.
|
||||
- **Stubs:** `custom_emojis`, filters, lists, announcements, trends, suggestions, `followed_tags`, preferences.
|
||||
- **Mapping:**
|
||||
- Avatar, ForeignAvatar and Group all map to Account (`group: true` for groups). `acct` uses a WebFinger-verified
|
||||
handle; `created_at` is truncated to the day; avatar and header always have a placeholder URL.
|
||||
- Post maps to Status. Reblogs wrap the original. Per-viewer flags are loaded in batches.
|
||||
- LocalGeo never appears in the Mastodon API.
|
||||
- **New Avatar settings:** `IsLocked`, `IsDiscoverable`, `IsIndexable`, `IsBot`, `HideCollections`,
|
||||
`DefaultVisibility`, `DefaultSensitive`, `DefaultLanguage`.
|
||||
|
||||
### P3 Social features
|
||||
- **Media pipeline:** `MediaAttachment` entity plus `/api/v1/media` and `/api/v2/media`.
|
||||
- NetVips: autorotate, strip all metadata, 4096 px cap, 640 px thumbnail.
|
||||
- Blurhash.Core.
|
||||
- FFMpegCore: remux only, with `-map_metadata -1`.
|
||||
- Stored in `/var/lib/privapub/media`, outside the web root that deploys replace. nginx serves `/media/` with
|
||||
`nosniff` and a strict CSP, and allows larger uploads only on the media endpoints.
|
||||
- A job deletes unattached uploads.
|
||||
- **Remote media proxy:** `/media/proxy/{hmac}/{url}`, going through the safe HTTP handler, with a ~5 GB disk LRU. The
|
||||
API and avatars use proxied URLs, so clients never contact remote hosts.
|
||||
- **Outbound attachments:** `mediaType`, alt text, `blurhash`, `focalPoint` and dimensions. Profile avatar and header
|
||||
images are sent as `Update{Person}`.
|
||||
- **Bookmarks and pins:** pins are served as the `featured` collection at `/peasants/{name}/trophies`, with featured
|
||||
tags at `/tattoos`.
|
||||
- **Blocks and mutes:** per avatar. **Block is not federated**: it sends Reject or Undo Follow instead and drops the
|
||||
blocked actor's traffic. Domain blocks per account as well.
|
||||
- **Reports:** inbound Flag becomes a `Report`. Outbound Flag is **sent by the instance actor**, so the reporting
|
||||
persona isn't revealed. Moderator endpoints on `/clientapi`.
|
||||
- **Locked accounts:** the follow-request flow.
|
||||
|
||||
### P4 Groups and privacy features
|
||||
- **Group model:** `Group.Kind`, `PostingPolicy`, `Rules`; `GroupMember.State`; new collections for
|
||||
moderators (`/wardens`), members (`/flock`) and featured (`/trophies`).
|
||||
- **Communities (FEP-1b12):**
|
||||
- `GroupDistributor` announces the full activity (Create, Update, Delete, Like or Undo) with `audience` to the
|
||||
group's followers. New posts additionally get `Announce(object)`, so Mastodon shows them.
|
||||
- Top-level posts render as `Page` with a title; comments as `Note`.
|
||||
- Posting from non-followers is allowed according to `PostingPolicy`.
|
||||
- Inbound Announces from remote groups are refetched from their origin.
|
||||
- A Mastodon client posts into a group by mentioning `@group@host`.
|
||||
- **Circles:**
|
||||
- The actor is undiscoverable, with manual approval and invitation-only joining.
|
||||
- Posts are addressed `to: [members, group]` and delivered to members' **personal** inboxes, never with Announce.
|
||||
- `SignedFetchAuthorizer` serves circle objects only to a signed request from a member, or from the instance actor
|
||||
of a member's server. Anything else gets 404.
|
||||
- `FEDERATION.md` documents the Mastodon limitation: Mastodon members' replies reach only whoever they mention.
|
||||
- **Local-only location posts:**
|
||||
- `Post.Location` becomes a GeoJSON point rounded to 2 decimals (about 1 km), with `RangeKm` clamped to 1–50 and a
|
||||
`2dsphere` index. Visibility is LocalGeo.
|
||||
- `/clientapi/post/nearby` runs `$geoNear` filtered by each post's own radius. The viewer's position is never stored.
|
||||
- The audience is always empty; ActivityPub endpoints and the Mastodon API never expose these posts.
|
||||
- **Persona hardening:** optionally re-key existing avatar ids; an optional `SecureMode` (signed GETs required); no
|
||||
suggestions or directories that could relate sibling avatars.
|
||||
|
||||
### P5 FEPs and polish (ongoing)
|
||||
- **FEP-8fcf:** followers synchronisation.
|
||||
- **FEP-5feb:** honour remote `indexable` in search.
|
||||
- **FEP-7628:** Move in both directions, plus editing `alsoKnownAs`.
|
||||
- **FEP-044f:** quotes and `interactionPolicy`.
|
||||
- **RFC 9421:** inbound verification with NSign, plus Content-Digest.
|
||||
- **Mastodon API extras:** polls, the streaming WebSocket (nginx Upgrade headers), Web Push (VAPID), and grouped
|
||||
notifications v2, after which the advertised version moves to 4.3.
|
||||
- **Backfill:** an author's outbox after following them.
|
||||
- **Later:** FEP-521a, FEP-8b32, FEP-e232, relays.
|
||||
|
||||
### Cut or deferred (deliberately)
|
||||
- **Cut:**
|
||||
- Link-preview cards: an SSRF and privacy risk.
|
||||
- Translation, trends, directory and lists (filters stay as stubs).
|
||||
- Scheduled posts.
|
||||
- The Mastodon admin API: moderation stays on `/clientapi`.
|
||||
- `POST /api/v1/accounts` registration: avatars are created through `/clientapi`.
|
||||
- Local custom emoji.
|
||||
- S3 storage.
|
||||
- JSON-LD and LD-signature processing.
|
||||
- Outbound RFC 9421.
|
||||
- **Deferred:** video transcoding (remux only for now).
|
||||
- **Out of scope:** moving decePubClient onto the Mastodon API.
|
||||
|
||||
## Verification (per phase)
|
||||
|
||||
- **Unit tests** (`PrivaPub.Tests`) on every phase. Fixture JSON is captured per peer into
|
||||
`Fixtures/{mastodon,gotosocial,misskey,akkoma,lemmy}/`. Renderers are checked against golden JSON. Inbound
|
||||
scenarios run as integration tests on a throwaway mongod.
|
||||
- **Interop:** **Fediverse Pasture** runs on the workstation with podman: Mastodon, GoToSocial, Misskey/Sharkey,
|
||||
Akkoma, plus Lemmy for P4. `tools/pasture/docker-compose.privapub.yml` runs PrivaPub with `AllowPrivateNetworks`;
|
||||
startup asserts that flag is false in Production. The matrix, per peer:
|
||||
- follow both ways;
|
||||
- public, unlisted, followers-only and direct posts both ways;
|
||||
- a reply landing in the remote thread;
|
||||
- a mention producing a notification;
|
||||
- edit, and delete answering 410;
|
||||
- like and boost counters;
|
||||
- CW and title rendering on Mastodon;
|
||||
- Pasture's odd-payload inputs always answered 202 or 4xx, never 500.
|
||||
|
||||
Also run verify.funfedi.dev against `/peasants/privapub` and one avatar.
|
||||
- **P0:** `curl` the inbox with junk and get 400/401. Resolving `127.0.0.1.nip.io` or a host with an `::1` record is
|
||||
refused. Swagger answers 404 in production.
|
||||
- **P1:** 500 deliveries queued for a dead host don't delay deliveries to live hosts.
|
||||
- **P2:** Tusky, Elk, Phanpy and Ivory each log in with two avatars of one root as separate accounts. Home paginates
|
||||
both ways; post, reply, DM, follow-by-search, notifications, CW, and edit or delete-and-redraft all work.
|
||||
`tools/smoke/mastodon-api.sh` runs after every deploy. A test asserts that no response for avatar A contains avatar
|
||||
B's id.
|
||||
- **P3:**
|
||||
- `exiftool` shows no GPS data on an uploaded original.
|
||||
- Elk renders the blurhash.
|
||||
- Phanpy's network tab shows no requests to remote hosts.
|
||||
- Like and boost round-trip with Mastodon and GtS, and a block cuts the follow both ways.
|
||||
- A Flag reaches the Mastodon admin UI.
|
||||
- **P4:**
|
||||
- Lemmy follows a community; posts and comments flow both ways; votes show; moderator deletes propagate.
|
||||
- A Mastodon member of a circle gets a limited post. An unsigned or non-member GET answers 404.
|
||||
- Two local avatars, 3 km and 8 km away, see a 5 km post correctly, and the post creates no delivery jobs.
|
||||
- **Every deploy:** `/build.json` reports the commit. `privapub.thepra.dev/peasants/privapub` and NodeInfo answer.
|
||||
|
||||
## Risks and sequencing
|
||||
|
||||
- Unique indexes fail on existing duplicates, so the dedupe migration runs first. Run a `mongodump` before every
|
||||
tagged deploy from P0 on.
|
||||
- Merge DmPost into Post and register `PrivacyIdGenerator` **before P2** exposes ids to clients.
|
||||
- The publish grows by about 20 MB (OpenIddict, NetVips.Native, FFMpegCore). Keep `deploy.yml`'s asserted file list
|
||||
current, and add ffmpeg in `deploy/max/setup.sh`.
|
||||
- Pasture's private-network and plain-HTTP allowances stay out of `appsettings.Production.json`, and startup asserts
|
||||
they're off.
|
||||
- Scale: this is several weeks of work. Each phase is a separate tagged release, and I report after each one.
|
||||
|
||||
Reference in new issue
Block a user