Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f4d6cbdf9 | ||
|
|
31542a181e | ||
|
|
51f2c10c6f | ||
|
|
a5d9a89445 | ||
|
|
0ccbcd558f | ||
|
|
c00865d805 |
No files matched your search
@@ -401,3 +401,4 @@ FodyWeavers.xsd
|
||||
|
||||
PrivaPub/media-store/
|
||||
PrivaPub/media-store-proxy/
|
||||
tools/pasture/.publish/
|
||||
@@ -69,7 +69,7 @@ PrivaPub/ ASP.NET Core Web API, net10.0
|
||||
Infrastructure/
|
||||
Http/ FederationHttp + SafeHttpHandlerFactory + IpRangeGuard: the only way out
|
||||
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
|
||||
Cli/ AdminCommands (`PrivaPub admin promote|demote <root>`)
|
||||
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
|
||||
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):
|
||||
|
||||
@@ -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`.
|
||||
7. **Remote HTML is sanitized before it is stored** (`ContentSanitizer`); `Post.ContentHtml` is what is shown,
|
||||
`ContentFormat` says what `Text` holds. Remote names are plain text.
|
||||
8. **Circles never federate.** A circle's actor, collections, WebFinger and inbox answer 404, and its posts are
|
||||
`IsLocalOnly`. Only communities are Group actors.
|
||||
8. **Circles federate to members only.** A circle is an undiscoverable Group actor that takes follow requests (the owner
|
||||
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
|
||||
`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`):
|
||||
@@ -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.**
|
||||
- `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.
|
||||
- **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
|
||||
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
|
||||
@@ -238,6 +253,9 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
|
||||
- Localised strings go through `IStringLocalizer<GenericRes>`.
|
||||
- Every `Display`/`ErrorMessage` resource key must exist in `FieldsNameResource`/`ErrorsResource`, including the
|
||||
`Designer.cs`, which the CLI build doesn't regenerate. A missing key throws at validation time.
|
||||
- **`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
|
||||
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 +
|
||||
@@ -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 ActivityPub endpoints with curl and `Accept: application/activity+json`.
|
||||
|
||||
Interop is checked against real servers: Fediverse Pasture with podman on the workstation, verify.funfedi.dev, and
|
||||
the owner's GoToSocial at social.arasaka.software. **Ask before acting from the owner's GoToSocial account.**
|
||||
Interop is checked against real servers, starting with the workstation's own pasture:
|
||||
|
||||
```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
|
||||
|
||||
|
||||
+30
-4
@@ -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
|
||||
- [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
|
||||
|
||||
- [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 community is a `Group` actor. It accepts `Follow` and re-shares (`Announce`) posts from its followers that address
|
||||
it. Full FEP-1b12 behaviour (announcing activities, `audience`, moderation) is planned.
|
||||
- A circle is private and does not federate yet: its actor, collections and WebFinger answer 404, and its posts are never
|
||||
delivered.
|
||||
- A **community** follows [FEP-1b12](https://codeberg.org/fediverse/fep/src/branch/main/fep/1b12/fep-1b12.md). Posts
|
||||
addressed to it (in `to`, `cc` or `audience`) are accepted according to its posting policy (followers, anyone, or
|
||||
moderators only) and the group `Announce`s the whole activity, with `audience` set, to its followers. A new post is
|
||||
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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
- **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 bool IsCommunity { get; set; }
|
||||
public string PostingPolicy { get; set; }//followers (default), anyone, moderators
|
||||
public bool IsDiscoverable { get; set; } = true;
|
||||
public bool ManuallyApprovesMembers { get; set; }
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ namespace PrivaPub.ClientModels.Group
|
||||
public bool RemoveInvitationPassword { get; set; }
|
||||
public bool RegenerateInvitationCode { get; set; }
|
||||
public bool? IsDiscoverable { get; set; }
|
||||
public string PostingPolicy { get; set; }
|
||||
public bool? ManuallyApprovesMembers { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ namespace PrivaPub.ClientModels.Group
|
||||
public string Url { get; set; }
|
||||
public string Handle { get; set; }
|
||||
public bool IsCommunity { get; set; }
|
||||
public string PostingPolicy { get; set; }
|
||||
public bool IsDiscoverable { get; set; }
|
||||
public bool ManuallyApprovesMembers { get; set; }
|
||||
public bool IsOwner { get; set; }
|
||||
|
||||
@@ -24,6 +24,15 @@ namespace PrivaPub.ClientModels.Post
|
||||
public string SpoilerText { get; set; }
|
||||
|
||||
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
|
||||
|
||||
@@ -17,6 +17,8 @@ namespace PrivaPub.ClientModels.Post
|
||||
public bool IsFederatedCopy { get; set; }
|
||||
public DateTime CreationDate { get; set; }
|
||||
public ViewPost ReblogOf { get; set; }
|
||||
public double? RangeKm { get; set; }
|
||||
public double? DistanceKm { get; set; }
|
||||
}
|
||||
|
||||
public class ViewDmGroup
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 UndoHandler(db, _local),
|
||||
new CreateHandler(db, _local, remote, delivery, _blocks, new Fanout(db), new RemotePosts(db, _local, remote, _blocks, queue)),
|
||||
new DeleteHandler(db, _local, remote, delivery),
|
||||
new UpdateHandler(db, _local, remote)
|
||||
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 GroupDistributor(delivery)),
|
||||
new UpdateHandler(db, _local, remote, new GroupDistributor(delivery))
|
||||
}, NullLogger<InboxProcessor>.Instance);
|
||||
}
|
||||
|
||||
@@ -323,7 +323,7 @@ namespace PrivaPub.Tests.Federation
|
||||
}
|
||||
|
||||
[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 (privateKey, publicKey) = Keys.NewKeyPair();
|
||||
@@ -339,8 +339,13 @@ namespace PrivaPub.Tests.Federation
|
||||
["object"] = actor.Uri
|
||||
};
|
||||
|
||||
Assert.False(actor.IsFederated);
|
||||
Assert.Equal(404, (await Deliver(bob, "/human-centipede", follow)).StatusCode);
|
||||
Assert.True(actor.IsCircle);
|
||||
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(new[] { 0.5f, -1f }, media.Focus);
|
||||
Assert.Equal(new DateTime(2026, 9, 30, 10, 0, 0, DateTimeKind.Utc), note.Published);
|
||||
Assert.Null(note.Updated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -57,7 +57,7 @@ namespace PrivaPub.Tests.Federation
|
||||
Language = "en",
|
||||
Mentions = new() { new PostMention { ActorURI = "https://m.example/users/bob", Handle = "@bob@m.example" } },
|
||||
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");
|
||||
@@ -67,7 +67,7 @@ namespace PrivaPub.Tests.Federation
|
||||
Assert.Equal("<p><strong>On <cats></strong></p><p>body</p>", note["content"]!.GetValue<string>());
|
||||
Assert.Equal("On <cats>", note["summary"]!.GetValue<string>());
|
||||
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[] { Alice.Followers, "https://m.example/users/bob" }, note["cc"]!.AsArray().Select(n => n!.GetValue<string>()));
|
||||
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);
|
||||
}
|
||||
|
||||
[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]
|
||||
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));
|
||||
|
||||
@@ -41,6 +41,7 @@ namespace PrivaPub.Tests.Support
|
||||
Remote = new RemoteActorService(Peer.Http(cache), Local, cache, Db);
|
||||
Delivery = new DeliveryService(Db, Queue);
|
||||
Fanout = new Fanout(Db);
|
||||
Groups = new GroupDistributor(Delivery);
|
||||
RemotePosts = new RemotePosts(Db, Local, Remote, new NoBlocks(), Queue);
|
||||
Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger<InboxReceiver>.Instance);
|
||||
Processor = new InboxProcessor(Remote, new IActivityHandler[]
|
||||
@@ -50,10 +51,10 @@ namespace PrivaPub.Tests.Support
|
||||
new RejectHandler(Db, Local),
|
||||
new UndoHandler(Db, Local),
|
||||
new LikeHandler(Db),
|
||||
new AnnounceHandler(Db, Local, RemotePosts, Fanout),
|
||||
new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts),
|
||||
new DeleteHandler(Db, Local, Remote, Delivery),
|
||||
new UpdateHandler(Db, Local, Remote),
|
||||
new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote),
|
||||
new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts, Groups),
|
||||
new DeleteHandler(Db, Local, Remote, Delivery, Groups),
|
||||
new UpdateHandler(Db, Local, Remote, Groups),
|
||||
new FlagHandler(Db, Local)
|
||||
}, NullLogger<InboxProcessor>.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);
|
||||
Media = new MediaService(new StaticOptions<MediaOptions>(new MediaOptions { Root = Path.Combine(Path.GetTempPath(), $"privapub-media-{Guid.NewGuid():N}") }),
|
||||
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);
|
||||
Timelines = new TimelineService(Db, new KeyLocalizer<GenericRes>());
|
||||
Relationships = new RelationshipService(Db, Follows, Delivery);
|
||||
@@ -82,6 +83,7 @@ namespace PrivaPub.Tests.Support
|
||||
public PostsService Posts { get; }
|
||||
public StatusService Statuses { get; }
|
||||
public MediaService Media { get; }
|
||||
public GroupDistributor Groups { get; }
|
||||
public Fanout Fanout { get; }
|
||||
public RemotePosts RemotePosts { get; }
|
||||
public TimelineService Timelines { get; }
|
||||
|
||||
@@ -14,13 +14,16 @@ namespace PrivaPub.Tests.Support
|
||||
{
|
||||
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];
|
||||
Id = $"{origin ?? peer.A}/users/{Name}";
|
||||
Type = type;
|
||||
peer.Serve($"/users/{Name}", Document().ToJsonString());
|
||||
}
|
||||
|
||||
public string Type { get; }
|
||||
|
||||
public string Name { get; }
|
||||
public string Id { get; }
|
||||
public string KeyId => Id + "#main-key";
|
||||
@@ -29,7 +32,7 @@ namespace PrivaPub.Tests.Support
|
||||
public JsonObject Document() => new()
|
||||
{
|
||||
["id"] = Id,
|
||||
["type"] = "Person",
|
||||
["type"] = Type,
|
||||
["preferredUsername"] = Name,
|
||||
["inbox"] = Id + "/inbox",
|
||||
["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)
|
||||
{
|
||||
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))
|
||||
{
|
||||
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 domain = parts[1].ToLowerInvariant();
|
||||
@@ -365,7 +365,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
if (avatar is { DeletionAt: null })
|
||||
return (_localActors.FromAvatar(avatar), default);
|
||||
var group = await _dbEntities.Groups.MatchID(id).ExecuteFirstAsync(token);
|
||||
if (group is { DeletionAt: null } && _localActors.FromGroup(group) is { IsFederated: true } community)
|
||||
if (group is { DeletionAt: null } && _localActors.FromGroup(group) is { IsFederated: true, IsCircle: false } community)
|
||||
return (community, default);
|
||||
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 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));
|
||||
else if (foreign != default)
|
||||
results.Accounts.Add(_mapper.Foreign(foreign));
|
||||
|
||||
@@ -6,6 +6,7 @@ using PrivaPub.Domain.Media;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Group;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.Social;
|
||||
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))
|
||||
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);
|
||||
foreach (var foreign in await _dbEntities.ForeignAvatars.Match(f => wanted.Contains(f.ID)).ExecuteAsync(token))
|
||||
accounts[foreign.ID] = Foreign(foreign);
|
||||
|
||||
@@ -32,6 +32,10 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
public async Task<IActionResult> Insert(InsertPostForm form, CancellationToken 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")]
|
||||
public async Task<IActionResult> Update(UpdatePostForm form, CancellationToken token) =>
|
||||
!ModelState.IsValid ? Invalid() : Answer(await _postsService.UpdatePost(User.GetUserId(), form, token));
|
||||
|
||||
@@ -122,7 +122,7 @@ namespace PrivaPub.Domain.Content
|
||||
if (parts.Length == 1 || parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var local = await _localActors.FindByUserName(parts[0], token);
|
||||
return local is { IsFederated: true, Kind: not LocalActorKind.Application }
|
||||
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)
|
||||
: default;
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ namespace PrivaPub.Domain.Social
|
||||
var (local, remote) = await ResolveTarget(target, token);
|
||||
if (local == default && remote == default)
|
||||
return default;
|
||||
if (local != default && (local.Id == follower.Id || !local.IsFederated || local.Kind == LocalActorKind.Application))
|
||||
if (local != default && (local.Id == follower.Id || !local.IsFederated || local.IsCircle || local.Kind == LocalActorKind.Application))
|
||||
return default;
|
||||
|
||||
var targetUri = local?.Uri ?? remote.ActorURI;
|
||||
|
||||
@@ -36,6 +36,9 @@ namespace PrivaPub.Domain.Statuses
|
||||
public string ConversationId { get; init; }
|
||||
public IReadOnlyList<string> Recipients { get; init; } = Array.Empty<string>();
|
||||
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)
|
||||
@@ -65,11 +68,13 @@ namespace PrivaPub.Domain.Statuses
|
||||
readonly IOutboxPublisher _outbox;
|
||||
readonly IFanout _fanout;
|
||||
readonly IMediaService _media;
|
||||
readonly IGroupDistributor _groups;
|
||||
|
||||
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;
|
||||
_groups = groups;
|
||||
_dbEntities = dbEntities;
|
||||
_localActors = localActors;
|
||||
_remoteActors = remoteActors;
|
||||
@@ -91,7 +96,7 @@ namespace PrivaPub.Domain.Statuses
|
||||
if (!string.IsNullOrEmpty(draft.GroupId))
|
||||
{
|
||||
var groupEntity = await _dbEntities.Groups.MatchID(draft.GroupId).ExecuteFirstAsync(token);
|
||||
if (groupEntity == default || groupEntity.DeletionAt.HasValue || !groupEntity.Members.Any(m => !m.IsForeign && m.AvatarId == author.Id))
|
||||
if (groupEntity == default || groupEntity.DeletionAt.HasValue || !await MayPost(groupEntity, author, token))
|
||||
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Group not found");
|
||||
group = _localActors.FromGroup(groupEntity);
|
||||
}
|
||||
@@ -102,9 +107,35 @@ namespace PrivaPub.Domain.Statuses
|
||||
if (parent != default && !await VisibilityPolicy.CanSee(parent, author.Id, token))
|
||||
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 isLocalOnly = group is { IsFederated: false };
|
||||
var visibility = isLocalOnly ? PostVisibility.Circle : draft.Visibility is PostVisibility.Circle or PostVisibility.LocalGeo ? PostVisibility.Public : draft.Visibility;
|
||||
string audienceUri = default;
|
||||
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
|
||||
{
|
||||
GroupUserId = author.Id,
|
||||
@@ -121,6 +152,7 @@ namespace PrivaPub.Domain.Statuses
|
||||
Mentions = rendered.Mentions.Select(ToMention).ToList(),
|
||||
Tags = rendered.Tags.ToList(),
|
||||
Media = media.Select(ToPostMedia).ToList(),
|
||||
AudienceURI = audienceUri,
|
||||
AnsweringToPostId = parent?.ID,
|
||||
InReplyToURI = parent?.ObjectURI ?? (IsRemoteUri(draft.InReplyTo) ? draft.InReplyTo : default),
|
||||
InReplyToAccountId = parent?.AuthorAccountId ?? parent?.GroupUserId,
|
||||
@@ -130,6 +162,11 @@ namespace PrivaPub.Domain.Statuses
|
||||
post.ID = (string)post.GenerateNewID();
|
||||
post.ObjectURI = author.PostUri(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;
|
||||
if (visibility == PostVisibility.Direct)
|
||||
@@ -165,8 +202,8 @@ namespace PrivaPub.Domain.Statuses
|
||||
await _fanout.Distribute(post, token);
|
||||
if (create != default)
|
||||
await _outbox.Publish(author, post, create, token);
|
||||
if (group is { IsFederated: true } && visibility is PostVisibility.Public or PostVisibility.Unlisted)
|
||||
await _delivery.EnqueueToFollowers(group, ActivityPubRenderer.Announce(group, post.ObjectURI, $"announce-{post.ID}"), token);
|
||||
if (create != default && group is { IsCircle: false } && visibility is PostVisibility.Public or PostVisibility.Unlisted)
|
||||
await _groups.Announce(group, create, post.ObjectURI, isNewPost: string.IsNullOrEmpty(post.InReplyToURI), token);
|
||||
return new StatusOutcome(post);
|
||||
}
|
||||
|
||||
@@ -232,6 +269,8 @@ namespace PrivaPub.Domain.Statuses
|
||||
["object"] = note
|
||||
};
|
||||
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);
|
||||
}
|
||||
@@ -262,6 +301,9 @@ namespace PrivaPub.Domain.Statuses
|
||||
var delete = ActivityPubRenderer.Delete(author, post.ObjectURI, $"delete-{post.ID}",
|
||||
new JsonArray(post.To.Select(t => (JsonNode)t).ToArray()), new JsonArray(post.Cc.Select(c => (JsonNode)c).ToArray()));
|
||||
await _delivery.Enqueue(author, audience, delete, token);
|
||||
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);
|
||||
}
|
||||
@@ -479,6 +521,20 @@ namespace PrivaPub.Domain.Statuses
|
||||
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)
|
||||
{
|
||||
if (ids == default || ids.Count == 0)
|
||||
|
||||
@@ -40,6 +40,9 @@ namespace PrivaPub.Domain.Timelines
|
||||
foreach (var following in followers)
|
||||
if (await Shows(following, post, token))
|
||||
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;
|
||||
case PostVisibility.Direct when !string.IsNullOrEmpty(post.ConversationId):
|
||||
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 ManuallyApprovesFollowers { get; init; }
|
||||
public bool IsFederated { get; init; } = true;
|
||||
public bool IsCircle { get; init; }
|
||||
public bool PostingRestrictedToModerators { get; init; }
|
||||
public bool IsBot { get; init; }
|
||||
public bool Indexable { get; init; }
|
||||
public AvatarSettings Settings { get; init; } = new();
|
||||
@@ -44,6 +46,8 @@ namespace PrivaPub.Federation.Actors
|
||||
public string Following => $"{Uri}/stalking";
|
||||
public string Featured => $"{Uri}/trophies";
|
||||
public string FeaturedTags => $"{Uri}/tattoos";
|
||||
public string Flock => $"{Uri}/flock";
|
||||
public string Wardens => $"{Uri}/wardens";
|
||||
public string SharedInbox => $"{BaseAddress}/human-centipede";
|
||||
public string Domain => new Uri(BaseAddress).Authority;
|
||||
public string Handle => $"{UserName}@{Domain}";
|
||||
@@ -219,9 +223,10 @@ namespace PrivaPub.Federation.Actors
|
||||
ThumbnailURL = group.ThumbnailURL,
|
||||
PrivateKeyPem = group.PrivateKey,
|
||||
PublicKeyPem = group.PublicKey,
|
||||
Discoverable = group.IsDiscoverable,
|
||||
ManuallyApprovesFollowers = group.ManuallyApprovesMembers,
|
||||
IsFederated = group.Kind == GroupKind.Community,
|
||||
Discoverable = group.Kind == GroupKind.Community && group.IsDiscoverable,
|
||||
ManuallyApprovesFollowers = group.Kind == GroupKind.Circle || group.ManuallyApprovesMembers,
|
||||
IsCircle = group.Kind == GroupKind.Circle,
|
||||
PostingRestrictedToModerators = group.PostingPolicy == PostingPolicy.Moderators,
|
||||
Published = group.CreationDate,
|
||||
BaseAddress = BaseAddress
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ using PrivaPub.StaticServices;
|
||||
using System.Text.Json;
|
||||
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
using PrivaPub.Federation.Objects;
|
||||
using PrivaPub.Federation.Signing;
|
||||
@@ -32,13 +33,16 @@ namespace PrivaPub.Federation.Actors
|
||||
readonly ILocalActorService _localActors;
|
||||
readonly IMemoryCache _cache;
|
||||
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;
|
||||
_localActors = localActors;
|
||||
_cache = cache;
|
||||
_dbEntities = dbEntities;
|
||||
_options = options;
|
||||
}
|
||||
|
||||
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]))
|
||||
return default;
|
||||
|
||||
var url = $"https://{parts[1]}/.well-known/webfinger?resource={Uri.EscapeDataString($"acct:{parts[0]}@{parts[1]}")}";
|
||||
using var fetched = await _http.GetJson(url, "application/jrd+json, application/json", sign: default, token);
|
||||
var query = $"/.well-known/webfinger?resource={Uri.EscapeDataString($"acct:{parts[0]}@{parts[1]}")}";
|
||||
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)
|
||||
return default;
|
||||
var document = fetched.Document;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
using MongoDB.Entities;
|
||||
@@ -15,12 +17,15 @@ using PrivaPub.Domain.Privacy;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Federation.Inbox;
|
||||
using PrivaPub.Federation.Signing;
|
||||
using PrivaPub.Infrastructure.Http;
|
||||
using PrivaPub.Models.Group;
|
||||
using PrivaPub.Infrastructure;
|
||||
|
||||
namespace PrivaPub.Federation.Controllers
|
||||
{
|
||||
[ApiController, Route("peasants")]
|
||||
public class PeasantsController : ControllerBase
|
||||
public class PeasantsController : ControllerBase, IAsyncActionFilter
|
||||
{
|
||||
const string ActivityContentType = "application/activity+json; charset=utf-8";
|
||||
const int OutboxSize = 20;
|
||||
@@ -29,10 +34,14 @@ namespace PrivaPub.Federation.Controllers
|
||||
readonly IInboxReceiver _inbox;
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly ILogger<PeasantsController> _logger;
|
||||
readonly ISignedFetchAuthorizer _fetches;
|
||||
readonly IOptionsMonitor<FederationOptions> _federation;
|
||||
|
||||
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;
|
||||
_inbox = inbox;
|
||||
_dbEntities = dbEntities;
|
||||
@@ -113,6 +122,34 @@ namespace PrivaPub.Federation.Controllers
|
||||
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")]
|
||||
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)
|
||||
{
|
||||
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)
|
||||
return await Tombstone(actor, postId, token) ?? NotFound();
|
||||
if (WantsHtml())
|
||||
@@ -202,6 +245,20 @@ namespace PrivaPub.Federation.Controllers
|
||||
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)
|
||||
{
|
||||
var local = await _localActors.FindByUserName(actor, token);
|
||||
@@ -257,6 +314,19 @@ namespace PrivaPub.Federation.Controllers
|
||||
&& !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)
|
||||
{
|
||||
if (result.Error != default)
|
||||
|
||||
@@ -26,9 +26,11 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
readonly ILocalActorService _localActors;
|
||||
readonly IRemotePosts _remotePosts;
|
||||
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;
|
||||
_localActors = localActors;
|
||||
_remotePosts = remotePosts;
|
||||
@@ -41,7 +43,10 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
{
|
||||
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")
|
||||
{
|
||||
await GroupActivity(inner, actor, token);
|
||||
return;
|
||||
}
|
||||
var objectUri = Id(inner);
|
||||
var announceId = Id(activity);
|
||||
if (objectUri == default || announceId == default)
|
||||
@@ -66,7 +71,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
: DateTime.UtcNow;
|
||||
var reblog = new PostEntity
|
||||
{
|
||||
ID = PrivacyIds.At(published),
|
||||
ID = PrivacyIds.Arrived(published),
|
||||
ObjectURI = announceId,
|
||||
ActivityURI = announceId,
|
||||
ActorURI = actor.ActorURI,
|
||||
@@ -96,6 +101,66 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
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
|
||||
{
|
||||
JsonArray array => array.Select(Id).Where(id => id != default).ToList(),
|
||||
|
||||
@@ -33,10 +33,12 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
readonly IDomainBlocks _domainBlocks;
|
||||
readonly IFanout _fanout;
|
||||
readonly IRemotePosts _remotePosts;
|
||||
readonly IGroupDistributor _groups;
|
||||
|
||||
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;
|
||||
_remotePosts = remotePosts;
|
||||
_dbEntities = dbEntities;
|
||||
@@ -85,10 +87,17 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
var parent = string.IsNullOrEmpty(note.InReplyTo)
|
||||
? default
|
||||
: await _dbEntities.Posts.Match(p => p.ObjectURI == note.InReplyTo && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
|
||||
var group = visibility is PostVisibility.Public or PostVisibility.Unlisted
|
||||
? localTargets.FirstOrDefault(t => t.Kind == LocalActorKind.Group)
|
||||
: default;
|
||||
if (group != default && !await IsAcceptedFollower(group, author.ActorURI, token))
|
||||
var circle = localTargets.FirstOrDefault(t => t is { Kind: LocalActorKind.Group, IsCircle: true });
|
||||
if (circle != default)
|
||||
{
|
||||
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;
|
||||
|
||||
var repliesToLocal = parent is { IsFederatedCopy: false } && visibility != PostVisibility.Direct;
|
||||
@@ -128,11 +137,22 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
await _fanout.Distribute(post, token);
|
||||
if (conversation != default)
|
||||
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);
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
var announce = ActivityPubRenderer.Announce(group, note.Id, $"announce-{post.ID}");
|
||||
await _delivery.EnqueueToFollowers(group, announce, token);
|
||||
}
|
||||
PostingPolicy.Anyone => true,
|
||||
PostingPolicy.Followers => await IsAcceptedFollower(community, actorUri, token),
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
async Task<DmGroup> FindOrCreateConversation(List<string> participantUris, string context, CancellationToken token)
|
||||
|
||||
@@ -26,9 +26,12 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
readonly ILocalActorService _localActors;
|
||||
readonly IRemoteActorService _remoteActors;
|
||||
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;
|
||||
_localActors = localActors;
|
||||
_remoteActors = remoteActors;
|
||||
@@ -67,6 +70,8 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
return;
|
||||
await DB.Default.DeleteAsync<PostEntity>(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);
|
||||
if (!string.IsNullOrEmpty(post.AnsweringToPostId))
|
||||
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.Objects;
|
||||
using PrivaPub.Federation.Outbox;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
@@ -21,9 +22,11 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly ILocalActorService _localActors;
|
||||
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;
|
||||
_localActors = localActors;
|
||||
_remoteActors = remoteActors;
|
||||
@@ -79,6 +82,8 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
post.EditedAt = note.Updated ?? DateTime.UtcNow;
|
||||
post.UpdateDate = DateTime.UtcNow;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ namespace PrivaPub.Federation.Inbox
|
||||
|
||||
return new PostEntity
|
||||
{
|
||||
ID = PrivacyIds.At(note.Published),
|
||||
ID = PrivacyIds.Arrived(note.Published),
|
||||
ObjectURI = note.Id,
|
||||
ActorURI = author.ActorURI,
|
||||
AuthorAccountId = author.ID,
|
||||
|
||||
@@ -163,7 +163,7 @@ namespace PrivaPub.Federation.Objects
|
||||
static DateTime? Time(string value) =>
|
||||
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed)
|
||||
? parsed.UtcDateTime
|
||||
: default;
|
||||
: (DateTime?)null;
|
||||
|
||||
static int? Int(JsonNode node) =>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,8 +32,10 @@ namespace PrivaPub.Federation.Outbox
|
||||
|
||||
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>();
|
||||
if (post.Visibility == PostVisibility.Circle)
|
||||
return await CircleMembers(post.GroupId, token);
|
||||
|
||||
var inboxes = new List<string>();
|
||||
if (post.Visibility is PostVisibility.Public or PostVisibility.Unlisted or PostVisibility.FollowersOnly)
|
||||
@@ -58,16 +60,21 @@ namespace PrivaPub.Federation.Outbox
|
||||
inboxes.Add(parentAuthor.InboxURL);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(post.GroupId) && post.Visibility is PostVisibility.Public or PostVisibility.Unlisted)
|
||||
{
|
||||
var group = await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
|
||||
if (group is { IsFederated: true })
|
||||
inboxes.AddRange(await _delivery.FollowerInboxes(group, token));
|
||||
}
|
||||
|
||||
return inboxes.Where(i => !string.IsNullOrEmpty(i)).Distinct(StringComparer.Ordinal).ToList();
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
var inboxes = await Audience(author, post, token);
|
||||
|
||||
@@ -48,7 +48,7 @@ namespace PrivaPub.Federation.Rendering
|
||||
string.IsNullOrEmpty(markdown) ? string.Empty : Markdown.ToHtml(markdown, Pipeline).Trim();
|
||||
|
||||
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) =>
|
||||
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)
|
||||
}).ToArray())
|
||||
};
|
||||
if (actor is { Kind: LocalActorKind.Group, IsCircle: false })
|
||||
{
|
||||
document["attributedTo"] = actor.Wardens;
|
||||
document["postingRestrictedToMods"] = actor.PostingRestrictedToModerators;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(actor.PictureURL))
|
||||
document["icon"] = new JsonObject { ["type"] = "Image", ["url"] = actor.PictureURL };
|
||||
if (!string.IsNullOrEmpty(actor.ThumbnailURL))
|
||||
@@ -109,17 +114,25 @@ namespace PrivaPub.Federation.Rendering
|
||||
var mentions = post.Mentions.Select(m => (JsonNode)m.ActorURI).ToArray();
|
||||
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.FollowersOnly => (new JsonArray(author.Followers), new JsonArray(mentions)),
|
||||
PostVisibility.Direct => (new JsonArray(mentions), new JsonArray()),
|
||||
_ => (new JsonArray(Public), new JsonArray(mentions.Prepend(author.Followers).ToArray()))
|
||||
};
|
||||
if (group != default)
|
||||
if (group is { IsCircle: false })
|
||||
cc.Add(group.Uri);
|
||||
|
||||
var note = NoteBody(post, author, to, cc, inReplyTo);
|
||||
if (group != default)
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -141,6 +154,13 @@ namespace PrivaPub.Federation.Rendering
|
||||
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)
|
||||
{
|
||||
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)));
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ namespace PrivaPub.Infrastructure.Data
|
||||
await Plain<Post>(token, p => p.ActorURI);
|
||||
await Plain<Post>(token, p => p.ConversationId, p => p.ID);
|
||||
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 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 AllowPlainHttp { get; set; }
|
||||
public bool SecureMode { get; set; }
|
||||
public bool AcceptAnyCertificate { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Net;
|
||||
using System.Net.Security;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace PrivaPub.Infrastructure.Http
|
||||
@@ -12,6 +13,9 @@ namespace PrivaPub.Infrastructure.Http
|
||||
{
|
||||
public static SocketsHttpHandler Create(FederationOptions options) => new()
|
||||
{
|
||||
SslOptions = options.AcceptAnyCertificate
|
||||
? new SslClientAuthenticationOptions { RemoteCertificateValidationCallback = (_, _, _, _) => true }
|
||||
: new SslClientAuthenticationOptions(),
|
||||
AllowAutoRedirect = false,
|
||||
UseProxy = false,
|
||||
UseCookies = false,
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
using MongoDB.Bson;
|
||||
|
||||
namespace PrivaPub.Infrastructure.Ids
|
||||
{
|
||||
public static class PrivacyIds
|
||||
{
|
||||
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)
|
||||
{
|
||||
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.Federation.Actors;
|
||||
using PrivaPub.Federation.Outbox;
|
||||
using PrivaPub.Federation.Signing;
|
||||
using PrivaPub.Federation.Inbox;
|
||||
using PrivaPub.Federation.Moderation;
|
||||
using PrivaPub.Federation.Inbox.Handlers;
|
||||
@@ -65,6 +66,8 @@ namespace PrivaPub.Middleware
|
||||
.AddSingleton<IRemoteActorService, RemoteActorService>()
|
||||
.AddSingleton<IDeliveryService, DeliveryService>()
|
||||
.AddSingleton<IOutboxPublisher, OutboxPublisher>()
|
||||
.AddSingleton<IGroupDistributor, GroupDistributor>()
|
||||
.AddSingleton<ISignedFetchAuthorizer, SignedFetchAuthorizer>()
|
||||
.AddSingleton<IFanout, Fanout>()
|
||||
.AddSingleton<IInboxReceiver, InboxReceiver>()
|
||||
.AddSingleton<IActivityHandler, FollowHandler>()
|
||||
|
||||
@@ -20,6 +20,8 @@ namespace PrivaPub.Models.Group
|
||||
public string PublicKey { 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 ManuallyApprovesMembers { get; set; }
|
||||
public string OwnerAvatarId { get; set; }
|
||||
@@ -49,6 +51,13 @@ namespace PrivaPub.Models.Group
|
||||
Community
|
||||
}
|
||||
|
||||
public enum PostingPolicy
|
||||
{
|
||||
Followers,
|
||||
Anyone,
|
||||
Moderators
|
||||
}
|
||||
|
||||
public enum GroupRole
|
||||
{
|
||||
Member,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
using MongoDB.Entities;
|
||||
|
||||
namespace PrivaPub.Models.Post
|
||||
@@ -11,6 +12,7 @@ namespace PrivaPub.Models.Post
|
||||
public string InReplyToAccountId { get; set; }
|
||||
public string GroupId { get; set; }
|
||||
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 PostVisibility Visibility { get; set; }
|
||||
public string Title { get; set; }
|
||||
@@ -24,6 +26,10 @@ namespace PrivaPub.Models.Post
|
||||
public List<PostMedia> Media { get; set; } = new();
|
||||
public List<float> Location { get; set; } = new();
|
||||
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 IsLocalOnly { get; set; }
|
||||
|
||||
|
||||
+8
-2
@@ -21,6 +21,8 @@ using PrivaPub.Models;
|
||||
using PrivaPub.Services;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
Log.Logger = new LoggerConfiguration().WriteTo.Console().CreateBootstrapLogger();
|
||||
|
||||
try
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
@@ -71,8 +73,8 @@ try
|
||||
}
|
||||
|
||||
var federationOptions = builder.Configuration.GetSection("Federation").Get<FederationOptions>() ?? new();
|
||||
if (builder.Environment.IsProduction() && (federationOptions.AllowPrivateNetworks || federationOptions.AllowPlainHttp))
|
||||
throw new InvalidOperationException("Federation:AllowPrivateNetworks and Federation:AllowPlainHttp are for test networks and must stay off in Production.");
|
||||
if (builder.Environment.IsProduction() && (federationOptions.AllowPrivateNetworks || federationOptions.AllowPlainHttp || federationOptions.AcceptAnyCertificate))
|
||||
throw new InvalidOperationException("Federation:AllowPrivateNetworks, AllowPlainHttp and AcceptAnyCertificate are for test networks and must stay off in Production.");
|
||||
|
||||
try
|
||||
{
|
||||
@@ -189,4 +191,8 @@ catch (Exception ex)
|
||||
Log.ForContext<Program>().Fatal(ex, $"{nameof(Program)}.{nameof(Program)}()");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
await Log.CloseAndFlushAsync();
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ namespace PrivaPub.Services
|
||||
PrivateKey = privateKey,
|
||||
PublicKey = publicKey,
|
||||
Kind = form.IsCommunity ? GroupKind.Community : GroupKind.Circle,
|
||||
PostingPolicy = Policy(form.PostingPolicy) ?? PostingPolicy.Followers,
|
||||
IsDiscoverable = form.IsDiscoverable,
|
||||
ManuallyApprovesMembers = form.ManuallyApprovesMembers,
|
||||
OwnerAvatarId = form.AvatarId,
|
||||
@@ -157,6 +158,8 @@ namespace PrivaPub.Services
|
||||
group.Description = form.Description;
|
||||
if (form.IsDiscoverable.HasValue)
|
||||
group.IsDiscoverable = form.IsDiscoverable.Value;
|
||||
if (Policy(form.PostingPolicy) is { } policy)
|
||||
group.PostingPolicy = policy;
|
||||
if (form.ManuallyApprovesMembers.HasValue)
|
||||
group.ManuallyApprovesMembers = form.ManuallyApprovesMembers.Value;
|
||||
if (form.RemoveInvitationPassword)
|
||||
@@ -316,6 +319,14 @@ namespace PrivaPub.Services
|
||||
!string.IsNullOrEmpty(rootUserId) && !string.IsNullOrEmpty(avatarId)
|
||||
&& 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}";
|
||||
|
||||
ViewGroup ToView(GroupEntity group, string avatarId)
|
||||
@@ -331,6 +342,7 @@ namespace PrivaPub.Services
|
||||
Url = actor.Uri,
|
||||
Handle = actor.Handle,
|
||||
IsCommunity = group.Kind == GroupKind.Community,
|
||||
PostingPolicy = group.PostingPolicy.ToString().ToLowerInvariant(),
|
||||
IsDiscoverable = group.IsDiscoverable,
|
||||
ManuallyApprovesMembers = group.ManuallyApprovesMembers,
|
||||
IsOwner = group.OwnerAvatarId == avatarId,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.Extensions.Localization;
|
||||
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.ClientModels;
|
||||
@@ -27,6 +28,7 @@ namespace PrivaPub.Services
|
||||
Task<WebResult> InsertPost(string rootUserId, InsertPostForm form, CancellationToken token);
|
||||
Task<WebResult> DeletePost(string rootUserId, DeletePostForm 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> InsertDm(string rootUserId, InsertDmForm form, CancellationToken token);
|
||||
Task<WebResult> GetDms(string rootUserId, string avatarId, string dmGroupId, CancellationToken token);
|
||||
@@ -68,7 +70,10 @@ namespace PrivaPub.Services
|
||||
Sensitive = form.HasContentWarning,
|
||||
Visibility = LocalVisibility(form.Visibility),
|
||||
InReplyTo = form.AnsweringToPostId,
|
||||
GroupId = form.GroupId
|
||||
GroupId = form.GroupId,
|
||||
Latitude = form.Latitude,
|
||||
Longitude = form.Longitude,
|
||||
RangeKm = form.RangeKm
|
||||
}, 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)
|
||||
{
|
||||
var result = new WebResult();
|
||||
|
||||
@@ -61,7 +61,7 @@ namespace PrivaPub.Web.Pages
|
||||
public async Task<IActionResult> OnGetAsync(string user, CancellationToken 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();
|
||||
if (WantsActivityJson())
|
||||
return Redirect(Actor.Uri);
|
||||
|
||||
+6
-4
@@ -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.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)
|
||||
- [ ] P3 Social features
|
||||
- [ ] P4 Groups and privacy 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)
|
||||
- [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
|
||||
|
||||
## 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
|
||||
scenarios run as integration tests on a throwaway mongod.
|
||||
- **Interop:** **Fediverse Pasture** runs on the workstation with podman: Mastodon, GoToSocial, Misskey/Sharkey,
|
||||
Akkoma, plus Lemmy for P4. `tools/pasture/docker-compose.privapub.yml` runs PrivaPub with `AllowPrivateNetworks`;
|
||||
startup asserts that flag is false in Production. The matrix, per peer:
|
||||
Akkoma, plus Lemmy for P4. As built, `tools/pasture/run.sh` runs PrivaPub and GoToSocial behind one Caddy on a
|
||||
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;
|
||||
- public, unlisted, followers-only and direct posts both ways;
|
||||
- a reply landing in the remote thread;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" } ]
|
||||
}
|
||||
}
|
||||
Executable
+117
@@ -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 ]
|
||||
Executable
+44
@@ -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
|
||||
Reference in new issue
Block a user