Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6dbf71964 | ||
|
|
8c2eba6cbb | ||
|
|
fcd35f5043 |
No files matched your search
@@ -95,6 +95,11 @@ jobs:
|
||||
[ "$served" = "$BUILD_COMMIT" ] || { echo "::error::served build is '$served', expected '$BUILD_COMMIT'"; exit 1; }
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' -H 'Accept: application/activity+json' "$PUBLIC_URL/peasants/privapub")
|
||||
[ "$code" = "200" ] || { echo "::error::the instance actor answered $code"; exit 1; }
|
||||
# SecureMode (owner decision 2026-10-04): an unsigned reader gets 401 from a persona, a browser the public page
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' -H 'Accept: application/activity+json' "$PUBLIC_URL/peasants/thepra")
|
||||
[ "$code" = "401" ] || { echo "::error::an unsigned GET of a persona answered $code, not 401: SecureMode is off"; exit 1; }
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' -H 'Accept: text/html' "$PUBLIC_URL/peasants/thepra")
|
||||
[ "$code" = "302" ] || { echo "::error::a browser asking for a persona got $code, not a redirect"; exit 1; }
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' "$PUBLIC_URL/.well-known/nodeinfo")
|
||||
[ "$code" = "200" ] || { echo "::error::nodeinfo answered $code"; exit 1; }
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' "$PUBLIC_URL/swagger/index.html")
|
||||
@@ -123,7 +128,7 @@ jobs:
|
||||
read -r token cid cs <<< "$(tools/smoke/oauth.sh "$PUBLIC_URL" "$login" "$password" thepra read)"
|
||||
echo "::add-mask::$token"
|
||||
tools/smoke/mastodon-api.sh "$PUBLIC_URL" "$token"
|
||||
discoverable=$(curl -fsS -H 'Accept: application/activity+json' "$PUBLIC_URL/peasants/thepra" | python3 -c "import json,sys; print(json.load(sys.stdin).get('discoverable'))")
|
||||
discoverable=$(curl -fsS -H "Authorization: Bearer $token" "$PUBLIC_URL/api/v1/accounts/verify_credentials" | python3 -c "import json,sys; print(json.load(sys.stdin).get('discoverable'))")
|
||||
[ "$discoverable" = "False" ] || { echo "::error::@thepra is discoverable"; exit 1; }
|
||||
curl -fsS -o /dev/null -X POST "$PUBLIC_URL/oauth/revoke" --data-urlencode "token=$token" \
|
||||
--data-urlencode "client_id=$cid" --data-urlencode "client_secret=$cs"
|
||||
|
||||
@@ -182,8 +182,17 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
|
||||
`ContentFormat` says what `Text` holds. Remote names are plain text.
|
||||
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.
|
||||
announced. Each member's copy also names that member in `cc` (`OutboxPublisher.Naming`, owner decision
|
||||
2026-10-04), because Mastodon and GoToSocial keep a post only when it names one of their accounts; Create, every
|
||||
Update and the Delete all go through `OutboxPublisher.Publish` for that. A reply to a circle post stays in the circle,
|
||||
and a circle post never asks a non-member for a quote. Circles never appear in search, lookups, mentions or profile
|
||||
pages.
|
||||
**A post that is not public is served only to a signed request from someone it was for** (`SignedFetchAuthorizer.MayRead`):
|
||||
a follower or an addressed account for followers-only, an addressed account for a DM, a member for a circle, or the
|
||||
instance actor of a server where one of them lives; 404 to anyone else, 410 to them once it is deleted. A circle
|
||||
refetch names the requesting member, or the members on the requesting server. A DM's `context`
|
||||
(`/peasants/{name}/whispers/{id}`) lists the conversation's posts for its participants. Mastodon deletes its copy
|
||||
when a refetch answers 404, which is why this matters.
|
||||
**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.
|
||||
@@ -243,6 +252,16 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
|
||||
|
||||
- **No root id in federation output, NodeInfo or logs, no IP next to an identity in logs, and no `ex.Message` to a
|
||||
client** ("Something went wrong." instead).
|
||||
- **Sign-in and recovery never tell which logins or addresses exist** (owner decision 2026-10-04): every failed sign-in
|
||||
gets "That username and password do not match." after the same hashing (`RootUsersService.Decoy`, a constant-time
|
||||
comparison), and only a correct password learns of a ban. Every recovery request answers the same sentence and
|
||||
queues a `SendRecovery` job, found or not; `RecoveryJob` sends the email and keeps only a SHA-256 of the code, for an
|
||||
hour. A recovered password ends every session of the root (`RootSessions`: `CredentialsChangedAt` for `/clientapi`
|
||||
JWTs, OpenIddict revocation for each persona).
|
||||
- **Deleting a root deletes everything public it had** (`RootRemoval`, from the admin route or `/clientapi/user/delete`
|
||||
with the password): its sessions end, each persona and each group it owns sends `Delete{Actor}` to followers, members
|
||||
and the accounts it follows, the personas' posts are emptied, and `/peasants/{name}`, its inbox and WebFinger answer
|
||||
410 (`LocalActorService.Gone`) while the names stay reserved. The root keeps only a `deleted-{id}` name.
|
||||
- **Never derive a public name from the root username.** Invitation sign-up takes `AvatarUserName` and refuses one
|
||||
equal to the login.
|
||||
- **One username space:** personas, groups and the instance reserve their name in `ReservedName` (unique index) before
|
||||
@@ -412,14 +431,17 @@ tools/pasture/run.sh down # removes e
|
||||
delete is checked as a 404 on `/api/v1/statuses/{id}`.
|
||||
- It creates its accounts locked, so the scenario approves alice's request through `/api/v1/follow_requests`, which
|
||||
also checks our pending (`requested`) state and the manual Accept.
|
||||
- 37 checks: discovery and follows both ways; posts and CW; a reply and its notification; likes and boosts both
|
||||
- 55 checks: discovery and follows both ways; posts and CW; a reply and its notification; likes and boosts both
|
||||
ways; DMs both ways and off public timelines; polls both ways; quote policy; link cards; edits and deletes both
|
||||
ways; unfollow; block and unblock; statistics.
|
||||
ways; communities and circles; locked personas; unfollow; block and unblock; statistics.
|
||||
- It files a circle post as a direct message and shows it only to the accounts it mentions, so like a DM it is
|
||||
checked in the member's `/api/v1/conversations`, never by URI.
|
||||
- **Mastodon (4.7.3):** web and sidekiq on the shared Postgres and Redis, `ALLOWED_PRIVATE_ADDRESSES` for the network.
|
||||
Its token comes from `rails runner` (no password grant). Without Elasticsearch its status search finds nothing, so
|
||||
deliveries are checked through `/api/v1/accounts/:id/statuses` of the sender as Mastodon knows them, or
|
||||
`Status.exists?` through `rails runner`. Its actors are numbered (`/ap/users/<id>`), so look URIs up rather than
|
||||
build them. 49 checks; circle posts are an expected failure (see `docs/INTEROP.md`, Mastodon).
|
||||
build them. Circle posts and a followers-only post survive its signed refetch (`ActivityPub::FetchRemoteStatusService`
|
||||
through `rails runner`). Inbound Block is the one expected failure until P7.
|
||||
- **Misskey (2026.10.0):** one container on the shared Postgres and Redis. A new Misskey federates with nobody
|
||||
(`federation: none`) until `admin/update-meta` says `all`, which `misskey_up` does. Its API is `POST /api/<endpoint>`
|
||||
with the token as `i` (`mk` in the scenario); `users/relation` answers a list, `users/notes` leaves replies out unless
|
||||
@@ -441,6 +463,9 @@ tools/pasture/run.sh down # removes e
|
||||
its own activity and every bare `Announce{object}` 400 by design, and the echo is still needed (see
|
||||
`docs/INTEROP.md`, Lemmy). A new Lemmy never sends what it queued for a server before its send worker for that
|
||||
server started, so the scenario waits for that worker (`lm_worker`) before its first follow. 20 checks; relayed votes and a moderator's removal are expected failures (P7).
|
||||
- **SecureMode:** `PRIVAPUB_ENV="Federation__SecureMode=true" run.sh up …`, as production runs. A check of what an
|
||||
unsigned reader sees uses `unserved` and `gone_unsigned` (`lib/interop.sh`), which expect 401 when SecureMode is on and
|
||||
404 or 410 when it is off.
|
||||
- **Crawler:** `PRIVAPUB_ENV="Statistics__Crawler__Enabled=true Statistics__Crawler__Seeds__0=mastodon.test"
|
||||
run.sh up mastodon`, then `interop.sh crawler`. `PRIVAPUB_ENV` passes any setting to the PrivaPub container.
|
||||
- `run.sh up` replaces every container, Mongo included, so each run starts clean. To keep the data, republish into
|
||||
|
||||
+11
-2
@@ -73,8 +73,10 @@ A group is either a **community** or a **circle**.
|
||||
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
|
||||
circle and its members collection, delivered to each member's own inbox and never announced. Each member's copy also
|
||||
names that member in `cc`, so servers that keep only posts naming one of their accounts (Mastodon, GoToSocial) keep
|
||||
it; it names no other member. The posts are served only to a signed request from a member, or from the instance actor
|
||||
of a member's server, and that copy names the member (or the members on that 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.
|
||||
@@ -192,6 +194,13 @@ Posts with a location (shown to nearby users of this server) never leave the ser
|
||||
deletes, must be on its actor's origin. An embedded object from another origin is fetched from that origin.
|
||||
- **Fetching.** All fetches are signed by the instance actor. They go only to public addresses, follow at most three
|
||||
redirects and read at most 1 MB.
|
||||
- **Reading our documents (SecureMode).** privapub.thepra.dev answers ActivityPub GETs only when they are signed, like
|
||||
Mastodon's authorized fetch; the instance actor `/peasants/privapub` is the exception, since its key is needed first.
|
||||
A browser asking for HTML is redirected to the public page instead.
|
||||
- **Posts that are not public** (followers-only, direct, circle) are served to a signed request from someone they were
|
||||
for, or from the instance actor of a server where someone they were for lives, and to nobody else (404). Once deleted
|
||||
they answer those same readers 410. A direct message's `context`, `/peasants/{name}/whispers/{id}`, is an
|
||||
`OrderedCollection` of the conversation's posts for its participants.
|
||||
- **HTML.** Received HTML is sanitised to Mastodon's allowlist.
|
||||
- **Keys we cannot fetch for now.** When a sender's key cannot be fetched because its server timed out or answered
|
||||
5xx, the inbox answers 503 with `Retry-After: 300` rather than 401.
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using PrivaPub.ClientModels.Resources;
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace PrivaPub.ClientModels.User
|
||||
{
|
||||
// Deleting one's own login, and with it every persona and group it owns, asks for the password again.
|
||||
public class RemoveSelfForm
|
||||
{
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
Display(Name = "Password", ResourceType = typeof(FieldsNameResource))]
|
||||
public string Password { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,7 @@ namespace PrivaPub.Tests.Domain
|
||||
{
|
||||
public string BaseAddress => Base;
|
||||
|
||||
public Task<GoneActor> Gone(string userName, CancellationToken token) => Task.FromResult<GoneActor>(default);
|
||||
public Task<LocalActor> FindByUserName(string userName, CancellationToken token) =>
|
||||
Task.FromResult(userName.Equals("alice", StringComparison.OrdinalIgnoreCase)
|
||||
? new LocalActor { Id = "a1", UserName = "alice", BaseAddress = Base, Kind = LocalActorKind.Person }
|
||||
|
||||
@@ -9,6 +9,7 @@ using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Group;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.StaticServices;
|
||||
using PrivaPub.Tests.Support;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
@@ -138,21 +139,84 @@ namespace PrivaPub.Tests.Federation
|
||||
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>()));
|
||||
// the member's copy names the member, so Mastodon and GoToSocial keep it, and names nobody else
|
||||
Assert.Equal(new[] { member.Id }, create["cc"]!.AsArray().Select(t => t!.GetValue<string>()));
|
||||
Assert.Equal(new[] { member.Id }, create["object"]!["cc"]!.AsArray().Select(t => t!.GetValue<string>()));
|
||||
Assert.Contains(create["object"]!["tag"]!.AsArray(), t => t!["type"]!.GetValue<string>() == "Mention" && t["href"]!.GetValue<string>() == member.Id);
|
||||
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 authorizer = new SignedFetchAuthorizer(_harness.Remote, new DbEntities());
|
||||
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(await authorizer.MayRead(outcome.Post, asMember, token));
|
||||
Assert.False(await authorizer.MayRead(outcome.Post, asOutsider, token));
|
||||
Assert.Equal(new[] { member.Id }, SignedFetchAuthorizer.CircleReaders(circleEntity, asMember));
|
||||
Assert.True(circle.IsCircle);
|
||||
Assert.False(circle.Discoverable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_circle_posts_edit_and_delete_reach_each_member_naming_only_that_member()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (_, alice) = await _harness.Persona("alice");
|
||||
var first = new RemoteActor(_harness.Peer, "first");
|
||||
var second = new RemoteActor(_harness.Peer, "second", _harness.Peer.B);
|
||||
var (_, circle) = await Group(GroupKind.Circle, PostingPolicy.Followers, alice.Id, first.Id, second.Id);
|
||||
await _harness.Remote.GetActor(first.Id, refresh: false, token);
|
||||
await _harness.Remote.GetActor(second.Id, refresh: false, token);
|
||||
|
||||
var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "just us", GroupId = circle.Id }, token);
|
||||
await _harness.Statuses.Edit(alice, outcome.Post.ID, new StatusDraft { Text = "just us, edited" }, token);
|
||||
await _harness.Statuses.Remove(alice, outcome.Post.ID, token);
|
||||
|
||||
foreach (var (member, other) in new[] { (first, second), (second, first) })
|
||||
{
|
||||
var sent = await _harness.Outgoing(member.Id + "/inbox");
|
||||
Assert.Equal(new[] { "Create", "Update", "Delete" }, sent.Select(a => a["type"]!.GetValue<string>()));
|
||||
Assert.All(sent, a => Assert.Contains(member.Id, a["cc"]!.AsArray().Select(c => c!.GetValue<string>())));
|
||||
Assert.All(sent, a => Assert.DoesNotContain(other.Id, a.ToJsonString()));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_reply_to_a_circle_post_stays_in_it_and_a_circle_post_asks_nobody_outside_for_a_quote()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (_, alice) = await _harness.Persona("alice");
|
||||
var (_, bob) = await _harness.Persona("bob");
|
||||
var (entity, circle) = await Group(GroupKind.Circle, PostingPolicy.Followers, alice.Id);
|
||||
entity.Members.Add(new GroupMember { AvatarId = bob.Id });
|
||||
await DB.Default.SaveAsync(entity, token);
|
||||
var outsider = new RemoteActor(_harness.Peer, "asked");
|
||||
await _harness.Remote.GetActor(outsider.Id, refresh: false, token);
|
||||
var asksFirst = new Post
|
||||
{
|
||||
ObjectURI = $"{Origin(outsider)}/notes/{Guid.NewGuid():N}",
|
||||
ActorURI = outsider.Id,
|
||||
IsFederatedCopy = true,
|
||||
Visibility = PostVisibility.Public,
|
||||
ContentHtml = "<p>ask me first</p>",
|
||||
QuotePolicy = new InteractionRule { Manual = new() { Addressing.Public } }
|
||||
};
|
||||
await DB.Default.SaveAsync(asksFirst, token);
|
||||
|
||||
var root = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "just us", GroupId = circle.Id }, token);
|
||||
var reply = await _harness.Statuses.Publish(bob, new StatusDraft { Text = "still just us", InReplyTo = root.Post.ID }, token);
|
||||
var quote = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "look", GroupId = circle.Id, QuotedStatusId = asksFirst.ID }, token);
|
||||
|
||||
Assert.Equal(PostVisibility.Circle, reply.Post.Visibility);
|
||||
Assert.Equal(circle.Id, reply.Post.GroupId);
|
||||
Assert.Equal(422, quote.Status);
|
||||
Assert.Empty(await _harness.Outgoing(outsider.Id + "/inbox"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Only_circle_members_can_post_into_a_circle()
|
||||
{
|
||||
|
||||
@@ -6,6 +6,8 @@ using MongoDB.Entities;
|
||||
using PrivaPub.ClientModels;
|
||||
using PrivaPub.Models.Group;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.Services;
|
||||
using PrivaPub.Models.Jobs;
|
||||
using PrivaPub.Tests.Support;
|
||||
using PrivaPub.Tests.Support.Host;
|
||||
|
||||
@@ -136,9 +138,11 @@ namespace PrivaPub.Tests.Http
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, ok.StatusCode);
|
||||
Assert.Equal(root.Id, (await ok.JsonBody())["userId"]!.GetValue<string>());
|
||||
// a wrong password and an unknown login get the same answer, so sign-in tells nobody which logins exist
|
||||
Assert.Equal(HttpStatusCode.BadRequest, wrong.StatusCode);
|
||||
Assert.Equal("Wrong password.", await Message(wrong));
|
||||
Assert.Equal(RootUsersService.NoMatch, await Message(wrong));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, unknown.StatusCode);
|
||||
Assert.Equal(await Message(wrong), await Message(unknown));
|
||||
Assert.Equal(HttpStatusCode.OK, logout.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, anonymousLogout.StatusCode);
|
||||
}
|
||||
@@ -290,42 +294,59 @@ namespace PrivaPub.Tests.Http
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Recovery_without_an_email_is_refused()
|
||||
public async Task Recovery_answers_the_same_whoever_asks_and_queues_the_work()
|
||||
{
|
||||
var root = await _host.SignUp("noemail");
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var since = DateTime.UtcNow.AddSeconds(-1);
|
||||
var withEmail = await _host.SignUp("withemail");
|
||||
var withoutEmail = await _host.SignUp("noemail");
|
||||
var email = $"{Guid.NewGuid():N}@example.test";
|
||||
using (var signedIn = _host.As(withEmail.Jwt))
|
||||
Assert.Equal(HttpStatusCode.OK, (await signedIn.PostJson("/clientapi/user/update", new { email })).StatusCode);
|
||||
using var client = _host.Client();
|
||||
|
||||
var response = await client.PostJson("/clientapi/user/recover/password", new { userName = root.UserName });
|
||||
var unknown = await client.PostJson("/clientapi/user/recover/password", new { userName = Name("nobody") });
|
||||
var neither = await client.PostJson("/clientapi/user/recover/password", new { });
|
||||
var answers = new[]
|
||||
{
|
||||
await client.PostJson("/clientapi/user/recover/password", new { userName = withEmail.UserName }),
|
||||
await client.PostJson("/clientapi/user/recover/password", new { email }),
|
||||
await client.PostJson("/clientapi/user/recover/password", new { userName = withoutEmail.UserName }),
|
||||
await client.PostJson("/clientapi/user/recover/password", new { userName = Name("nobody") }),
|
||||
await client.PostJson("/clientapi/user/recover/password", new { email = $"{Guid.NewGuid():N}@example.test" })
|
||||
};
|
||||
var bodies = new List<string>();
|
||||
foreach (var answer in answers)
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, answer.StatusCode);
|
||||
bodies.Add(await answer.Content.ReadAsStringAsync(token));
|
||||
}
|
||||
|
||||
Assert.Equal(HttpStatusCode.Locked, response.StatusCode);
|
||||
Assert.Contains("doesn't have an email", await Message(response));
|
||||
Assert.False(await DB.Default.Find<EmailRecovery>().Match(r => r.RootUserId == root.Id).ExecuteAnyAsync(TestContext.Current.CancellationToken));
|
||||
Assert.Equal(HttpStatusCode.NotFound, unknown.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, neither.StatusCode);
|
||||
Assert.Single(bodies.Distinct());
|
||||
Assert.Contains("recovery link is on its way", bodies[0]);
|
||||
var queued = await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.SendRecovery && j.CreatedAt >= since).ExecuteAsync(token);
|
||||
Assert.True(queued.Count >= answers.Length);
|
||||
Assert.Contains(queued, j => j.Payload.Contains(withEmail.Id));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await client.PostJson("/clientapi/user/recover/password", new { })).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Recovery_through_an_unreachable_mail_server_says_so_and_nothing_more()
|
||||
public async Task The_recovery_job_keeps_only_a_hash_and_an_unreachable_mail_server_says_nothing_to_anyone()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var root = await _host.SignUp("smtp");
|
||||
var email = $"{Guid.NewGuid():N}@example.test";
|
||||
using (var signedIn = _host.As(root.Jwt))
|
||||
Assert.Equal(HttpStatusCode.OK, (await signedIn.PostJson("/clientapi/user/update", new { email })).StatusCode);
|
||||
using var client = _host.Client();
|
||||
|
||||
var byName = await client.PostJson("/clientapi/user/recover/password", new { userName = root.UserName });
|
||||
var byEmail = await client.PostJson("/clientapi/user/recover/password", new { email });
|
||||
var answer = await client.PostJson("/clientapi/user/recover/password", new { userName = root.UserName });
|
||||
await _host.Run(j => j.Kind == JobKind.SendRecovery && j.Payload.Contains(root.Id), token);
|
||||
|
||||
foreach (var response in new[] { byName, byEmail })
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
|
||||
Assert.Equal("Failed to send email.", JsonNode.Parse(body)!["errorMessage"]!.GetValue<string>());
|
||||
foreach (var leak in new[] { "xception", "efused", "ocket", "127.0.0.1", "smtp", "SMTP", " at " })
|
||||
Assert.DoesNotContain(leak, body);
|
||||
}
|
||||
Assert.Equal(HttpStatusCode.OK, answer.StatusCode);
|
||||
var recovery = await DB.Default.Find<EmailRecovery>().Match(r => r.RootUserId == root.Id).ExecuteFirstAsync(token);
|
||||
Assert.NotNull(recovery);
|
||||
Assert.Null(recovery.RecoveryCode);
|
||||
Assert.Equal(64, recovery.CodeHash.Length);
|
||||
Assert.InRange(recovery.ExpiresAt, DateTime.UtcNow.AddMinutes(50), DateTime.UtcNow.AddMinutes(61));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -348,7 +369,10 @@ namespace PrivaPub.Tests.Http
|
||||
{
|
||||
var root = await _host.SignUp("recover");
|
||||
var code = Guid.NewGuid().ToString("N") + Guid.NewGuid().ToString("N");
|
||||
await DB.Default.SaveAsync(new EmailRecovery { RootUserId = root.Id, RecoveryCode = code }, TestContext.Current.CancellationToken);
|
||||
await DB.Default.SaveAsync(new EmailRecovery { RootUserId = root.Id, CodeHash = RecoveryJob.Hash(code), ExpiresAt = DateTime.UtcNow.AddHours(1) },
|
||||
TestContext.Current.CancellationToken);
|
||||
var persona = await _host.Persona(root, "recovered");
|
||||
var apiToken = await _host.MastodonToken(persona);
|
||||
using var client = _host.Client();
|
||||
|
||||
var valid = await client.PostAsync("/clientapi/user/recover/valid", new StringContent($"\"{code}\"", Encoding.UTF8, "application/json"), TestContext.Current.CancellationToken);
|
||||
@@ -361,6 +385,29 @@ namespace PrivaPub.Tests.Http
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await LogIn(root.UserName, root.Password)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, (await LogIn(root.UserName, NewPassword)).StatusCode);
|
||||
Assert.False(await DB.Default.Find<EmailRecovery>().Match(r => r.RootUserId == root.Id).ExecuteAnyAsync(TestContext.Current.CancellationToken));
|
||||
// whoever held the old sessions may be why the password was recovered: they end
|
||||
using (var oldSession = _host.As(root.Jwt))
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await oldSession.GetAsync("/clientapi/user/sniff/again", TestContext.Current.CancellationToken)).StatusCode);
|
||||
using (var oldApp = _host.Client())
|
||||
{
|
||||
oldApp.DefaultRequestHeaders.Authorization = new("Bearer", apiToken);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await oldApp.GetAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken)).StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_expired_recovery_code_changes_nothing()
|
||||
{
|
||||
var root = await _host.SignUp("expired");
|
||||
var code = Guid.NewGuid().ToString("N") + Guid.NewGuid().ToString("N");
|
||||
await DB.Default.SaveAsync(new EmailRecovery { RootUserId = root.Id, CodeHash = RecoveryJob.Hash(code), ExpiresAt = DateTime.UtcNow.AddMinutes(-1) },
|
||||
TestContext.Current.CancellationToken);
|
||||
using var client = _host.Client();
|
||||
|
||||
var change = await client.PostJson("/clientapi/user/recover/update/password", new { newPassword = NewPassword, repeatedPassword = NewPassword, recoveryCode = code });
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, change.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, (await LogIn(root.UserName, root.Password)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -322,11 +322,66 @@ namespace PrivaPub.Tests.Http
|
||||
Assert.Equal(new[] { circle.Uri, circle.Uri + "/flock" }, Strings(asMember.Json["to"]));
|
||||
Assert.DoesNotContain(ActivityPubRenderer.Public, asMember.Text);
|
||||
Assert.Equal(HttpStatusCode.OK, asMemberServer.Status);
|
||||
// a refetch names the member, as the member's copy did; the instance actor sees the members on its server only
|
||||
Assert.Equal(new[] { member.Id }, Strings(asMember.Json["cc"]));
|
||||
Assert.Equal(new[] { member.Id }, Strings(asMemberServer.Json["cc"]));
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(path)).Status);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(path, Browser)).Status);
|
||||
foreach (var outsider in new[] { neighbour, stranger, strangerServer })
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(outsider.SignedGet(path))).Status);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch($"/peasants/{owner.UserName}/grunts/create-{post.ID}")).Status);
|
||||
var create = $"/peasants/{owner.UserName}/grunts/create-{post.ID}";
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(create)).Status);
|
||||
var createAsMember = await _client.Fetch(member.SignedGet(create));
|
||||
Assert.Equal(HttpStatusCode.OK, createAsMember.Status);
|
||||
Assert.Equal(post.ObjectURI, createAsMember.Json["object"]!["id"]!.GetValue<string>());
|
||||
Assert.Equal(new[] { member.Id }, Strings(createAsMember.Json["object"]!["cc"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Followers_only_and_direct_posts_are_served_to_signed_fetches_from_those_they_were_for()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var owner = await _host.Persona(await _host.SignUp(), "private");
|
||||
var follower = new RemoteActor(_peer, "follower");
|
||||
var followerServer = new RemoteActor(_peer, "instance", type: "Application");
|
||||
var recipient = new RemoteActor(_peer, "recipient");
|
||||
var stranger = new RemoteActor(_peer, "stranger", _peer.B);
|
||||
_peer.WebFinger(recipient);
|
||||
await DB.Default.SaveAsync(new Follower
|
||||
{
|
||||
LocalActorId = owner.Id,
|
||||
LocalActorKind = LocalActorKind.Person,
|
||||
ActorURI = follower.Id,
|
||||
InboxURL = follower.Id + "/inbox"
|
||||
}, token);
|
||||
var quiet = await _host.Publish(owner, "for followers", PostVisibility.FollowersOnly);
|
||||
var direct = await _host.Publish(owner, new StatusDraft { Text = $"@{recipient.Handle()} just you", PlainText = true, Visibility = PostVisibility.Direct });
|
||||
var quietPath = $"/peasants/{owner.UserName}/scribbles/{quiet.ID}";
|
||||
var directPath = $"/peasants/{owner.UserName}/scribbles/{direct.ID}";
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, (await _client.Fetch(follower.SignedGet(quietPath))).Status);
|
||||
Assert.Equal(HttpStatusCode.OK, (await _client.Fetch(followerServer.SignedGet(quietPath))).Status);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(stranger.SignedGet(quietPath))).Status);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(quietPath)).Status);
|
||||
var asRecipient = await _client.Fetch(recipient.SignedGet(directPath));
|
||||
Assert.Equal(HttpStatusCode.OK, asRecipient.Status);
|
||||
Assert.Contains(recipient.Id, Strings(asRecipient.Json["to"]));
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(follower.SignedGet(directPath))).Status);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(directPath)).Status);
|
||||
|
||||
// the DM's context is the conversation, for its participants only
|
||||
var context = PathOf(asRecipient.Json["context"]!.GetValue<string>());
|
||||
var conversation = await _client.Fetch(recipient.SignedGet(context));
|
||||
Assert.Equal(HttpStatusCode.OK, conversation.Status);
|
||||
Assert.Equal(new[] { direct.ObjectURI }, Strings(conversation.Json["orderedItems"]));
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(stranger.SignedGet(context))).Status);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(context)).Status);
|
||||
|
||||
// once deleted, those it was for learn it is gone; everyone else still learns nothing
|
||||
await _host.Remove(owner, quiet);
|
||||
Assert.Equal(HttpStatusCode.Gone, (await _client.Fetch(follower.SignedGet(quietPath))).Status);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(stranger.SignedGet(quietPath))).Status);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(quietPath)).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -463,6 +518,10 @@ namespace PrivaPub.Tests.Http
|
||||
Assert.Equal("Application", instance.Json["type"]!.GetValue<string>());
|
||||
foreach (var path in paths[..^1])
|
||||
Assert.True((await client.Fetch(reader.SignedGet(path))).Status == HttpStatusCode.OK, $"{path} refused a signed GET");
|
||||
// a browser is not asked for a signature: it is only sent to the public pages
|
||||
var browser = await client.Fetch($"/peasants/{persona.UserName}", Browser);
|
||||
Assert.Equal(HttpStatusCode.Redirect, browser.Status);
|
||||
Assert.Equal(HttpStatusCode.Redirect, (await client.Fetch($"/peasants/{persona.UserName}/scribbles/{post.ID}", Browser)).Status);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.Tests.Support;
|
||||
using PrivaPub.Tests.Support.Host;
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
using GroupEntity = PrivaPub.Models.Group.Group;
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
|
||||
namespace PrivaPub.Tests.Http
|
||||
{
|
||||
// Deleting a root deletes its personas and their groups everywhere (owner decision 2026-10-04).
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class RootRemovalTests : IAsyncLifetime
|
||||
{
|
||||
PrivaPubHost _host;
|
||||
HttpClient _client;
|
||||
Peer _peer;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||
_host = await PrivaPubHost.Shared();
|
||||
_client = _host.Client();
|
||||
_peer = await Peer.Start();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_client?.Dispose();
|
||||
if (_peer != default)
|
||||
await _peer.DisposeAsync();
|
||||
}
|
||||
|
||||
async Task<HttpResponseMessage> AdminRemoves(params string[] rootIds)
|
||||
{
|
||||
var admin = await _host.Admin();
|
||||
using var client = _host.As(admin.Jwt);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Delete, "/clientapi/admin/remove/users") { Content = JsonContent.Create(new { userIdList = rootIds }) };
|
||||
return await client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_removed_roots_personas_and_groups_are_deleted_everywhere_and_say_so()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var root = await _host.SignUp("leaving");
|
||||
var persona = await _host.Persona(root, "leaving");
|
||||
var follower = new RemoteActor(_peer, "fan");
|
||||
var member = new RemoteActor(_peer, "member");
|
||||
await DB.Default.SaveAsync(new Follower { LocalActorId = persona.Id, LocalActorKind = LocalActorKind.Person, ActorURI = follower.Id, InboxURL = follower.Id + "/inbox" }, token);
|
||||
var group = await _host.Group(persona, community: true);
|
||||
var groupId = group["id"]!.GetValue<string>();
|
||||
await DB.Default.SaveAsync(new Follower { LocalActorId = groupId, LocalActorKind = LocalActorKind.Group, ActorURI = member.Id, InboxURL = member.Id + "/inbox" }, token);
|
||||
var post = await _host.Publish(persona, "goodbye soon");
|
||||
var apiToken = await _host.MastodonToken(persona);
|
||||
var since = DateTime.UtcNow.AddSeconds(-1);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, (await AdminRemoves(root.Id)).StatusCode);
|
||||
|
||||
var toFollower = Assert.Single(await Jobs.Deliveries(follower.Id + "/inbox", since, token), d => d["type"]!.GetValue<string>() == "Delete");
|
||||
Assert.Equal(persona.ActorUri(), toFollower["object"]!.GetValue<string>());
|
||||
Assert.Equal(persona.ActorUri(), toFollower["actor"]!.GetValue<string>());
|
||||
var groupName = group["userName"]!.GetValue<string>();
|
||||
Assert.Contains(await Jobs.Deliveries(member.Id + "/inbox", since, token),
|
||||
d => d["type"]!.GetValue<string>() == "Delete" && d["object"]!.GetValue<string>() == $"{PrivaPubHost.Base}/peasants/{groupName}");
|
||||
var gone = await _client.Fetch($"/peasants/{persona.UserName}");
|
||||
Assert.Equal(HttpStatusCode.Gone, gone.Status);
|
||||
Assert.Equal("Person", gone.Json["formerType"]!.GetValue<string>());
|
||||
Assert.Equal(HttpStatusCode.Gone, (await _client.Fetch($"/peasants/{groupName}")).Status);
|
||||
Assert.Equal(HttpStatusCode.Gone, (await _client.GetAsync($"/.well-known/webfinger?resource=acct:{persona.UserName}@{PrivaPubHost.Host}", token)).StatusCode);
|
||||
var stored = await DB.Default.Find<PostEntity>().MatchID(post.ID).ExecuteFirstAsync(token);
|
||||
Assert.NotNull(stored.DeletedAt);
|
||||
Assert.Null(stored.ContentHtml);
|
||||
Assert.NotNull((await DB.Default.Find<GroupEntity>().MatchID(groupId).ExecuteFirstAsync(token)).DeletionAt);
|
||||
var removed = await DB.Default.Find<RootUser>().MatchID(root.Id).ExecuteFirstAsync(token);
|
||||
Assert.Equal($"deleted-{root.Id}", removed.UserName);
|
||||
Assert.Null(removed.HashedPassword);
|
||||
using var app = _host.Client();
|
||||
app.DefaultRequestHeaders.Authorization = new("Bearer", apiToken);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await app.GetAsync("/api/v1/accounts/verify_credentials", token)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_second_removal_does_not_collide_with_the_first()
|
||||
{
|
||||
var first = await _host.SignUp("first");
|
||||
var second = await _host.SignUp("second");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, (await AdminRemoves(first.Id)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, (await AdminRemoves(second.Id)).StatusCode);
|
||||
|
||||
Assert.NotNull((await DB.Default.Find<RootUser>().MatchID(second.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken)).DeletedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_root_deletes_itself_only_with_its_password()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var root = await _host.SignUp("self");
|
||||
var persona = await _host.Persona(root, "self");
|
||||
using var client = _host.As(root.Jwt);
|
||||
|
||||
var refused = await client.PostJson("/clientapi/user/delete", new { password = "Not-The-Password-1" });
|
||||
Assert.Equal(HttpStatusCode.Forbidden, refused.StatusCode);
|
||||
Assert.Null((await DB.Default.Find<Avatar>().MatchID(persona.Id).ExecuteFirstAsync(token)).DeletionAt);
|
||||
|
||||
var accepted = await client.PostJson("/clientapi/user/delete", new { password = root.Password });
|
||||
Assert.Equal(HttpStatusCode.OK, accepted.StatusCode);
|
||||
Assert.NotNull((await DB.Default.Find<Avatar>().MatchID(persona.Id).ExecuteFirstAsync(token)).DeletionAt);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/clientapi/user/sniff/again", token)).StatusCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -273,7 +273,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(p => p.LocalQuotePolicy, policy).ExecuteAsync(token);
|
||||
post.LocalQuotePolicy = policy;
|
||||
if (!post.IsLocalOnly)
|
||||
await _outbox.Publish(Me, post, ActivityPubRenderer.UpdateOf(post, Me, $"policy-{DateTime.UtcNow.Ticks}"), token);
|
||||
await _outbox.PublishUpdate(Me, post, $"policy-{DateTime.UtcNow.Ticks}", token);
|
||||
return Json(await _mapper.Status(post, MyId, token));
|
||||
}
|
||||
|
||||
|
||||
@@ -361,7 +361,7 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
if (!result.IsValid)
|
||||
return StatusCode(result.StatusCode, result);
|
||||
|
||||
return Ok(result);
|
||||
return Ok(result.Data);//the same sentence for everyone
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -414,26 +414,23 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
}
|
||||
}
|
||||
|
||||
//[HttpDelete, Route("delete"), Authorize(Policy = Policies.IsUser)]
|
||||
//public async Task<IActionResult> RemoveSelf()
|
||||
//{
|
||||
// var result = new WebResult();
|
||||
// try
|
||||
// {
|
||||
// //result = await UsersService.RemoveUserAsync(User.GetUserId());
|
||||
// if (!result.IsValid)
|
||||
// return StatusCode(result.StatusCode, result);
|
||||
|
||||
// await HttpContext.SignOutAsync();
|
||||
|
||||
// return Ok();
|
||||
// }
|
||||
// catch (Exception ex)
|
||||
// {
|
||||
// Logger.LogError(ex, $"{nameof(User)}.{nameof(RemoveSelf)}()");
|
||||
// return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
||||
// }
|
||||
//}
|
||||
[HttpPost, Route("/clientapi/user/delete"), EnableRateLimiting(RateLimiting.Accounts), Authorize(Policy = Policies.IsUser)]
|
||||
public async Task<IActionResult> RemoveSelf(RemoveSelfForm form)
|
||||
{
|
||||
var result = new WebResult();
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(result.Invalidate(Localizer["Invalid model."]));
|
||||
try
|
||||
{
|
||||
result = await UsersService.RemoveSelfAsync(User.GetUserId(), form.Password);
|
||||
return result.IsValid ? Ok() : StatusCode(result.StatusCode, result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"{nameof(User)}.{nameof(RemoveSelf)}()");
|
||||
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
||||
}
|
||||
}
|
||||
|
||||
#endregion User endpoints
|
||||
|
||||
|
||||
@@ -233,7 +233,7 @@ namespace PrivaPub.Domain.Statuses
|
||||
return JobOutcome.Done;
|
||||
await Closing(post, author, token);
|
||||
if (!post.IsLocalOnly)
|
||||
await _outbox.Publish(author, post, ActivityPubRenderer.UpdateOf(post, author, $"poll-{DateTime.UtcNow.Ticks}"), token);
|
||||
await _outbox.PublishUpdate(author, post, $"poll-{DateTime.UtcNow.Ticks}", token);
|
||||
return JobOutcome.Done;
|
||||
}
|
||||
|
||||
|
||||
@@ -239,7 +239,7 @@ namespace PrivaPub.Domain.Statuses
|
||||
.ExecuteAsync(token);
|
||||
await DB.Default.Update<PostEntity>().MatchID(quoted.ID).Modify(b => b.Inc(p => p.QuotesCount, 1)).ExecuteAsync(token);
|
||||
if (!post.IsLocalOnly)
|
||||
await _outbox.Publish(author, post, ActivityPubRenderer.UpdateOf(post, author, "quote-approved"), token);
|
||||
await _outbox.PublishUpdate(author, post, "quote-approved", token);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -119,6 +119,11 @@ namespace PrivaPub.Domain.Statuses
|
||||
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
|
||||
if (parent != default && !await VisibilityPolicy.CanSee(parent, author.Id, token))
|
||||
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
|
||||
// a reply in a circle stays in the circle, whichever client wrote it: Mastodon clients know nothing of groups
|
||||
if (group == default && parent is { Visibility: PostVisibility.Circle } && !string.IsNullOrEmpty(parent.GroupId)
|
||||
&& await _dbEntities.Groups.MatchID(parent.GroupId).ExecuteFirstAsync(token) is { DeletionAt: null } parentCircle
|
||||
&& await MayPost(parentCircle, author, token))
|
||||
group = _localActors.FromGroup(parentCircle);
|
||||
|
||||
PostEntity quoted = default;
|
||||
var quotePermission = QuotePermission.Denied;
|
||||
@@ -161,6 +166,9 @@ namespace PrivaPub.Domain.Statuses
|
||||
var visibility = located ? PostVisibility.LocalGeo
|
||||
: group is { IsCircle: true } ? PostVisibility.Circle
|
||||
: draft.Visibility is PostVisibility.Circle or PostVisibility.LocalGeo ? PostVisibility.Public : draft.Visibility;
|
||||
// asking a quoted post's author for permission would show them the circle post
|
||||
if (visibility == PostVisibility.Circle && quoted != default && quotePermission != QuotePermission.Granted)
|
||||
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: A circle post can only quote a post that needs no permission");
|
||||
var post = new PostEntity
|
||||
{
|
||||
GroupUserId = author.Id,
|
||||
@@ -340,7 +348,7 @@ 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);
|
||||
await _outbox.Publish(author, post, delete, token);//the post in hand still has its group and mentions
|
||||
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);
|
||||
|
||||
@@ -62,6 +62,7 @@ namespace PrivaPub.Federation.Actors
|
||||
{
|
||||
string BaseAddress { get; }
|
||||
Task<LocalActor> FindByUserName(string userName, CancellationToken token);
|
||||
Task<GoneActor> Gone(string userName, CancellationToken token);
|
||||
Task<LocalActor> FindById(LocalActorKind kind, string id, CancellationToken token);
|
||||
Task<LocalActor> FindByUri(string actorUri, CancellationToken token);
|
||||
Task<LocalActor> GetInstanceActor(CancellationToken token);
|
||||
@@ -71,6 +72,9 @@ namespace PrivaPub.Federation.Actors
|
||||
LocalActor FromGroup(GroupEntity group);
|
||||
}
|
||||
|
||||
// a persona or group that was deleted: its name stays reserved, and its documents answer 410 with this
|
||||
public sealed record GoneActor(string Uri, string FormerType, DateTime DeletedAt);
|
||||
|
||||
public class LocalActorService : ILocalActorService
|
||||
{
|
||||
public const string InstanceUserName = "privapub";
|
||||
@@ -113,6 +117,18 @@ namespace PrivaPub.Federation.Actors
|
||||
return group == default ? default : FromGroup(group);
|
||||
}
|
||||
|
||||
public async Task<GoneActor> Gone(string userName, CancellationToken token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(userName))
|
||||
return default;
|
||||
userName = userName.ToLowerInvariant();
|
||||
var avatar = await _dbEntities.Avatars.Match(a => a.UserName == userName && a.DeletionAt.HasValue).ExecuteFirstAsync(token);
|
||||
if (avatar != default)
|
||||
return new GoneActor($"{BaseAddress}/peasants/{avatar.UserName}", "Person", avatar.DeletionAt.Value);
|
||||
var group = await _dbEntities.Groups.Match(g => g.UserName == userName && g.DeletionAt.HasValue).ExecuteFirstAsync(token);
|
||||
return group == default ? default : new GoneActor($"{BaseAddress}/peasants/{group.UserName}", "Group", group.DeletionAt.Value);
|
||||
}
|
||||
|
||||
public async Task<LocalActor> FindById(LocalActorKind kind, string id, CancellationToken token)
|
||||
{
|
||||
switch (kind)
|
||||
|
||||
@@ -17,6 +17,7 @@ using PrivaPub.Domain.Privacy;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Federation.Inbox;
|
||||
using PrivaPub.Federation.Outbox;
|
||||
using PrivaPub.Federation.Signing;
|
||||
using PrivaPub.Infrastructure.Http;
|
||||
using PrivaPub.Models.Group;
|
||||
@@ -52,6 +53,20 @@ namespace PrivaPub.Federation.Controllers
|
||||
public async Task<IActionResult> GetActor(string actor, CancellationToken token)
|
||||
{
|
||||
var local = await _localActors.FindByUserName(actor, token);
|
||||
if (local == default && await _localActors.Gone(actor, token) is { } gone)
|
||||
return new ContentResult
|
||||
{
|
||||
Content = new JsonObject
|
||||
{
|
||||
["@context"] = ActivityPubRenderer.ActivityStreams,
|
||||
["id"] = gone.Uri,
|
||||
["type"] = "Tombstone",
|
||||
["formerType"] = gone.FormerType,
|
||||
["deleted"] = ActivityPubRenderer.Timestamp(gone.DeletedAt)
|
||||
}.ToJsonString(),
|
||||
ContentType = ActivityContentType,
|
||||
StatusCode = StatusCodes.Status410Gone
|
||||
};
|
||||
if (local is not { IsFederated: true })
|
||||
return NotFound();
|
||||
if (WantsHtml() && local.Kind != LocalActorKind.Application)
|
||||
@@ -185,11 +200,13 @@ 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)
|
||||
if (post == default && await SignedPost(actor, postId, token) is { } signed)
|
||||
{
|
||||
var circleNote = ActivityPubRenderer.Note(circlePost.Post, circlePost.Author, circlePost.Circle, circlePost.Post.InReplyToURI);
|
||||
circleNote["@context"] = ActivityPubRenderer.Context();
|
||||
return Activity(circleNote);
|
||||
if (signed.Post.DeletedAt.HasValue)
|
||||
return TombstoneOf(signed.Author, signed.Post);
|
||||
var signedNote = (JsonObject)signed.Note.DeepClone();
|
||||
signedNote["@context"] = ActivityPubRenderer.Context();
|
||||
return Activity(signedNote);
|
||||
}
|
||||
if (post == default)
|
||||
return await Tombstone(actor, postId, token) ?? NotFound();
|
||||
@@ -243,13 +260,44 @@ namespace PrivaPub.Federation.Controllers
|
||||
if (!activityId.StartsWith("create-", StringComparison.Ordinal))
|
||||
return NotFound();
|
||||
var (local, post) = await PublicPost(actor, activityId["create-".Length..], token);
|
||||
return post == default ? NotFound() : Activity(await CreateFor(post, local, token));
|
||||
if (post != default)
|
||||
return Activity(await CreateFor(post, local, token));
|
||||
return await SignedPost(actor, activityId["create-".Length..], token) is { Post.DeletedAt: null } signed
|
||||
? Activity(ActivityPubRenderer.Create(signed.Author, (JsonObject)signed.Note.DeepClone(), activityId))
|
||||
: NotFound();
|
||||
}
|
||||
|
||||
// A DM's `context`: the conversation's posts, for its participants (or their servers' instance actors) only.
|
||||
[HttpGet, Route("{actor}/whispers/{conversationId}")]
|
||||
public async Task<IActionResult> Whispers(string actor, string conversationId, CancellationToken token)
|
||||
{
|
||||
var local = await _localActors.FindByUserName(actor, token);
|
||||
if (local is not { IsFederated: true, Kind: LocalActorKind.Person })
|
||||
return NotFound();
|
||||
var uri = local.ConversationUri(conversationId);
|
||||
var conversation = await _dbEntities.DmGroups.Match(g => g.ID == conversationId && g.ConversationURI == uri && !g.DeletionAt.HasValue).ExecuteFirstAsync(token);
|
||||
if (conversation == default || !SignedFetchAuthorizer.MayReadConversation(conversation, await _fetches.Requester(Request, token)))
|
||||
return NotFound();
|
||||
var posts = await _dbEntities.Posts
|
||||
.Match(p => p.ConversationId == conversationId && p.Visibility == PostVisibility.Direct && !p.DeletedAt.HasValue)
|
||||
.Sort(p => p.CreationDate, Order.Ascending)
|
||||
.ExecuteAsync(token);
|
||||
return Activity(new JsonObject
|
||||
{
|
||||
["@context"] = ActivityPubRenderer.ActivityStreams,
|
||||
["id"] = uri,
|
||||
["type"] = "OrderedCollection",
|
||||
["totalItems"] = posts.Count,
|
||||
["orderedItems"] = new JsonArray(posts.Select(p => (JsonNode)p.ObjectURI).ToArray())
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost, Route("{actor}/mouth"), EnableRateLimiting(RateLimiting.Inbox)]
|
||||
public async Task<IActionResult> Inbox(string actor, CancellationToken token)
|
||||
{
|
||||
var local = await _localActors.FindByUserName(actor, token);
|
||||
if (local == default && await _localActors.Gone(actor, token) != default)
|
||||
return StatusCode(StatusCodes.Status410Gone);
|
||||
if (local is not { IsFederated: true })
|
||||
return Answer(_inbox.NoSuchRecipient(Request));
|
||||
return Answer(await _inbox.Receive(Request, local, token));
|
||||
@@ -275,18 +323,37 @@ namespace PrivaPub.Federation.Controllers
|
||||
return (local, post);
|
||||
}
|
||||
|
||||
async Task<(LocalActor Author, LocalActor Circle, PostEntity Post)?> CirclePost(string actor, string postId, CancellationToken token)
|
||||
// A followers-only, direct or circle post (deleted ones included), for a signed request from someone it was for
|
||||
// (SignedFetchAuthorizer.MayRead). It is rendered as it was delivered: a circle post also names, in cc, the requesting
|
||||
// member, or the members on the requesting instance actor's server.
|
||||
async Task<(LocalActor Author, PostEntity Post, JsonObject Note)?> SignedPost(string actor, string postId, CancellationToken token)
|
||||
{
|
||||
var local = await _localActors.FindByUserName(actor, token);
|
||||
if (local is not { Kind: LocalActorKind.Person })
|
||||
if (local is not { IsFederated: true, 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)
|
||||
.Match(p => p.ID == postId && p.GroupUserId == local.Id && !p.IsFederatedCopy && p.ReblogOfPostId == null
|
||||
&& (p.Visibility == PostVisibility.FollowersOnly || p.Visibility == PostVisibility.Direct || 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)))
|
||||
if (post == default)
|
||||
return default;
|
||||
return (local, _localActors.FromGroup(circle), post);
|
||||
var requester = await _fetches.Requester(Request, token);
|
||||
if (!await _fetches.MayRead(post, requester, token))
|
||||
return default;
|
||||
if (post.Visibility == PostVisibility.Circle)
|
||||
{
|
||||
var circle = await _dbEntities.Groups.MatchID(post.GroupId).ExecuteFirstAsync(token);
|
||||
var note = ActivityPubRenderer.Note(post, local, _localActors.FromGroup(circle), post.InReplyToURI);
|
||||
var readers = SignedFetchAuthorizer.CircleReaders(circle, requester);
|
||||
var named = readers.Count == 0 ? new List<Models.User.ForeignAvatar>() : await _dbEntities.ForeignAvatars.Match(a => readers.Contains(a.ActorURI)).ExecuteAsync(token);
|
||||
return (local, post, OutboxPublisher.Naming(note, named));
|
||||
}
|
||||
var rendered = post.Visibility == PostVisibility.Direct
|
||||
? ActivityPubRenderer.DirectNote(post, local, Array.Empty<(string, string)>(), post.ContextURI)
|
||||
: ActivityPubRenderer.Note(post, local, default, post.InReplyToURI);
|
||||
rendered["to"] = new JsonArray(post.To.Select(t => (JsonNode)t).ToArray());
|
||||
rendered["cc"] = new JsonArray(post.Cc.Select(c => (JsonNode)c).ToArray());
|
||||
return (local, post, rendered);
|
||||
}
|
||||
|
||||
async Task<IActionResult> Tombstone(string actor, string postId, CancellationToken token)
|
||||
@@ -298,8 +365,11 @@ namespace PrivaPub.Federation.Controllers
|
||||
.Match(p => p.ID == postId && p.GroupUserId == local.Id && !p.IsFederatedCopy && p.DeletedAt.HasValue
|
||||
&& (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted))
|
||||
.ExecuteFirstAsync(token);
|
||||
if (deleted == default)
|
||||
return default;
|
||||
return deleted == default ? default : TombstoneOf(local, deleted);
|
||||
}
|
||||
|
||||
ContentResult TombstoneOf(LocalActor local, PostEntity deleted)
|
||||
{
|
||||
var tombstone = new JsonObject
|
||||
{
|
||||
["@context"] = ActivityPubRenderer.ActivityStreams,
|
||||
@@ -349,7 +419,9 @@ namespace PrivaPub.Federation.Controllers
|
||||
{
|
||||
if (HttpMethods.IsGet(Request.Method))
|
||||
Response.Headers.Vary = "Accept";
|
||||
if (_federation.CurrentValue.SecureMode && HttpMethods.IsGet(Request.Method)
|
||||
// SecureMode asks every reader of ActivityPub documents for a signature, except for the instance actor, whose key
|
||||
// peers need first, and except for browsers, which only get redirected to the public pages
|
||||
if (_federation.CurrentValue.SecureMode && HttpMethods.IsGet(Request.Method) && !WantsHtml()
|
||||
&& !string.Equals(context.RouteData.Values["actor"] as string, LocalActorService.InstanceUserName, StringComparison.OrdinalIgnoreCase)
|
||||
&& await _fetches.Requester(Request, HttpContext.RequestAborted) == default)
|
||||
{
|
||||
|
||||
@@ -49,6 +49,8 @@ namespace PrivaPub.Federation.Controllers
|
||||
if (parts.Length != 2 || !parts[1].Equals(domain, StringComparison.OrdinalIgnoreCase))
|
||||
return NotFound();
|
||||
actor = await _localActors.FindByUserName(parts[0], token);
|
||||
if (actor == default && await _localActors.Gone(parts[0], token) != default)
|
||||
return StatusCode(StatusCodes.Status410Gone);
|
||||
}
|
||||
else
|
||||
actor = await _localActors.FindByUri(resource, token);
|
||||
|
||||
@@ -2,6 +2,7 @@ using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
@@ -14,6 +15,7 @@ namespace PrivaPub.Federation.Outbox
|
||||
{
|
||||
Task<IReadOnlyList<string>> Audience(LocalActor author, PostEntity post, CancellationToken token);
|
||||
Task Publish(LocalActor author, PostEntity post, JsonObject activity, CancellationToken token);
|
||||
Task PublishUpdate(LocalActor author, PostEntity post, string reason, CancellationToken token);
|
||||
Task PublishProfile(LocalActor actor, CancellationToken token);
|
||||
}
|
||||
|
||||
@@ -73,25 +75,79 @@ namespace PrivaPub.Federation.Outbox
|
||||
return inboxes.Where(i => !string.IsNullOrEmpty(i)).Distinct(StringComparer.Ordinal).ToList();
|
||||
}
|
||||
|
||||
async Task<IReadOnlyList<string>> CircleMembers(string groupId, CancellationToken token)
|
||||
async Task<IReadOnlyList<string>> CircleMembers(string groupId, CancellationToken token) =>
|
||||
(await CircleRecipients(groupId, token)).Select(r => r.InboxURL).Distinct(StringComparer.Ordinal).ToList();
|
||||
|
||||
async Task<IReadOnlyList<ForeignAvatar>> CircleRecipients(string groupId, CancellationToken token)
|
||||
{
|
||||
var circle = string.IsNullOrEmpty(groupId) ? default : await _dbEntities.Groups.MatchID(groupId).ExecuteFirstAsync(token);
|
||||
if (circle == default)
|
||||
return Array.Empty<string>();
|
||||
return Array.Empty<ForeignAvatar>();
|
||||
var remote = circle.Members.Where(m => m.IsForeign).Select(m => m.AvatarId).ToList();
|
||||
if (remote.Count == 0)
|
||||
return Array.Empty<string>();
|
||||
return Array.Empty<ForeignAvatar>();
|
||||
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();
|
||||
.Where(a => !string.IsNullOrEmpty(a.InboxURL))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task Publish(LocalActor author, PostEntity post, JsonObject activity, CancellationToken token)
|
||||
{
|
||||
if (post.Visibility == PostVisibility.Circle)
|
||||
{
|
||||
foreach (var member in await CircleRecipients(post.GroupId, token))
|
||||
await _delivery.Enqueue(author, new[] { member.InboxURL }, Naming(activity, new[] { member }), token);
|
||||
return;
|
||||
}
|
||||
var inboxes = await Audience(author, post, token);
|
||||
if (inboxes.Count > 0)
|
||||
await _delivery.Enqueue(author, inboxes, activity, token);
|
||||
}
|
||||
|
||||
public async Task PublishUpdate(LocalActor author, PostEntity post, string reason, CancellationToken token)
|
||||
{
|
||||
var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
|
||||
await Publish(author, post, ActivityPubRenderer.UpdateOf(post, author, group, reason), token);
|
||||
}
|
||||
|
||||
// Mastodon keeps a post only when it names one of its own accounts, and GoToSocial shows a post that is neither
|
||||
// public nor for followers only to the accounts it mentions; a circle post names only the circle. So each member's
|
||||
// copy, or a member's refetch, also names that member in cc and mentions them, silently, in its tags (owner decision
|
||||
// 2026-10-04): it tells each member nothing but that they are in the circle.
|
||||
public static JsonObject Naming(JsonObject activityOrObject, IEnumerable<ForeignAvatar> members)
|
||||
{
|
||||
var named = members.ToList();
|
||||
var copy = (JsonObject)activityOrObject.DeepClone();
|
||||
if (copy["object"] is JsonObject inner)
|
||||
{
|
||||
Name(copy, named, mention: false);
|
||||
if (inner.ContainsKey("to"))
|
||||
Name(inner, named, mention: true);
|
||||
}
|
||||
else
|
||||
Name(copy, named, mention: copy.ContainsKey("attributedTo"));
|
||||
return copy;
|
||||
}
|
||||
|
||||
static void Name(JsonObject node, IReadOnlyList<ForeignAvatar> members, bool mention)
|
||||
{
|
||||
var cc = node["cc"] as JsonArray ?? new JsonArray();
|
||||
var tags = node["tag"] as JsonArray ?? new JsonArray();
|
||||
foreach (var member in members)
|
||||
{
|
||||
if (!cc.Any(c => c?.GetValue<string>() == member.ActorURI))
|
||||
cc.Add(member.ActorURI);
|
||||
if (mention && !tags.Any(t => t?["href"]?.GetValue<string>() == member.ActorURI))
|
||||
tags.Add(new JsonObject { ["type"] = "Mention", ["href"] = member.ActorURI, ["name"] = Handle(member) });
|
||||
}
|
||||
node["cc"] = cc;
|
||||
if (mention)
|
||||
node["tag"] = tags;
|
||||
}
|
||||
|
||||
static string Handle(ForeignAvatar member) =>
|
||||
string.IsNullOrEmpty(member.UserName) ? member.ActorURI : $"@{member.UserName}@{new Uri(member.ActorURI).Authority}";
|
||||
|
||||
public async Task PublishProfile(LocalActor actor, CancellationToken token)
|
||||
{
|
||||
var document = ActivityPubRenderer.Actor(actor);
|
||||
|
||||
@@ -178,11 +178,11 @@ namespace PrivaPub.Federation.Rendering
|
||||
return note;
|
||||
}
|
||||
|
||||
public static JsonObject UpdateOf(PostEntity post, LocalActor author, string reason)
|
||||
public static JsonObject UpdateOf(PostEntity post, LocalActor author, LocalActor group, string reason)
|
||||
{
|
||||
var note = post.Visibility == PostVisibility.Direct
|
||||
? DirectNote(post, author, Array.Empty<(string, string)>(), post.ContextURI)
|
||||
: Note(post, author, default, post.InReplyToURI);
|
||||
: Note(post, author, group, post.InReplyToURI);
|
||||
note["to"] = new JsonArray(post.To.Select(t => (JsonNode)t).ToArray());
|
||||
note["cc"] = new JsonArray(post.Cc.Select(c => (JsonNode)c).ToArray());
|
||||
return new JsonObject
|
||||
|
||||
@@ -1,23 +1,34 @@
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Objects;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using GroupEntity = PrivaPub.Models.Group.Group;
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
|
||||
namespace PrivaPub.Federation.Signing
|
||||
{
|
||||
public interface ISignedFetchAuthorizer
|
||||
{
|
||||
Task<ForeignAvatar> Requester(HttpRequest request, CancellationToken token);
|
||||
Task<bool> MayRead(PostEntity post, ForeignAvatar requester, CancellationToken token);
|
||||
}
|
||||
|
||||
// Who may refetch a post that is not public (owner decision 2026-10-04). A server that received a followers-only post,
|
||||
// a DM or a circle post refetches it, signed by the account it was for or by its instance actor, and Mastodon deletes its
|
||||
// copy when the refetch answers 404. So the post is served to a signed request from someone it was for, or from the
|
||||
// instance actor of a server where someone it was for lives; everyone else still gets 404.
|
||||
public class SignedFetchAuthorizer : ISignedFetchAuthorizer
|
||||
{
|
||||
readonly IRemoteActorService _remoteActors;
|
||||
readonly DbEntities _dbEntities;
|
||||
|
||||
public SignedFetchAuthorizer(IRemoteActorService remoteActors)
|
||||
public SignedFetchAuthorizer(IRemoteActorService remoteActors, DbEntities dbEntities)
|
||||
{
|
||||
_remoteActors = remoteActors;
|
||||
_dbEntities = dbEntities;
|
||||
}
|
||||
|
||||
public async Task<ForeignAvatar> Requester(HttpRequest request, CancellationToken token)
|
||||
@@ -33,8 +44,48 @@ namespace PrivaPub.Federation.Signing
|
||||
return actor != default && HttpSignatures.Verify(actor.PublicKey, signingString, parameters.Signature) ? actor : default;
|
||||
}
|
||||
|
||||
public async Task<bool> MayRead(PostEntity post, ForeignAvatar requester, CancellationToken token)
|
||||
{
|
||||
if (post == default || requester == default)
|
||||
return false;
|
||||
switch (post.Visibility)
|
||||
{
|
||||
case PostVisibility.Public or PostVisibility.Unlisted:
|
||||
return true;
|
||||
case PostVisibility.Circle:
|
||||
var circle = string.IsNullOrEmpty(post.GroupId) ? default : await _dbEntities.Groups.MatchID(post.GroupId).ExecuteFirstAsync(token);
|
||||
return circle != default && MayReadCircle(circle, requester);
|
||||
case PostVisibility.Direct:
|
||||
return Addressed(post).Any(uri => Is(requester, uri));
|
||||
case PostVisibility.FollowersOnly:
|
||||
if (Addressed(post).Any(uri => Is(requester, uri)))
|
||||
return true;
|
||||
var followers = await _dbEntities.Followers
|
||||
.Match(f => f.LocalActorId == post.GroupUserId && f.LocalActorKind == LocalActorKind.Person && f.IsAccepted)
|
||||
.ExecuteAsync(token);
|
||||
return followers.Any(f => Is(requester, f.ActorURI));
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static IEnumerable<string> Addressed(PostEntity post) =>
|
||||
post.To.Concat(post.Cc).Concat(post.Mentions.Select(m => m.ActorURI)).Where(uri => !string.IsNullOrEmpty(uri));
|
||||
|
||||
// the account itself, or the instance actor of the server it lives on
|
||||
static bool Is(ForeignAvatar requester, string actorUri) =>
|
||||
actorUri == requester.ActorURI || requester.AvatarType == AvatarType.Application && Origin.Same(actorUri, requester.ActorURI);
|
||||
|
||||
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)));
|
||||
requester != default && circle.Members.Any(m => m.IsForeign && Is(requester, m.AvatarId));
|
||||
|
||||
public static bool MayReadConversation(Models.Group.DmGroup conversation, ForeignAvatar requester) =>
|
||||
requester != default && conversation.Members.Any(m => m.IsForeign && Is(requester, m.AvatarId));
|
||||
|
||||
// the members a refetch names in cc: the requesting member, or the members on an instance actor's server
|
||||
public static IReadOnlyList<string> CircleReaders(GroupEntity circle, ForeignAvatar requester) =>
|
||||
requester == default
|
||||
? Array.Empty<string>()
|
||||
: circle.Members.Where(m => m.IsForeign && Is(requester, m.AvatarId)).Select(m => m.AvatarId).ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Models.User;
|
||||
|
||||
namespace PrivaPub.Infrastructure.Data.Migrations
|
||||
{
|
||||
// Recovery codes were stored in plain text and never expired; from now on only a hash with a one-hour expiry is kept.
|
||||
// The old codes are dropped: anyone mid-recovery asks again.
|
||||
public class _011_recovery_codes_are_hashed : IMigration
|
||||
{
|
||||
public async Task UpgradeAsync() =>
|
||||
await DB.Default.DeleteAsync<EmailRecovery>(r => r.CodeHash == null);
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,7 @@ namespace PrivaPub.Middleware
|
||||
.AddSingleton<IPollService, PollService>()
|
||||
.AddSingleton<IQuoteService, QuoteService>()
|
||||
.AddSingleton<IJobHandler, PollRefreshJob>()
|
||||
.AddSingleton<IJobHandler, RecoveryJob>()
|
||||
.AddSingleton<IJobHandler, PollCloseJob>()
|
||||
.AddSingleton<InstanceDescriber>()
|
||||
.AddSingleton<IJobHandler>(services => services.GetRequiredService<InstanceDescriber>())
|
||||
@@ -182,6 +183,8 @@ namespace PrivaPub.Middleware
|
||||
return service
|
||||
.AddTransient<IDataService, DataService>()
|
||||
.AddTransient<IRootUsersService, RootUsersService>()
|
||||
.AddScoped<IRootSessions, RootSessions>()
|
||||
.AddScoped<IRootRemoval, RootRemoval>()
|
||||
.AddTransient<IPublicAvatarUsersService, PublicAvatarUsersService>()
|
||||
.AddTransient<IPrivateAvatarUsersService, PrivateAvatarUsersService>()
|
||||
.AddTransient<IGroupUsersService, GroupUsersService>()
|
||||
|
||||
@@ -29,7 +29,8 @@ namespace PrivaPub.Models.Jobs
|
||||
FetchPreview,
|
||||
RollupDay,
|
||||
CrawlPlan,
|
||||
CrawlInstance
|
||||
CrawlInstance,
|
||||
SendRecovery
|
||||
}
|
||||
|
||||
public enum JobState
|
||||
|
||||
@@ -5,7 +5,9 @@ namespace PrivaPub.Models.User
|
||||
public class EmailRecovery : Entity
|
||||
{
|
||||
public string RootUserId { get; set; }
|
||||
public string RecoveryCode { get; set; }
|
||||
public string RecoveryCode { get; set; }//plaintext codes before 2026-10-04; migration _011 removed them, never written now
|
||||
public string CodeHash { get; set; }//sha-256 of the code, hex; the code itself exists only in the email
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public string RequestIP { get; set; }
|
||||
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ namespace PrivaPub.Models.User
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? DeletedAt { get; set; }
|
||||
public DateTime? CredentialsChangedAt { get; set; }//a /clientapi token issued before this is refused (JwtEvents)
|
||||
}
|
||||
|
||||
public class ContactItem
|
||||
|
||||
@@ -16,6 +16,7 @@ namespace PrivaPub.Services
|
||||
Task<WebResult> UnbanUserAsync(UsersIds usersIds);
|
||||
|
||||
Task<WebResult> RemoveUserAsync(UsersIds usersIds);
|
||||
Task<WebResult> RemoveSelfAsync(string rootId, string password);
|
||||
|
||||
Task<WebResult> UpdateUserAsync(UserForm userEmailForm, string userId);
|
||||
|
||||
|
||||
@@ -33,6 +33,12 @@ namespace PrivaPub.Services
|
||||
context.Fail("The account can no longer be used.");
|
||||
return;
|
||||
}
|
||||
// a password recovery ends every session made before it (RootSessions)
|
||||
if (root.CredentialsChangedAt is { } changed && context.SecurityToken.ValidFrom < changed.AddSeconds(-1))
|
||||
{
|
||||
context.Fail("The account's password was changed.");
|
||||
return;
|
||||
}
|
||||
if (context.Principal.Identity is not ClaimsIdentity identity)
|
||||
return;
|
||||
foreach (var policy in PolicyClaims)
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
using MailKit.Net.Smtp;
|
||||
|
||||
using Microsoft.Extensions.Localization;
|
||||
|
||||
using MimeKit;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Infrastructure.Jobs;
|
||||
using PrivaPub.Models.Jobs;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.Resources;
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace PrivaPub.Services
|
||||
{
|
||||
public sealed record RecoveryPayload(string RootUserId, string Host);
|
||||
|
||||
// Sends a password recovery link. Every request queues one of these, for an account that exists or not, so the answer
|
||||
// to the request and its timing say nothing; SMTP's slowness and failures happen here, where nobody waits on them.
|
||||
// The code exists only in the email: the database keeps its hash, for an hour.
|
||||
public class RecoveryJob : IJobHandler
|
||||
{
|
||||
public const string Host = "recovery";
|
||||
public static readonly TimeSpan CodeLifetime = TimeSpan.FromHours(1);
|
||||
|
||||
readonly AppConfigurationService _app;
|
||||
readonly IStringLocalizer<GenericRes> _localizer;
|
||||
readonly ILogger<RecoveryJob> _logger;
|
||||
|
||||
public RecoveryJob(AppConfigurationService app, IStringLocalizer<GenericRes> localizer, ILogger<RecoveryJob> logger)
|
||||
{
|
||||
_app = app;
|
||||
_localizer = localizer;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public JobKind Kind => JobKind.SendRecovery;
|
||||
public int Concurrency => 1;
|
||||
public int MaxAttempts => 3;
|
||||
public int PerHostLimit => 1;
|
||||
|
||||
public static string Hash(string code) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(code ?? string.Empty)));
|
||||
|
||||
public async Task<JobOutcome> Handle(Job job, CancellationToken token)
|
||||
{
|
||||
var payload = JsonSerializer.Deserialize<RecoveryPayload>(job.Payload);
|
||||
if (string.IsNullOrEmpty(payload?.RootUserId))
|
||||
return JobOutcome.Done;//asked for an account that does not exist: there is nobody to write to
|
||||
var user = await DB.Default.Find<RootUser>().MatchID(payload.RootUserId).ExecuteFirstAsync(token);
|
||||
if (user is not { DeletedAt: null, IsBanned: false } || string.IsNullOrEmpty(user.Email))
|
||||
return JobOutcome.Done;
|
||||
|
||||
var code = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(32));
|
||||
await DB.Default.DeleteAsync<EmailRecovery>(r => r.RootUserId == user.ID);
|
||||
await DB.Default.SaveAsync(new EmailRecovery { RootUserId = user.ID, CodeHash = Hash(code), ExpiresAt = DateTime.UtcNow + CodeLifetime }, token);
|
||||
|
||||
var email = _app.AppConfiguration.EmailConfiguration;
|
||||
var message = new MimeMessage();
|
||||
message.From.Add(new MailboxAddress("PrivaPub", email.SmtpUsername));
|
||||
message.To.Add(MailboxAddress.Parse(user.Email));
|
||||
message.Subject = _localizer["PrivaPub - Password recovery link"];
|
||||
message.Body = new TextPart("plain")
|
||||
{
|
||||
Text = string.Format(_localizer[
|
||||
"Hello,\n\nSomeone asked to reset the password of the PrivaPub login {0}. If it was you, open this link within an hour:\n{1}\n\nIf it was not you, ignore this email: nothing changes."],
|
||||
user.UserName, $"{payload.Host}/password-recovery?rc={code}")
|
||||
};
|
||||
try
|
||||
{
|
||||
using var smtp = new SmtpClient();
|
||||
await smtp.ConnectAsync(email.SmtpServer, email.SmtpPort, email.UseSSL, token);
|
||||
await smtp.AuthenticateAsync(email.SmtpUsername, email.SmtpPassword, token);
|
||||
await smtp.SendAsync(message, token);
|
||||
await smtp.DisconnectAsync(quit: true, token);
|
||||
return JobOutcome.Done;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException || !token.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogWarning(ex, "The recovery email could not be sent");
|
||||
return JobOutcome.Retry("smtp");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Outbox;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using GroupEntity = PrivaPub.Models.Group.Group;
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
|
||||
namespace PrivaPub.Services
|
||||
{
|
||||
public interface IRootRemoval
|
||||
{
|
||||
Task<bool> Remove(string rootId, CancellationToken token);
|
||||
}
|
||||
|
||||
// Deleting a root deletes everything public it had (owner decision 2026-10-04): each persona, and each group a persona
|
||||
// owns, is announced deleted with a Delete of the actor to everyone who follows it, every member and everyone it
|
||||
// follows; their posts are emptied; their names stay reserved, and their documents answer 410 from then on. The root's
|
||||
// sessions end first, so nothing acts as it while it goes.
|
||||
public class RootRemoval : IRootRemoval
|
||||
{
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly ILocalActorService _localActors;
|
||||
readonly IDeliveryService _delivery;
|
||||
readonly IRootSessions _sessions;
|
||||
|
||||
public RootRemoval(DbEntities dbEntities, ILocalActorService localActors, IDeliveryService delivery, IRootSessions sessions)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
_localActors = localActors;
|
||||
_delivery = delivery;
|
||||
_sessions = sessions;
|
||||
}
|
||||
|
||||
public async Task<bool> Remove(string rootId, CancellationToken token)
|
||||
{
|
||||
var root = await _dbEntities.RootUsers.MatchID(rootId).Match(u => u.DeletedAt == null).ExecuteFirstAsync(token);
|
||||
if (root == default)
|
||||
return false;
|
||||
await _sessions.Revoke(root.ID, token);
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var avatarIds = (await _dbEntities.RootToAvatars.Match(ra => ra.RootId == root.ID).ExecuteAsync(token)).Select(ra => ra.AvatarId).ToList();
|
||||
var avatars = await _dbEntities.Avatars.Match(a => avatarIds.Contains(a.ID) && !a.DeletionAt.HasValue).ExecuteAsync(token);
|
||||
foreach (var avatar in avatars)
|
||||
{
|
||||
foreach (var group in await _dbEntities.Groups.Match(g => g.OwnerAvatarId == avatar.ID && !g.DeletionAt.HasValue).ExecuteAsync(token))
|
||||
{
|
||||
await Announce(_localActors.FromGroup(group), group.Members.Where(m => m.IsForeign).Select(m => m.AvatarId), token);
|
||||
await DB.Default.Update<GroupEntity>().MatchID(group.ID).Modify(g => g.DeletionAt, now).ExecuteAsync(token);
|
||||
}
|
||||
var persona = _localActors.FromAvatar(avatar);
|
||||
var followed = (await _dbEntities.Followings.Match(f => f.AvatarId == avatar.ID && !f.TargetIsLocal).ExecuteAsync(token))
|
||||
.Select(f => f.TargetActorURI);
|
||||
await Announce(persona, followed, token);
|
||||
await DB.Default.Update<Avatar>().MatchID(avatar.ID).Modify(a => a.DeletionAt, now).ExecuteAsync(token);
|
||||
await Empty(avatar.ID, now, token);
|
||||
}
|
||||
|
||||
await DB.Default.Update<RootUser>().MatchID(root.ID)
|
||||
.Modify(u => u.UserName, $"deleted-{root.ID}")//unique, so a second deletion never collides with the first
|
||||
.Modify(u => u.Email, null)
|
||||
.Modify(u => u.HashedPassword, null)
|
||||
.Modify(u => u.Policies, new List<string>())
|
||||
.Modify(u => u.IsBanned, false)
|
||||
.Modify(u => u.IsEmailValidated, false)
|
||||
.Modify(u => u.DeletedAt, now)
|
||||
.ExecuteAsync(token);
|
||||
await DB.Default.DeleteAsync<EmailRecovery>(r => r.RootUserId == root.ID);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Delete{Actor}: to its followers' (shared) inboxes, and to the inboxes of the other accounts named
|
||||
async Task Announce(LocalActor actor, IEnumerable<string> others, CancellationToken token)
|
||||
{
|
||||
var inboxes = (await _delivery.FollowerInboxes(actor, token)).ToList();
|
||||
var named = others.Where(uri => !string.IsNullOrEmpty(uri)).Distinct().ToList();
|
||||
if (named.Count > 0)
|
||||
inboxes.AddRange((await _dbEntities.ForeignAvatars.Match(a => named.Contains(a.ActorURI)).ExecuteAsync(token))
|
||||
.Select(a => string.IsNullOrEmpty(a.SharedInboxURL) ? a.InboxURL : a.SharedInboxURL));
|
||||
await _delivery.Enqueue(actor, inboxes.Where(i => !string.IsNullOrEmpty(i)).Distinct(), new JsonObject
|
||||
{
|
||||
["@context"] = ActivityPubRenderer.Context(),
|
||||
["id"] = actor.ActivityUri("delete-actor"),
|
||||
["type"] = "Delete",
|
||||
["actor"] = actor.Uri,
|
||||
["object"] = actor.Uri,
|
||||
["to"] = new JsonArray(ActivityPubRenderer.Public),
|
||||
["cc"] = new JsonArray(actor.Followers)
|
||||
}, token);
|
||||
}
|
||||
|
||||
// a persona's posts keep their ids and lose their content, like a single deleted post; a group writes none of its own
|
||||
static async Task Empty(string avatarId, DateTime now, CancellationToken token)
|
||||
{
|
||||
var postIds = (await DB.Default.Find<PostEntity>().Match(p => p.GroupUserId == avatarId && !p.IsFederatedCopy && !p.DeletedAt.HasValue)
|
||||
.Project(p => p.Include(x => x.ID)).ExecuteAsync(token)).Select(p => p.ID).ToList();
|
||||
if (postIds.Count == 0)
|
||||
return;
|
||||
await DB.Default.Update<PostEntity>().Match(p => postIds.Contains(p.ID))
|
||||
.Modify(p => p.DeletedAt, now)
|
||||
.Modify(p => p.Text, null)
|
||||
.Modify(p => p.ContentHtml, null)
|
||||
.Modify(p => p.Title, null)
|
||||
.Modify(p => p.SpoilerText, null)
|
||||
.Modify(p => p.Media, new List<Models.Post.PostMedia>())
|
||||
.Modify(p => p.Revisions, new List<Models.Post.PostRevision>())
|
||||
.ExecuteAsync(token);
|
||||
await DB.Default.DeleteAsync<TimelineEntry>(e => postIds.Contains(e.PostId) || postIds.Contains(e.ReblogOfPostId));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using OpenIddict.Abstractions;
|
||||
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
namespace PrivaPub.Services
|
||||
{
|
||||
public interface IRootSessions
|
||||
{
|
||||
Task Revoke(string rootId, CancellationToken token);
|
||||
}
|
||||
|
||||
// Ends everything a root is signed in with: its /clientapi tokens, through CredentialsChangedAt (checked by JwtEvents),
|
||||
// and the Mastodon API tokens and authorizations of each of its personas. Used when its password is recovered and when
|
||||
// it is deleted.
|
||||
public class RootSessions : IRootSessions
|
||||
{
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly IOpenIddictTokenManager _tokens;
|
||||
readonly IOpenIddictAuthorizationManager _authorizations;
|
||||
|
||||
public RootSessions(DbEntities dbEntities, IOpenIddictTokenManager tokens, IOpenIddictAuthorizationManager authorizations)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
_tokens = tokens;
|
||||
_authorizations = authorizations;
|
||||
}
|
||||
|
||||
public async Task Revoke(string rootId, CancellationToken token)
|
||||
{
|
||||
await DB.Default.Update<RootUser>().MatchID(rootId).Modify(u => u.CredentialsChangedAt, DateTime.UtcNow).ExecuteAsync(token);
|
||||
foreach (var link in await _dbEntities.RootToAvatars.Match(ra => ra.RootId == rootId).ExecuteAsync(token))
|
||||
{
|
||||
await _tokens.RevokeBySubjectAsync(link.AvatarId, token);
|
||||
await _authorizations.RevokeBySubjectAsync(link.AvatarId, token);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,12 @@ using PrivaPub.ClientModels.User;
|
||||
using PrivaPub.Models;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.Resources;
|
||||
using PrivaPub.Infrastructure.Jobs;
|
||||
using PrivaPub.Models.Jobs;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
#pragma warning disable 8603
|
||||
#pragma warning disable 8625
|
||||
@@ -31,8 +34,14 @@ namespace PrivaPub.Services
|
||||
readonly ILogger<RootUsersService> Logger;
|
||||
readonly AppConfigurationService AppConfigurationService;
|
||||
readonly AuthTokenManager AuthTokenManager;
|
||||
readonly IJobQueue Jobs;
|
||||
readonly IRootSessions Sessions;
|
||||
readonly IRootRemoval Removal;
|
||||
|
||||
public RootUsersService(
|
||||
IJobQueue jobs,
|
||||
IRootSessions sessions,
|
||||
IRootRemoval removal,
|
||||
IStringLocalizer<GenericRes> localizer,
|
||||
ILogger<RootUsersService> logger,
|
||||
IPasswordHasher passwordHasher,
|
||||
@@ -40,6 +49,9 @@ namespace PrivaPub.Services
|
||||
AppConfigurationService appConfigurationService,
|
||||
AuthTokenManager authTokenManager)
|
||||
{
|
||||
Jobs = jobs;
|
||||
Sessions = sessions;
|
||||
Removal = removal;
|
||||
DbEntities = dbEntities;
|
||||
AuthTokenManager = authTokenManager;
|
||||
PasswordHasher = passwordHasher;
|
||||
@@ -48,6 +60,12 @@ namespace PrivaPub.Services
|
||||
AppConfigurationService = appConfigurationService;
|
||||
}
|
||||
|
||||
public const string NoMatch = "That username and password do not match.";
|
||||
static string _decoy;
|
||||
|
||||
// a hash nobody's password matches, so an unknown login costs the same hashing as a wrong password
|
||||
string Decoy => _decoy ??= PasswordHasher.Hash(Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(16)));
|
||||
|
||||
public async Task<WebResult> SignUpAsync(LoginForm signUpForm, string invitationCode = default,
|
||||
bool isPasswordRequired = false)
|
||||
{
|
||||
@@ -114,20 +132,16 @@ namespace PrivaPub.Services
|
||||
var result = new WebResult();
|
||||
try
|
||||
{
|
||||
// One answer, after the same work, whether the login is unknown, deleted or the password wrong: sign-in must not
|
||||
// tell anyone which logins exist. Only someone who knows the password learns the login is banned.
|
||||
loginForm.UserName = loginForm.UserName.ToLower();
|
||||
if (!await DbEntities.RootUsers.Match(u => u.UserName == loginForm.UserName && u.DeletedAt == null)
|
||||
.ExecuteAnyAsync())
|
||||
return result.Invalidate(Localizer["Username '{0}' not found.", loginForm.UserName]);
|
||||
|
||||
var user = await DbEntities.RootUsers.Match(u => u.UserName == loginForm.UserName).ExecuteFirstAsync();
|
||||
var user = await DbEntities.RootUsers.Match(u => u.UserName == loginForm.UserName && u.DeletedAt == null).ExecuteFirstAsync();
|
||||
var (verified, needsUpgrade) = PasswordHasher.Check(user?.HashedPassword ?? Decoy, loginForm.Password);
|
||||
if (user?.HashedPassword == default || !verified)
|
||||
return result.Invalidate(Localizer[NoMatch]);
|
||||
if (user.IsBanned)
|
||||
return result.Invalidate(Localizer["User '{0}' banned.", user.UserName]);
|
||||
|
||||
var (verified, needsUpgrade) = PasswordHasher.Check(user.HashedPassword, loginForm.Password);
|
||||
|
||||
if (!verified)
|
||||
return result.Invalidate(Localizer["Wrong password."]);
|
||||
|
||||
if (needsUpgrade)
|
||||
result.ErrorMessage = Localizer["Needs upgrade!"];
|
||||
|
||||
@@ -261,24 +275,12 @@ namespace PrivaPub.Services
|
||||
var result = new WebResult();
|
||||
try
|
||||
{
|
||||
var users = await DbEntities.RootUsers.Match(u => usersIds.UserIdList.Contains(u.ID) && u.DeletedAt == default).ExecuteAsync();
|
||||
if (users == null || users.Count == 0)
|
||||
var removed = 0;
|
||||
foreach (var id in usersIds.UserIdList.Distinct())
|
||||
if (await Removal.Remove(id, CancellationToken.None))
|
||||
removed++;
|
||||
if (removed == 0)
|
||||
return result.Invalidate(Localizer["User already deleted."]);
|
||||
|
||||
foreach (var user in users)
|
||||
{
|
||||
user.Email = Localizer["Deleted user"];
|
||||
user.HashedPassword = null;
|
||||
user.Policies.Clear();
|
||||
user.IsBanned = false;
|
||||
user.IsEmailValidated = false;
|
||||
//user.TempSecret = null;
|
||||
user.UserName = Localizer["Deleted user"];
|
||||
user.DeletedAt = DateTime.UtcNow;
|
||||
|
||||
await DB.Default.SaveAsync(user);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -288,6 +290,26 @@ namespace PrivaPub.Services
|
||||
}
|
||||
}
|
||||
|
||||
// A root deletes itself only with its password, so a stolen session cannot.
|
||||
public async Task<WebResult> RemoveSelfAsync(string rootId, string password)
|
||||
{
|
||||
var result = new WebResult();
|
||||
try
|
||||
{
|
||||
var user = await DbEntities.RootUsers.MatchID(rootId).Match(u => u.DeletedAt == null).ExecuteFirstAsync();
|
||||
var (verified, _) = PasswordHasher.Check(user?.HashedPassword ?? Decoy, password ?? string.Empty);
|
||||
if (user?.HashedPassword == default || !verified)
|
||||
return result.Invalidate(Localizer[NoMatch], StatusCodes.Status403Forbidden);
|
||||
await Removal.Remove(user.ID, CancellationToken.None);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(RemoveSelfAsync)}()");
|
||||
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<WebResult> BanUserAsync(UsersIds usersIds)
|
||||
{
|
||||
var result = new WebResult();
|
||||
@@ -354,141 +376,22 @@ namespace PrivaPub.Services
|
||||
}
|
||||
}
|
||||
|
||||
public const string RecoverySent = "If that account has an email address, a recovery link is on its way. It works for one hour.";
|
||||
|
||||
// The same answer, after the same work, whether the account exists, has an email or can be written to: recovery must
|
||||
// not tell anyone which logins or addresses exist. RecoveryJob does the rest, out of the request.
|
||||
public async Task<WebResult> SetupAndSendRecoveryEmail(PasswordRecoveryForm passwordRecoveryForm, string host)
|
||||
{
|
||||
var result = new WebResult();
|
||||
try
|
||||
{
|
||||
var usernameExists = false;
|
||||
if (passwordRecoveryForm.IsEmailDisabled)
|
||||
{
|
||||
if (!await DbEntities.RootUsers.Match(u => u.UserName == passwordRecoveryForm.UserName && u.DeletedAt == null)
|
||||
.ExecuteAnyAsync())
|
||||
return result.Invalidate(Localizer["Username '{0}' not found.", passwordRecoveryForm.UserName],
|
||||
StatusCodes.Status404NotFound);
|
||||
usernameExists = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!await DbEntities.RootUsers.Match(u => u.Email == passwordRecoveryForm.Email && u.DeletedAt == null)
|
||||
.ExecuteAnyAsync())
|
||||
return result.Invalidate(Localizer["Username '{0}' not found.", passwordRecoveryForm.UserName],
|
||||
StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
var user = default(RootUser);
|
||||
if (usernameExists)
|
||||
user = await DbEntities.RootUsers.Match(u => u.UserName == passwordRecoveryForm.UserName).ExecuteFirstAsync();
|
||||
else
|
||||
user = await DbEntities.RootUsers.Match(u => u.Email == passwordRecoveryForm.Email).ExecuteFirstAsync();
|
||||
|
||||
if (string.IsNullOrEmpty(user.Email))
|
||||
return result.Invalidate(Localizer["This User doesn't have an email, no way to recover."],
|
||||
StatusCodes.Status423Locked);
|
||||
|
||||
var emailRecovery = await DbEntities.EmailRecoveries
|
||||
.Match(er => er.RootUserId == user.ID).ExecuteFirstAsync();
|
||||
|
||||
if (emailRecovery is null)
|
||||
{
|
||||
emailRecovery = new()
|
||||
{
|
||||
RootUserId = user.ID
|
||||
};
|
||||
await DB.Default.SaveAsync(emailRecovery);
|
||||
}
|
||||
|
||||
using var recoveryCodeGenerator = new Password(true, true, true, false, 127);
|
||||
emailRecovery.RecoveryCode = recoveryCodeGenerator.Next();
|
||||
await DB.Default.SaveAsync(emailRecovery);
|
||||
|
||||
using (var smtpClient = new SmtpClient())
|
||||
{
|
||||
try
|
||||
{
|
||||
await smtpClient.ConnectAsync(AppConfigurationService.AppConfiguration.EmailConfiguration.SmtpServer, AppConfigurationService.AppConfiguration.EmailConfiguration.SmtpPort,
|
||||
AppConfigurationService.AppConfiguration.EmailConfiguration.UseSSL);
|
||||
if (!smtpClient.IsConnected)
|
||||
{
|
||||
Logger.LogError($"Failed to connect to the SMTP server({AppConfigurationService.AppConfiguration.EmailConfiguration.SmtpServer}).");
|
||||
return result.Invalidate(Localizer["Failed to send email."], StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"Error at connection to the SMTP server({AppConfigurationService.AppConfiguration.EmailConfiguration.SmtpServer}).");
|
||||
return result.Invalidate(Localizer["Failed to send email."], StatusCodes.Status503ServiceUnavailable, exception: ex);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await smtpClient.AuthenticateAsync(AppConfigurationService.AppConfiguration.EmailConfiguration.SmtpUsername, AppConfigurationService.AppConfiguration.EmailConfiguration.SmtpPassword);
|
||||
if (!smtpClient.IsAuthenticated)
|
||||
{
|
||||
Logger.LogError($"Failed SMTP authentication of {AppConfigurationService.AppConfiguration.EmailConfiguration.SmtpUsername}.");
|
||||
return result.Invalidate(Localizer["Failed to send email."],
|
||||
StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"Failed SMTP authentication of {AppConfigurationService.AppConfiguration.EmailConfiguration.SmtpUsername}.");
|
||||
return result.Invalidate(Localizer["Failed to send email."],
|
||||
StatusCodes.Status503ServiceUnavailable, exception: ex);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var toParsed = await smtpClient.VerifyAsync(user.Email);
|
||||
if (toParsed == null)
|
||||
return result.Invalidate(Localizer["Invalid email."], StatusCodes.Status400BadRequest);
|
||||
}
|
||||
catch (OperationCanceledException ex)
|
||||
{
|
||||
Logger.LogWarning(
|
||||
"SMTP operation canceled at email verification: {Error}", ex.Message);
|
||||
}
|
||||
catch (SmtpCommandException ex)
|
||||
{
|
||||
Logger.LogWarning(
|
||||
"SMTP command exception at email verification: {Error}", ex.Message);
|
||||
}
|
||||
catch (SmtpProtocolException ex)
|
||||
{
|
||||
Logger.LogWarning(
|
||||
"SMTP protocol exception at email verification: {Error}", ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning("Exception at email verification: {Error}", ex.Message);
|
||||
}
|
||||
|
||||
var message = new MimeMessage();
|
||||
message.From.Add(new MailboxAddress("PrivaPub", AppConfigurationService.AppConfiguration.EmailConfiguration.SmtpUsername));
|
||||
message.To.Add(MailboxAddress.Parse(user.Email));
|
||||
message.Subject = Localizer["PrivaPub - Password recovery link"];
|
||||
message.Body = new TextPart("plain")
|
||||
{
|
||||
Text = string.Format(Localizer[@"Hey {0},
|
||||
|
||||
Eugene from collAnon, following is the password recovery link:
|
||||
{1}
|
||||
|
||||
-- Eugene"], user.UserName, $"{host}/password-recovery?rc={emailRecovery.RecoveryCode}")
|
||||
};
|
||||
try
|
||||
{
|
||||
await smtpClient.SendAsync(message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"Error at email sending to {user.Email} from {AppConfigurationService.AppConfiguration.EmailConfiguration.SmtpUsername}.");
|
||||
return result.Invalidate(Localizer["Failed to send email."], StatusCodes.Status503ServiceUnavailable, exception: ex);
|
||||
}
|
||||
|
||||
await smtpClient.DisconnectAsync(quit: true);
|
||||
}
|
||||
|
||||
var userName = passwordRecoveryForm.UserName?.ToLowerInvariant();
|
||||
var user = passwordRecoveryForm.IsEmailDisabled
|
||||
? await DbEntities.RootUsers.Match(u => u.UserName == userName && u.DeletedAt == null).ExecuteFirstAsync()
|
||||
: await DbEntities.RootUsers.Match(u => u.Email == passwordRecoveryForm.Email && u.DeletedAt == null).ExecuteFirstAsync();
|
||||
await Jobs.Enqueue(JobKind.SendRecovery, JsonSerializer.Serialize(new RecoveryPayload(user?.ID, host)), RecoveryJob.Host,
|
||||
dedupeKey: default, CancellationToken.None);
|
||||
result.Data = Localizer[RecoverySent].Value;
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -498,16 +401,19 @@ Eugene from collAnon, following is the password recovery link:
|
||||
}
|
||||
}
|
||||
|
||||
Task<EmailRecovery> LiveRecovery(string recoveryCode)
|
||||
{
|
||||
var hash = RecoveryJob.Hash(recoveryCode);
|
||||
var now = DateTime.UtcNow;
|
||||
return DbEntities.EmailRecoveries.Match(er => er.CodeHash == hash && er.ExpiresAt > now).ExecuteFirstAsync();
|
||||
}
|
||||
|
||||
public async Task<WebResult> IsValidRecoveryCode(string recoveryCode)
|
||||
{
|
||||
var result = new WebResult();
|
||||
try
|
||||
{
|
||||
var isValidRecoveryCode = await DbEntities.EmailRecoveries
|
||||
.Match(er => er.RecoveryCode == recoveryCode).ExecuteAnyAsync();
|
||||
|
||||
result.Data = isValidRecoveryCode;
|
||||
|
||||
result.Data = await LiveRecovery(recoveryCode) != default;
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -516,33 +422,24 @@ Eugene from collAnon, following is the password recovery link:
|
||||
}
|
||||
}
|
||||
|
||||
// A recovered password ends every session the root had: whoever had them may be why it was recovered.
|
||||
public async Task<WebResult> ChangePassword(NewPasswordForm newPasswordForm)
|
||||
{
|
||||
var result = new WebResult();
|
||||
try
|
||||
{
|
||||
var isValidRecoveryCode = await DbEntities.EmailRecoveries
|
||||
.Match(er => er.RecoveryCode == newPasswordForm.RecoveryCode).ExecuteAnyAsync();
|
||||
if (!isValidRecoveryCode)
|
||||
var recovery = await LiveRecovery(newPasswordForm.RecoveryCode);
|
||||
var user = recovery == default ? default
|
||||
: await DbEntities.RootUsers.MatchID(recovery.RootUserId).Match(u => u.DeletedAt == null && !u.IsBanned).ExecuteFirstAsync();
|
||||
if (user == default)
|
||||
return result.Invalidate(Localizer["Invalid recovery code."], StatusCodes.Status404NotFound);
|
||||
|
||||
var emailRecovery = await DbEntities.EmailRecoveries
|
||||
.Match(er => er.RecoveryCode == newPasswordForm.RecoveryCode)
|
||||
.Project(er => er.Include(nameof(EmailRecovery.RootUserId)))
|
||||
.ExecuteFirstAsync();
|
||||
|
||||
if (emailRecovery is null || emailRecovery.RootUserId is null)
|
||||
return result.Invalidate(Localizer["User not found."]);
|
||||
|
||||
var newHashedPassword = PasswordHasher.Hash(newPasswordForm.NewPassword);
|
||||
|
||||
_ = await DB.Default.Update<RootUser>()
|
||||
.Match(u => u.ID == emailRecovery.RootUserId && u.DeletedAt == null)
|
||||
.Modify(u => u.HashedPassword, newHashedPassword)
|
||||
await DB.Default.Update<RootUser>().MatchID(user.ID)
|
||||
.Modify(u => u.HashedPassword, PasswordHasher.Hash(newPasswordForm.NewPassword))
|
||||
.Modify(u => u.UpdatedAt, DateTime.UtcNow)
|
||||
.ExecuteAsync();
|
||||
|
||||
_ = await DB.Default.DeleteAsync<EmailRecovery>(er => er.RecoveryCode == newPasswordForm.RecoveryCode);
|
||||
|
||||
await DB.Default.DeleteAsync<EmailRecovery>(er => er.RootUserId == user.ID);
|
||||
await Sessions.Revoke(user.ID, CancellationToken.None);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -44,6 +44,7 @@ namespace PrivaPub.StaticServices
|
||||
|
||||
var token = new JwtSecurityToken(issuer: Configuration["AppConfiguration:Jwt:Issuer"], audience: Configuration["AppConfiguration:Jwt:Audience"],
|
||||
claims: claims,
|
||||
notBefore: DateTime.UtcNow,//its issue time, which JwtEvents compares with RootUser.CredentialsChangedAt
|
||||
expires: expiration,
|
||||
signingCredentials: new(securityKey, SecurityAlgorithms.HmacSha512)
|
||||
);
|
||||
|
||||
@@ -31,7 +31,7 @@ namespace PrivaPub.StaticServices
|
||||
using (var algorithm = new Rfc2898DeriveBytes(password, salt, iterations, HashAlgorithmName.SHA512))
|
||||
{
|
||||
var keyToCheck = algorithm.GetBytes(KeySize);
|
||||
var verified = keyToCheck.SequenceEqual(key);
|
||||
var verified = CryptographicOperations.FixedTimeEquals(keyToCheck, key);
|
||||
return (verified, needsUpgrade);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
"Registrations": {
|
||||
"Mode": "Invitations"
|
||||
},
|
||||
"Federation": {
|
||||
"SecureMode": true
|
||||
},
|
||||
"Statistics": {
|
||||
"Crawler": {
|
||||
"Enabled": true,
|
||||
|
||||
+11
-4
@@ -169,15 +169,19 @@ Priorities, used throughout:
|
||||
Findings:
|
||||
- **Mastodon 4.7 names its actors by number**: `https://mastodon.test/ap/users/<id>`, inbox `…/ap/users/<id>/inbox`. Nothing here may assume
|
||||
`/users/<name>`.
|
||||
- **It drops circle posts** (expected failure in the scenario).
|
||||
- **It dropped circle posts** until each member's copy named that member (owner decision 2026-10-04, v1.19.0).
|
||||
- A post addressed only to `[circle, circle/flock]` parses as `direct` there, and Mastodon keeps a `direct` post
|
||||
only if it names a local account or arrived in a known account's inbox (`Create#addresses_local_accounts?`).
|
||||
- But `ActivityPub::InboxesController#account_required?` looks only at `params[:account_username]`. A delivery to
|
||||
the numeric `/ap/users/:account_id/inbox` it now advertises therefore reaches the worker with no recipient and
|
||||
is rejected.
|
||||
- DMs are unaffected because they name the recipient.
|
||||
- Fixing it on our side means naming the member (or that server's members) in each copy's `cc`, which changes what a
|
||||
circle reveals: **owner decision pending**. Reporting it upstream is the other half.
|
||||
- Each member's copy now names that member in `cc` and mentions them silently, so Mastodon keeps it, and its signed
|
||||
refetch (`ActivityPub::FetchRemoteStatusService`, by its instance actor) gets the post back naming the members on
|
||||
that server. The same holds for a followers-only post's refetch, which used to answer 404, and a 404 on refetch
|
||||
makes Mastodon delete its copy. Reporting the numeric-inbox recipient loss upstream is still worth doing.
|
||||
- **With SecureMode on** (all six peers, 2026-10-04) every federation check passes: Mastodon, GoToSocial, Misskey,
|
||||
Sharkey, Akkoma and Lemmy all sign their fetches, and fetch our instance actor's key unsigned first.
|
||||
- Inbound `Block` from Mastodon is not enforced yet (P7).
|
||||
|
||||
### GoToSocial: 0.22.1 (2026-07-20)
|
||||
@@ -233,7 +237,10 @@ Findings:
|
||||
|
||||
**Pasture evidence (2026-10-03, GoToSocial 0.22.1, `tools/pasture/scenarios/gts.sh`):** 37 checks pass, three runs in a
|
||||
row. That is the original 33 plus four on statistics: described as gotosocial, inbound and outbound traffic counted,
|
||||
no account named.
|
||||
no account named. Since 2026-10-04 (v1.19.0) circle posts reach a GoToSocial member too. GoToSocial files a post for
|
||||
neither the public nor the author's followers as a direct message, like our DMs, and shows it only to the accounts it
|
||||
mentions. Being in `cc` stored it but left it invisible, so each member's copy also mentions that member silently.
|
||||
Such posts are then found in the member's conversations, never by a search on their URI.
|
||||
|
||||
### Misskey family: Misskey 2026.10.0, Sharkey 2025.4.7, Iceshrimp.NET 2026.1.2-beta, CherryPick 4.17
|
||||
|
||||
|
||||
+9
-3
@@ -31,9 +31,15 @@ Written 2026-10-01 from the original 2023 code, the decePubClient UI, a federati
|
||||
|
||||
Both open items were closed without a root step (owner decisions, 2026-10-04): the server fetches its geolocation
|
||||
databases itself, and the deploy makes and signs in as @thepra.
|
||||
- [ ] Everything on in production (owner decisions 2026-10-04): v1.18.0 self-updating geolocation, @thepra, the crawler
|
||||
on, sign-up by invitation, one registrations switch; then signed audiences with circles for everyone and SecureMode on,
|
||||
account privacy, and one answer everywhere (the mismatch sweep).
|
||||
- [ ] Everything on in production (owner decisions 2026-10-04):
|
||||
- v1.18.0, deployed and verified 2026-10-04: geolocation that updates itself (DB-IP Lite 2026-10 loaded), the deploy
|
||||
signing in as @thepra (undiscoverable), the crawler on (1010 servers known within the hour), sign-up by invitation
|
||||
with one registrations switch;
|
||||
- v1.19.0, 2026-10-04: circle posts reach Mastodon and GoToSocial members (each copy names and mentions its member),
|
||||
followers-only, direct and circle posts served to signed refetches from those they were for, SecureMode on (all six
|
||||
pasture peers pass under it), and account privacy (sign-in and recovery say nothing, recovery codes hashed for an
|
||||
hour, a recovered password ends every session, a deleted root's personas and groups are deleted everywhere);
|
||||
- still to come: one answer everywhere (the mismatch sweep).
|
||||
- [ ] P7 Threads, communities, moderation, the social graph
|
||||
- [ ] P8 Signatures, discovery, the long tail
|
||||
|
||||
|
||||
@@ -15,6 +15,13 @@ site() { curl -k --resolve "$1:6443:127.0.0.1" "${@:2}"; }
|
||||
pfetch() { curl -sk --connect-to privapub.test:443:127.0.0.1:6443 "$@"; }
|
||||
|
||||
# make_png <path>: an 8x8 red PNG, for uploads
|
||||
# the status an unsigned ActivityPub GET of a PrivaPub document gets
|
||||
pstatus() { pfetch -o /dev/null -w '%{http_code}' -H 'Accept: application/activity+json' "$1"; }
|
||||
# SecureMode (PRIVAPUB_ENV="Federation__SecureMode=true") answers every unsigned GET but the instance actor's 401, so a
|
||||
# post's author answering 401 means it is on; then 401 is what "not served" and "gone" look like to an unsigned reader
|
||||
secure_mode() { [ "$(pstatus "${1%%/scribbles/*}")" = "401" ]; }
|
||||
unserved() { local code; code=$(pstatus "$1"); if secure_mode "$1"; then [ "$code" = 401 ]; else [ "$code" = 404 ]; fi; }
|
||||
gone_unsigned() { local code; code=$(pstatus "$1"); if secure_mode "$1"; then [ "$code" = 401 ]; else [ "$code" = 410 ]; fi; }
|
||||
make_png() { python3 -c "
|
||||
import struct,zlib
|
||||
w=h=8
|
||||
|
||||
@@ -165,12 +165,12 @@ until_true 20 '[ "$(gcurl -s -H "$GH" "$G/api/v1/accounts/relationships?id[]=$ci
|
||||
curl -s -o /dev/null -X POST $P/clientapi/post/insert -H 'Content-Type: application/json' -H "Authorization: Bearer $jwt" \
|
||||
-d "{\"avatarId\":\"$alice_id\",\"text\":\"only the circle on GoToSocial sees this\",\"groupId\":\"$circle\"}"
|
||||
circle_uri=$(podman exec pasture-mongo mongosh --quiet PrivaPub --eval 'print(db.Post.findOne({Text:/only the circle on GoToSocial/}).ObjectURI)')
|
||||
if until_true 15 '[ "$(on_gts "$circle_uri" | j "print(len(d[\"statuses\"]))")" = "1" ]'; then
|
||||
ok "a circle post reaches its GoToSocial member"
|
||||
else
|
||||
xf "a circle post reaches its GoToSocial member (GoToSocial keeps no post addressed only to a collection it does not know)"
|
||||
fi
|
||||
[ "$(pfetch -o /dev/null -w '%{http_code}' -H 'Accept: application/activity+json' "$circle_uri")" = "404" ] && ok "the circle post is not served unsigned" || ko "circle post served unsigned"
|
||||
# GoToSocial files a post for neither the public nor the author's followers as a direct message (as it does our DMs),
|
||||
# shown only to the accounts it mentions; so each member's copy names and silently mentions that member. Like a DM it
|
||||
# is then in gtsuser's conversations, never in a search by URI.
|
||||
until_true 15 'gcurl -s -H "$GH" "$G/api/v1/conversations" | grep -q "only the circle on GoToSocial sees this"' \
|
||||
&& ok "a circle post, naming its member, reaches its GoToSocial member" || ko "circle post missing on GoToSocial"
|
||||
unserved "$circle_uri" && ok "the circle post is not served unsigned" || ko "circle post served unsigned"
|
||||
|
||||
echo "locked personas"
|
||||
LT=$(privapub_token locked_alice); LH="Authorization: Bearer $LT"
|
||||
|
||||
@@ -39,7 +39,10 @@ cw_uri=$(curl -s -X POST -H "$AH" $P/api/v1/statuses -d 'status=behind a warning
|
||||
until_true 30 '[ "$(m_status_by_uri "$alice_on_m" "$cw_uri" | j "print(d and d[\"spoiler_text\"]==\"spoilers\" and d[\"sensitive\"])")" = "True" ]' && ok "a content warning survives to Mastodon" || ko "content warning lost on Mastodon"
|
||||
fo_uri=$(curl -s -X POST -H "$AH" $P/api/v1/statuses -d 'status=only for followers&visibility=private' | j "print(d['uri'])")
|
||||
until_true 30 '[ "$(m_status_by_uri "$alice_on_m" "$fo_uri" | j "print(d and d[\"visibility\"])")" = "private" ]' && ok "a followers-only post reaches Mastodon as private" || ko "followers-only post missing or widened on Mastodon"
|
||||
[ "$(pfetch -o /dev/null -w '%{http_code}' -H 'Accept: application/activity+json' "$fo_uri")" = "404" ] && ok "the followers-only post is not served unsigned" || ko "followers-only post served unsigned"
|
||||
unserved "$fo_uri" && ok "the followers-only post is not served unsigned" || ko "followers-only post served unsigned"
|
||||
# Mastodon deletes its copy when a refetch answers 404, so a signed refetch from a follower's server is answered
|
||||
fo_refetch=$(podman exec pasture-mastodon bin/rails runner "s = ActivityPub::FetchRemoteStatusService.new.call('$fo_uri'); puts(s.present? ? s.visibility : 'lost')" 2>/dev/null | tail -1)
|
||||
[ "$fo_refetch" = "private" ] && ok "Mastodon's signed refetch of the followers-only post keeps it private" || ko "Mastodon's refetch of the followers-only post: $fo_refetch"
|
||||
|
||||
echo " replies"
|
||||
mcurl -o /dev/null -X POST -H "$MH" "$M/api/v1/statuses" -d "status=@alice_masto@privapub.test replying from Mastodon&in_reply_to_id=$a_post_on_m&visibility=public"
|
||||
@@ -112,7 +115,7 @@ cw_on_m=$(m_status_by_uri "$alice_on_m" "$cw_uri" | j "print(d['id'])")
|
||||
cw_id=$(curl -s -H "$AH" "$P/api/v1/accounts/$a_poll_id/statuses" | j "print(next(s['id'] for s in d if s['uri']=='$cw_uri'))")
|
||||
curl -s -o /dev/null -X DELETE -H "$AH" "$P/api/v1/statuses/$cw_id"
|
||||
until_true 30 '[ "$(mcurl -o /dev/null -w "%{http_code}" -H "$MH" "$M/api/v1/statuses/$cw_on_m")" = "404" ]' && ok "alice_masto's delete reaches Mastodon" || ko "delete not applied on Mastodon"
|
||||
[ "$(pfetch -o /dev/null -w '%{http_code}' -H 'Accept: application/activity+json' "$cw_uri")" = "410" ] && ok "the deleted post answers 410" || ko "deleted post does not answer 410"
|
||||
gone_unsigned "$cw_uri" && ok "the deleted post answers 410" || ko "deleted post does not answer 410"
|
||||
mcurl -o /dev/null -X DELETE -H "$MH" "$M/api/v1/statuses/$m_poll"
|
||||
until_true 30 '! curl -s -H "$AH" "$P/api/v1/timelines/home" | grep -q "Mastodon asks"' && ok "mastouser's delete reaches PrivaPub" || ko "Mastodon's delete not applied on PrivaPub"
|
||||
|
||||
@@ -144,20 +147,19 @@ circle_post=$(curl -s -X POST $P/clientapi/post/insert -H 'Content-Type: applica
|
||||
-d "{\"avatarId\":\"$alice_id\",\"text\":\"only the circle sees this\",\"groupId\":\"$circle\"}")
|
||||
circle_uri=$(podman exec pasture-mongo mongosh --quiet PrivaPub --eval 'print(db.Post.findOne({Text:/only the circle sees this/},{ObjectURI:1}).ObjectURI)')
|
||||
# Mastodon 4.7 learns whose personal inbox a delivery reached only from /users/<name>/inbox, never from the numeric
|
||||
# /ap/users/<id>/inbox it now advertises, and keeps a post naming no local account only for that recipient
|
||||
# (InboxesController#account_required?, Create#addresses_local_accounts?). A circle post names only the circle.
|
||||
# /ap/users/<id>/inbox it now advertises, and keeps a post naming no local account of its own only for that recipient
|
||||
# (InboxesController#account_required?, Create#addresses_local_accounts?). So each member's copy names that member.
|
||||
m_stored() { podman exec pasture-mastodon bin/rails runner "puts Status.exists?(uri: '$1')" 2>/dev/null | tail -1; }
|
||||
if until_true 15 '[ "$(m_stored "$circle_uri")" = "true" ]'; then
|
||||
ok "a circle post reaches its Mastodon member"
|
||||
else
|
||||
xf "a circle post reaches its Mastodon member (Mastodon 4.7 loses the recipient of numeric-inbox deliveries; owner decision pending)"
|
||||
fi
|
||||
until_true 15 '[ "$(m_stored "$circle_uri")" = "true" ]' && ok "a circle post, naming its member, reaches its Mastodon member" || ko "circle post missing on Mastodon"
|
||||
# a signed refetch, as Mastodon does it, is answered for a member's server and names the member, so the copy stays
|
||||
refetched=$(podman exec pasture-mastodon bin/rails runner "s = ActivityPub::FetchRemoteStatusService.new.call('$circle_uri'); puts(s.present? ? 'kept' : 'lost')" 2>/dev/null | tail -1)
|
||||
[ "$refetched" = "kept" ] && ok "Mastodon's signed refetch of the circle post keeps it" || ko "Mastodon's refetch of the circle post failed ($refetched)"
|
||||
mastodon_user outsider
|
||||
OT=$(mastodon_token outsider)
|
||||
alice_on_m_o=$(mcurl -H "Authorization: Bearer $OT" "$M/api/v2/search?q=@alice_masto@privapub.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
|
||||
[ "$(mcurl -H "Authorization: Bearer $OT" "$M/api/v1/accounts/$alice_on_m_o/statuses?limit=40" | j "print(any(s['uri']=='$circle_uri' for s in d))")" = "False" ] \
|
||||
&& ok "another Mastodon user does not see the circle post" || ko "the circle post leaked to a non-member"
|
||||
[ "$(pfetch -o /dev/null -w '%{http_code}' -H 'Accept: application/activity+json' "$circle_uri")" = "404" ] && ok "the circle post is not served unsigned" || ko "circle post served unsigned"
|
||||
unserved "$circle_uri" && ok "the circle post is not served unsigned" || ko "circle post served unsigned"
|
||||
|
||||
echo " reports"
|
||||
curl -s -o /dev/null -X POST -H "$AH" "$P/api/v1/reports" -d "account_id=$masto_on_p&comment=pasture-report-$circle_name&forward=true"
|
||||
|
||||
Reference in new issue
Block a user