Compare commits

...
6 Commits
Author SHA1 Message Date
thepraandClaude Opus 5.5 8f4d6cbdf9 A private fediverse on the workstation: PrivaPub against a real GoToSocial
Build / Build (push) Successful in 57s
Deploy / privapub.thepra.dev (push) Successful in 1m12s
tools/pasture/run.sh starts PrivaPub, GoToSocial and Mongo on one podman network behind Caddy's internal CA, and
interop.sh drives both through their own client APIs: follows (one to a locked account), posts, CW, replies, likes,
boosts, DMs, edits, deletes and unfollow. All 25 checks pass, three fresh runs in a row.

- Federation:AcceptAnyCertificate joins the two test-network switches; startup refuses all three in Production.
- WebFinger falls back to http only when AllowPlainHttp is on.
- A bootstrap logger, so a failure before the host is built is no longer silent.
- P4 is ticked in the roadmap, with what has not been run live (Lemmy, a Mastodon circle member).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 13:19:16 +02:00
thepraandClaude Opus 5.5 31542a181e Remote posts take their timeline place on arrival, and an unedited one has no edit date
Found by the first live run against GoToSocial:

- A remote post's id came from its published second plus random bytes, so a reply arriving in the same second as
  the post it answers could sort under it, and a late arrival landed behind a client's since_id. A post published
  within the last hour now gets an id for its arrival; backfill keeps its published time.
- NoteParser.Time returned default(DateTime) for a missing "updated", so every remote post was stored as edited in
  year one. Migration _006 clears the stored ones.
- published now carries milliseconds, so peers that derive ids from it (GoToSocial, Mastodon) keep our posts in
  order within a second.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 13:19:16 +02:00
thepraandClaude Opus 5.5 51f2c10c6f Community actors name their moderators the way Lemmy reads them
Build / Build (push) Successful in 41s
Deploy / privapub.thepra.dev (push) Successful in 1m1s
attributedTo points at /wardens and postingRestrictedToMods reflects the
posting policy.

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 12:24:30 +02:00
54 changed files with 1160 additions and 77 deletions

No files matched your search

+1
View File
@@ -401,3 +401,4 @@ FodyWeavers.xsd
PrivaPub/media-store/ PrivaPub/media-store/
PrivaPub/media-store-proxy/ PrivaPub/media-store-proxy/
tools/pasture/.publish/
+55 -6
View File
@@ -69,7 +69,7 @@ PrivaPub/ ASP.NET Core Web API, net10.0
Infrastructure/ Infrastructure/
Http/ FederationHttp + SafeHttpHandlerFactory + IpRangeGuard: the only way out Http/ FederationHttp + SafeHttpHandlerFactory + IpRangeGuard: the only way out
Jobs/ JobQueue (leases), JobWorker, Backoff, HostCircuitBreaker Jobs/ JobQueue (leases), JobWorker, Backoff, HostCircuitBreaker
Ids/ PrivacyIds (day-only ids for personas and groups, published-time ids for remote posts) Ids/ PrivacyIds (day-only ids for personas and groups, arrival-ordered ids for remote posts)
Data/ Indexes (created at start), EntityMaps.Warm, Migrations/_NNN_*.cs Data/ Indexes (created at start), EntityMaps.Warm, Migrations/_NNN_*.cs
Cli/ AdminCommands (`PrivaPub admin promote|demote <root>`) Cli/ AdminCommands (`PrivaPub admin promote|demote <root>`)
RateLimiting.cs accounts (per client address) and inbox (per sending origin) policies RateLimiting.cs accounts (per client address) and inbox (per sending origin) policies
@@ -129,7 +129,8 @@ cd PrivaPub && ASPNETCORE_ENVIRONMENT=Development \
Development config (`appsettings.Development.json`) binds HTTPS 7195 with HTTP/2 only; the overrides above make it 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 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. `Federation__AllowPrivateNetworks=true`, `Federation__AllowPlainHttp=true` and `Federation__AcceptAnyCertificate=true`,
which startup refuses in Production.
Promoting an admin on the box (signing up as "admin" grants nothing): Promoting an admin on the box (signing up as "admin" grants nothing):
@@ -166,8 +167,13 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
- the shared inbox is advertised in `endpoints.sharedInbox`. - the shared inbox is advertised in `endpoints.sharedInbox`.
7. **Remote HTML is sanitized before it is stored** (`ContentSanitizer`); `Post.ContentHtml` is what is shown, 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. `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 8. **Circles federate to members only.** A circle is an undiscoverable Group actor that takes follow requests (the owner
`IsLocalOnly`. Only communities are Group actors. approves); its posts are addressed to the circle and its `/flock`, delivered to members' personal inboxes, never
announced, and served only to a signed request from a member or a member's instance actor
(`SignedFetchAuthorizer`), 404 otherwise. Circles never appear in search, lookups, mentions or profile pages.
**Communities** are FEP-1b12 groups: `GroupDistributor` announces the whole activity (plus the object for new posts,
for Mastodon), top-level posts are `Page`s with a `name`, posting follows `Group.PostingPolicy`.
Located posts (`LocalGeo`) are the only local-only posts.
9. **A DM joins a conversation only by `DmGroup.ParticipantsKey`**, the exact set of its participants; a remote 9. **A DM joins a conversation only by `DmGroup.ParticipantsKey`**, the exact set of its participants; a remote
`context` decides nothing. DMs are `Post`s with `Visibility = Direct` and a `ConversationId` (`DmPost` is legacy). `context` decides nothing. DMs are `Post`s with `Visibility = Direct` and a `ConversationId` (`DmPost` is legacy).
10. **Nothing slow happens inside a request.** Deliveries and inbox processing are `Job`s (`Infrastructure/Jobs`): 10. **Nothing slow happens inside a request.** Deliveries and inbox processing are `Job`s (`Infrastructure/Jobs`):
@@ -221,6 +227,15 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
- **Collection name = class name, so never rename an entity class.** - **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. - `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. - New fields must be additive: a deploy rollback restores the binary, not the database.
- **Post ids are the timeline order, so they follow arrival, not `published`.** `PrivacyIds.Arrived` gives a remote post
published within the last hour (or in the future) a fresh `ObjectId`, which sorts after every post already stored,
and only backfill keeps a `published`-derived id. With `published` ids a reply arriving in the same second could sort
under the post it answers, and a late arrival landed behind a client's `since_id` and was never seen. `created_at`
comes from `CreationDate`, never from the id.
- **The same ordering problem exists on the other side, so `published` carries milliseconds**
(`ActivityPubRenderer.Timestamp`). GoToSocial (ULIDs) and Mastodon (Snowflakes) derive a remote status's id from
`published` at millisecond resolution. With whole seconds, two of our posts from the same second sorted at random
there.
- **Startup order:** `EntityMaps.Warm()` (every entity's class map, one at a time; two mapped at once throw "An item - **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`, 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 `_NNN_` order, each runs once), then `Indexes.Create()`. A new entity needs nothing; a new unique index needs a
@@ -238,6 +253,9 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
- Localised strings go through `IStringLocalizer<GenericRes>`. - Localised strings go through `IStringLocalizer<GenericRes>`.
- Every `Display`/`ErrorMessage` resource key must exist in `FieldsNameResource`/`ErrorsResource`, including the - 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. `Designer.cs`, which the CLI build doesn't regenerate. A missing key throws at validation time.
- **`cond ? value : default` with a value-type branch is the type's default, not null:** `false`, `0`, or year one
stored as an edit date on every remote post. Write `(T?)null`. It has shipped four times (`MastodonParams.Bool/Int`,
`NoteParser.Int`, `NoteParser.Time`).
- ActivityPub output is built with `System.Text.Json.Nodes` in `ActivityPubRenderer`, not typed models. Inbound - 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. 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 + - Libraries chosen for the roadmap: HtmlSanitizer, Markdig (`DisableHtml`), NSign (RFC 9421 inbound), OpenIddict +
@@ -256,8 +274,39 @@ Beyond the tests, verify by building, running locally, and exercising:
- the client API (sign up, create an avatar, a group, a post); - the client API (sign up, create an avatar, a group, a post);
- the ActivityPub endpoints with curl and `Accept: application/activity+json`. - 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 Interop is checked against real servers, starting with the workstation's own pasture:
the owner's GoToSocial at social.arasaka.software. **Ask before acting from the owner's GoToSocial account.**
```bash
DOTNET=~/.dotnet/dotnet tools/pasture/run.sh up # podman: PrivaPub + the latest GoToSocial + Mongo, behind Caddy
tools/pasture/interop.sh # 25 checks, each side driven through its own Mastodon API
tools/pasture/run.sh down
```
- **Two sites on one podman network, one Caddy in front.** `privapub.test` and `gts.test` are network aliases of the
Caddy container, which serves both with its internal CA (`tls internal`). Both servers are told to accept any
certificate: PrivaPub through `appsettings.Pasture.json`, GoToSocial through `GTS_HTTP_CLIENT_TLS_INSECURE_SKIP_VERIFY`.
GoToSocial WebFingers and fetches over https only, so plain http between them is not an option.
- From the workstation, PrivaPub's API is `http://127.0.0.1:6971`. GoToSocial is reached as `https://gts.test:6443`
with `curl -k --resolve gts.test:6443:127.0.0.1`, because its sign-in cookie is bound to the host name.
- **GoToSocial's cached home timeline can stop taking new posts after its first read**, its owner's own included, while
a `min_id` query shows them all. So a delivery is checked by looking the object up by URI with `resolve=false`
(`on_gts`), which answers from GoToSocial's database and never fetches from us. A home-timeline check there proves
nothing, in either direction. A deleted status still turns up in that search as a "deleted status" stub, so a delete
is checked as a 404 on `/api/v1/statuses/{id}`.
- **GoToSocial creates its accounts locked**, so `interop.sh` approves alice's request through
`/api/v1/follow_requests`. That also checks our pending (`requested`) state and the manual Accept.
- The scenario covers:
- discovery and follows both ways;
- posts and CW;
- a reply and its notification;
- likes and boosts both ways;
- DMs both ways, and the DM staying off public timelines;
- edit, delete both ways, and unfollow.
- `run.sh up` replaces every container, Mongo included, so each run starts clean. To keep the data, republish into
`tools/pasture/.publish` and `podman restart pasture-privapub`; that is how a migration is tried on dirty data.
After the pasture, verify.funfedi.dev and the owner's GoToSocial at social.arasaka.software. **Ask before acting from
the owner's GoToSocial account.**
## Deploy ## Deploy
+30 -4
View File
@@ -10,6 +10,20 @@ PrivaPub is an ActivityPub server written in C#. This document follows
- [HTTP Signatures](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures), `rsa-sha256` / `hs2019` with RSA keys - [HTTP Signatures](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures), `rsa-sha256` / `hs2019` with RSA keys
- [NodeInfo](https://nodeinfo.diaspora.software/) 2.0 and 2.1 - [NodeInfo](https://nodeinfo.diaspora.software/) 2.0 and 2.1
## Tested against
- **GoToSocial 0.22.1, end to end.** It runs in a private network on the workstation (`tools/pasture/`) and is driven
through its own client API. Checked both ways:
- follows, including to a locked account;
- public posts with a content warning;
- replies with notifications;
- likes and boosts;
- direct messages;
- edits and deletes;
- unfollow.
- **Mastodon, Misskey, Lemmy and PeerTube, by unit tests only.** The tests feed the parser documents written in each
server's shape. No live exchange with any of them has run yet.
## Supported FEPs ## Supported FEPs
- [FEP-67ff: FEDERATION.md](https://codeberg.org/fediverse/fep/src/branch/main/fep/67ff/fep-67ff.md) - [FEP-67ff: FEDERATION.md](https://codeberg.org/fediverse/fep/src/branch/main/fep/67ff/fep-67ff.md)
@@ -47,10 +61,18 @@ The names are the project's own and are stable; resolve actors through WebFinger
A group is either a **community** or a **circle**. A group is either a **community** or a **circle**.
- A community is a `Group` actor. It accepts `Follow` and re-shares (`Announce`) posts from its followers that address - A **community** follows [FEP-1b12](https://codeberg.org/fediverse/fep/src/branch/main/fep/1b12/fep-1b12.md). Posts
it. Full FEP-1b12 behaviour (announcing activities, `audience`, moderation) is planned. addressed to it (in `to`, `cc` or `audience`) are accepted according to its posting policy (followers, anyone, or
- A circle is private and does not federate yet: its actor, collections and WebFinger answer 404, and its posts are never moderators only) and the group `Announce`s the whole activity, with `audience` set, to its followers. A new post is
delivered. also announced as an object so Mastodon shows it. Updates and deletes of community content are announced too. A
top-level post is a `Page` with a `name`. Members are counted at `/flock`; moderators are listed at `/wardens`, which
the actor's `attributedTo` points to, with `postingRestrictedToMods` as Lemmy expects. A mention of a community posts into it.
- A **circle** is private. Its actor is not discoverable and every follow is a request. Its posts are addressed to the
circle and its members collection, delivered to each member's own inbox and never announced. They are served only
to a signed request from a member, or from the instance actor of a member's server; anyone else gets 404. A
Mastodon member's replies reach only the people they mention.
- Announces from **remote** groups (Lemmy communities) are followed through to the activity: the object is fetched
from its own origin, never taken from the announce.
## Activities ## Activities
@@ -85,6 +107,10 @@ tags. A post's title becomes `name` and is also the first, bold line of `content
Visibility is expressed in `to`/`cc` the way Mastodon does it: public, unlisted, followers-only and direct. Inbound Visibility is expressed in `to`/`cc` the way Mastodon does it: public, unlisted, followers-only and direct. Inbound
followers-only posts are recognised by the author's own `followers` collection. followers-only posts are recognised by the author's own `followers` collection.
## Local-only posts
Posts with a location (shown to nearby users of this server) never leave the server, in any form.
## Security rules a peer will notice ## Security rules a peer will notice
- **Signatures.** Inbox POSTs must be signed over `(request-target)`, `host`, `digest` and `date` (or `(created)`). The - **Signatures.** Inbox POSTs must be signed over `(request-target)`, `host`, `digest` and `date` (or `(created)`). The
@@ -25,6 +25,7 @@ namespace PrivaPub.ClientModels.Group
public string InvitationPassword { get; set; } public string InvitationPassword { get; set; }
public bool IsCommunity { get; set; } public bool IsCommunity { get; set; }
public string PostingPolicy { get; set; }//followers (default), anyone, moderators
public bool IsDiscoverable { get; set; } = true; public bool IsDiscoverable { get; set; } = true;
public bool ManuallyApprovesMembers { get; set; } public bool ManuallyApprovesMembers { get; set; }
} }
@@ -20,6 +20,7 @@ namespace PrivaPub.ClientModels.Group
public bool RemoveInvitationPassword { get; set; } public bool RemoveInvitationPassword { get; set; }
public bool RegenerateInvitationCode { get; set; } public bool RegenerateInvitationCode { get; set; }
public bool? IsDiscoverable { get; set; } public bool? IsDiscoverable { get; set; }
public string PostingPolicy { get; set; }
public bool? ManuallyApprovesMembers { get; set; } public bool? ManuallyApprovesMembers { get; set; }
} }
} }
+1
View File
@@ -9,6 +9,7 @@ namespace PrivaPub.ClientModels.Group
public string Url { get; set; } public string Url { get; set; }
public string Handle { get; set; } public string Handle { get; set; }
public bool IsCommunity { get; set; } public bool IsCommunity { get; set; }
public string PostingPolicy { get; set; }
public bool IsDiscoverable { get; set; } public bool IsDiscoverable { get; set; }
public bool ManuallyApprovesMembers { get; set; } public bool ManuallyApprovesMembers { get; set; }
public bool IsOwner { get; set; } public bool IsOwner { get; set; }
@@ -24,6 +24,15 @@ namespace PrivaPub.ClientModels.Post
public string SpoilerText { get; set; } public string SpoilerText { get; set; }
public string Visibility { get; set; }//public (default), unlisted, followersonly public string Visibility { get; set; }//public (default), unlisted, followersonly
[Range(-90, 90, ErrorMessageResourceName = "Range", ErrorMessageResourceType = typeof(ErrorsResource))]
public double? Latitude { get; set; }
[Range(-180, 180, ErrorMessageResourceName = "Range", ErrorMessageResourceType = typeof(ErrorsResource))]
public double? Longitude { get; set; }
[Range(1, 50, ErrorMessageResourceName = "Range", ErrorMessageResourceType = typeof(ErrorsResource))]
public double? RangeKm { get; set; }
} }
public class UpdatePostForm public class UpdatePostForm
+2
View File
@@ -17,6 +17,8 @@ namespace PrivaPub.ClientModels.Post
public bool IsFederatedCopy { get; set; } public bool IsFederatedCopy { get; set; }
public DateTime CreationDate { get; set; } public DateTime CreationDate { get; set; }
public ViewPost ReblogOf { get; set; } public ViewPost ReblogOf { get; set; }
public double? RangeKm { get; set; }
public double? DistanceKm { get; set; }
} }
public class ViewDmGroup public class ViewDmGroup
+77
View File
@@ -0,0 +1,77 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Post;
using PrivaPub.Domain.Privacy;
using PrivaPub.Federation.Outbox;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.Post;
using PrivaPub.Tests.Support;
using System.Text.Json;
namespace PrivaPub.Tests.Domain
{
[Trait("Category", "Integration")]
public sealed class NearbyTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
[Fact]
public async Task A_located_post_reaches_only_those_within_its_radius_and_never_leaves_the_server()
{
var token = TestContext.Current.CancellationToken;
var latitude = 10 + Random.Shared.NextDouble() * 50;
var longitude = 10 + Random.Shared.NextDouble() * 50;
var (aliceRoot, alice) = await _harness.Persona("alice");
var (bobRoot, bob) = await _harness.Persona("bob");
var (carolRoot, carol) = await _harness.Persona("carol");
var follower = new RemoteActor(_harness.Peer, "follower");
await _harness.FollowedBy(alice, follower);
var created = await _harness.Posts.InsertPost(aliceRoot, new InsertPostForm
{
AvatarId = alice.Id, Text = "free bread at the bakery", Latitude = latitude, Longitude = longitude, RangeKm = 5
}, token);
var post = await DB.Default.Find<Post>().OneAsync(((ViewPost)created.Data).Id, token);
var three = (List<ViewPost>)(await _harness.Posts.Nearby(bobRoot, bob.Id, latitude + 0.027, longitude, token)).Data;
var eight = (List<ViewPost>)(await _harness.Posts.Nearby(carolRoot, carol.Id, latitude + 0.072, longitude, token)).Data;
Assert.Equal(PostVisibility.LocalGeo, post.Visibility);
Assert.Equal(Math.Round(latitude, 2), post.Geo.Coordinates[1]);
Assert.Contains(three, p => p.Id == post.ID);
Assert.InRange(three.First(p => p.Id == post.ID).DistanceKm.Value, 2.0, 4.0);
Assert.DoesNotContain(eight, p => p.Id == post.ID);
Assert.False(await VisibilityPolicy.CanSee(post, bob.Id, token));
var outgoing = (await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.Deliver).ExecuteAsync(token))
.Select(j => JsonSerializer.Deserialize<DeliveryPayload>(j.Payload))
.Where(p => p.Body.Contains(post.ID));
Assert.Empty(outgoing);
}
[Fact]
public async Task A_located_post_cannot_be_direct_or_in_a_group()
{
var (root, alice) = await _harness.Persona("alice");
var result = await _harness.Statuses.Publish(alice, new PrivaPub.Domain.Statuses.StatusDraft
{
Text = "x", Latitude = 45, Longitude = 7, Visibility = PostVisibility.Direct
}, TestContext.Current.CancellationToken);
Assert.False(result.Ok);
}
}
}
+198
View File
@@ -0,0 +1,198 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Social;
using PrivaPub.Domain.Statuses;
using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Rendering;
using PrivaPub.Federation.Signing;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Group;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
using GroupEntity = PrivaPub.Models.Group.Group;
namespace PrivaPub.Tests.Federation
{
[Trait("Category", "Integration")]
public sealed class GroupTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
async Task<(GroupEntity Entity, PrivaPub.Federation.Actors.LocalActor Actor)> Group(GroupKind kind, PostingPolicy policy, string ownerId, params string[] remoteMembers)
{
var (privateKey, publicKey) = PrivaPub.Federation.Actors.Keys.NewKeyPair();
var group = new GroupEntity
{
UserName = $"{kind}{Guid.NewGuid():N}"[..20].ToLowerInvariant(),
Kind = kind,
PostingPolicy = policy,
PrivateKey = privateKey,
PublicKey = publicKey,
Members = new() { new GroupMember { AvatarId = ownerId, Role = GroupRole.Owner } }
};
group.Members.AddRange(remoteMembers.Select(m => new GroupMember { AvatarId = m, IsForeign = true }));
await DB.Default.SaveAsync(group);
return (group, _harness.Local.FromGroup(group));
}
static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority);
static JsonObject Create(RemoteActor author, IEnumerable<string> to, string audience = default)
{
var note = new JsonObject
{
["id"] = $"{Origin(author)}/notes/{Guid.NewGuid():N}",
["type"] = "Note",
["attributedTo"] = author.Id,
["content"] = "<p>to the group</p>",
["to"] = new JsonArray(to.Select(t => (JsonNode)t).ToArray())
};
if (audience != default)
note["audience"] = audience;
return new JsonObject { ["id"] = $"{Origin(author)}/activities/{Guid.NewGuid():N}", ["type"] = "Create", ["actor"] = author.Id, ["object"] = note };
}
[Fact]
public async Task A_follower_posting_to_a_community_is_announced_activity_and_object()
{
var token = TestContext.Current.CancellationToken;
var (_, owner) = await _harness.Persona("owner");
var (_, community) = await Group(GroupKind.Community, PostingPolicy.Followers, owner.Id);
var lemmy = new RemoteActor(_harness.Peer, "lemmy");
var stranger = new RemoteActor(_harness.Peer, "stranger");
await _harness.FollowedBy(community, lemmy);
await _harness.Deliver(lemmy, "/human-centipede", Create(lemmy, new[] { community.Uri, Addressing.Public }, community.Uri));
await _harness.Deliver(stranger, "/human-centipede", Create(stranger, new[] { community.Uri, Addressing.Public }, community.Uri));
Assert.True(await DB.Default.Find<Post>().Match(p => p.GroupId == community.Id && p.ActorURI == lemmy.Id).ExecuteAnyAsync(token));
Assert.False(await DB.Default.Find<Post>().Match(p => p.GroupId == community.Id && p.ActorURI == stranger.Id).ExecuteAnyAsync(token));
var announces = (await _harness.Outgoing(lemmy.SharedInbox)).Where(a => a["actor"]!.GetValue<string>() == community.Uri).ToList();
Assert.Equal(2, announces.Count);
Assert.Contains(announces, a => a["object"] is JsonObject inner && inner["type"]!.GetValue<string>() == "Create" && a["audience"]!.GetValue<string>() == community.Uri);
Assert.Contains(announces, a => a["object"] is JsonValue);
}
[Fact]
public async Task An_open_community_takes_posts_from_anyone()
{
var token = TestContext.Current.CancellationToken;
var (_, owner) = await _harness.Persona("owner");
var (_, community) = await Group(GroupKind.Community, PostingPolicy.Anyone, owner.Id);
var stranger = new RemoteActor(_harness.Peer, "stranger");
await _harness.Deliver(stranger, "/human-centipede", Create(stranger, new[] { community.Uri, Addressing.Public }));
Assert.True(await DB.Default.Find<Post>().Match(p => p.GroupId == community.Id && p.ActorURI == stranger.Id).ExecuteAnyAsync(token));
}
[Fact]
public async Task Mentioning_a_community_posts_into_it_as_a_titled_page()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var (_, community) = await Group(GroupKind.Community, PostingPolicy.Anyone, alice.Id);
var follower = new RemoteActor(_harness.Peer, "follower");
await _harness.FollowedBy(community, follower);
var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = $"@{community.UserName} a new thread", PlainText = true }, token);
Assert.Equal(community.Id, outcome.Post.GroupId);
var note = ActivityPubRenderer.Note(outcome.Post, alice, community, default);
Assert.Equal("Page", note["type"]!.GetValue<string>());
Assert.Equal(community.Uri, note["audience"]!.GetValue<string>());
Assert.False(string.IsNullOrEmpty(note["name"]!.GetValue<string>()));
Assert.Contains(await _harness.Outgoing(follower.SharedInbox), a => a["type"]!.GetValue<string>() == "Announce");
}
[Fact]
public async Task A_circle_post_goes_only_to_members_and_only_members_may_read_it()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var member = new RemoteActor(_harness.Peer, "member");
var outsider = new RemoteActor(_harness.Peer, "outsider");
var (circleEntity, circle) = await Group(GroupKind.Circle, PostingPolicy.Followers, alice.Id, member.Id);
await _harness.Remote.GetActor(member.Id, refresh: false, token);
var follower = new RemoteActor(_harness.Peer, "follower");
await _harness.FollowedBy(alice, follower);
var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "just us", GroupId = circle.Id }, token);
Assert.Equal(PostVisibility.Circle, outcome.Post.Visibility);
var create = Assert.Single(await _harness.Outgoing(member.Id + "/inbox"));
Assert.Equal(new[] { circle.Uri, circle.Flock }, create["object"]!["to"]!.AsArray().Select(t => t!.GetValue<string>()));
Assert.DoesNotContain(Addressing.Public, create.ToJsonString());
Assert.Empty(await _harness.Outgoing(follower.SharedInbox));
Assert.Empty((await _harness.Outgoing(member.SharedInbox)).Where(a => a["type"]!.GetValue<string>() == "Announce"));
var authorizer = new SignedFetchAuthorizer(_harness.Remote);
var path = new Uri(outcome.Post.ObjectURI).AbsolutePath;
var asMember = await authorizer.Requester(member.Get(Harness.Host, path), token);
var asOutsider = await authorizer.Requester(outsider.Get(Harness.Host, path), token);
Assert.True(SignedFetchAuthorizer.MayReadCircle(circleEntity, asMember));
Assert.False(SignedFetchAuthorizer.MayReadCircle(circleEntity, asOutsider));
Assert.False(SignedFetchAuthorizer.MayReadCircle(circleEntity, default));
Assert.True(circle.IsCircle);
Assert.False(circle.Discoverable);
}
[Fact]
public async Task Only_circle_members_can_post_into_a_circle()
{
var token = TestContext.Current.CancellationToken;
var (aliceRoot, alice) = await _harness.Persona("alice");
var member = new RemoteActor(_harness.Peer, "member");
var outsider = new RemoteActor(_harness.Peer, "outsider");
var (_, circle) = await Group(GroupKind.Circle, PostingPolicy.Followers, alice.Id, member.Id);
await _harness.Deliver(member, "/human-centipede", Create(member, new[] { circle.Uri, circle.Flock }));
await _harness.Deliver(outsider, "/human-centipede", Create(outsider, new[] { circle.Uri, circle.Flock }));
var stored = await DB.Default.Find<Post>().Match(p => p.GroupId == circle.Id).ExecuteAsync(token);
Assert.Equal(member.Id, Assert.Single(stored).ActorURI);
Assert.Equal(PostVisibility.Circle, stored[0].Visibility);
Assert.True(await DB.Default.Find<TimelineEntry>().Match(e => e.AvatarId == alice.Id && e.PostId == stored[0].ID).ExecuteAnyAsync(token));
}
[Fact]
public async Task A_followed_remote_community_announcing_a_post_puts_it_in_home()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var lemmyCommunity = new RemoteActor(_harness.Peer, "cats", type: "Group");
var poster = new RemoteActor(_harness.Peer, "poster");
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = lemmyCommunity.Id }, token);
await DB.Default.Update<Following>().Match(f => f.AvatarId == alice.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token);
var create = Create(poster, new[] { lemmyCommunity.Id, Addressing.Public }, lemmyCommunity.Id);
var noteId = create["object"]!["id"]!.GetValue<string>();
_harness.Peer.Serve(new Uri(noteId).AbsolutePath, create["object"]!.ToJsonString());
await _harness.Deliver(lemmyCommunity, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(lemmyCommunity)}/activities/announce/{Guid.NewGuid():N}", ["type"] = "Announce", ["actor"] = lemmyCommunity.Id,
["to"] = new JsonArray(Addressing.Public), ["object"] = create
});
var post = await DB.Default.Find<Post>().Match(p => p.ObjectURI == noteId).ExecuteSingleAsync(token);
Assert.Equal(lemmyCommunity.Id, post.AudienceURI);
Assert.True(await DB.Default.Find<TimelineEntry>().Match(e => e.AvatarId == alice.Id && e.PostId == post.ID).ExecuteAnyAsync(token));
}
}
}
@@ -54,9 +54,9 @@ namespace PrivaPub.Tests.Federation
{ {
new FollowHandler(db, _local, remote, delivery), new FollowHandler(db, _local, remote, delivery),
new UndoHandler(db, _local), new UndoHandler(db, _local),
new CreateHandler(db, _local, remote, delivery, _blocks, new Fanout(db), new RemotePosts(db, _local, remote, _blocks, queue)), new CreateHandler(db, _local, remote, delivery, _blocks, new Fanout(db), new RemotePosts(db, _local, remote, _blocks, queue), new GroupDistributor(delivery)),
new DeleteHandler(db, _local, remote, delivery), new DeleteHandler(db, _local, remote, delivery, new GroupDistributor(delivery)),
new UpdateHandler(db, _local, remote) new UpdateHandler(db, _local, remote, new GroupDistributor(delivery))
}, NullLogger<InboxProcessor>.Instance); }, NullLogger<InboxProcessor>.Instance);
} }
@@ -323,7 +323,7 @@ namespace PrivaPub.Tests.Federation
} }
[Fact] [Fact]
public async Task A_circle_is_not_a_federated_actor() public async Task A_circle_only_takes_follow_requests()
{ {
var token = TestContext.Current.CancellationToken; var token = TestContext.Current.CancellationToken;
var (privateKey, publicKey) = Keys.NewKeyPair(); var (privateKey, publicKey) = Keys.NewKeyPair();
@@ -339,8 +339,13 @@ namespace PrivaPub.Tests.Federation
["object"] = actor.Uri ["object"] = actor.Uri
}; };
Assert.False(actor.IsFederated); Assert.True(actor.IsCircle);
Assert.Equal(404, (await Deliver(bob, "/human-centipede", follow)).StatusCode); Assert.True(actor.ManuallyApprovesFollowers);
Assert.False(actor.Discoverable);
Assert.Equal(202, (await Deliver(bob, "/human-centipede", follow)).StatusCode);
var request = await DB.Default.Find<Follower>().Match(f => f.LocalActorId == circle.ID).ExecuteSingleAsync(token);
Assert.False(request.IsAccepted);
Assert.DoesNotContain(circle.Members, m => m.AvatarId == bob.Id);
} }
} }
} }
@@ -54,6 +54,7 @@ namespace PrivaPub.Tests.Federation
Assert.Equal("a cat", media.Description); Assert.Equal("a cat", media.Description);
Assert.Equal(new[] { 0.5f, -1f }, media.Focus); Assert.Equal(new[] { 0.5f, -1f }, media.Focus);
Assert.Equal(new DateTime(2026, 9, 30, 10, 0, 0, DateTimeKind.Utc), note.Published); Assert.Equal(new DateTime(2026, 9, 30, 10, 0, 0, DateTimeKind.Utc), note.Published);
Assert.Null(note.Updated);
} }
[Fact] [Fact]
+2 -2
View File
@@ -57,7 +57,7 @@ namespace PrivaPub.Tests.Federation
Language = "en", Language = "en",
Mentions = new() { new PostMention { ActorURI = "https://m.example/users/bob", Handle = "@bob@m.example" } }, Mentions = new() { new PostMention { ActorURI = "https://m.example/users/bob", Handle = "@bob@m.example" } },
Tags = new() { "cats" }, Tags = new() { "cats" },
CreationDate = new DateTime(2026, 9, 30, 17, 45, 12, DateTimeKind.Utc) CreationDate = new DateTime(2026, 9, 30, 17, 45, 12, 345, DateTimeKind.Utc)
}; };
var note = ActivityPubRenderer.Note(post, Alice, default, "https://m.example/users/bob/statuses/1"); var note = ActivityPubRenderer.Note(post, Alice, default, "https://m.example/users/bob/statuses/1");
@@ -67,7 +67,7 @@ namespace PrivaPub.Tests.Federation
Assert.Equal("<p><strong>On &lt;cats&gt;</strong></p><p>body</p>", note["content"]!.GetValue<string>()); Assert.Equal("<p><strong>On &lt;cats&gt;</strong></p><p>body</p>", note["content"]!.GetValue<string>());
Assert.Equal("On <cats>", note["summary"]!.GetValue<string>()); Assert.Equal("On <cats>", note["summary"]!.GetValue<string>());
Assert.True(note["sensitive"]!.GetValue<bool>()); Assert.True(note["sensitive"]!.GetValue<bool>());
Assert.Equal("2026-09-30T17:45:12Z", note["published"]!.GetValue<string>()); Assert.Equal("2026-09-30T17:45:12.345Z", note["published"]!.GetValue<string>());
Assert.Equal(new[] { ActivityPubRenderer.Public }, note["to"]!.AsArray().Select(n => n!.GetValue<string>())); Assert.Equal(new[] { ActivityPubRenderer.Public }, note["to"]!.AsArray().Select(n => n!.GetValue<string>()));
Assert.Equal(new[] { Alice.Followers, "https://m.example/users/bob" }, note["cc"]!.AsArray().Select(n => n!.GetValue<string>())); Assert.Equal(new[] { Alice.Followers, "https://m.example/users/bob" }, note["cc"]!.AsArray().Select(n => n!.GetValue<string>()));
var tags = note["tag"]!.AsArray(); var tags = note["tag"]!.AsArray();
@@ -33,6 +33,22 @@ namespace PrivaPub.Tests.Infrastructure
Assert.Equal(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), ObjectId.Parse(older).CreationTime); Assert.Equal(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), ObjectId.Parse(older).CreationTime);
} }
[Fact]
public void A_post_arriving_live_sorts_after_everything_already_stored()
{
var local = ObjectId.GenerateNewId().ToString();
var reply = PrivacyIds.Arrived(DateTime.UtcNow.AddSeconds(-30));
var skewed = PrivacyIds.Arrived(DateTime.UtcNow.AddYears(50));
Assert.True(string.CompareOrdinal(local, reply) < 0);
Assert.True(string.CompareOrdinal(reply, skewed) < 0);
Assert.True(ObjectId.Parse(skewed).CreationTime < DateTime.UtcNow.AddMinutes(1));
}
[Fact]
public void A_backfilled_post_keeps_its_published_time() =>
Assert.Equal(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), ObjectId.Parse(PrivacyIds.Arrived(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc))).CreationTime);
[Fact] [Fact]
public void A_published_time_in_the_future_is_clamped() => public void A_published_time_in_the_future_is_clamped() =>
Assert.True(ObjectId.Parse(PrivacyIds.At(DateTime.UtcNow.AddYears(50))).CreationTime < DateTime.UtcNow.AddDays(2)); Assert.True(ObjectId.Parse(PrivacyIds.At(DateTime.UtcNow.AddYears(50))).CreationTime < DateTime.UtcNow.AddDays(2));
+7 -5
View File
@@ -41,6 +41,7 @@ namespace PrivaPub.Tests.Support
Remote = new RemoteActorService(Peer.Http(cache), Local, cache, Db); Remote = new RemoteActorService(Peer.Http(cache), Local, cache, Db);
Delivery = new DeliveryService(Db, Queue); Delivery = new DeliveryService(Db, Queue);
Fanout = new Fanout(Db); Fanout = new Fanout(Db);
Groups = new GroupDistributor(Delivery);
RemotePosts = new RemotePosts(Db, Local, Remote, new NoBlocks(), Queue); RemotePosts = new RemotePosts(Db, Local, Remote, new NoBlocks(), Queue);
Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger<InboxReceiver>.Instance); Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger<InboxReceiver>.Instance);
Processor = new InboxProcessor(Remote, new IActivityHandler[] Processor = new InboxProcessor(Remote, new IActivityHandler[]
@@ -50,10 +51,10 @@ namespace PrivaPub.Tests.Support
new RejectHandler(Db, Local), new RejectHandler(Db, Local),
new UndoHandler(Db, Local), new UndoHandler(Db, Local),
new LikeHandler(Db), new LikeHandler(Db),
new AnnounceHandler(Db, Local, RemotePosts, Fanout), new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote),
new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts), new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts, Groups),
new DeleteHandler(Db, Local, Remote, Delivery), new DeleteHandler(Db, Local, Remote, Delivery, Groups),
new UpdateHandler(Db, Local, Remote), new UpdateHandler(Db, Local, Remote, Groups),
new FlagHandler(Db, Local) new FlagHandler(Db, Local)
}, NullLogger<InboxProcessor>.Instance); }, NullLogger<InboxProcessor>.Instance);
Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance); Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance);
@@ -61,7 +62,7 @@ namespace PrivaPub.Tests.Support
Outbox = new OutboxPublisher(Db, Local, Delivery); Outbox = new OutboxPublisher(Db, Local, Delivery);
Media = new MediaService(new StaticOptions<MediaOptions>(new MediaOptions { Root = Path.Combine(Path.GetTempPath(), $"privapub-media-{Guid.NewGuid():N}") }), Media = new MediaService(new StaticOptions<MediaOptions>(new MediaOptions { Root = Path.Combine(Path.GetTempPath(), $"privapub-media-{Guid.NewGuid():N}") }),
Local, default, NullLogger<MediaService>.Instance); Local, default, NullLogger<MediaService>.Instance);
Statuses = new StatusService(Db, Local, Remote, Delivery, Content, Outbox, Fanout, Media); Statuses = new StatusService(Db, Local, Remote, Delivery, Content, Outbox, Fanout, Media, Groups);
Posts = new PostsService(Db, Local, Statuses, new KeyLocalizer<GenericRes>(), NullLogger<PostsService>.Instance); Posts = new PostsService(Db, Local, Statuses, new KeyLocalizer<GenericRes>(), NullLogger<PostsService>.Instance);
Timelines = new TimelineService(Db, new KeyLocalizer<GenericRes>()); Timelines = new TimelineService(Db, new KeyLocalizer<GenericRes>());
Relationships = new RelationshipService(Db, Follows, Delivery); Relationships = new RelationshipService(Db, Follows, Delivery);
@@ -82,6 +83,7 @@ namespace PrivaPub.Tests.Support
public PostsService Posts { get; } public PostsService Posts { get; }
public StatusService Statuses { get; } public StatusService Statuses { get; }
public MediaService Media { get; } public MediaService Media { get; }
public GroupDistributor Groups { get; }
public Fanout Fanout { get; } public Fanout Fanout { get; }
public RemotePosts RemotePosts { get; } public RemotePosts RemotePosts { get; }
public TimelineService Timelines { get; } public TimelineService Timelines { get; }
+20 -2
View File
@@ -14,13 +14,16 @@ namespace PrivaPub.Tests.Support
{ {
readonly RSA _key = RSA.Create(2048); readonly RSA _key = RSA.Create(2048);
public RemoteActor(Peer peer, string name, string origin = default) public RemoteActor(Peer peer, string name, string origin = default, string type = "Person")
{ {
Name = $"{name}{Guid.NewGuid():N}"[..20]; Name = $"{name}{Guid.NewGuid():N}"[..20];
Id = $"{origin ?? peer.A}/users/{Name}"; Id = $"{origin ?? peer.A}/users/{Name}";
Type = type;
peer.Serve($"/users/{Name}", Document().ToJsonString()); peer.Serve($"/users/{Name}", Document().ToJsonString());
} }
public string Type { get; }
public string Name { get; } public string Name { get; }
public string Id { get; } public string Id { get; }
public string KeyId => Id + "#main-key"; public string KeyId => Id + "#main-key";
@@ -29,7 +32,7 @@ namespace PrivaPub.Tests.Support
public JsonObject Document() => new() public JsonObject Document() => new()
{ {
["id"] = Id, ["id"] = Id,
["type"] = "Person", ["type"] = Type,
["preferredUsername"] = Name, ["preferredUsername"] = Name,
["inbox"] = Id + "/inbox", ["inbox"] = Id + "/inbox",
["followers"] = Id + "/followers", ["followers"] = Id + "/followers",
@@ -41,6 +44,21 @@ namespace PrivaPub.Tests.Support
} }
}; };
public HttpRequest Get(string host, string path)
{
var date = DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture);
var signingString = $"(request-target): get {path}\nhost: {host}\ndate: {date}";
var signature = Convert.ToBase64String(_key.SignData(Encoding.UTF8.GetBytes(signingString), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
var context = new DefaultHttpContext();
context.Request.Method = "GET";
context.Request.Host = new HostString(host);
context.Request.Path = path;
context.Features.Get<IHttpRequestFeature>().RawTarget = path;
context.Request.Headers["Date"] = date;
context.Request.Headers["Signature"] = $"keyId=\"{KeyId}\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date\",signature=\"{signature}\"";
return context.Request;
}
public HttpRequest Post(string host, string path, JsonNode activity) public HttpRequest Post(string host, string path, JsonNode activity)
{ {
var body = Encoding.UTF8.GetBytes(activity.ToJsonString()); var body = Encoding.UTF8.GetBytes(activity.ToJsonString());
@@ -114,7 +114,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
if (parts.Length == 1 || parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase)) if (parts.Length == 1 || parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase))
{ {
var local = await _localActors.FindByUserName(parts[0], token); var local = await _localActors.FindByUserName(parts[0], token);
return local is { IsFederated: true, Kind: not LocalActorKind.Application } ? Json(await _mapper.Local(local, false, token)) : NotFoundError(); return local is { IsFederated: true, IsCircle: false, Kind: not LocalActorKind.Application } ? Json(await _mapper.Local(local, false, token)) : NotFoundError();
} }
var userName = parts[0]; var userName = parts[0];
var domain = parts[1].ToLowerInvariant(); var domain = parts[1].ToLowerInvariant();
@@ -365,7 +365,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
if (avatar is { DeletionAt: null }) if (avatar is { DeletionAt: null })
return (_localActors.FromAvatar(avatar), default); return (_localActors.FromAvatar(avatar), default);
var group = await _dbEntities.Groups.MatchID(id).ExecuteFirstAsync(token); var group = await _dbEntities.Groups.MatchID(id).ExecuteFirstAsync(token);
if (group is { DeletionAt: null } && _localActors.FromGroup(group) is { IsFederated: true } community) if (group is { DeletionAt: null } && _localActors.FromGroup(group) is { IsFederated: true, IsCircle: false } community)
return (community, default); return (community, default);
return (default, await _dbEntities.ForeignAvatars.MatchID(id).ExecuteFirstAsync(token)); return (default, await _dbEntities.ForeignAvatars.MatchID(id).ExecuteFirstAsync(token));
} }
@@ -56,7 +56,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
{ {
var local = await _localActors.FindByUri(q, token); var local = await _localActors.FindByUri(q, token);
var foreign = local == default ? (resolve ? await _remoteActors.GetActor(q, refresh: false, token) : default) : default; var foreign = local == default ? (resolve ? await _remoteActors.GetActor(q, refresh: false, token) : default) : default;
if (local is { IsFederated: true }) if (local is { IsFederated: true, IsCircle: false })
results.Accounts.Add(await _mapper.Local(local, false, token)); results.Accounts.Add(await _mapper.Local(local, false, token));
else if (foreign != default) else if (foreign != default)
results.Accounts.Add(_mapper.Foreign(foreign)); results.Accounts.Add(_mapper.Foreign(foreign));
@@ -6,6 +6,7 @@ using PrivaPub.Domain.Media;
using PrivaPub.Federation.Actors; using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Rendering; using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Federation; using PrivaPub.Models.Federation;
using PrivaPub.Models.Group;
using PrivaPub.Models.Post; using PrivaPub.Models.Post;
using PrivaPub.Models.Social; using PrivaPub.Models.Social;
using PrivaPub.Models.User; using PrivaPub.Models.User;
@@ -124,7 +125,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
foreach (var avatar in await _dbEntities.Avatars.Match(a => wanted.Contains(a.ID) && !a.DeletionAt.HasValue).ExecuteAsync(token)) foreach (var avatar in await _dbEntities.Avatars.Match(a => wanted.Contains(a.ID) && !a.DeletionAt.HasValue).ExecuteAsync(token))
accounts[avatar.ID] = await Local(_localActors.FromAvatar(avatar), false, token); accounts[avatar.ID] = await Local(_localActors.FromAvatar(avatar), false, token);
foreach (var group in await _dbEntities.Groups.Match(g => wanted.Contains(g.ID) && !g.DeletionAt.HasValue).ExecuteAsync(token)) foreach (var group in await _dbEntities.Groups.Match(g => wanted.Contains(g.ID) && !g.DeletionAt.HasValue && g.Kind == GroupKind.Community).ExecuteAsync(token))
accounts[group.ID] = await Local(_localActors.FromGroup(group), false, token); accounts[group.ID] = await Local(_localActors.FromGroup(group), false, token);
foreach (var foreign in await _dbEntities.ForeignAvatars.Match(f => wanted.Contains(f.ID)).ExecuteAsync(token)) foreach (var foreign in await _dbEntities.ForeignAvatars.Match(f => wanted.Contains(f.ID)).ExecuteAsync(token))
accounts[foreign.ID] = Foreign(foreign); accounts[foreign.ID] = Foreign(foreign);
@@ -32,6 +32,10 @@ namespace PrivaPub.Controllers.ClientToServer
public async Task<IActionResult> Insert(InsertPostForm form, CancellationToken token) => public async Task<IActionResult> Insert(InsertPostForm form, CancellationToken token) =>
!ModelState.IsValid ? Invalid() : Answer(await _postsService.InsertPost(User.GetUserId(), form, token)); !ModelState.IsValid ? Invalid() : Answer(await _postsService.InsertPost(User.GetUserId(), form, token));
[HttpGet, Route("/clientapi/post/nearby")]
public async Task<IActionResult> Nearby([FromQuery] string avatarId, [FromQuery] double latitude, [FromQuery] double longitude, CancellationToken token) =>
Answer(await _postsService.Nearby(User.GetUserId(), avatarId, latitude, longitude, token));
[HttpPost, Route("/clientapi/post/update")] [HttpPost, Route("/clientapi/post/update")]
public async Task<IActionResult> Update(UpdatePostForm form, CancellationToken token) => public async Task<IActionResult> Update(UpdatePostForm form, CancellationToken token) =>
!ModelState.IsValid ? Invalid() : Answer(await _postsService.UpdatePost(User.GetUserId(), form, token)); !ModelState.IsValid ? Invalid() : Answer(await _postsService.UpdatePost(User.GetUserId(), form, token));
+1 -1
View File
@@ -122,7 +122,7 @@ namespace PrivaPub.Domain.Content
if (parts.Length == 1 || parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase)) if (parts.Length == 1 || parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase))
{ {
var local = await _localActors.FindByUserName(parts[0], token); var local = await _localActors.FindByUserName(parts[0], token);
return local is { IsFederated: true, Kind: not LocalActorKind.Application } return local is { IsFederated: true, IsCircle: false, Kind: not LocalActorKind.Application }
? new ResolvedMention(local.Uri, local.UserName, local.Domain, local.Uri, true, local.Id, local.Inbox) ? new ResolvedMention(local.Uri, local.UserName, local.Domain, local.Uri, true, local.Id, local.Inbox)
: default; : default;
} }
+1 -1
View File
@@ -95,7 +95,7 @@ namespace PrivaPub.Domain.Social
var (local, remote) = await ResolveTarget(target, token); var (local, remote) = await ResolveTarget(target, token);
if (local == default && remote == default) if (local == default && remote == default)
return default; return default;
if (local != default && (local.Id == follower.Id || !local.IsFederated || local.Kind == LocalActorKind.Application)) if (local != default && (local.Id == follower.Id || !local.IsFederated || local.IsCircle || local.Kind == LocalActorKind.Application))
return default; return default;
var targetUri = local?.Uri ?? remote.ActorURI; var targetUri = local?.Uri ?? remote.ActorURI;
+62 -6
View File
@@ -36,6 +36,9 @@ namespace PrivaPub.Domain.Statuses
public string ConversationId { get; init; } public string ConversationId { get; init; }
public IReadOnlyList<string> Recipients { get; init; } = Array.Empty<string>(); public IReadOnlyList<string> Recipients { get; init; } = Array.Empty<string>();
public IReadOnlyList<string> MediaIds { get; init; } public IReadOnlyList<string> MediaIds { get; init; }
public double? Latitude { get; init; }
public double? Longitude { get; init; }
public double? RangeKm { get; init; }
} }
public sealed record StatusOutcome(PostEntity Post, int Status = StatusCodes.Status200OK, string Error = default) public sealed record StatusOutcome(PostEntity Post, int Status = StatusCodes.Status200OK, string Error = default)
@@ -65,11 +68,13 @@ namespace PrivaPub.Domain.Statuses
readonly IOutboxPublisher _outbox; readonly IOutboxPublisher _outbox;
readonly IFanout _fanout; readonly IFanout _fanout;
readonly IMediaService _media; readonly IMediaService _media;
readonly IGroupDistributor _groups;
public StatusService(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery, public StatusService(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
IContentRenderer content, IOutboxPublisher outbox, IFanout fanout, IMediaService media) IContentRenderer content, IOutboxPublisher outbox, IFanout fanout, IMediaService media, IGroupDistributor groups)
{ {
_media = media; _media = media;
_groups = groups;
_dbEntities = dbEntities; _dbEntities = dbEntities;
_localActors = localActors; _localActors = localActors;
_remoteActors = remoteActors; _remoteActors = remoteActors;
@@ -91,7 +96,7 @@ namespace PrivaPub.Domain.Statuses
if (!string.IsNullOrEmpty(draft.GroupId)) if (!string.IsNullOrEmpty(draft.GroupId))
{ {
var groupEntity = await _dbEntities.Groups.MatchID(draft.GroupId).ExecuteFirstAsync(token); var groupEntity = await _dbEntities.Groups.MatchID(draft.GroupId).ExecuteFirstAsync(token);
if (groupEntity == default || groupEntity.DeletionAt.HasValue || !groupEntity.Members.Any(m => !m.IsForeign && m.AvatarId == author.Id)) if (groupEntity == default || groupEntity.DeletionAt.HasValue || !await MayPost(groupEntity, author, token))
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Group not found"); return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Group not found");
group = _localActors.FromGroup(groupEntity); group = _localActors.FromGroup(groupEntity);
} }
@@ -102,9 +107,35 @@ namespace PrivaPub.Domain.Statuses
if (parent != default && !await VisibilityPolicy.CanSee(parent, author.Id, token)) if (parent != default && !await VisibilityPolicy.CanSee(parent, author.Id, token))
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found"); return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
var located = draft.Latitude.HasValue || draft.Longitude.HasValue;
if (located && (draft.Latitude is not (>= -90 and <= 90) || draft.Longitude is not (>= -180 and <= 180) || group != default
|| draft.Visibility == PostVisibility.Direct))
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: A located post needs a valid position and no group or recipients");
var rendered = draft.PlainText ? await _content.PlainText(draft.Text ?? string.Empty, token) : await _content.Markdown(draft.Text ?? string.Empty, token); var rendered = draft.PlainText ? await _content.PlainText(draft.Text ?? string.Empty, token) : await _content.Markdown(draft.Text ?? string.Empty, token);
var isLocalOnly = group is { IsFederated: false }; string audienceUri = default;
var visibility = isLocalOnly ? PostVisibility.Circle : draft.Visibility is PostVisibility.Circle or PostVisibility.LocalGeo ? PostVisibility.Public : draft.Visibility; if (group == default && !located && draft.Visibility is PostVisibility.Public or PostVisibility.Unlisted)
foreach (var mention in rendered.Mentions)
{
if (mention.IsLocal)
{
var mentioned = await _dbEntities.Groups.MatchID(mention.AccountId).ExecuteFirstAsync(token);
if (mentioned is { DeletionAt: null, Kind: GroupKind.Community } && await MayPost(mentioned, author, token))
{
group = _localActors.FromGroup(mentioned);
break;
}
}
else if (await _dbEntities.ForeignAvatars.MatchID(mention.AccountId).ExecuteFirstAsync(token) is { AvatarType: Models.User.AvatarType.Group } remoteGroup)
{
audienceUri = remoteGroup.ActorURI;
break;
}
}
var isLocalOnly = located;
var visibility = located ? PostVisibility.LocalGeo
: group is { IsCircle: true } ? PostVisibility.Circle
: draft.Visibility is PostVisibility.Circle or PostVisibility.LocalGeo ? PostVisibility.Public : draft.Visibility;
var post = new PostEntity var post = new PostEntity
{ {
GroupUserId = author.Id, GroupUserId = author.Id,
@@ -121,6 +152,7 @@ namespace PrivaPub.Domain.Statuses
Mentions = rendered.Mentions.Select(ToMention).ToList(), Mentions = rendered.Mentions.Select(ToMention).ToList(),
Tags = rendered.Tags.ToList(), Tags = rendered.Tags.ToList(),
Media = media.Select(ToPostMedia).ToList(), Media = media.Select(ToPostMedia).ToList(),
AudienceURI = audienceUri,
AnsweringToPostId = parent?.ID, AnsweringToPostId = parent?.ID,
InReplyToURI = parent?.ObjectURI ?? (IsRemoteUri(draft.InReplyTo) ? draft.InReplyTo : default), InReplyToURI = parent?.ObjectURI ?? (IsRemoteUri(draft.InReplyTo) ? draft.InReplyTo : default),
InReplyToAccountId = parent?.AuthorAccountId ?? parent?.GroupUserId, InReplyToAccountId = parent?.AuthorAccountId ?? parent?.GroupUserId,
@@ -130,6 +162,11 @@ namespace PrivaPub.Domain.Statuses
post.ID = (string)post.GenerateNewID(); post.ID = (string)post.GenerateNewID();
post.ObjectURI = author.PostUri(post.ID); post.ObjectURI = author.PostUri(post.ID);
post.Url = author.PostHtmlUrl(post.ID); post.Url = author.PostHtmlUrl(post.ID);
if (located)
{
post.Geo = new Coordinates2D(Math.Round(draft.Longitude.Value, 2), Math.Round(draft.Latitude.Value, 2));
post.RangeKm = (float)Math.Clamp(draft.RangeKm ?? 5, 1, 50);
}
JsonObject create = default; JsonObject create = default;
if (visibility == PostVisibility.Direct) if (visibility == PostVisibility.Direct)
@@ -165,8 +202,8 @@ namespace PrivaPub.Domain.Statuses
await _fanout.Distribute(post, token); await _fanout.Distribute(post, token);
if (create != default) if (create != default)
await _outbox.Publish(author, post, create, token); await _outbox.Publish(author, post, create, token);
if (group is { IsFederated: true } && visibility is PostVisibility.Public or PostVisibility.Unlisted) if (create != default && group is { IsCircle: false } && visibility is PostVisibility.Public or PostVisibility.Unlisted)
await _delivery.EnqueueToFollowers(group, ActivityPubRenderer.Announce(group, post.ObjectURI, $"announce-{post.ID}"), token); await _groups.Announce(group, create, post.ObjectURI, isNewPost: string.IsNullOrEmpty(post.InReplyToURI), token);
return new StatusOutcome(post); return new StatusOutcome(post);
} }
@@ -232,6 +269,8 @@ namespace PrivaPub.Domain.Statuses
["object"] = note ["object"] = note
}; };
await _outbox.Publish(author, post, update, token); await _outbox.Publish(author, post, update, token);
if (group is { IsCircle: false })
await _groups.Announce(group, update, post.ObjectURI, isNewPost: false, token);
} }
return new StatusOutcome(post); return new StatusOutcome(post);
} }
@@ -262,6 +301,9 @@ namespace PrivaPub.Domain.Statuses
var delete = ActivityPubRenderer.Delete(author, post.ObjectURI, $"delete-{post.ID}", var delete = ActivityPubRenderer.Delete(author, post.ObjectURI, $"delete-{post.ID}",
new JsonArray(post.To.Select(t => (JsonNode)t).ToArray()), new JsonArray(post.Cc.Select(c => (JsonNode)c).ToArray())); new JsonArray(post.To.Select(t => (JsonNode)t).ToArray()), new JsonArray(post.Cc.Select(c => (JsonNode)c).ToArray()));
await _delivery.Enqueue(author, audience, delete, token); await _delivery.Enqueue(author, audience, delete, token);
var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
if (group is { IsCircle: false })
await _groups.Announce(group, delete, post.ObjectURI, isNewPost: false, token);
} }
return new StatusOutcome(post); return new StatusOutcome(post);
} }
@@ -479,6 +521,20 @@ namespace PrivaPub.Domain.Statuses
return (await _dbEntities.ForeignAvatars.MatchID(post.AuthorAccountId).ExecuteFirstAsync(token))?.InboxURL; return (await _dbEntities.ForeignAvatars.MatchID(post.AuthorAccountId).ExecuteFirstAsync(token))?.InboxURL;
} }
async Task<bool> MayPost(Models.Group.Group group, LocalActor author, CancellationToken token)
{
var member = group.Members.FirstOrDefault(m => !m.IsForeign && m.AvatarId == author.Id);
if (group.Kind == GroupKind.Circle)
return member != default;
return group.PostingPolicy switch
{
PostingPolicy.Anyone => true,
PostingPolicy.Moderators => member?.Role is GroupRole.Owner or GroupRole.Moderator,
_ => member != default || await _dbEntities.Followings
.Match(f => f.AvatarId == author.Id && f.TargetAccountId == group.ID && f.State == FollowState.Accepted).ExecuteAnyAsync(token)
};
}
async Task<List<MediaAttachment>> Media(LocalActor author, IReadOnlyList<string> ids, string postId, CancellationToken token) async Task<List<MediaAttachment>> Media(LocalActor author, IReadOnlyList<string> ids, string postId, CancellationToken token)
{ {
if (ids == default || ids.Count == 0) if (ids == default || ids.Count == 0)
+3
View File
@@ -40,6 +40,9 @@ namespace PrivaPub.Domain.Timelines
foreach (var following in followers) foreach (var following in followers)
if (await Shows(following, post, token)) if (await Shows(following, post, token))
recipients.Add(following.AvatarId); recipients.Add(following.AvatarId);
if (!string.IsNullOrEmpty(post.AudienceURI))
foreach (var member in await _dbEntities.Followings.Match(f => f.TargetActorURI == post.AudienceURI && f.State == FollowState.Accepted).ExecuteAsync(token))
recipients.Add(member.AvatarId);
break; break;
case PostVisibility.Direct when !string.IsNullOrEmpty(post.ConversationId): case PostVisibility.Direct when !string.IsNullOrEmpty(post.ConversationId):
var conversation = await _dbEntities.DmGroups.MatchID(post.ConversationId).ExecuteFirstAsync(token); var conversation = await _dbEntities.DmGroups.MatchID(post.ConversationId).ExecuteFirstAsync(token);
@@ -29,6 +29,8 @@ namespace PrivaPub.Federation.Actors
public bool Discoverable { get; init; } = true; public bool Discoverable { get; init; } = true;
public bool ManuallyApprovesFollowers { get; init; } public bool ManuallyApprovesFollowers { get; init; }
public bool IsFederated { get; init; } = true; public bool IsFederated { get; init; } = true;
public bool IsCircle { get; init; }
public bool PostingRestrictedToModerators { get; init; }
public bool IsBot { get; init; } public bool IsBot { get; init; }
public bool Indexable { get; init; } public bool Indexable { get; init; }
public AvatarSettings Settings { get; init; } = new(); public AvatarSettings Settings { get; init; } = new();
@@ -44,6 +46,8 @@ namespace PrivaPub.Federation.Actors
public string Following => $"{Uri}/stalking"; public string Following => $"{Uri}/stalking";
public string Featured => $"{Uri}/trophies"; public string Featured => $"{Uri}/trophies";
public string FeaturedTags => $"{Uri}/tattoos"; public string FeaturedTags => $"{Uri}/tattoos";
public string Flock => $"{Uri}/flock";
public string Wardens => $"{Uri}/wardens";
public string SharedInbox => $"{BaseAddress}/human-centipede"; public string SharedInbox => $"{BaseAddress}/human-centipede";
public string Domain => new Uri(BaseAddress).Authority; public string Domain => new Uri(BaseAddress).Authority;
public string Handle => $"{UserName}@{Domain}"; public string Handle => $"{UserName}@{Domain}";
@@ -219,9 +223,10 @@ namespace PrivaPub.Federation.Actors
ThumbnailURL = group.ThumbnailURL, ThumbnailURL = group.ThumbnailURL,
PrivateKeyPem = group.PrivateKey, PrivateKeyPem = group.PrivateKey,
PublicKeyPem = group.PublicKey, PublicKeyPem = group.PublicKey,
Discoverable = group.IsDiscoverable, Discoverable = group.Kind == GroupKind.Community && group.IsDiscoverable,
ManuallyApprovesFollowers = group.ManuallyApprovesMembers, ManuallyApprovesFollowers = group.Kind == GroupKind.Circle || group.ManuallyApprovesMembers,
IsFederated = group.Kind == GroupKind.Community, IsCircle = group.Kind == GroupKind.Circle,
PostingRestrictedToModerators = group.PostingPolicy == PostingPolicy.Moderators,
Published = group.CreationDate, Published = group.CreationDate,
BaseAddress = BaseAddress BaseAddress = BaseAddress
}; };
@@ -6,6 +6,7 @@ using PrivaPub.StaticServices;
using System.Text.Json; using System.Text.Json;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using PrivaPub.Federation.Objects; using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Signing; using PrivaPub.Federation.Signing;
@@ -32,13 +33,16 @@ namespace PrivaPub.Federation.Actors
readonly ILocalActorService _localActors; readonly ILocalActorService _localActors;
readonly IMemoryCache _cache; readonly IMemoryCache _cache;
readonly DbEntities _dbEntities; readonly DbEntities _dbEntities;
readonly IOptionsMonitor<FederationOptions> _options;
public RemoteActorService(IFederationHttp http, ILocalActorService localActors, IMemoryCache cache, DbEntities dbEntities) public RemoteActorService(IFederationHttp http, ILocalActorService localActors, IMemoryCache cache, DbEntities dbEntities,
IOptionsMonitor<FederationOptions> options = default)
{ {
_http = http; _http = http;
_localActors = localActors; _localActors = localActors;
_cache = cache; _cache = cache;
_dbEntities = dbEntities; _dbEntities = dbEntities;
_options = options;
} }
public async Task<FetchedJson> FetchObject(string uri, CancellationToken token) public async Task<FetchedJson> FetchObject(string uri, CancellationToken token)
@@ -122,8 +126,11 @@ namespace PrivaPub.Federation.Actors
if (parts is not { Length: 2 } || string.IsNullOrEmpty(parts[0]) || string.IsNullOrEmpty(parts[1])) if (parts is not { Length: 2 } || string.IsNullOrEmpty(parts[0]) || string.IsNullOrEmpty(parts[1]))
return default; return default;
var url = $"https://{parts[1]}/.well-known/webfinger?resource={Uri.EscapeDataString($"acct:{parts[0]}@{parts[1]}")}"; var query = $"/.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); using var fetched = await _http.GetJson($"https://{parts[1]}{query}", "application/jrd+json, application/json", sign: default, token)
?? (_options?.CurrentValue.AllowPlainHttp == true
? await _http.GetJson($"http://{parts[1]}{query}", "application/jrd+json, application/json", sign: default, token)
: default);
if (fetched == default) if (fetched == default)
return default; return default;
var document = fetched.Document; var document = fetched.Document;
@@ -1,4 +1,6 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Options;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using MongoDB.Entities; using MongoDB.Entities;
@@ -15,12 +17,15 @@ using PrivaPub.Domain.Privacy;
using PrivaPub.Federation.Actors; using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Rendering; using PrivaPub.Federation.Rendering;
using PrivaPub.Federation.Inbox; using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Signing;
using PrivaPub.Infrastructure.Http;
using PrivaPub.Models.Group;
using PrivaPub.Infrastructure; using PrivaPub.Infrastructure;
namespace PrivaPub.Federation.Controllers namespace PrivaPub.Federation.Controllers
{ {
[ApiController, Route("peasants")] [ApiController, Route("peasants")]
public class PeasantsController : ControllerBase public class PeasantsController : ControllerBase, IAsyncActionFilter
{ {
const string ActivityContentType = "application/activity+json; charset=utf-8"; const string ActivityContentType = "application/activity+json; charset=utf-8";
const int OutboxSize = 20; const int OutboxSize = 20;
@@ -29,10 +34,14 @@ namespace PrivaPub.Federation.Controllers
readonly IInboxReceiver _inbox; readonly IInboxReceiver _inbox;
readonly DbEntities _dbEntities; readonly DbEntities _dbEntities;
readonly ILogger<PeasantsController> _logger; readonly ILogger<PeasantsController> _logger;
readonly ISignedFetchAuthorizer _fetches;
readonly IOptionsMonitor<FederationOptions> _federation;
public PeasantsController(ILocalActorService localActors, IInboxReceiver inbox, DbEntities dbEntities, public PeasantsController(ILocalActorService localActors, IInboxReceiver inbox, DbEntities dbEntities,
ILogger<PeasantsController> logger) ILogger<PeasantsController> logger, ISignedFetchAuthorizer fetches, IOptionsMonitor<FederationOptions> federation)
{ {
_fetches = fetches;
_federation = federation;
_localActors = localActors; _localActors = localActors;
_inbox = inbox; _inbox = inbox;
_dbEntities = dbEntities; _dbEntities = dbEntities;
@@ -113,6 +122,34 @@ namespace PrivaPub.Federation.Controllers
return local is not { IsFederated: true } ? NotFound() : Activity(ActivityPubRenderer.OrderedCollection(local.Following, 0, default)); return local is not { IsFederated: true } ? NotFound() : Activity(ActivityPubRenderer.OrderedCollection(local.Following, 0, default));
} }
[HttpGet, Route("{actor}/flock")]
public async Task<IActionResult> Members(string actor, CancellationToken token)
{
var local = await _localActors.FindByUserName(actor, token);
if (local is not { IsFederated: true, Kind: LocalActorKind.Group })
return NotFound();
var group = await _dbEntities.Groups.MatchID(local.Id).ExecuteFirstAsync(token);
if (local.IsCircle && !SignedFetchAuthorizer.MayReadCircle(group, await _fetches.Requester(Request, token)))
return NotFound();
return Activity(ActivityPubRenderer.OrderedCollection(local.Flock, group.Members.Count, default));
}
[HttpGet, Route("{actor}/wardens")]
public async Task<IActionResult> Moderators(string actor, CancellationToken token)
{
var local = await _localActors.FindByUserName(actor, token);
if (local is not { IsFederated: true, Kind: LocalActorKind.Group })
return NotFound();
var group = await _dbEntities.Groups.MatchID(local.Id).ExecuteFirstAsync(token);
if (local.IsCircle && !SignedFetchAuthorizer.MayReadCircle(group, await _fetches.Requester(Request, token)))
return NotFound();
var moderators = new List<JsonNode>();
foreach (var member in group.Members.Where(m => !m.IsForeign && m.Role is GroupRole.Owner or GroupRole.Moderator))
if (await _localActors.FindById(LocalActorKind.Person, member.AvatarId, token) is { } moderator)
moderators.Add(moderator.Uri);
return Activity(ActivityPubRenderer.OrderedCollection(local.Wardens, moderators.Count, moderators));
}
[HttpGet, Route("{actor}/trophies")] [HttpGet, Route("{actor}/trophies")]
public async Task<IActionResult> Featured(string actor, CancellationToken token) public async Task<IActionResult> Featured(string actor, CancellationToken token)
{ {
@@ -145,6 +182,12 @@ namespace PrivaPub.Federation.Controllers
public async Task<IActionResult> Post(string actor, string postId, CancellationToken token) public async Task<IActionResult> Post(string actor, string postId, CancellationToken token)
{ {
var (local, post) = await PublicPost(actor, postId, token); var (local, post) = await PublicPost(actor, postId, token);
if (post == default && await CirclePost(actor, postId, token) is { } circlePost)
{
var circleNote = ActivityPubRenderer.Note(circlePost.Post, circlePost.Author, circlePost.Circle, circlePost.Post.InReplyToURI);
circleNote["@context"] = ActivityPubRenderer.Context();
return Activity(circleNote);
}
if (post == default) if (post == default)
return await Tombstone(actor, postId, token) ?? NotFound(); return await Tombstone(actor, postId, token) ?? NotFound();
if (WantsHtml()) if (WantsHtml())
@@ -202,6 +245,20 @@ namespace PrivaPub.Federation.Controllers
return (local, post); return (local, post);
} }
async Task<(LocalActor Author, LocalActor Circle, PostEntity Post)?> CirclePost(string actor, string postId, CancellationToken token)
{
var local = await _localActors.FindByUserName(actor, token);
if (local is not { Kind: LocalActorKind.Person })
return default;
var post = await _dbEntities.Posts
.Match(p => p.ID == postId && p.GroupUserId == local.Id && !p.IsFederatedCopy && !p.DeletedAt.HasValue && p.Visibility == PostVisibility.Circle)
.ExecuteFirstAsync(token);
var circle = post == default ? default : await _dbEntities.Groups.MatchID(post.GroupId).ExecuteFirstAsync(token);
if (circle == default || !SignedFetchAuthorizer.MayReadCircle(circle, await _fetches.Requester(Request, token)))
return default;
return (local, _localActors.FromGroup(circle), post);
}
async Task<IActionResult> Tombstone(string actor, string postId, CancellationToken token) async Task<IActionResult> Tombstone(string actor, string postId, CancellationToken token)
{ {
var local = await _localActors.FindByUserName(actor, token); var local = await _localActors.FindByUserName(actor, token);
@@ -257,6 +314,19 @@ namespace PrivaPub.Federation.Controllers
&& !accept.Contains("ld+json", StringComparison.OrdinalIgnoreCase); && !accept.Contains("ld+json", StringComparison.OrdinalIgnoreCase);
} }
[NonAction]
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
if (_federation.CurrentValue.SecureMode && HttpMethods.IsGet(Request.Method)
&& !string.Equals(context.RouteData.Values["actor"] as string, LocalActorService.InstanceUserName, StringComparison.OrdinalIgnoreCase)
&& await _fetches.Requester(Request, HttpContext.RequestAborted) == default)
{
context.Result = StatusCode(StatusCodes.Status401Unauthorized);
return;
}
await next();
}
IActionResult Answer(InboxResult result) IActionResult Answer(InboxResult result)
{ {
if (result.Error != default) if (result.Error != default)
@@ -26,9 +26,11 @@ namespace PrivaPub.Federation.Inbox.Handlers
readonly ILocalActorService _localActors; readonly ILocalActorService _localActors;
readonly IRemotePosts _remotePosts; readonly IRemotePosts _remotePosts;
readonly IFanout _fanout; readonly IFanout _fanout;
readonly IRemoteActorService _remoteActors;
public AnnounceHandler(DbEntities dbEntities, ILocalActorService localActors, IRemotePosts remotePosts, IFanout fanout) public AnnounceHandler(DbEntities dbEntities, ILocalActorService localActors, IRemotePosts remotePosts, IFanout fanout, IRemoteActorService remoteActors)
{ {
_remoteActors = remoteActors;
_dbEntities = dbEntities; _dbEntities = dbEntities;
_localActors = localActors; _localActors = localActors;
_remotePosts = remotePosts; _remotePosts = remotePosts;
@@ -41,7 +43,10 @@ namespace PrivaPub.Federation.Inbox.Handlers
{ {
var inner = activity["object"]; var inner = activity["object"];
if (inner is JsonObject && Value(inner, "type") is "Create" or "Update" or "Delete" or "Like" or "Dislike" or "Undo" or "Add" or "Remove" or "Block") if (inner is JsonObject && Value(inner, "type") is "Create" or "Update" or "Delete" or "Like" or "Dislike" or "Undo" or "Add" or "Remove" or "Block")
{
await GroupActivity(inner, actor, token);
return; return;
}
var objectUri = Id(inner); var objectUri = Id(inner);
var announceId = Id(activity); var announceId = Id(activity);
if (objectUri == default || announceId == default) if (objectUri == default || announceId == default)
@@ -66,7 +71,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
: DateTime.UtcNow; : DateTime.UtcNow;
var reblog = new PostEntity var reblog = new PostEntity
{ {
ID = PrivacyIds.At(published), ID = PrivacyIds.Arrived(published),
ObjectURI = announceId, ObjectURI = announceId,
ActivityURI = announceId, ActivityURI = announceId,
ActorURI = actor.ActorURI, ActorURI = actor.ActorURI,
@@ -96,6 +101,66 @@ namespace PrivaPub.Federation.Inbox.Handlers
await _fanout.Distribute(reblog, token); await _fanout.Distribute(reblog, token);
} }
async Task GroupActivity(JsonNode inner, ForeignAvatar group, CancellationToken token)
{
if (group.AvatarType != AvatarType.Group
|| !await _dbEntities.Followings.Match(f => f.TargetActorURI == group.ActorURI && f.State == FollowState.Accepted).ExecuteAnyAsync(token))
return;
var objectUri = Id(inner["object"]);
switch (Value(inner, "type"))
{
case "Create" when objectUri != default:
if (await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteAnyAsync(token))
return;
var post = await _remotePosts.StoreContext(objectUri, 0, token);
if (post == default)
return;
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(p => p.AudienceURI, group.ActorURI).ExecuteAsync(token);
post.AudienceURI = group.ActorURI;
await _fanout.Distribute(post, token);
break;
case "Update" when objectUri != default:
var stored = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.AudienceURI == group.ActorURI && !p.DeletedAt.HasValue)
.ExecuteFirstAsync(token);
if (stored == default)
return;
using (var fetched = await _remoteActors.FetchObject(objectUri, token))
{
var note = fetched == default ? default : NoteParser.Parse(System.Text.Json.Nodes.JsonNode.Parse(fetched.Root.GetRawText()));
if (note == default || note.AttributedTo != stored.ActorURI)
return;
stored.Revisions.Add(new PostRevision
{
Title = stored.Title,
SpoilerText = stored.SpoilerText,
ContentHtml = stored.ContentHtml,
HasContentWarning = stored.HasContentWarning,
EditedAt = stored.EditedAt ?? stored.CreationDate
});
stored.Title = note.Title;
stored.SpoilerText = note.SpoilerText;
stored.HasContentWarning = note.Sensitive;
stored.Text = note.ContentHtml;
stored.ContentHtml = note.ContentHtml;
stored.Tags = note.Tags.ToList();
stored.Media = note.Attachments.ToList();
stored.EditedAt = note.Updated ?? DateTime.UtcNow;
await DB.Default.SaveAsync(stored, token);
}
break;
case "Delete" when objectUri != default:
var deleted = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.AudienceURI == group.ActorURI).ExecuteFirstAsync(token);
if (deleted == default)
return;
using (var check = await _remoteActors.FetchObject(objectUri, token))
if (check != default && Value(System.Text.Json.Nodes.JsonNode.Parse(check.Root.GetRawText()), "type") != "Tombstone")
return;
await DB.Default.DeleteAsync<PostEntity>(deleted.ID);
await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == deleted.ID);
break;
}
}
static List<string> Strings(JsonNode node) => node switch static List<string> Strings(JsonNode node) => node switch
{ {
JsonArray array => array.Select(Id).Where(id => id != default).ToList(), JsonArray array => array.Select(Id).Where(id => id != default).ToList(),
@@ -33,10 +33,12 @@ namespace PrivaPub.Federation.Inbox.Handlers
readonly IDomainBlocks _domainBlocks; readonly IDomainBlocks _domainBlocks;
readonly IFanout _fanout; readonly IFanout _fanout;
readonly IRemotePosts _remotePosts; readonly IRemotePosts _remotePosts;
readonly IGroupDistributor _groups;
public CreateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery, public CreateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
IDomainBlocks domainBlocks, IFanout fanout, IRemotePosts remotePosts) IDomainBlocks domainBlocks, IFanout fanout, IRemotePosts remotePosts, IGroupDistributor groups)
{ {
_groups = groups;
_fanout = fanout; _fanout = fanout;
_remotePosts = remotePosts; _remotePosts = remotePosts;
_dbEntities = dbEntities; _dbEntities = dbEntities;
@@ -85,10 +87,17 @@ namespace PrivaPub.Federation.Inbox.Handlers
var parent = string.IsNullOrEmpty(note.InReplyTo) var parent = string.IsNullOrEmpty(note.InReplyTo)
? default ? default
: await _dbEntities.Posts.Match(p => p.ObjectURI == note.InReplyTo && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); : await _dbEntities.Posts.Match(p => p.ObjectURI == note.InReplyTo && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
var group = visibility is PostVisibility.Public or PostVisibility.Unlisted var circle = localTargets.FirstOrDefault(t => t is { Kind: LocalActorKind.Group, IsCircle: true });
? localTargets.FirstOrDefault(t => t.Kind == LocalActorKind.Group) if (circle != default)
: default; {
if (group != default && !await IsAcceptedFollower(group, author.ActorURI, token)) if (!await IsCircleMember(circle, author.ActorURI, token))
return;
visibility = PostVisibility.Circle;
}
var group = circle ?? (visibility is PostVisibility.Public or PostVisibility.Unlisted
? localTargets.FirstOrDefault(t => t is { Kind: LocalActorKind.Group, IsCircle: false })
: default);
if (group is { IsCircle: false } && !await MayPost(group, author.ActorURI, token))
group = default; group = default;
var repliesToLocal = parent is { IsFederatedCopy: false } && visibility != PostVisibility.Direct; var repliesToLocal = parent is { IsFederatedCopy: false } && visibility != PostVisibility.Direct;
@@ -128,11 +137,22 @@ namespace PrivaPub.Federation.Inbox.Handlers
await _fanout.Distribute(post, token); await _fanout.Distribute(post, token);
if (conversation != default) if (conversation != default)
await DB.Default.Update<DmGroup>().MatchID(conversation.ID).Modify(g => g.UpdatedAt, DateTime.UtcNow).ExecuteAsync(token); await DB.Default.Update<DmGroup>().MatchID(conversation.ID).Modify(g => g.UpdatedAt, DateTime.UtcNow).ExecuteAsync(token);
if (group != default) if (group is { IsCircle: false })
{ await _groups.Announce(group, activity.AsObject(), note.Id, isNewPost: string.IsNullOrEmpty(note.InReplyTo), token);
var announce = ActivityPubRenderer.Announce(group, note.Id, $"announce-{post.ID}");
await _delivery.EnqueueToFollowers(group, announce, token);
} }
async Task<bool> IsCircleMember(LocalActor circle, string actorUri, CancellationToken token) =>
await _dbEntities.Groups.Match(g => g.ID == circle.Id && g.Members.Any(m => m.IsForeign && m.AvatarId == actorUri)).ExecuteAnyAsync(token);
async Task<bool> MayPost(LocalActor community, string actorUri, CancellationToken token)
{
var entity = await _dbEntities.Groups.MatchID(community.Id).ExecuteFirstAsync(token);
return entity?.PostingPolicy switch
{
PostingPolicy.Anyone => true,
PostingPolicy.Followers => await IsAcceptedFollower(community, actorUri, token),
_ => false
};
} }
async Task<DmGroup> FindOrCreateConversation(List<string> participantUris, string context, CancellationToken token) async Task<DmGroup> FindOrCreateConversation(List<string> participantUris, string context, CancellationToken token)
@@ -26,9 +26,12 @@ namespace PrivaPub.Federation.Inbox.Handlers
readonly ILocalActorService _localActors; readonly ILocalActorService _localActors;
readonly IRemoteActorService _remoteActors; readonly IRemoteActorService _remoteActors;
readonly IDeliveryService _delivery; readonly IDeliveryService _delivery;
readonly IGroupDistributor _groups;
public DeleteHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery) public DeleteHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
IGroupDistributor groups)
{ {
_groups = groups;
_dbEntities = dbEntities; _dbEntities = dbEntities;
_localActors = localActors; _localActors = localActors;
_remoteActors = remoteActors; _remoteActors = remoteActors;
@@ -67,6 +70,8 @@ namespace PrivaPub.Federation.Inbox.Handlers
return; return;
await DB.Default.DeleteAsync<PostEntity>(post.ID); await DB.Default.DeleteAsync<PostEntity>(post.ID);
await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == post.ID || e.ReblogOfPostId == post.ID); await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == post.ID || e.ReblogOfPostId == post.ID);
if (!string.IsNullOrEmpty(post.GroupId) && await _localActors.FindById(LocalActorKind.Group, post.GroupId, token) is { IsCircle: false } community)
await _groups.Announce(community, activity.AsObject(), post.ObjectURI, isNewPost: false, token);
await DB.Default.DeleteAsync<PostEntity>(p => p.ReblogOfPostId == post.ID); await DB.Default.DeleteAsync<PostEntity>(p => p.ReblogOfPostId == post.ID);
if (!string.IsNullOrEmpty(post.AnsweringToPostId)) if (!string.IsNullOrEmpty(post.AnsweringToPostId))
await DB.Default.Update<PostEntity>().MatchID(post.AnsweringToPostId).Modify(b => b.Inc(p => p.RepliesCount, -1)).ExecuteAsync(token); await DB.Default.Update<PostEntity>().MatchID(post.AnsweringToPostId).Modify(b => b.Inc(p => p.RepliesCount, -1)).ExecuteAsync(token);
@@ -2,6 +2,7 @@ using MongoDB.Entities;
using PrivaPub.Federation.Actors; using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Objects; using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Outbox;
using PrivaPub.Models.Post; using PrivaPub.Models.Post;
using PrivaPub.Models.User; using PrivaPub.Models.User;
using PrivaPub.StaticServices; using PrivaPub.StaticServices;
@@ -21,9 +22,11 @@ namespace PrivaPub.Federation.Inbox.Handlers
readonly DbEntities _dbEntities; readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors; readonly ILocalActorService _localActors;
readonly IRemoteActorService _remoteActors; readonly IRemoteActorService _remoteActors;
readonly IGroupDistributor _groups;
public UpdateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors) public UpdateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IGroupDistributor groups)
{ {
_groups = groups;
_dbEntities = dbEntities; _dbEntities = dbEntities;
_localActors = localActors; _localActors = localActors;
_remoteActors = remoteActors; _remoteActors = remoteActors;
@@ -79,6 +82,8 @@ namespace PrivaPub.Federation.Inbox.Handlers
post.EditedAt = note.Updated ?? DateTime.UtcNow; post.EditedAt = note.Updated ?? DateTime.UtcNow;
post.UpdateDate = DateTime.UtcNow; post.UpdateDate = DateTime.UtcNow;
await DB.Default.SaveAsync(post, token); await DB.Default.SaveAsync(post, token);
if (!string.IsNullOrEmpty(post.GroupId) && await _localActors.FindById(Models.Federation.LocalActorKind.Group, post.GroupId, token) is { IsCircle: false } community)
await _groups.Announce(community, activity.AsObject(), post.ObjectURI, isNewPost: false, token);
} }
} }
} }
+1 -1
View File
@@ -60,7 +60,7 @@ namespace PrivaPub.Federation.Inbox
return new PostEntity return new PostEntity
{ {
ID = PrivacyIds.At(note.Published), ID = PrivacyIds.Arrived(note.Published),
ObjectURI = note.Id, ObjectURI = note.Id,
ActorURI = author.ActorURI, ActorURI = author.ActorURI,
AuthorAccountId = author.ID, AuthorAccountId = author.ID,
+1 -1
View File
@@ -163,7 +163,7 @@ namespace PrivaPub.Federation.Objects
static DateTime? Time(string value) => static DateTime? Time(string value) =>
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed) DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed)
? parsed.UtcDateTime ? parsed.UtcDateTime
: default; : (DateTime?)null;
static int? Int(JsonNode node) => static int? Int(JsonNode node) =>
node is JsonValue value && value.TryGetValue<int>(out var number) && number is > 0 and < 100_000 ? number : (int?)null; node is JsonValue value && value.TryGetValue<int>(out var number) && number is > 0 and < 100_000 ? number : (int?)null;
@@ -0,0 +1,48 @@
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Rendering;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;
namespace PrivaPub.Federation.Outbox
{
public interface IGroupDistributor
{
Task Announce(LocalActor group, JsonObject activity, string objectUri, bool isNewPost, CancellationToken token);
}
public class GroupDistributor : IGroupDistributor
{
readonly IDeliveryService _delivery;
public GroupDistributor(IDeliveryService delivery)
{
_delivery = delivery;
}
public async Task Announce(LocalActor group, JsonObject activity, string objectUri, bool isNewPost, CancellationToken token)
{
if (group is not { IsCircle: false, Kind: Models.Federation.LocalActorKind.Group })
return;
var embedded = (JsonObject)activity.DeepClone();
embedded.Remove("@context");
embedded["audience"] ??= group.Uri;
var key = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(activity["id"]?.GetValue<string>() ?? embedded.ToJsonString())))[..24];
var announce = new JsonObject
{
["@context"] = ActivityPubRenderer.Context(),
["id"] = group.ActivityUri($"announce-{key}"),
["type"] = "Announce",
["actor"] = group.Uri,
["to"] = new JsonArray(ActivityPubRenderer.Public),
["cc"] = new JsonArray(group.Followers),
["audience"] = group.Uri,
["object"] = embedded
};
await _delivery.EnqueueToFollowers(group, announce, token);
if (isNewPost && !string.IsNullOrEmpty(objectUri))
await _delivery.EnqueueToFollowers(group, ActivityPubRenderer.Announce(group, objectUri, $"announce-object-{key}"), token);
}
}
}
+14 -7
View File
@@ -32,8 +32,10 @@ namespace PrivaPub.Federation.Outbox
public async Task<IReadOnlyList<string>> Audience(LocalActor author, PostEntity post, CancellationToken token) public async Task<IReadOnlyList<string>> Audience(LocalActor author, PostEntity post, CancellationToken token)
{ {
if (post.Visibility is PostVisibility.Circle or PostVisibility.LocalGeo || post.IsLocalOnly) if (post.Visibility == PostVisibility.LocalGeo || post.IsLocalOnly)
return Array.Empty<string>(); return Array.Empty<string>();
if (post.Visibility == PostVisibility.Circle)
return await CircleMembers(post.GroupId, token);
var inboxes = new List<string>(); var inboxes = new List<string>();
if (post.Visibility is PostVisibility.Public or PostVisibility.Unlisted or PostVisibility.FollowersOnly) if (post.Visibility is PostVisibility.Public or PostVisibility.Unlisted or PostVisibility.FollowersOnly)
@@ -58,14 +60,19 @@ namespace PrivaPub.Federation.Outbox
inboxes.Add(parentAuthor.InboxURL); inboxes.Add(parentAuthor.InboxURL);
} }
if (!string.IsNullOrEmpty(post.GroupId) && post.Visibility is PostVisibility.Public or PostVisibility.Unlisted) return inboxes.Where(i => !string.IsNullOrEmpty(i)).Distinct(StringComparer.Ordinal).ToList();
{
var group = await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
if (group is { IsFederated: true })
inboxes.AddRange(await _delivery.FollowerInboxes(group, token));
} }
return inboxes.Where(i => !string.IsNullOrEmpty(i)).Distinct(StringComparer.Ordinal).ToList(); async Task<IReadOnlyList<string>> CircleMembers(string groupId, CancellationToken token)
{
var circle = string.IsNullOrEmpty(groupId) ? default : await _dbEntities.Groups.MatchID(groupId).ExecuteFirstAsync(token);
if (circle == default)
return Array.Empty<string>();
var remote = circle.Members.Where(m => m.IsForeign).Select(m => m.AvatarId).ToList();
if (remote.Count == 0)
return Array.Empty<string>();
return (await _dbEntities.ForeignAvatars.Match(a => remote.Contains(a.ActorURI)).ExecuteAsync(token))
.Select(a => a.InboxURL).Where(i => !string.IsNullOrEmpty(i)).Distinct(StringComparer.Ordinal).ToList();
} }
public async Task Publish(LocalActor author, PostEntity post, JsonObject activity, CancellationToken token) public async Task Publish(LocalActor author, PostEntity post, JsonObject activity, CancellationToken token)
@@ -48,7 +48,7 @@ namespace PrivaPub.Federation.Rendering
string.IsNullOrEmpty(markdown) ? string.Empty : Markdown.ToHtml(markdown, Pipeline).Trim(); string.IsNullOrEmpty(markdown) ? string.Empty : Markdown.ToHtml(markdown, Pipeline).Trim();
public static string Timestamp(DateTime value) => public static string Timestamp(DateTime value) =>
DateTime.SpecifyKind(value, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture); DateTime.SpecifyKind(value, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture);
public static string Day(DateTime value) => public static string Day(DateTime value) =>
DateTime.SpecifyKind(value, DateTimeKind.Utc).Date.ToString("yyyy-MM-ddT00:00:00Z", CultureInfo.InvariantCulture); DateTime.SpecifyKind(value, DateTimeKind.Utc).Date.ToString("yyyy-MM-ddT00:00:00Z", CultureInfo.InvariantCulture);
@@ -97,6 +97,11 @@ namespace PrivaPub.Federation.Rendering
["value"] = FieldValue(field.Value) ["value"] = FieldValue(field.Value)
}).ToArray()) }).ToArray())
}; };
if (actor is { Kind: LocalActorKind.Group, IsCircle: false })
{
document["attributedTo"] = actor.Wardens;
document["postingRestrictedToMods"] = actor.PostingRestrictedToModerators;
}
if (!string.IsNullOrEmpty(actor.PictureURL)) if (!string.IsNullOrEmpty(actor.PictureURL))
document["icon"] = new JsonObject { ["type"] = "Image", ["url"] = actor.PictureURL }; document["icon"] = new JsonObject { ["type"] = "Image", ["url"] = actor.PictureURL };
if (!string.IsNullOrEmpty(actor.ThumbnailURL)) if (!string.IsNullOrEmpty(actor.ThumbnailURL))
@@ -109,17 +114,25 @@ namespace PrivaPub.Federation.Rendering
var mentions = post.Mentions.Select(m => (JsonNode)m.ActorURI).ToArray(); var mentions = post.Mentions.Select(m => (JsonNode)m.ActorURI).ToArray();
var (to, cc) = post.Visibility switch var (to, cc) = post.Visibility switch
{ {
PostVisibility.Circle when group != default => (new JsonArray(group.Uri, group.Flock), new JsonArray(mentions)),
PostVisibility.Unlisted => (new JsonArray(author.Followers), new JsonArray(mentions.Prepend(Public).ToArray())), PostVisibility.Unlisted => (new JsonArray(author.Followers), new JsonArray(mentions.Prepend(Public).ToArray())),
PostVisibility.FollowersOnly => (new JsonArray(author.Followers), new JsonArray(mentions)), PostVisibility.FollowersOnly => (new JsonArray(author.Followers), new JsonArray(mentions)),
PostVisibility.Direct => (new JsonArray(mentions), new JsonArray()), PostVisibility.Direct => (new JsonArray(mentions), new JsonArray()),
_ => (new JsonArray(Public), new JsonArray(mentions.Prepend(author.Followers).ToArray())) _ => (new JsonArray(Public), new JsonArray(mentions.Prepend(author.Followers).ToArray()))
}; };
if (group != default) if (group is { IsCircle: false })
cc.Add(group.Uri); cc.Add(group.Uri);
var note = NoteBody(post, author, to, cc, inReplyTo); var note = NoteBody(post, author, to, cc, inReplyTo);
if (group != default) if (group != default)
note["audience"] = group.Uri; note["audience"] = group.Uri;
else if (!string.IsNullOrEmpty(post.AudienceURI))
note["audience"] = post.AudienceURI;
if (group is { IsCircle: false } && string.IsNullOrEmpty(inReplyTo))
{
note["type"] = "Page";
note["name"] = post.Title ?? Headline(post);
}
return note; return note;
} }
@@ -141,6 +154,13 @@ namespace PrivaPub.Federation.Rendering
return note; return note;
} }
static string Headline(PostEntity post)
{
var text = System.Text.RegularExpressions.Regex.Replace(post.ContentHtml ?? post.Text ?? string.Empty, "<[^>]+>", " ");
text = WebUtility.HtmlDecode(System.Text.RegularExpressions.Regex.Replace(text, "\\s+", " ")).Trim();
return text.Length <= 100 ? text : text[..97] + "...";
}
static JsonObject NoteBody(PostEntity post, LocalActor author, JsonArray to, JsonArray cc, string inReplyTo) static JsonObject NoteBody(PostEntity post, LocalActor author, JsonArray to, JsonArray cc, string inReplyTo)
{ {
var content = post.ContentHtml ?? Html(post.Text); var content = post.ContentHtml ?? Html(post.Text);
@@ -0,0 +1,40 @@
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.User;
using GroupEntity = PrivaPub.Models.Group.Group;
namespace PrivaPub.Federation.Signing
{
public interface ISignedFetchAuthorizer
{
Task<ForeignAvatar> Requester(HttpRequest request, CancellationToken token);
}
public class SignedFetchAuthorizer : ISignedFetchAuthorizer
{
readonly IRemoteActorService _remoteActors;
public SignedFetchAuthorizer(IRemoteActorService remoteActors)
{
_remoteActors = remoteActors;
}
public async Task<ForeignAvatar> Requester(HttpRequest request, CancellationToken token)
{
var parameters = HttpSignatures.Parse(request.Headers["Signature"].ToString());
if (parameters == default || HttpSignatures.CheckRequest(request, parameters, body: default) != default)
return default;
var signingString = HttpSignatures.SigningString(request, parameters);
var actor = await _remoteActors.GetActorByKeyId(parameters.KeyId, refresh: false, token);
if (actor != default && HttpSignatures.Verify(actor.PublicKey, signingString, parameters.Signature))
return actor;
actor = await _remoteActors.GetActorByKeyId(parameters.KeyId, refresh: true, token);
return actor != default && HttpSignatures.Verify(actor.PublicKey, signingString, parameters.Signature) ? actor : default;
}
public static bool MayReadCircle(GroupEntity circle, ForeignAvatar requester) =>
requester != default && circle.Members.Any(m => m.IsForeign
&& (m.AvatarId == requester.ActorURI || requester.AvatarType == AvatarType.Application && Origin.Same(m.AvatarId, requester.ActorURI)));
}
}
+1
View File
@@ -21,6 +21,7 @@ namespace PrivaPub.Infrastructure.Data
await Plain<Post>(token, p => p.ActorURI); await Plain<Post>(token, p => p.ActorURI);
await Plain<Post>(token, p => p.ConversationId, p => p.ID); await Plain<Post>(token, p => p.ConversationId, p => p.ID);
await Plain<Post>(token, p => p.InReplyToURI); await Plain<Post>(token, p => p.InReplyToURI);
await DB.Default.Index<Post>().Key(p => p.Geo, KeyType.Geo2DSphere).CreateAsync(token);
await Unique<DmPost>(p => p.ObjectURI, Builders<DmPost>.Filter.Type(p => p.ObjectURI, BsonType.String), token); await 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 Plain<DmPost>(token, p => p.GroupId, p => p.ID);
@@ -0,0 +1,26 @@
using MongoDB.Entities;
using PrivaPub.Models.Post;
namespace PrivaPub.Infrastructure.Data.Migrations
{
public class _006_unedited_remote_posts_lose_year_one : IMigration
{
public async Task UpgradeAsync()
{
var posts = await DB.Default.Find<Post>()
.Match(p => p.EditedAt == DateTime.MinValue || p.UpdateDate == DateTime.MinValue || p.Revisions.Any(r => r.EditedAt == DateTime.MinValue))
.ExecuteAsync();
foreach (var post in posts)
{
foreach (var revision in post.Revisions.Where(r => r.EditedAt == DateTime.MinValue))
revision.EditedAt = post.CreationDate;
await DB.Default.Update<Post>().MatchID(post.ID)
.Modify(p => p.EditedAt, post.EditedAt == DateTime.MinValue ? null : post.EditedAt)
.Modify(p => p.UpdateDate, post.UpdateDate == DateTime.MinValue ? post.CreationDate : post.UpdateDate)
.Modify(p => p.Revisions, post.Revisions)
.ExecuteAsync();
}
}
}
}
@@ -4,5 +4,7 @@ namespace PrivaPub.Infrastructure.Http
{ {
public bool AllowPrivateNetworks { get; set; } public bool AllowPrivateNetworks { get; set; }
public bool AllowPlainHttp { get; set; } public bool AllowPlainHttp { get; set; }
public bool SecureMode { get; set; }
public bool AcceptAnyCertificate { get; set; }
} }
} }
@@ -1,4 +1,5 @@
using System.Net; using System.Net;
using System.Net.Security;
using System.Net.Sockets; using System.Net.Sockets;
namespace PrivaPub.Infrastructure.Http namespace PrivaPub.Infrastructure.Http
@@ -12,6 +13,9 @@ namespace PrivaPub.Infrastructure.Http
{ {
public static SocketsHttpHandler Create(FederationOptions options) => new() public static SocketsHttpHandler Create(FederationOptions options) => new()
{ {
SslOptions = options.AcceptAnyCertificate
? new SslClientAuthenticationOptions { RemoteCertificateValidationCallback = (_, _, _, _) => true }
: new SslClientAuthenticationOptions(),
AllowAutoRedirect = false, AllowAutoRedirect = false,
UseProxy = false, UseProxy = false,
UseCookies = false, UseCookies = false,
@@ -1,12 +1,19 @@
using System.Buffers.Binary; using System.Buffers.Binary;
using System.Security.Cryptography; using System.Security.Cryptography;
using MongoDB.Bson;
namespace PrivaPub.Infrastructure.Ids namespace PrivaPub.Infrastructure.Ids
{ {
public static class PrivacyIds public static class PrivacyIds
{ {
public static string ForDay(DateTime when) => At(DateTime.SpecifyKind(when, DateTimeKind.Utc).Date); public static string ForDay(DateTime when) => At(DateTime.SpecifyKind(when, DateTimeKind.Utc).Date);
public static string Arrived(DateTime published) =>
DateTime.SpecifyKind(published, DateTimeKind.Utc) > DateTime.UtcNow - LiveWindow ? ObjectId.GenerateNewId().ToString() : At(published);
static readonly TimeSpan LiveWindow = TimeSpan.FromHours(1);
public static string At(DateTime when) public static string At(DateTime when)
{ {
var seconds = Math.Clamp(new DateTimeOffset(DateTime.SpecifyKind(when, DateTimeKind.Utc)).ToUnixTimeSeconds(), 0, var seconds = Math.Clamp(new DateTimeOffset(DateTime.SpecifyKind(when, DateTimeKind.Utc)).ToUnixTimeSeconds(), 0,
@@ -15,6 +15,7 @@ using PrivaPub.Services.ClientToServer.Private;
using PrivaPub.Services.ClientToServer.Public; using PrivaPub.Services.ClientToServer.Public;
using PrivaPub.Federation.Actors; using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Outbox; using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Signing;
using PrivaPub.Federation.Inbox; using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Moderation; using PrivaPub.Federation.Moderation;
using PrivaPub.Federation.Inbox.Handlers; using PrivaPub.Federation.Inbox.Handlers;
@@ -65,6 +66,8 @@ namespace PrivaPub.Middleware
.AddSingleton<IRemoteActorService, RemoteActorService>() .AddSingleton<IRemoteActorService, RemoteActorService>()
.AddSingleton<IDeliveryService, DeliveryService>() .AddSingleton<IDeliveryService, DeliveryService>()
.AddSingleton<IOutboxPublisher, OutboxPublisher>() .AddSingleton<IOutboxPublisher, OutboxPublisher>()
.AddSingleton<IGroupDistributor, GroupDistributor>()
.AddSingleton<ISignedFetchAuthorizer, SignedFetchAuthorizer>()
.AddSingleton<IFanout, Fanout>() .AddSingleton<IFanout, Fanout>()
.AddSingleton<IInboxReceiver, InboxReceiver>() .AddSingleton<IInboxReceiver, InboxReceiver>()
.AddSingleton<IActivityHandler, FollowHandler>() .AddSingleton<IActivityHandler, FollowHandler>()
+9
View File
@@ -20,6 +20,8 @@ namespace PrivaPub.Models.Group
public string PublicKey { get; set; } public string PublicKey { get; set; }
public GroupKind Kind { get; set; } public GroupKind Kind { get; set; }
public PostingPolicy PostingPolicy { get; set; }
public List<string> Rules { get; set; } = new();
public bool IsDiscoverable { get; set; } = true; public bool IsDiscoverable { get; set; } = true;
public bool ManuallyApprovesMembers { get; set; } public bool ManuallyApprovesMembers { get; set; }
public string OwnerAvatarId { get; set; } public string OwnerAvatarId { get; set; }
@@ -49,6 +51,13 @@ namespace PrivaPub.Models.Group
Community Community
} }
public enum PostingPolicy
{
Followers,
Anyone,
Moderators
}
public enum GroupRole public enum GroupRole
{ {
Member, Member,
+6
View File
@@ -1,3 +1,4 @@
using MongoDB.Bson.Serialization.Attributes;
using MongoDB.Entities; using MongoDB.Entities;
namespace PrivaPub.Models.Post namespace PrivaPub.Models.Post
@@ -11,6 +12,7 @@ namespace PrivaPub.Models.Post
public string InReplyToAccountId { get; set; } public string InReplyToAccountId { get; set; }
public string GroupId { get; set; } public string GroupId { get; set; }
public string ConversationId { get; set; }//DmGroup.ID of a direct post public string ConversationId { get; set; }//DmGroup.ID of a direct post
public string AudienceURI { get; set; }//a remote community the post was made in
public string ReblogOfPostId { get; set; } public string ReblogOfPostId { get; set; }
public PostVisibility Visibility { get; set; } public PostVisibility Visibility { get; set; }
public string Title { get; set; } public string Title { get; set; }
@@ -24,6 +26,10 @@ namespace PrivaPub.Models.Post
public List<PostMedia> Media { get; set; } = new(); public List<PostMedia> Media { get; set; } = new();
public List<float> Location { get; set; } = new(); public List<float> Location { get; set; } = new();
public float RangeKm { get; set; } = 5.0f; public float RangeKm { get; set; } = 5.0f;
[BsonIgnoreIfNull]
public Coordinates2D Geo { get; set; }
[BsonIgnoreIfNull]
public double? Distance { get; set; }
public bool HasContentWarning { get; set; } = false; public bool HasContentWarning { get; set; } = false;
public bool IsLocalOnly { get; set; } public bool IsLocalOnly { get; set; }
+8 -2
View File
@@ -21,6 +21,8 @@ using PrivaPub.Models;
using PrivaPub.Services; using PrivaPub.Services;
using PrivaPub.StaticServices; using PrivaPub.StaticServices;
Log.Logger = new LoggerConfiguration().WriteTo.Console().CreateBootstrapLogger();
try try
{ {
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -71,8 +73,8 @@ try
} }
var federationOptions = builder.Configuration.GetSection("Federation").Get<FederationOptions>() ?? new(); var federationOptions = builder.Configuration.GetSection("Federation").Get<FederationOptions>() ?? new();
if (builder.Environment.IsProduction() && (federationOptions.AllowPrivateNetworks || federationOptions.AllowPlainHttp)) if (builder.Environment.IsProduction() && (federationOptions.AllowPrivateNetworks || federationOptions.AllowPlainHttp || federationOptions.AcceptAnyCertificate))
throw new InvalidOperationException("Federation:AllowPrivateNetworks and Federation:AllowPlainHttp are for test networks and must stay off in Production."); throw new InvalidOperationException("Federation:AllowPrivateNetworks, AllowPlainHttp and AcceptAnyCertificate are for test networks and must stay off in Production.");
try try
{ {
@@ -189,4 +191,8 @@ catch (Exception ex)
Log.ForContext<Program>().Fatal(ex, $"{nameof(Program)}.{nameof(Program)}()"); Log.ForContext<Program>().Fatal(ex, $"{nameof(Program)}.{nameof(Program)}()");
Environment.ExitCode = 1; Environment.ExitCode = 1;
} }
finally
{
await Log.CloseAndFlushAsync();
}
+12
View File
@@ -116,6 +116,7 @@ namespace PrivaPub.Services
PrivateKey = privateKey, PrivateKey = privateKey,
PublicKey = publicKey, PublicKey = publicKey,
Kind = form.IsCommunity ? GroupKind.Community : GroupKind.Circle, Kind = form.IsCommunity ? GroupKind.Community : GroupKind.Circle,
PostingPolicy = Policy(form.PostingPolicy) ?? PostingPolicy.Followers,
IsDiscoverable = form.IsDiscoverable, IsDiscoverable = form.IsDiscoverable,
ManuallyApprovesMembers = form.ManuallyApprovesMembers, ManuallyApprovesMembers = form.ManuallyApprovesMembers,
OwnerAvatarId = form.AvatarId, OwnerAvatarId = form.AvatarId,
@@ -157,6 +158,8 @@ namespace PrivaPub.Services
group.Description = form.Description; group.Description = form.Description;
if (form.IsDiscoverable.HasValue) if (form.IsDiscoverable.HasValue)
group.IsDiscoverable = form.IsDiscoverable.Value; group.IsDiscoverable = form.IsDiscoverable.Value;
if (Policy(form.PostingPolicy) is { } policy)
group.PostingPolicy = policy;
if (form.ManuallyApprovesMembers.HasValue) if (form.ManuallyApprovesMembers.HasValue)
group.ManuallyApprovesMembers = form.ManuallyApprovesMembers.Value; group.ManuallyApprovesMembers = form.ManuallyApprovesMembers.Value;
if (form.RemoveInvitationPassword) if (form.RemoveInvitationPassword)
@@ -316,6 +319,14 @@ namespace PrivaPub.Services
!string.IsNullOrEmpty(rootUserId) && !string.IsNullOrEmpty(avatarId) !string.IsNullOrEmpty(rootUserId) && !string.IsNullOrEmpty(avatarId)
&& await _dbEntities.RootToAvatars.Match(ra => ra.RootId == rootUserId && ra.AvatarId == avatarId).ExecuteAnyAsync(token); && await _dbEntities.RootToAvatars.Match(ra => ra.RootId == rootUserId && ra.AvatarId == avatarId).ExecuteAnyAsync(token);
static PostingPolicy? Policy(string value) => value?.ToLowerInvariant() switch
{
"anyone" => PostingPolicy.Anyone,
"moderators" => PostingPolicy.Moderators,
"followers" => PostingPolicy.Followers,
_ => (PostingPolicy?)null
};
static string NewInvitationCode() => $"{Guid.NewGuid():N}{Guid.NewGuid():N}"; static string NewInvitationCode() => $"{Guid.NewGuid():N}{Guid.NewGuid():N}";
ViewGroup ToView(GroupEntity group, string avatarId) ViewGroup ToView(GroupEntity group, string avatarId)
@@ -331,6 +342,7 @@ namespace PrivaPub.Services
Url = actor.Uri, Url = actor.Uri,
Handle = actor.Handle, Handle = actor.Handle,
IsCommunity = group.Kind == GroupKind.Community, IsCommunity = group.Kind == GroupKind.Community,
PostingPolicy = group.PostingPolicy.ToString().ToLowerInvariant(),
IsDiscoverable = group.IsDiscoverable, IsDiscoverable = group.IsDiscoverable,
ManuallyApprovesMembers = group.ManuallyApprovesMembers, ManuallyApprovesMembers = group.ManuallyApprovesMembers,
IsOwner = group.OwnerAvatarId == avatarId, IsOwner = group.OwnerAvatarId == avatarId,
+35 -1
View File
@@ -1,5 +1,6 @@
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
using MongoDB.Driver;
using MongoDB.Entities; using MongoDB.Entities;
using PrivaPub.ClientModels; using PrivaPub.ClientModels;
@@ -27,6 +28,7 @@ namespace PrivaPub.Services
Task<WebResult> InsertPost(string rootUserId, InsertPostForm form, CancellationToken token); Task<WebResult> InsertPost(string rootUserId, InsertPostForm form, CancellationToken token);
Task<WebResult> DeletePost(string rootUserId, DeletePostForm form, CancellationToken token); Task<WebResult> DeletePost(string rootUserId, DeletePostForm form, CancellationToken token);
Task<WebResult> UpdatePost(string rootUserId, UpdatePostForm form, CancellationToken token); Task<WebResult> UpdatePost(string rootUserId, UpdatePostForm form, CancellationToken token);
Task<WebResult> Nearby(string rootUserId, string avatarId, double latitude, double longitude, CancellationToken token);
Task<WebResult> GetPosts(string rootUserId, string avatarId, string groupId, CancellationToken token); Task<WebResult> GetPosts(string rootUserId, string avatarId, string groupId, CancellationToken token);
Task<WebResult> InsertDm(string rootUserId, InsertDmForm form, CancellationToken token); Task<WebResult> InsertDm(string rootUserId, InsertDmForm form, CancellationToken token);
Task<WebResult> GetDms(string rootUserId, string avatarId, string dmGroupId, CancellationToken token); Task<WebResult> GetDms(string rootUserId, string avatarId, string dmGroupId, CancellationToken token);
@@ -68,7 +70,10 @@ namespace PrivaPub.Services
Sensitive = form.HasContentWarning, Sensitive = form.HasContentWarning,
Visibility = LocalVisibility(form.Visibility), Visibility = LocalVisibility(form.Visibility),
InReplyTo = form.AnsweringToPostId, InReplyTo = form.AnsweringToPostId,
GroupId = form.GroupId GroupId = form.GroupId,
Latitude = form.Latitude,
Longitude = form.Longitude,
RangeKm = form.RangeKm
}, token), nameof(InsertPost)); }, token), nameof(InsertPost));
} }
@@ -193,6 +198,35 @@ namespace PrivaPub.Services
public async Task<WebResult> Nearby(string rootUserId, string avatarId, double latitude, double longitude, CancellationToken token)
{
var result = new WebResult();
var viewer = await OwnedAvatar(rootUserId, avatarId, token);
if (viewer == default)
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
if (latitude is < -90 or > 90 || longitude is < -180 or > 180)
return result.Invalidate(_localizer["Invalid model."]);
var query = new MongoDB.Bson.BsonDocument
{
["Visibility"] = (int)PostVisibility.LocalGeo,
["DeletedAt"] = MongoDB.Bson.BsonNull.Value
};
var candidates = await DB.Default.GeoNear<PostEntity>(new Coordinates2D(longitude, latitude), p => p.Distance, true, 50_000, default, default, query)
.Limit(200)
.ToListAsync(token);
var near = candidates.Where(p => p.Distance <= p.RangeKm * 1000).ToList();
var hidden = await Domain.Relationships.Hidden.AuthorsHiddenFrom(viewer.Id, near.Select(p => p.ActorURI), forNotifications: false, token);
result.Data = near.Where(p => !hidden.Contains(p.ActorURI)).Select(p =>
{
var view = ToView(p);
view.RangeKm = p.RangeKm;
view.DistanceKm = Math.Round(p.Distance.Value / 1000, 1);
return view;
}).ToList();
return result;
}
async Task<WebResult> Answer(Func<Task<StatusOutcome>> action, string operation) async Task<WebResult> Answer(Func<Task<StatusOutcome>> action, string operation)
{ {
var result = new WebResult(); var result = new WebResult();
+1 -1
View File
@@ -61,7 +61,7 @@ namespace PrivaPub.Web.Pages
public async Task<IActionResult> OnGetAsync(string user, CancellationToken token) public async Task<IActionResult> OnGetAsync(string user, CancellationToken token)
{ {
Actor = await _localActors.FindByUserName(user, token); Actor = await _localActors.FindByUserName(user, token);
if (Actor is not { IsFederated: true } || Actor.Kind == LocalActorKind.Application) if (Actor is not { IsFederated: true, IsCircle: false } || Actor.Kind == LocalActorKind.Application)
return NotFound(); return NotFound();
if (WantsActivityJson()) if (WantsActivityJson())
return Redirect(Actor.Uri); return Redirect(Actor.Uri);
+6 -4
View File
@@ -8,8 +8,8 @@ Written 2026-10-01 from the original 2023 code, the decePubClient UI, a federati
- [x] P1.1 Infrastructure and content model: v1.2.0, deployed and verified 2026-10-01 - [x] P1.1 Infrastructure and content model: v1.2.0, deployed and verified 2026-10-01
- [x] P1.2 Social graph and timelines: v1.3.0, deployed and verified 2026-10-01 - [x] P1.2 Social graph and timelines: v1.3.0, deployed and verified 2026-10-01
- [x] P2 Mastodon client API: v1.4.0, deployed 2026-10-01; OAuth and the anonymous API verified on production, the signed-in API verified locally (no real-client login on production yet) - [x] P2 Mastodon client API: v1.4.0, deployed 2026-10-01; OAuth and the anonymous API verified on production, the signed-in API verified locally (no real-client login on production yet)
- [ ] P3 Social features - [x] P3 Social features: v1.5.0, deployed 2026-10-01; media, proxy, blocks, mutes, bookmarks, pins and reports verified by tests and locally (no upload on production yet)
- [ ] P4 Groups and privacy features - [x] P4 Groups and privacy features: v1.6.0, deployed 2026-10-01; communities, circles and local-only located posts verified by tests. v1.6.1 adds the pasture (`tools/pasture/`): live interop with GoToSocial 0.22.1 passes all 25 checks, three runs in a row. Lemmy and a live Mastodon circle member are not run yet; the pasture has GoToSocial only
- [ ] P5 FEPs and polish - [ ] P5 FEPs and polish
## Intent ## Intent
@@ -417,8 +417,10 @@ The first refactor commit is a pure move with namespaces only. Logic changes fol
`Fixtures/{mastodon,gotosocial,misskey,akkoma,lemmy}/`. Renderers are checked against golden JSON. Inbound `Fixtures/{mastodon,gotosocial,misskey,akkoma,lemmy}/`. Renderers are checked against golden JSON. Inbound
scenarios run as integration tests on a throwaway mongod. scenarios run as integration tests on a throwaway mongod.
- **Interop:** **Fediverse Pasture** runs on the workstation with podman: Mastodon, GoToSocial, Misskey/Sharkey, - **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`; Akkoma, plus Lemmy for P4. As built, `tools/pasture/run.sh` runs PrivaPub and GoToSocial behind one Caddy on a
startup asserts that flag is false in Production. The matrix, per peer: podman network, with `AllowPrivateNetworks`, `AllowPlainHttp` and `AcceptAnyCertificate`; startup refuses all three in
Production. `tools/pasture/interop.sh` drives the matrix for GoToSocial; the other peers are still to be added. The
matrix, per peer:
- follow both ways; - follow both ways;
- public, unlisted, followers-only and direct posts both ways; - public, unlisted, followers-only and direct posts both ways;
- a reply landing in the remote thread; - a reply landing in the remote thread;
+14
View File
@@ -0,0 +1,14 @@
{
local_certs
skip_install_trust
}
privapub.test {
tls internal
reverse_proxy pasture-privapub:80
}
gts.test {
tls internal
reverse_proxy pasture-gts:80
}
+33
View File
@@ -0,0 +1,33 @@
{
"MongoSettings": {
"Database": "PrivaPub",
"LogsDatabase": "logs",
"ConnectionString": "mongodb://mongo:27017"
},
"AppConfiguration": {
"Version": "0.0.0",
"MaxAllowedUploadFiles": 3,
"MaxAllowedFileSize": 2097152,
"SupportedLanguages": [ "en" ],
"BackendBaseAddress": "https://privapub.test",
"FrontendBaseAddress": "https://privapub.test",
"HashingOptions": { "Iterations": 10101 },
"Jwt": {
"Key": "pasture-only-key-not-a-secret-pasture-only-key-not-a-secret-0123456789",
"Issuer": "http://privapub.test",
"Audience": "http://privapub.test",
"HoursTimeout": 24
}
},
"Federation": {
"AllowPrivateNetworks": true,
"AllowPlainHttp": true,
"AcceptAnyCertificate": true
},
"Media": { "Root": "/tmp/privapub-media" },
"Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:80", "Protocols": "Http1AndHttp2" } } },
"Serilog": {
"MinimumLevel": { "Default": "Information", "Override": { "Microsoft": "Warning", "System": "Warning" } },
"WriteTo": [ { "Name": "Console" } ]
}
}
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env bash
# Drives PrivaPub and GoToSocial (started by run.sh) through each other's federation, as their users would.
set -uo pipefail
P=http://127.0.0.1:6971; G=https://gts.test:6443
gcurl() { curl -k --resolve gts.test:6443:127.0.0.1 "$@"; }
work=$(mktemp -d); trap 'rm -rf "$work"' EXIT
pass=0; fail=0
ok() { echo " ok $*"; pass=$((pass+1)); }
ko() { echo " FAIL $*"; fail=$((fail+1)); }
j() { python3 -c "import sys,json
try: d=json.load(sys.stdin)
except Exception: d=None
$1" 2>/dev/null; }
until_true() { local tries=$1; shift; for i in $(seq 1 "$tries"); do if eval "$@"; then return 0; fi; sleep 2; done; return 1; }
# GoToSocial's cached home timeline can stop taking new posts after its first read, so a delivery is checked by looking
# the object up by URI with resolve=false: that answers from GoToSocial's database and never fetches from us.
on_gts() { gcurl -s -G -H "$GH" "$G/api/v2/search" --data-urlencode "q=$1" -d resolve=false -d type=statuses; }
# --- PrivaPub persona and token
root=$(curl -s -X POST $P/clientapi/user/signup -H 'Content-Type: application/json' -d '{"userName":"pastureroot","password":"Pasture-Pass-1!"}')
jwt=$(echo "$root" | j "print(d['token'])"); rootid=$(echo "$root" | j "print(d['userId'])")
if [ -z "$jwt" ]; then jwt=$(curl -s -X POST $P/clientapi/user/login -H 'Content-Type: application/json' -d '{"userName":"pastureroot","password":"Pasture-Pass-1!"}' | j "print(d['token'])"); rootid=$(curl -s -X POST $P/clientapi/user/login -H 'Content-Type: application/json' -d '{"userName":"pastureroot","password":"Pasture-Pass-1!"}' | j "print(d['userId'])"); fi
curl -s -o /dev/null -X POST $P/clientapi/avatar/private/insert -H 'Content-Type: application/json' -H "Authorization: Bearer $jwt" \
-d "{\"rootId\":\"$rootid\",\"userName\":\"alice\",\"name\":\"Alice of PrivaPub\",\"biography\":\"testing federation\"}"
app=$(curl -s -X POST $P/api/v1/apps -d 'client_name=pasture&redirect_uris=urn:ietf:wg:oauth:2.0:oob&scopes=read+write+follow')
cid=$(echo "$app" | j "print(d['client_id'])"); cs=$(echo "$app" | j "print(d['client_secret'])")
q="client_id=$cid&redirect_uri=urn:ietf:wg:oauth:2.0:oob&response_type=code&scope=read+write+follow"
xt=$(curl -s -c $work/pj -b $work/pj "$P/oauth/login?returnUrl=/oauth/authorize?$q" | grep -o 'name="__RequestVerificationToken" type="hidden" value="[^"]*"' | sed 's/.*value="//;s/"//')
curl -s -o /dev/null -c $work/pj -b $work/pj -X POST $P/oauth/login --data-urlencode "returnUrl=/oauth/authorize?$q" --data-urlencode "__RequestVerificationToken=$xt" -d 'userName=pastureroot&password=Pasture-Pass-1!'
curl -s -c $work/pj -b $work/pj "$P/oauth/authorize?$q&signed_in=1" > $work/choose.html
form=$(python3 - "$work/choose.html" <<'PY'
import re,sys,urllib.parse,html
s=open(sys.argv[1]).read()
pairs=[(k,html.unescape(v)) for k,v in re.findall(r'<input type="hidden" name="([^"]*)" value="([^"]*)"',s)]
print(urllib.parse.urlencode(pairs+[("avatarId",re.findall(r'name="avatarId" value="([^"]*)"',s)[0]),("decision","allow")]))
PY
)
code=$(curl -s -c $work/pj -b $work/pj -X POST $P/oauth/authorize --data "$form" | grep -o '<code>[^<]*</code>' | sed 's/<[^>]*>//g')
PT=$(curl -s -X POST $P/oauth/token -d "grant_type=authorization_code&code=$code&client_id=$cid&client_secret=$cs&redirect_uri=urn:ietf:wg:oauth:2.0:oob" | j "print(d['access_token'])")
PH="Authorization: Bearer $PT"
[ -n "$PT" ] && ok "PrivaPub token for alice" || { ko "PrivaPub token"; exit 1; }
# --- GoToSocial token
gapp=$(gcurl -s -X POST $G/api/v1/apps -d 'client_name=pasture&redirect_uris=urn:ietf:wg:oauth:2.0:oob&scopes=read+write+follow')
gid=$(echo "$gapp" | j "print(d['client_id'])"); gs=$(echo "$gapp" | j "print(d['client_secret'])")
gcurl -s -o /dev/null -c $work/gj -b $work/gj "$G/oauth/authorize?client_id=$gid&redirect_uri=urn:ietf:wg:oauth:2.0:oob&response_type=code&scope=read+write+follow"
gcurl -s -o /dev/null -c $work/gj -b $work/gj -X POST $G/auth/sign_in --data-urlencode 'username=gtsuser@gts.test' --data-urlencode 'password=Gts-Pasture-Pass-1!'
gcode=$(gcurl -s -o /dev/null -w '%{redirect_url}' -c $work/gj -b $work/gj -X POST "$G/oauth/authorize" | sed -n 's/.*[?&]code=\([^&]*\).*/\1/p')
GT=$(gcurl -s -X POST $G/oauth/token -d "grant_type=authorization_code&code=$gcode&client_id=$gid&client_secret=$gs&redirect_uri=urn:ietf:wg:oauth:2.0:oob&scope=read+write+follow" | j "print(d['access_token'])")
GH="Authorization: Bearer $GT"
[ -n "$GT" ] && ok "GoToSocial token for gtsuser" || { ko "GoToSocial token (code '$gcode')"; exit 1; }
echo "discovery"
alice_on_gts=$(gcurl -s -H "$GH" "$G/api/v2/search?q=@alice@privapub.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
[ -n "$alice_on_gts" ] && ok "GoToSocial resolves @alice@privapub.test" || ko "GoToSocial cannot resolve alice"
gts_on_pp=$(curl -s -H "$PH" "$P/api/v2/search?q=gtsuser@gts.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
[ -n "$gts_on_pp" ] && ok "PrivaPub resolves @gtsuser@gts.test" || ko "PrivaPub cannot resolve gtsuser"
echo "follows"
gcurl -s -o /dev/null -X POST -H "$GH" $G/api/v1/accounts/$alice_on_gts/follow
until_true 20 '[ "$(gcurl -s -H "$GH" "$G/api/v1/accounts/relationships?id[]=$alice_on_gts" | j "print(d[0][\"following\"])")" = "True" ]' && ok "gtsuser follows alice (Accept arrived)" || ko "gtsuser's follow of alice not accepted"
curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/accounts/$gts_on_pp/follow
until_true 10 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/relationships?id[]=$gts_on_pp" | j "print(d[0][\"requested\"])")" = "True" ]' && ok "alice's follow of locked gtsuser waits as a request" || ko "alice's follow is not shown as requested"
until_true 20 'gcurl -s -H "$GH" "$G/api/v1/follow_requests" | j "print(any(a[\"id\"]==\"$alice_on_gts\" for a in d))" | grep -q True' && ok "the request reaches gtsuser's follow requests" || ko "follow request missing on GoToSocial"
gcurl -s -o /dev/null -X POST -H "$GH" $G/api/v1/follow_requests/$alice_on_gts/authorize
until_true 20 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/relationships?id[]=$gts_on_pp" | j "print(d[0][\"following\"])")" = "True" ]' && ok "alice follows gtsuser (Accept arrived)" || ko "alice's follow of gtsuser not accepted"
echo "posts"
pp_status=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d 'status=Hello from PrivaPub #pasture&visibility=public')
pp_post=$(echo "$pp_status" | j "print(d['id'])"); pp_uri=$(echo "$pp_status" | j "print(d['uri'])")
until_true 20 'on_gts "$pp_uri" | j "print(len(d[\"statuses\"]))" | grep -q 1' && ok "alice's post reaches GoToSocial" || ko "alice's post missing on GoToSocial"
gts_post=$(gcurl -s -X POST -H "$GH" $G/api/v1/statuses -d 'status=Hello from GoToSocial&visibility=public' | j "print(d['id'])")
until_true 20 'curl -s -H "$PH" "$P/api/v1/timelines/home" | grep -q "Hello from GoToSocial"' && ok "gtsuser's post reaches alice's home" || ko "gtsuser's post missing on PrivaPub"
[ "$(curl -s -H "$PH" "$P/api/v1/timelines/home" | j "print(next(s['edited_at'] for s in d if 'Hello from GoToSocial' in s['content']))")" = "None" ] && ok "an unedited remote post carries no edited_at" || ko "unedited remote post reports an edit"
cw_status=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d 'status=behind a warning&spoiler_text=spoilers&visibility=public')
cw=$(echo "$cw_status" | j "print(d['id'])"); cw_uri=$(echo "$cw_status" | j "print(d['uri'])")
until_true 20 'on_gts "$cw_uri" | j "print(any(s[\"spoiler_text\"]==\"spoilers\" and s[\"sensitive\"] for s in d[\"statuses\"]))" | grep -q True' && ok "content warning survives to GoToSocial" || ko "content warning lost"
cw_on_gts=$(on_gts "$cw_uri" | j "print(d['statuses'][0]['id'])")
echo "replies and mentions"
pp_on_gts=$(on_gts "$pp_uri" | j "print(d['statuses'][0]['id'])")
gcurl -s -o /dev/null -X POST -H "$GH" $G/api/v1/statuses -d "status=@alice@privapub.test nice to meet you&in_reply_to_id=$pp_on_gts&visibility=public"
until_true 20 'curl -s -H "$PH" "$P/api/v1/notifications" | j "print(any(n[\"type\"]==\"mention\" for n in d))" | grep -q True' && ok "gtsuser's reply notifies alice" || ko "reply did not notify alice"
until_true 10 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$pp_post/context" | j "print(len(d[\"descendants\"]))")" -ge 1 ]' && ok "the reply threads under alice's post" || ko "reply not threaded"
echo "likes and boosts"
gts_on_pp_post=$(curl -s -H "$PH" "$P/api/v1/timelines/home" | j "print(next(s['id'] for s in d if 'Hello from GoToSocial' in s['content']))")
curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/statuses/$gts_on_pp_post/favourite
curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/statuses/$gts_on_pp_post/reblog
until_true 20 'gcurl -s -H "$GH" "$G/api/v1/notifications" | j "print(any(n[\"type\"]==\"favourite\" for n in d))" | grep -q True' && ok "alice's like reaches GoToSocial" || ko "like not received"
until_true 20 'gcurl -s -H "$GH" "$G/api/v1/notifications" | j "print(any(n[\"type\"]==\"reblog\" for n in d))" | grep -q True' && ok "alice's boost reaches GoToSocial" || ko "boost not received"
gcurl -s -o /dev/null -X POST -H "$GH" $G/api/v1/statuses/$pp_on_gts/favourite
gcurl -s -o /dev/null -X POST -H "$GH" $G/api/v1/statuses/$pp_on_gts/reblog
until_true 20 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$pp_post" | j "print(d[\"favourites_count\"])")" = "1" ]' && ok "gtsuser's like counts on PrivaPub" || ko "like not counted"
until_true 20 'curl -s -H "$PH" "$P/api/v1/notifications" | j "print(any(n[\"type\"]==\"reblog\" for n in d))" | grep -q True' && ok "gtsuser's boost notifies alice" || ko "boost not notified"
echo "direct messages"
curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/statuses -d 'status=@gtsuser@gts.test a secret&visibility=direct'
until_true 20 'gcurl -s -H "$GH" "$G/api/v1/conversations" | grep -q "a secret"' && ok "alice's DM reaches gtsuser" || ko "DM missing on GoToSocial"
gcurl -s -o /dev/null -X POST -H "$GH" $G/api/v1/statuses -d 'status=@alice@privapub.test a secret back&visibility=direct'
until_true 20 'curl -s -H "$PH" "$P/api/v1/conversations" | grep -q "a secret back"' && ok "gtsuser's DM reaches alice" || ko "DM missing on PrivaPub"
until_true 5 '! gcurl -s -H "$GH" "$G/api/v1/timelines/public?local=false" | grep -q "a secret"' && ok "the DM is not public on GoToSocial" || ko "DM leaked to a public timeline"
echo "edits and deletes"
curl -s -o /dev/null -X PUT -H "$PH" $P/api/v1/statuses/$pp_post -d 'status=Hello from PrivaPub, edited'
until_true 20 'gcurl -s -H "$GH" "$G/api/v1/statuses/$pp_on_gts" | grep -q "edited"' && ok "alice's edit reaches GoToSocial" || ko "edit not applied"
curl -s -o /dev/null -X DELETE -H "$PH" $P/api/v1/statuses/$cw
[ -n "$cw_on_gts" ] && until_true 20 '[ "$(gcurl -s -o /dev/null -w "%{http_code}" -H "$GH" "$G/api/v1/statuses/$cw_on_gts")" = "404" ]' && ok "alice's delete reaches GoToSocial" || ko "delete not applied"
gcurl -s -o /dev/null -X DELETE -H "$GH" $G/api/v1/statuses/$gts_post
until_true 20 '! curl -s -H "$PH" "$P/api/v1/timelines/home" | grep -q "Hello from GoToSocial"' && ok "gtsuser's delete reaches PrivaPub" || ko "remote delete not applied"
echo "unfollow"
curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/accounts/$gts_on_pp/unfollow
until_true 20 '[ "$(gcurl -s -H "$GH" "$G/api/v1/accounts/relationships?id[]=$alice_on_gts" | j "print(d[0][\"followed_by\"])")" = "False" ]' && ok "alice's unfollow reaches GoToSocial" || ko "unfollow not applied"
echo; echo "$pass passed, $fail failed"
[ "$fail" = 0 ]
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# A private test fediverse on one podman network: PrivaPub (privapub.test) and GoToSocial (gts.test) behind Caddy,
# whose internal CA both sides are told to accept. From the workstation: PrivaPub's API at http://127.0.0.1:6971,
# both sites at https://{privapub,gts}.test:6443 (curl --resolve ...:6443:127.0.0.1 -k).
# usage: tools/pasture/run.sh up | down | logs <name>
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"; repo="$(cd "$here/../.." && pwd)"
net=privapub-pasture; publish="$here/.publish"
case "${1:-up}" in
up)
dotnet=${DOTNET:-$(command -v dotnet || echo ~/.dotnet/dotnet)}
"$dotnet" publish "$repo/PrivaPub/PrivaPub.csproj" -c Release -r linux-x64 --self-contained true -o "$publish" -v quiet
cp "$here/appsettings.Pasture.json" "$publish/"
podman network exists $net || podman network create $net >/dev/null
podman run -d --replace --name pasture-mongo --network $net --network-alias mongo docker.io/library/mongo:8 --quiet >/dev/null
podman run -d --replace --name pasture-caddy --network $net --network-alias privapub.test --network-alias gts.test \
-p 127.0.0.1:6443:443 --sysctl net.ipv4.ip_unprivileged_port_start=0 -v "$here/Caddyfile:/etc/caddy/Caddyfile:Z,ro" \
docker.io/library/caddy:2 >/dev/null
podman run -d --replace --name pasture-privapub --network $net -p 127.0.0.1:6971:80 \
--sysctl net.ipv4.ip_unprivileged_port_start=0 -e ASPNETCORE_ENVIRONMENT=Pasture -w /app -v "$publish:/app:Z,ro" \
mcr.microsoft.com/dotnet/runtime-deps:10.0 /app/PrivaPub >/dev/null
podman run -d --replace --name pasture-gts --network $net -p 127.0.0.1:6972:80 \
--sysctl net.ipv4.ip_unprivileged_port_start=0 \
-e GTS_HOST=gts.test -e GTS_PROTOCOL=https -e GTS_PORT=80 -e GTS_BIND_ADDRESS=0.0.0.0 -e GTS_HTTP_CLIENT_TLS_INSECURE_SKIP_VERIFY=true \
-e GTS_DB_TYPE=sqlite -e GTS_DB_ADDRESS=/gotosocial/storage/sqlite.db -e GTS_STORAGE_LOCAL_BASE_PATH=/gotosocial/storage \
-e GTS_LETSENCRYPT_ENABLED=false -e GTS_HTTP_CLIENT_ALLOW_IPS=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 \
-e GTS_TRUSTED_PROXIES=0.0.0.0/0 -e GTS_LOG_LEVEL=info -e GTS_INSTANCE_EXPOSE_PUBLIC_TIMELINE=true \
docker.io/superseriousbusiness/gotosocial:latest >/dev/null
for i in $(seq 1 60); do curl -fs -o /dev/null http://127.0.0.1:6971/build.json && curl -fs -o /dev/null http://127.0.0.1:6972/api/v1/instance && break; sleep 2; done
podman exec pasture-gts /gotosocial/gotosocial admin account create --username gtsuser --email gtsuser@gts.test --password 'Gts-Pasture-Pass-1!' >/dev/null 2>&1 || true
podman exec pasture-gts /gotosocial/gotosocial admin account confirm --username gtsuser >/dev/null 2>&1 || true
podman restart pasture-gts >/dev/null
for i in $(seq 1 60); do curl -fs -o /dev/null http://127.0.0.1:6972/api/v1/instance && break; sleep 2; done
echo "privapub: http://127.0.0.1:6971, https://privapub.test:6443 gotosocial: https://gts.test:6443"
;;
down)
podman rm -f pasture-caddy pasture-privapub pasture-gts pasture-mongo >/dev/null 2>&1 || true
podman network rm $net >/dev/null 2>&1 || true
;;
logs)
podman logs --tail 200 "pasture-${2:-privapub}"
;;
esac