Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
125a49a1a0 | ||
|
|
5e34517e73 | ||
|
|
85fdff606d | ||
|
|
00b2685cf4 | ||
|
|
7c6fe80f1b | ||
|
|
e6729c77e7 |
No files matched your search
@@ -15,5 +15,5 @@ jobs:
|
|||||||
- name: Build
|
- name: Build
|
||||||
run: dotnet build PrivaPub.sln -c Release
|
run: dotnet build PrivaPub.sln -c Release
|
||||||
|
|
||||||
- name: Test
|
- name: Test (unit and integration, on a throwaway mongod)
|
||||||
run: dotnet test PrivaPub.sln -c Release --no-build
|
run: tools/ci/with-test-mongod.sh dotnet test PrivaPub.sln -c Release --no-build
|
||||||
@@ -28,8 +28,8 @@ jobs:
|
|||||||
echo "BUILD_REF=${GITHUB_REF_NAME:-master}" >> "$GITHUB_ENV"
|
echo "BUILD_REF=${GITHUB_REF_NAME:-master}" >> "$GITHUB_ENV"
|
||||||
echo "BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_ENV"
|
echo "BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Test
|
- name: Test (unit and integration, on a throwaway mongod)
|
||||||
run: dotnet test PrivaPub.sln -c Release
|
run: tools/ci/with-test-mongod.sh dotnet test PrivaPub.sln -c Release
|
||||||
|
|
||||||
- name: Publish
|
- name: Publish
|
||||||
run: |
|
run: |
|
||||||
@@ -90,6 +90,8 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Verify what is being served
|
- name: Verify what is being served
|
||||||
|
env:
|
||||||
|
SMOKE_TOKEN: ${{ secrets.PRIVAPUB_SMOKE_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
served=$(curl -fsS "$PUBLIC_URL/build.json" | python3 -c "import json,sys; print(json.load(sys.stdin).get('commit',''))")
|
served=$(curl -fsS "$PUBLIC_URL/build.json" | python3 -c "import json,sys; print(json.load(sys.stdin).get('commit',''))")
|
||||||
[ "$served" = "$BUILD_COMMIT" ] || { echo "::error::served build is '$served', expected '$BUILD_COMMIT'"; exit 1; }
|
[ "$served" = "$BUILD_COMMIT" ] || { echo "::error::served build is '$served', expected '$BUILD_COMMIT'"; exit 1; }
|
||||||
@@ -104,5 +106,6 @@ jobs:
|
|||||||
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/activity+json' \
|
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/activity+json' \
|
||||||
--data '{"type":"Follow","actor":"https://example.org/users/x","object":"'"$PUBLIC_URL"'/peasants/privapub"}' "$PUBLIC_URL/human-centipede")
|
--data '{"type":"Follow","actor":"https://example.org/users/x","object":"'"$PUBLIC_URL"'/peasants/privapub"}' "$PUBLIC_URL/human-centipede")
|
||||||
[ "$code" = "401" ] || { echo "::error::an unsigned inbox POST answered $code"; exit 1; }
|
[ "$code" = "401" ] || { echo "::error::an unsigned inbox POST answered $code"; exit 1; }
|
||||||
tools/smoke/mastodon-api.sh "$PUBLIC_URL"
|
tools/smoke/mastodon-api.sh "$PUBLIC_URL" "$SMOKE_TOKEN"
|
||||||
|
[ -n "$SMOKE_TOKEN" ] || echo "::warning::PRIVAPUB_SMOKE_TOKEN is not set, so the signed-in API was not checked"
|
||||||
echo "::notice::serving $served"
|
echo "::notice::serving $served"
|
||||||
@@ -48,6 +48,7 @@ actor-scoped route gets a name in the same spirit, agreed with the owner, and a
|
|||||||
| Notes | `/peasants/{name}/scribbles/{id}` |
|
| Notes | `/peasants/{name}/scribbles/{id}` |
|
||||||
| Activities | `/peasants/{name}/grunts/{id}` (`create-{postId}` resolves) |
|
| Activities | `/peasants/{name}/grunts/{id}` (`create-{postId}` resolves) |
|
||||||
| DM context | `/peasants/{name}/whispers/{id}` |
|
| DM context | `/peasants/{name}/whispers/{id}` |
|
||||||
|
| Quote permission (FEP-044f `QuoteAuthorization`) | `/peasants/{name}/parrot-licences/{id}` |
|
||||||
| Token refresh | `/clientapi/user/sniff/again` |
|
| Token refresh | `/clientapi/user/sniff/again` |
|
||||||
|
|
||||||
Agreed for later phases: `/gossip`, `/drool`, `/echoes` (replies, likes, shares), `/trophies` (featured), `/tattoos`
|
Agreed for later phases: `/gossip`, `/drool`, `/echoes` (replies, likes, shares), `/trophies` (featured), `/tattoos`
|
||||||
@@ -123,6 +124,7 @@ dotnet build PrivaPub.sln -c Release
|
|||||||
dotnet test PrivaPub.sln # unit tests; integration tests skip
|
dotnet test PrivaPub.sln # unit tests; integration tests skip
|
||||||
PRIVAPUB_TEST_MONGOD=1 dotnet test PrivaPub.sln # all of them, against mongod on 127.0.0.1:27017
|
PRIVAPUB_TEST_MONGOD=1 dotnet test PrivaPub.sln # all of them, against mongod on 127.0.0.1:27017
|
||||||
# (PRIVAPUB_TEST_MONGO overrides; a fresh database per run, dropped after)
|
# (PRIVAPUB_TEST_MONGO overrides; a fresh database per run, dropped after)
|
||||||
|
tools/ci/with-test-mongod.sh dotnet test PrivaPub.sln # all of them, on a throwaway mongod, as CI runs them
|
||||||
cd PrivaPub && ASPNETCORE_ENVIRONMENT=Development \
|
cd PrivaPub && ASPNETCORE_ENVIRONMENT=Development \
|
||||||
Kestrel__Endpoints__Http__Url=http://127.0.0.1:6970 Kestrel__Endpoints__Http__Protocols=Http1AndHttp2 \
|
Kestrel__Endpoints__Http__Url=http://127.0.0.1:6970 Kestrel__Endpoints__Http__Protocols=Http1AndHttp2 \
|
||||||
AppConfiguration__BackendBaseAddress=http://127.0.0.1:6970 MongoSettings__Database=PrivaPubTest \
|
AppConfiguration__BackendBaseAddress=http://127.0.0.1:6970 MongoSettings__Database=PrivaPubTest \
|
||||||
@@ -197,7 +199,10 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
|
|||||||
3. Read parameters through `Params` (query, form and JSON merged Rails-style), never MVC binding. A value type read
|
3. Read parameters through `Params` (query, form and JSON merged Rails-style), never MVC binding. A value type read
|
||||||
from a conditional must say `(int?)null`, not `default`: that bug once made every list one item long.
|
from a conditional must say `(int?)null`, not `default`: that bug once made every list one item long.
|
||||||
4. Answer with `Json(...)` (snake_case, explicit nulls) or `Error(status, message)`; page lists with `Page` and `Link`.
|
4. Answer with `Json(...)` (snake_case, explicit nulls) or `Error(status, message)`; page lists with `Page` and `Link`.
|
||||||
5. Unsupported features answer empty lists or 422 with a message, never 404 or 500, so clients degrade.
|
5. Unsupported features answer empty lists or 422 with a message, never 404 or 500, so clients degrade. Any `/api`
|
||||||
|
error that leaves without a body (a challenge, a 404 from routing, a 429) gets Mastodon's `{"error": ...}` from
|
||||||
|
`UseMastodonErrorBodies`. `NeverFiveHundredTests` walks every route with junk ids, anonymously, as a persona and
|
||||||
|
with a junk token.
|
||||||
6. Advertise `4.2.0 (compatible; PrivaPub)` until grouped notifications exist.
|
6. Advertise `4.2.0 (compatible; PrivaPub)` until grouped notifications exist.
|
||||||
7. **What a Mastodon `Status` cannot say goes in `Status.privapub`** (`PrivaPubStatus`): object type, title, excerpt,
|
7. **What a Mastodon `Status` cannot say goes in `Status.privapub`** (`PrivaPubStatus`): object type, title, excerpt,
|
||||||
cover, the author's source, link, video, audio and event details, and up/down votes. Every media URL in it goes
|
cover, the author's source, link, video, audio and event details, and up/down votes. Every media URL in it goes
|
||||||
@@ -279,6 +284,10 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
|
|||||||
`QuotesCount` moves with the accepted state, never with the raw key. When a persona quotes, `QuotePermission` decides:
|
`QuotesCount` moves with the accepted state, never with the raw key. When a persona quotes, `QuotePermission` decides:
|
||||||
asking first for posts that state a policy, quoting at once for posts that state none, refusing otherwise. Only `quote`
|
asking first for posts that state a policy, quoting at once for posts that state none, refusing otherwise. Only `quote`
|
||||||
of a post that asks for consent puts `quote` in our JSON; older-key quotes leave it out, as Sharkey learned they must.
|
of a post that asks for consent puts `quote` in our JSON; older-key quotes leave it out, as Sharkey learned they must.
|
||||||
|
- **Our quote policy is `ActivityPubRenderer.QuotableBy`:** the post's `LocalQuotePolicy`, falling back to the persona's
|
||||||
|
`Settings.QuotePolicy` (default `public`, owner decision), and always `nobody` for anything but public and unlisted. The
|
||||||
|
same rule writes `canQuote`, answers `QuoteRequest`s (`QuoteService.ReceiveRequest`) and fills `quote_approval`, so they
|
||||||
|
cannot disagree. A persona quoting another persona gets a parrot-licence too, so other servers see an approved quote.
|
||||||
- **A server is described on arrival, never on read.** The first record from a host enqueues `DescribeInstance` (its
|
- **A server is described on arrival, never on read.** The first record from a host enqueues `DescribeInstance` (its
|
||||||
NodeInfo, at most once a week, into `RemoteInstance`), so opening the details view tells nobody anything.
|
NodeInfo, at most once a week, into `RemoteInstance`), so opening the details view tells nobody anything.
|
||||||
- **Post ids are the timeline order, so they follow arrival, not `published`.** `PrivacyIds.Arrived` gives a remote post
|
- **Post ids are the timeline order, so they follow arrival, not `published`.** `PrivacyIds.Arrived` gives a remote post
|
||||||
@@ -311,18 +320,37 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
|
|||||||
stored as an edit date on every remote post. Write `(T?)null`. It has shipped four times (`MastodonParams.Bool/Int`,
|
stored as an edit date on every remote post. Write `(T?)null`. It has shipped four times (`MastodonParams.Bool/Int`,
|
||||||
`NoteParser.Int`, `NoteParser.Time`).
|
`NoteParser.Int`, `NoteParser.Time`).
|
||||||
- ActivityPub output is built with `System.Text.Json.Nodes` in `ActivityPubRenderer`, not typed models. Inbound
|
- ActivityPub output is built with `System.Text.Json.Nodes` in `ActivityPubRenderer`, not typed models. Inbound
|
||||||
documents are read through `InboxService.Id`/`Value`, which handle string, object and array.
|
documents are read through `ActivityJson.Id`/`Value`, which handle string, object and array.
|
||||||
- Libraries chosen for the roadmap: HtmlSanitizer, Markdig (`DisableHtml`), NSign (RFC 9421 inbound), OpenIddict +
|
- Libraries chosen for the roadmap: HtmlSanitizer, Markdig (`DisableHtml`), NSign (RFC 9421 inbound), OpenIddict +
|
||||||
OpenIddict.MongoDb, NetVips, Blurhash.Core, FFMpegCore. No ImageSharp (licence key enforced), no MassTransit.
|
OpenIddict.MongoDb, NetVips, Blurhash.Core, FFMpegCore. No ImageSharp (licence key enforced), no MassTransit.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
`PrivaPub.Tests` (xUnit v3). Unit tests need nothing; tests marked `Category=Integration` need a mongod and skip
|
`PrivaPub.Tests` (xUnit v3). Unit tests need nothing; tests marked `Category=Integration` need a mongod and skip
|
||||||
without `PRIVAPUB_TEST_MONGOD=1`. CI runs the unit tests only (the box's mongods are production).
|
without `PRIVAPUB_TEST_MONGOD=1`. CI (`build.yml` and `deploy.yml`) runs all of them through
|
||||||
|
`tools/ci/with-test-mongod.sh`, which starts a throwaway mongod on a random localhost port and deletes it afterwards.
|
||||||
|
The box's own mongods are production, so `MongoFixture` refuses port 27022, a data directory under `/var/lib/privapub`,
|
||||||
|
and in CI anything but the wrapper's mongod. With `PRIVAPUB_TEST_REQUIRE_MONGOD=1`, which the wrapper sets, a missing
|
||||||
|
mongod fails the run instead of silently skipping half the tests.
|
||||||
|
|
||||||
- `Support/Peer` is an in-process HTTP server answering on two origins (`127.0.0.1` and `localhost`), so origin rules
|
- `Support/Peer` is an in-process HTTP server answering on two origins (`127.0.0.1` and `localhost`), so origin rules
|
||||||
can be tested; `Support/RemoteActor` signs real deliveries with its own key.
|
can be tested; `Support/RemoteActor` signs real deliveries with its own key.
|
||||||
- Inbox scenarios go through `InboxService.Receive` with a signed request, not through the private handlers.
|
- Inbox scenarios go through `InboxReceiver.Receive` with a signed request, not through the private handlers.
|
||||||
|
- `Support/Host/PrivaPubHost` is the whole server under test (`WebApplicationFactory<Program>`, environment `Testing`,
|
||||||
|
configured only through `UseSetting`, on the fixture's database). `PrivaPubHost.Shared()` boots it once per run;
|
||||||
|
`SecureModeHost` is the same with `Federation:SecureMode`. Background workers are removed, so a test runs the jobs it
|
||||||
|
queued with `host.Run(j => ...)` or `host.RunInbox(activityId)`. `Accounts` signs a root up, adds personas, and gets a
|
||||||
|
Mastodon token through the real `/oauth` code flow; `RemoteActor.SignedPost`/`SignedGet` sign `HttpRequestMessage`s
|
||||||
|
for the real `/peasants` routes. Each client gets its own `X-Test-Client` address, so rate limits don't collide.
|
||||||
|
- All test classes share one database and run in parallel, so a test touches only rows it made:
|
||||||
|
- random names and GUIDs;
|
||||||
|
- `Harness.Outgoing` sees only deliveries queued since that harness started, because Peer ports are reused;
|
||||||
|
- a worker gets a scoped `new JobQueue(j => ...)` so it never leases another test's jobs;
|
||||||
|
- domain blocks are set with `DomainBlocks.Load`, never written to the database.
|
||||||
|
|
||||||
|
A test that must change something database-wide (drop indexes, run a migration over every post, let deliveries to
|
||||||
|
`localhost` fail and trip its breaker) goes in `[Xunit.Collection(nameof(Exclusive))]`, which runs alone, and
|
||||||
|
cleans up after itself. Pure logic belongs in an unconditional unit test, not in a Mongo-gated class.
|
||||||
|
|
||||||
Beyond the tests, verify by building, running locally, and exercising:
|
Beyond the tests, verify by building, running locally, and exercising:
|
||||||
- the client API (sign up, create an avatar, a group, a post);
|
- the client API (sign up, create an avatar, a group, a post);
|
||||||
@@ -332,7 +360,7 @@ Interop is checked against real servers, starting with the workstation's own pas
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
DOTNET=~/.dotnet/dotnet tools/pasture/run.sh up # podman: PrivaPub + the latest GoToSocial + Mongo, behind Caddy
|
DOTNET=~/.dotnet/dotnet tools/pasture/run.sh up # podman: PrivaPub + the latest GoToSocial + Mongo, behind Caddy
|
||||||
tools/pasture/interop.sh # 25 checks, each side driven through its own Mastodon API
|
tools/pasture/interop.sh # 33 checks, each side driven through its own Mastodon API
|
||||||
tools/pasture/run.sh down
|
tools/pasture/run.sh down
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+8
-1
@@ -50,6 +50,7 @@ actor document, a collection, NodeInfo or a delivery relates two avatars of the
|
|||||||
| Followers / following | `/peasants/{name}/groupies`, `/peasants/{name}/stalking` |
|
| Followers / following | `/peasants/{name}/groupies`, `/peasants/{name}/stalking` |
|
||||||
| Objects | `/peasants/{name}/scribbles/{id}` |
|
| Objects | `/peasants/{name}/scribbles/{id}` |
|
||||||
| Activities | `/peasants/{name}/grunts/{id}` |
|
| Activities | `/peasants/{name}/grunts/{id}` |
|
||||||
|
| Quote permissions (`QuoteAuthorization`) | `/peasants/{name}/parrot-licences/{id}` |
|
||||||
| Direct-message context | `/peasants/{name}/whispers/{id}` |
|
| Direct-message context | `/peasants/{name}/whispers/{id}` |
|
||||||
| Profile and post pages | `/@{name}`, `/@{name}/{id}` |
|
| Profile and post pages | `/@{name}`, `/@{name}/{id}` |
|
||||||
|
|
||||||
@@ -130,7 +131,13 @@ An incoming `Update` without a newer `updated` only refreshes counts and details
|
|||||||
stamp we can verify; the post is then updated with `quoteAuthorization`.
|
stamp we can verify; the post is then updated with `quoteAuthorization`.
|
||||||
- Posts that state no policy are quoted the older way, without `quote`.
|
- Posts that state no policy are quoted the older way, without `quote`.
|
||||||
- Quoting posts are also delivered to the quoted author.
|
- Quoting posts are also delivered to the quoted author.
|
||||||
- **Not yet.** Our own posts cannot be quoted by others yet: we do not issue stamps.
|
- **Quoting our accounts.** Our public and unlisted posts state `interactionPolicy.canQuote`. By default anyone may quote,
|
||||||
|
automatically (as on Mastodon); an account can choose followers only or nobody, and so can each post.
|
||||||
|
- A `QuoteRequest` is answered with `Accept{result}` naming a stamp at `/peasants/{name}/parrot-licences/{id}`, or
|
||||||
|
with `Reject`.
|
||||||
|
- A stamp is a `QuoteAuthorization` naming both posts. A revoked stamp answers 410 and is announced with
|
||||||
|
`Delete{stamp}`.
|
||||||
|
- Followers-only posts and DMs can never be quoted.
|
||||||
|
|
||||||
**Custom emoji** (`Emoji` tags) are read on posts, display names, bios and profile fields, at most 64 per object.
|
**Custom emoji** (`Emoji` tags) are read on posts, display names, bios and profile fields, at most 64 per object.
|
||||||
**Profiles** keep their header, profile fields, `manuallyApprovesFollowers`, `published`, `movedTo`, `indexable`,
|
**Profiles** keep their header, profile fields, `manuallyApprovesFollowers`, `published`, `movedTo`, `indexable`,
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
|
||||||
|
using PrivaPub.Federation.Moderation;
|
||||||
|
using PrivaPub.Models.Federation;
|
||||||
|
|
||||||
|
namespace PrivaPub.Tests.Federation
|
||||||
|
{
|
||||||
|
public class DomainBlocksTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void A_block_covers_subdomains_but_not_lookalikes()
|
||||||
|
{
|
||||||
|
var blocks = new DomainBlocks(NullLogger<DomainBlocks>.Instance);
|
||||||
|
blocks.Load(new[] { new DomainBlock { Domain = "evil.example", Severity = DomainBlockSeverity.Silence } });
|
||||||
|
|
||||||
|
Assert.NotNull(blocks.Find("evil.example"));
|
||||||
|
Assert.NotNull(blocks.Find("A.Evil.Example."));
|
||||||
|
Assert.Null(blocks.Find("notevil.example"));
|
||||||
|
Assert.False(blocks.IsSuspended("evil.example"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_suspension_is_found_from_any_subdomain()
|
||||||
|
{
|
||||||
|
var blocks = new DomainBlocks(NullLogger<DomainBlocks>.Instance);
|
||||||
|
blocks.Load(new[]
|
||||||
|
{
|
||||||
|
new DomainBlock { Domain = " Spam.Example. ", Severity = DomainBlockSeverity.Suspend },
|
||||||
|
new DomainBlock { Domain = "", Severity = DomainBlockSeverity.Suspend }
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.True(blocks.IsSuspended("deep.inside.spam.example"));
|
||||||
|
Assert.False(blocks.IsSuspended("example"));
|
||||||
|
Assert.Null(blocks.Find(default));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -269,10 +269,7 @@ namespace PrivaPub.Tests.Federation
|
|||||||
var token = TestContext.Current.CancellationToken;
|
var token = TestContext.Current.CancellationToken;
|
||||||
var alice = await LocalAvatar("alice");
|
var alice = await LocalAvatar("alice");
|
||||||
var bob = new RemoteActor(_peer, "bob", _peer.B);
|
var bob = new RemoteActor(_peer, "bob", _peer.B);
|
||||||
await DB.Default.SaveAsync(new DomainBlock { Domain = "localhost", Severity = DomainBlockSeverity.Suspend }, token);
|
_blocks.Load(new[] { new DomainBlock { Domain = "localhost", Severity = DomainBlockSeverity.Suspend } });
|
||||||
try
|
|
||||||
{
|
|
||||||
await _blocks.Reload(token);
|
|
||||||
var before = _peer.Requests.Count;
|
var before = _peer.Requests.Count;
|
||||||
|
|
||||||
var result = await Deliver(bob, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri));
|
var result = await Deliver(bob, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri));
|
||||||
@@ -281,27 +278,6 @@ namespace PrivaPub.Tests.Federation
|
|||||||
Assert.Equal(before, _peer.Requests.Count);
|
Assert.Equal(before, _peer.Requests.Count);
|
||||||
Assert.False(await DB.Default.Find<Post>().Match(p => p.ActorURI == bob.Id).ExecuteAnyAsync(token));
|
Assert.False(await DB.Default.Find<Post>().Match(p => p.ActorURI == bob.Id).ExecuteAnyAsync(token));
|
||||||
}
|
}
|
||||||
finally
|
|
||||||
{
|
|
||||||
await DB.Default.DeleteAsync<DomainBlock>(b => b.Domain == "localhost");
|
|
||||||
await _blocks.Reload(token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void A_block_covers_subdomains_but_not_lookalikes()
|
|
||||||
{
|
|
||||||
var blocks = new DomainBlocks(NullLogger<DomainBlocks>.Instance);
|
|
||||||
typeof(DomainBlocks).GetField("_blocks", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
|
|
||||||
.SetValue(blocks, new Dictionary<string, DomainBlock> { ["evil.example"] = new() { Domain = "evil.example", Severity = DomainBlockSeverity.Silence } });
|
|
||||||
typeof(DomainBlocks).GetField("_loadedAt", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
|
|
||||||
.SetValue(blocks, DateTime.UtcNow);
|
|
||||||
|
|
||||||
Assert.NotNull(blocks.Find("evil.example"));
|
|
||||||
Assert.NotNull(blocks.Find("A.Evil.Example."));
|
|
||||||
Assert.Null(blocks.Find("notevil.example"));
|
|
||||||
Assert.False(blocks.IsSuspended("evil.example"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task A_bad_signature_is_a_401()
|
public async Task A_bad_signature_is_a_401()
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using PrivaPub.Federation.Objects;
|
||||||
|
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace PrivaPub.Tests.Federation
|
||||||
|
{
|
||||||
|
public class ObjectRecordsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void An_object_larger_than_the_cap_keeps_only_its_hash()
|
||||||
|
{
|
||||||
|
var big = new JsonObject { ["content"] = new string('x', ObjectRecords.MaxRawBytes + 1) };
|
||||||
|
|
||||||
|
var (text, hash, bytes, truncated) = ObjectRecords.Capture(big);
|
||||||
|
|
||||||
|
Assert.Null(text);
|
||||||
|
Assert.True(truncated);
|
||||||
|
Assert.True(bytes > ObjectRecords.MaxRawBytes);
|
||||||
|
Assert.Equal(64, hash.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_small_object_is_kept_whole_with_its_hash()
|
||||||
|
{
|
||||||
|
var small = new JsonObject { ["type"] = "Note", ["content"] = "hi" };
|
||||||
|
|
||||||
|
var (text, hash, bytes, truncated) = ObjectRecords.Capture(small);
|
||||||
|
|
||||||
|
Assert.Equal(small.ToJsonString(), text);
|
||||||
|
Assert.False(truncated);
|
||||||
|
Assert.Equal(text.Length, bytes);
|
||||||
|
Assert.Equal(64, hash.Length);
|
||||||
|
Assert.Equal(default, ObjectRecords.Capture(default));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,18 +68,5 @@ namespace PrivaPub.Tests.Federation
|
|||||||
Assert.Equal("<p>hi again</p>", JsonNode.Parse(Assert.Single(record.Revisions).Raw)!["content"]!.GetValue<string>());
|
Assert.Equal("<p>hi again</p>", JsonNode.Parse(Assert.Single(record.Revisions).Raw)!["content"]!.GetValue<string>());
|
||||||
Assert.True(await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.DescribeInstance && j.Payload == record.Host).ExecuteAnyAsync(token));
|
Assert.True(await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.DescribeInstance && j.Payload == record.Host).ExecuteAnyAsync(token));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void An_object_larger_than_the_cap_keeps_only_its_hash()
|
|
||||||
{
|
|
||||||
var big = new JsonObject { ["content"] = new string('x', ObjectRecords.MaxRawBytes + 1) };
|
|
||||||
|
|
||||||
var (text, hash, bytes, truncated) = ObjectRecords.Capture(big);
|
|
||||||
|
|
||||||
Assert.Null(text);
|
|
||||||
Assert.True(truncated);
|
|
||||||
Assert.True(bytes > ObjectRecords.MaxRawBytes);
|
|
||||||
Assert.Equal(64, hash.Length);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,15 +139,15 @@ namespace PrivaPub.Tests.Federation
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task A_legacy_post_is_quoted_without_asking_and_another_persona_cannot_be_quoted_yet()
|
public async Task A_legacy_post_is_quoted_without_asking_and_a_persona_that_allows_nobody_cannot_be_quoted()
|
||||||
{
|
{
|
||||||
var token = TestContext.Current.CancellationToken;
|
var token = TestContext.Current.CancellationToken;
|
||||||
var (aliceRoot, alice) = await _harness.Persona("alice");
|
var (aliceRoot, alice) = await _harness.Persona("alice");
|
||||||
var (bobRoot, bob) = await _harness.Persona("bob");
|
var (bobRoot, bob) = await _harness.Persona("bob");
|
||||||
var kitty = new RemoteActor(_harness.Peer, "kitty");
|
var kitty = new RemoteActor(_harness.Peer, "kitty");
|
||||||
var legacy = await Delivered(kitty, alice.Uri);
|
var legacy = await Delivered(kitty, alice.Uri);
|
||||||
await _harness.Posts.InsertPost(bobRoot, new InsertPostForm { AvatarId = bob.Id, Text = "mine" }, token);
|
bob.Settings.QuotePolicy = PrivaPub.Models.User.QuotePolicies.Nobody;
|
||||||
var bobs = await DB.Default.Find<Post>().Match(p => p.GroupUserId == bob.Id).ExecuteFirstAsync(token);
|
var bobs = (await _harness.Statuses.Publish(bob, new StatusDraft { Text = "mine" }, token)).Post;
|
||||||
|
|
||||||
var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "nice", QuotedStatusId = legacy.ID }, token);
|
var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "nice", QuotedStatusId = legacy.ID }, token);
|
||||||
Assert.Equal(QuoteState.Accepted, outcome.Post.QuoteState);
|
Assert.Equal(QuoteState.Accepted, outcome.Post.QuoteState);
|
||||||
@@ -155,5 +155,67 @@ namespace PrivaPub.Tests.Federation
|
|||||||
Assert.False((await _harness.Statuses.Publish(alice, new StatusDraft { Text = "and this", QuotedStatusId = bobs.ID }, token)).Ok);
|
Assert.False((await _harness.Statuses.Publish(alice, new StatusDraft { Text = "and this", QuotedStatusId = bobs.ID }, token)).Ok);
|
||||||
Assert.True((await _harness.Statuses.Publish(bob, new StatusDraft { Text = "me again", QuotedStatusId = bobs.ID }, token)).Ok);
|
Assert.True((await _harness.Statuses.Publish(bob, new StatusDraft { Text = "me again", QuotedStatusId = bobs.ID }, token)).Ok);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
JsonObject Request(RemoteActor quoter, string quotedUri, string quotingId) => new()
|
||||||
|
{
|
||||||
|
["id"] = NewId(quoter, "activities"), ["type"] = "QuoteRequest", ["actor"] = quoter.Id, ["object"] = quotedUri,
|
||||||
|
["instrument"] = new JsonObject { ["id"] = quotingId, ["type"] = "Note", ["attributedTo"] = quoter.Id, ["quote"] = quotedUri, ["content"] = "<p>so true</p>" }
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Anyone_may_quote_a_persona_by_default_and_the_licence_can_be_revoked()
|
||||||
|
{
|
||||||
|
var token = TestContext.Current.CancellationToken;
|
||||||
|
var (_, alice) = await _harness.Persona("alice");
|
||||||
|
var mallory = new RemoteActor(_harness.Peer, "mallory");
|
||||||
|
await _harness.Deliver(mallory, "/human-centipede", new JsonObject { ["id"] = NewId(mallory, "follows"), ["type"] = "Follow", ["actor"] = mallory.Id, ["object"] = alice.Uri });
|
||||||
|
var original = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "quote me" }, token)).Post;
|
||||||
|
var created = (await _harness.Outgoing(mallory.Id + "/inbox")).Single(a => a["type"]!.GetValue<string>() == "Create")["object"]!;
|
||||||
|
Assert.Equal(Addressing.Public, created["interactionPolicy"]!["canQuote"]!["automaticApproval"]![0]!.GetValue<string>());
|
||||||
|
|
||||||
|
var quotingId = NewId(mallory, "notes");
|
||||||
|
await _harness.Deliver(mallory, "/human-centipede", Request(mallory, original.ObjectURI, quotingId));
|
||||||
|
var accept = Assert.Single(await _harness.Outgoing(mallory.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "Accept" && a["result"] != null);
|
||||||
|
var licence = accept["result"]!.GetValue<string>();
|
||||||
|
Assert.StartsWith(alice.Uri + "/parrot-licences/", licence);
|
||||||
|
|
||||||
|
var quoting = await Delivered(mallory, alice.Uri, n => { n["id"] = quotingId; n["quote"] = original.ObjectURI; n["quoteAuthorization"] = licence; });
|
||||||
|
Assert.Equal(QuoteState.Accepted, quoting.QuoteState);
|
||||||
|
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(original.ID, token)).QuotesCount);
|
||||||
|
|
||||||
|
Assert.True(await _harness.Quotes.RevokeLicence(original, quoting, token));
|
||||||
|
Assert.Equal(QuoteState.Revoked, (await DB.Default.Find<Post>().OneAsync(quoting.ID, token)).QuoteState);
|
||||||
|
Assert.Contains(await _harness.Outgoing(mallory.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "Delete" && a["object"]!.GetValue<string>() == licence);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_followers_only_persona_turns_a_stranger_down()
|
||||||
|
{
|
||||||
|
var token = TestContext.Current.CancellationToken;
|
||||||
|
var (_, alice) = await _harness.Persona("alice");
|
||||||
|
alice.Settings.QuotePolicy = PrivaPub.Models.User.QuotePolicies.Followers;
|
||||||
|
var original = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "friends only quoting" }, token)).Post;
|
||||||
|
var stranger = new RemoteActor(_harness.Peer, "stranger");
|
||||||
|
|
||||||
|
await _harness.Deliver(stranger, "/human-centipede", Request(stranger, original.ObjectURI, NewId(stranger, "notes")));
|
||||||
|
|
||||||
|
Assert.Equal("Reject", Assert.Single(await _harness.Outgoing(stranger.Id + "/inbox"))["type"]!.GetValue<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task One_persona_quotes_another_with_a_licence()
|
||||||
|
{
|
||||||
|
var token = TestContext.Current.CancellationToken;
|
||||||
|
var (_, alice) = await _harness.Persona("alice");
|
||||||
|
var (_, bob) = await _harness.Persona("bob");
|
||||||
|
var original = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "public thought" }, token)).Post;
|
||||||
|
|
||||||
|
var outcome = await _harness.Statuses.Publish(bob, new StatusDraft { Text = "agreed", QuotedStatusId = original.ID }, token);
|
||||||
|
|
||||||
|
Assert.True(outcome.Ok);
|
||||||
|
Assert.Equal(QuoteState.Accepted, outcome.Post.QuoteState);
|
||||||
|
Assert.StartsWith(alice.Uri + "/parrot-licences/", outcome.Post.QuoteAuthorizationURI);
|
||||||
|
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(original.ID, token)).QuotesCount);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc.Controllers;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
using MongoDB.Entities;
|
||||||
|
|
||||||
|
using PrivaPub.Federation.Inbox;
|
||||||
|
using PrivaPub.Infrastructure.Jobs;
|
||||||
|
using PrivaPub.Models;
|
||||||
|
using PrivaPub.Models.Jobs;
|
||||||
|
using PrivaPub.Models.Post;
|
||||||
|
using PrivaPub.Tests.Support;
|
||||||
|
using PrivaPub.Tests.Support.Host;
|
||||||
|
|
||||||
|
using System.Net;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace PrivaPub.Tests.Http
|
||||||
|
{
|
||||||
|
[Trait("Category", "Integration")]
|
||||||
|
public sealed class HostBootTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
PrivaPubHost _host;
|
||||||
|
|
||||||
|
public async ValueTask InitializeAsync()
|
||||||
|
{
|
||||||
|
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||||
|
_host = await PrivaPubHost.Shared();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task The_host_shares_the_fixture_database()
|
||||||
|
{
|
||||||
|
Assert.True(await DB.Default.Find<AppConfiguration>().ExecuteAnyAsync(TestContext.Current.CancellationToken));
|
||||||
|
using var client = _host.Client();
|
||||||
|
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/build.json", TestContext.Current.CancellationToken)).StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task The_harness_handles_the_same_activities_as_the_server()
|
||||||
|
{
|
||||||
|
await using var harness = await Harness.Start();
|
||||||
|
var registered = _host.Services.GetServices<IActivityHandler>().Select(h => h.GetType()).OrderBy(t => t.Name);
|
||||||
|
var harnessed = harness.Handlers.Select(h => h.GetType()).OrderBy(t => t.Name);
|
||||||
|
|
||||||
|
Assert.Equal(registered, harnessed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Every_job_kind_has_exactly_one_handler()
|
||||||
|
{
|
||||||
|
var handlers = _host.Services.GetServices<IJobHandler>().GroupBy(h => h.Kind).ToDictionary(g => g.Key, g => g.Count());
|
||||||
|
|
||||||
|
foreach (var kind in Enum.GetValues<JobKind>())
|
||||||
|
Assert.True(handlers.GetValueOrDefault(kind) == 1, $"{kind} has {handlers.GetValueOrDefault(kind)} handlers");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Every_controller_and_page_model_can_be_made()
|
||||||
|
{
|
||||||
|
using var scope = _host.Services.CreateScope();
|
||||||
|
var controllers = _host.Get<IActionDescriptorCollectionProvider>().ActionDescriptors.Items
|
||||||
|
.OfType<ControllerActionDescriptor>()
|
||||||
|
.Select(d => d.ControllerTypeInfo.AsType())
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
var pages = typeof(Program).Assembly.GetTypes().Where(t => t.IsSubclassOf(typeof(PageModel)) && !t.IsAbstract).ToList();
|
||||||
|
|
||||||
|
Assert.NotEmpty(controllers);
|
||||||
|
Assert.NotEmpty(pages);
|
||||||
|
foreach (var type in controllers.Concat(pages))
|
||||||
|
Assert.NotNull(ActivatorUtilities.CreateInstance(scope.ServiceProvider, type));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_service_graph_is_valid()
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
foreach (var descriptor in _host.Registered)
|
||||||
|
((ICollection<ServiceDescriptor>)services).Add(descriptor);
|
||||||
|
|
||||||
|
using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateOnBuild = true, ValidateScopes = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Swagger_is_not_served_outside_development()
|
||||||
|
{
|
||||||
|
using var client = _host.Client();
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/swagger/index.html", TestContext.Current.CancellationToken)).StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_persona_gets_a_mastodon_token_through_the_real_code_flow()
|
||||||
|
{
|
||||||
|
var root = await _host.SignUp();
|
||||||
|
var persona = await _host.Persona(root, "boot");
|
||||||
|
|
||||||
|
var token = await _host.MastodonToken(persona);
|
||||||
|
|
||||||
|
using var client = _host.As(token);
|
||||||
|
var account = await client.GetStringAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken);
|
||||||
|
Assert.Contains($"\"id\":\"{persona.Id}\"", account);
|
||||||
|
Assert.DoesNotContain(root.Id, account);
|
||||||
|
Assert.DoesNotContain(root.UserName, account);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_signed_delivery_through_the_real_route_is_queued_and_stored()
|
||||||
|
{
|
||||||
|
var token = TestContext.Current.CancellationToken;
|
||||||
|
await using var peer = await Peer.Start();
|
||||||
|
var persona = await _host.Persona(await _host.SignUp(), "mouth");
|
||||||
|
var bob = new RemoteActor(peer, "bob");
|
||||||
|
var noteId = $"{peer.A}/notes/{Guid.NewGuid():N}";
|
||||||
|
var activity = new JsonObject
|
||||||
|
{
|
||||||
|
["id"] = noteId + "/activity",
|
||||||
|
["type"] = "Create",
|
||||||
|
["actor"] = bob.Id,
|
||||||
|
["to"] = new JsonArray($"{PrivaPubHost.Base}/peasants/{persona.UserName}"),
|
||||||
|
["object"] = new JsonObject
|
||||||
|
{
|
||||||
|
["id"] = noteId,
|
||||||
|
["type"] = "Note",
|
||||||
|
["attributedTo"] = bob.Id,
|
||||||
|
["to"] = new JsonArray($"{PrivaPubHost.Base}/peasants/{persona.UserName}"),
|
||||||
|
["content"] = "<p>hello through the front door</p>",
|
||||||
|
["published"] = DateTime.UtcNow.ToString("O")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
using var client = _host.Client();
|
||||||
|
|
||||||
|
var response = await client.SendAsync(bob.SignedPost($"/peasants/{persona.UserName}/mouth", activity), token);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||||
|
Assert.Equal(1, await _host.RunInbox(noteId + "/activity", token));
|
||||||
|
var stored = await DB.Default.Find<Post>().Match(p => p.ObjectURI == noteId).ExecuteFirstAsync(token);
|
||||||
|
Assert.Equal(PostVisibility.Direct, stored.Visibility);
|
||||||
|
Assert.Contains("front door", stored.ContentHtml);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Microsoft.AspNetCore.Routing.Patterns;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
using PrivaPub.Tests.Support;
|
||||||
|
using PrivaPub.Tests.Support.Host;
|
||||||
|
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace PrivaPub.Tests.Http
|
||||||
|
{
|
||||||
|
[Trait("Category", "Integration")]
|
||||||
|
public sealed class NeverFiveHundredTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
static readonly string[] Junk = { "x", "000000000000000000000000", "%2e%2e", new string('a', 5000), "5f0c1a2b3c4d5e6f70819203" };
|
||||||
|
|
||||||
|
PrivaPubHost _host;
|
||||||
|
|
||||||
|
public async ValueTask InitializeAsync()
|
||||||
|
{
|
||||||
|
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||||
|
_host = await PrivaPubHost.Shared();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||||
|
|
||||||
|
public static TheoryData<string> Credentials() => new() { "anonymous", "persona", "junk" };
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(Credentials))]
|
||||||
|
public async Task No_route_answers_500_to_junk(string credentials)
|
||||||
|
{
|
||||||
|
var token = TestContext.Current.CancellationToken;
|
||||||
|
var bearer = credentials switch
|
||||||
|
{
|
||||||
|
"persona" => await _host.MastodonToken(await _host.Persona(await _host.SignUp(), "junk")),
|
||||||
|
"junk" => "not-a-token",
|
||||||
|
_ => default
|
||||||
|
};
|
||||||
|
var jwt = credentials == "persona" ? (await _host.SignUp()).Jwt : bearer;
|
||||||
|
var failures = new List<string>();
|
||||||
|
|
||||||
|
foreach (var (method, template) in Routes())
|
||||||
|
foreach (var value in Junk)
|
||||||
|
{
|
||||||
|
var path = Fill(template, value);
|
||||||
|
using var request = new HttpRequestMessage(new HttpMethod(method), path);
|
||||||
|
request.Headers.Add(PrivaPubHost.ClientHeader, $"10.9.{Random.Shared.Next(256)}.{Random.Shared.Next(1, 255)}");
|
||||||
|
var auth = path.StartsWith("/api/", StringComparison.Ordinal) || path.StartsWith("/oauth/", StringComparison.Ordinal) ? bearer : jwt;
|
||||||
|
if (auth != default)
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", auth);
|
||||||
|
if (method is "POST" or "PUT" or "PATCH")
|
||||||
|
request.Content = new StringContent("{}", Encoding.UTF8, "application/json");
|
||||||
|
using var client = _host.Client();
|
||||||
|
HttpResponseMessage response;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
response = await client.SendAsync(request, token);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
failures.Add($"{method} {Short(path)} threw {ex.GetType().Name}: {ex.Message}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
using (response)
|
||||||
|
{
|
||||||
|
if ((int)response.StatusCode >= 500)
|
||||||
|
failures.Add($"{method} {Short(path)} answered {(int)response.StatusCode}");
|
||||||
|
else if (path.StartsWith("/api/", StringComparison.Ordinal) && (int)response.StatusCode >= 400 && !await IsMastodonError(response))
|
||||||
|
failures.Add($"{method} {Short(path)} answered {(int)response.StatusCode} without a Mastodon error body");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(failures.Count == 0, $"{failures.Count} problems as {credentials}:\n" + string.Join("\n", failures.Distinct().Take(80)));
|
||||||
|
}
|
||||||
|
|
||||||
|
IEnumerable<(string Method, string Template)> Routes()
|
||||||
|
{
|
||||||
|
var endpoints = _host.Services.GetRequiredService<EndpointDataSource>().Endpoints.OfType<RouteEndpoint>();
|
||||||
|
foreach (var endpoint in endpoints)
|
||||||
|
{
|
||||||
|
var methods = endpoint.Metadata.GetMetadata<IHttpMethodMetadata>()?.HttpMethods;
|
||||||
|
var template = "/" + endpoint.RoutePattern.RawText?.TrimStart('/');
|
||||||
|
foreach (var method in methods is { Count: > 0 } ? methods : new[] { "GET", "POST" })
|
||||||
|
yield return (method, template);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static string Fill(string template, string value)
|
||||||
|
{
|
||||||
|
var pattern = RoutePatternFactory.Parse(template);
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
foreach (var segment in pattern.PathSegments)
|
||||||
|
{
|
||||||
|
builder.Append('/');
|
||||||
|
foreach (var part in segment.Parts)
|
||||||
|
builder.Append(part switch
|
||||||
|
{
|
||||||
|
RoutePatternLiteralPart literal => literal.Content,
|
||||||
|
RoutePatternParameterPart => value,
|
||||||
|
RoutePatternSeparatorPart separator => separator.Content,
|
||||||
|
_ => string.Empty
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return builder.Length == 0 ? "/" : builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task<bool> IsMastodonError(HttpResponseMessage response)
|
||||||
|
{
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonNode.Parse(body) is JsonObject error && error["error"] is JsonValue;
|
||||||
|
}
|
||||||
|
catch (System.Text.Json.JsonException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static string Short(string path) => path.Length > 120 ? path[..120] + "…" : path;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
|
||||||
|
using MongoDB.Entities;
|
||||||
|
|
||||||
|
using PrivaPub.Federation.Actors;
|
||||||
|
using PrivaPub.Federation.Outbox;
|
||||||
|
using PrivaPub.Infrastructure.Jobs;
|
||||||
|
using PrivaPub.Models;
|
||||||
|
using PrivaPub.Models.Jobs;
|
||||||
|
using PrivaPub.Models.User;
|
||||||
|
using PrivaPub.StaticServices;
|
||||||
|
using PrivaPub.Tests.Support;
|
||||||
|
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace PrivaPub.Tests.Infrastructure
|
||||||
|
{
|
||||||
|
[Xunit.Collection(nameof(Exclusive))]
|
||||||
|
[Trait("Category", "Integration")]
|
||||||
|
public sealed class DeliveryWorkerTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
static readonly string[] PeerHosts = { "localhost", "127.0.0.1" };
|
||||||
|
|
||||||
|
Peer _peer;
|
||||||
|
|
||||||
|
public async ValueTask InitializeAsync()
|
||||||
|
{
|
||||||
|
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||||
|
_peer = await Peer.Start();
|
||||||
|
await DB.Default.DeleteAsync<RemoteInstance>(i => PeerHosts.Contains(i.Host));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (_peer == default)
|
||||||
|
return;
|
||||||
|
await _peer.DisposeAsync();
|
||||||
|
await DB.Default.DeleteAsync<RemoteInstance>(i => PeerHosts.Contains(i.Host));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_dead_host_does_not_hold_up_deliveries_to_live_ones()
|
||||||
|
{
|
||||||
|
var token = TestContext.Current.CancellationToken;
|
||||||
|
var (privateKey, publicKey) = Keys.NewKeyPair();
|
||||||
|
var avatar = new Avatar { UserName = $"sender{Guid.NewGuid():N}"[..20], PrivateKey = privateKey, PublicKey = publicKey };
|
||||||
|
await DB.Default.SaveAsync(avatar, token);
|
||||||
|
var local = new LocalActorService(new DbEntities(), new StaticOptions<AppConfiguration>(new AppConfiguration { BackendBaseAddress = "https://privapub.test" }));
|
||||||
|
var sender = local.FromAvatar(avatar);
|
||||||
|
var run = $"https://privapub.test/a/{Guid.NewGuid():N}/";
|
||||||
|
var queue = new JobQueue(j => j.DedupeKey.StartsWith(run));
|
||||||
|
var delivery = new DeliveryService(new DbEntities(), queue);
|
||||||
|
_peer.Answer("/dead/inbox", 503, TimeSpan.FromMilliseconds(300));
|
||||||
|
_peer.Answer("/live/inbox", 202);
|
||||||
|
|
||||||
|
var deadInboxes = Enumerable.Range(0, 60).Select(i => $"{_peer.B}/dead/inbox?{i}");
|
||||||
|
await delivery.Enqueue(sender, deadInboxes, new JsonObject { ["id"] = run + "dead", ["type"] = "Create" }, token);
|
||||||
|
await delivery.Enqueue(sender, new[] { $"{_peer.A}/live/inbox" }, new JsonObject { ["id"] = run + "live", ["type"] = "Create" }, token);
|
||||||
|
|
||||||
|
var handler = new DeliveryJobHandler(local, Peer.Http(), new HostCircuitBreaker(new MemoryCache(new MemoryCacheOptions())), NullLogger<DeliveryJobHandler>.Instance);
|
||||||
|
using var worker = new JobWorker(queue, new IJobHandler[] { handler }, NullLogger<JobWorker>.Instance);
|
||||||
|
await worker.StartAsync(token);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Until(() => Task.FromResult(_peer.Requests.Any(r => r.Path == "/live/inbox")), token);
|
||||||
|
var deadSoFar = _peer.Requests.Count(r => r.Path == "/dead/inbox");
|
||||||
|
var live = Assert.Single(_peer.Requests, r => r.Path == "/live/inbox");
|
||||||
|
Assert.Contains($"keyId=\"{sender.KeyId}\"", live.Signature);
|
||||||
|
Assert.True(deadSoFar < 30, $"{deadSoFar} dead deliveries were tried before the live one");
|
||||||
|
|
||||||
|
await Until(() => DB.Default.Find<Job>().Match(j => j.DedupeKey.StartsWith(run + "dead") && j.State == JobState.Pending && j.Attempts > 0).ExecuteAnyAsync(token), token);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await worker.StopAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
var retried = await DB.Default.Find<Job>().Match(j => j.DedupeKey.StartsWith(run + "dead") && j.Attempts > 0 && j.State == JobState.Pending).ExecuteFirstAsync(token);
|
||||||
|
Assert.Equal("503", retried.LastError?.Split(' ')[0]);
|
||||||
|
Assert.True(retried.RunAt > DateTime.UtcNow);
|
||||||
|
Assert.True((await DB.Default.Find<RemoteInstance>().Match(i => i.Host == "localhost").ExecuteFirstAsync(token))?.ConsecutiveFailures > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task Until(Func<Task<bool>> condition, CancellationToken token)
|
||||||
|
{
|
||||||
|
var deadline = DateTime.UtcNow.AddSeconds(30);
|
||||||
|
while (!await condition())
|
||||||
|
{
|
||||||
|
Assert.True(DateTime.UtcNow < deadline, "timed out");
|
||||||
|
await Task.Delay(50, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
|
||||||
|
using MongoDB.Entities;
|
||||||
|
|
||||||
|
using PrivaPub.Infrastructure.Jobs;
|
||||||
|
using PrivaPub.Models.Jobs;
|
||||||
|
using PrivaPub.Tests.Support;
|
||||||
|
|
||||||
|
namespace PrivaPub.Tests.Infrastructure
|
||||||
|
{
|
||||||
|
[Trait("Category", "Integration")]
|
||||||
|
public sealed class HostCircuitBreakerTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
readonly string _host = $"breaker{Guid.NewGuid():N}.example";
|
||||||
|
|
||||||
|
public ValueTask InitializeAsync()
|
||||||
|
{
|
||||||
|
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (MongoFixture.Enabled)
|
||||||
|
await DB.Default.DeleteAsync<RemoteInstance>(i => i.Host == _host);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_host_is_quarantined_only_past_the_threshold_and_freed_by_a_success()
|
||||||
|
{
|
||||||
|
var token = TestContext.Current.CancellationToken;
|
||||||
|
var breaker = new HostCircuitBreaker(new MemoryCache(new MemoryCacheOptions()));
|
||||||
|
|
||||||
|
for (var i = 1; i < HostCircuitBreaker.Threshold; i++)
|
||||||
|
await breaker.Failed(_host, "503", token);
|
||||||
|
Assert.Null(await breaker.UnavailableUntil(_host, token));
|
||||||
|
|
||||||
|
await breaker.Failed(_host, "503", token);
|
||||||
|
var until = await breaker.UnavailableUntil(_host, token);
|
||||||
|
Assert.NotNull(until);
|
||||||
|
Assert.InRange(until.Value, DateTime.UtcNow.AddMinutes(55), DateTime.UtcNow.AddMinutes(65));
|
||||||
|
var instance = await DB.Default.Find<RemoteInstance>().Match(i => i.Host == _host).ExecuteFirstAsync(token);
|
||||||
|
Assert.Equal(HostCircuitBreaker.Threshold, instance.ConsecutiveFailures);
|
||||||
|
Assert.Equal("503", instance.LastError);
|
||||||
|
|
||||||
|
await breaker.Succeeded(_host, token);
|
||||||
|
Assert.Null(await breaker.UnavailableUntil(_host, token));
|
||||||
|
instance = await DB.Default.Find<RemoteInstance>().Match(i => i.Host == _host).ExecuteFirstAsync(token);
|
||||||
|
Assert.Equal(0, instance.ConsecutiveFailures);
|
||||||
|
Assert.NotNull(instance.LastSuccessAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_healthy_host_is_written_at_most_once_an_hour()
|
||||||
|
{
|
||||||
|
var token = TestContext.Current.CancellationToken;
|
||||||
|
var breaker = new HostCircuitBreaker(new MemoryCache(new MemoryCacheOptions()));
|
||||||
|
|
||||||
|
await breaker.Succeeded(_host, token);
|
||||||
|
var first = (await DB.Default.Find<RemoteInstance>().Match(i => i.Host == _host).ExecuteFirstAsync(token)).LastSuccessAt;
|
||||||
|
await breaker.Succeeded(_host, token);
|
||||||
|
var second = (await DB.Default.Find<RemoteInstance>().Match(i => i.Host == _host).ExecuteFirstAsync(token)).LastSuccessAt;
|
||||||
|
|
||||||
|
Assert.Equal(first, second);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ using PrivaPub.Tests.Support;
|
|||||||
|
|
||||||
namespace PrivaPub.Tests.Infrastructure
|
namespace PrivaPub.Tests.Infrastructure
|
||||||
{
|
{
|
||||||
|
[Xunit.Collection(nameof(Exclusive))]
|
||||||
[Trait("Category", "Integration")]
|
[Trait("Category", "Integration")]
|
||||||
public class IndexTests
|
public class IndexTests
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,19 +1,9 @@
|
|||||||
using Microsoft.Extensions.Caching.Memory;
|
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
|
||||||
|
|
||||||
using MongoDB.Entities;
|
using MongoDB.Entities;
|
||||||
|
|
||||||
using PrivaPub.Federation.Actors;
|
|
||||||
using PrivaPub.Federation.Outbox;
|
|
||||||
using PrivaPub.Infrastructure.Jobs;
|
using PrivaPub.Infrastructure.Jobs;
|
||||||
using PrivaPub.Models;
|
|
||||||
using PrivaPub.Models.Jobs;
|
using PrivaPub.Models.Jobs;
|
||||||
using PrivaPub.Models.User;
|
|
||||||
using PrivaPub.StaticServices;
|
|
||||||
using PrivaPub.Tests.Support;
|
using PrivaPub.Tests.Support;
|
||||||
|
|
||||||
using System.Text.Json.Nodes;
|
|
||||||
|
|
||||||
namespace PrivaPub.Tests.Infrastructure
|
namespace PrivaPub.Tests.Infrastructure
|
||||||
{
|
{
|
||||||
public class BackoffTests
|
public class BackoffTests
|
||||||
@@ -38,19 +28,13 @@ namespace PrivaPub.Tests.Infrastructure
|
|||||||
[Trait("Category", "Integration")]
|
[Trait("Category", "Integration")]
|
||||||
public sealed class JobQueueTests : IAsyncLifetime
|
public sealed class JobQueueTests : IAsyncLifetime
|
||||||
{
|
{
|
||||||
Peer _peer;
|
public ValueTask InitializeAsync()
|
||||||
|
|
||||||
public async ValueTask InitializeAsync()
|
|
||||||
{
|
{
|
||||||
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||||
_peer = await Peer.Start();
|
return ValueTask.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||||
{
|
|
||||||
if (_peer != default)
|
|
||||||
await _peer.DisposeAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
static Job NewJob(string host = default, string dedupe = default) => new()
|
static Job NewJob(string host = default, string dedupe = default) => new()
|
||||||
{
|
{
|
||||||
@@ -102,44 +86,9 @@ namespace PrivaPub.Tests.Infrastructure
|
|||||||
var job = new Job { Kind = (JobKind)98, State = JobState.Running, LeasedUntil = DateTime.UtcNow.AddMinutes(-1) };
|
var job = new Job { Kind = (JobKind)98, State = JobState.Running, LeasedUntil = DateTime.UtcNow.AddMinutes(-1) };
|
||||||
await DB.Default.SaveAsync(job, token);
|
await DB.Default.SaveAsync(job, token);
|
||||||
|
|
||||||
await new JobQueue().Reap(token);
|
await new JobQueue(j => j.ID == job.ID).Reap(token);
|
||||||
|
|
||||||
Assert.Equal(JobState.Pending, (await DB.Default.Find<Job>().OneAsync(job.ID, token)).State);
|
Assert.Equal(JobState.Pending, (await DB.Default.Find<Job>().OneAsync(job.ID, token)).State);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task A_dead_host_does_not_hold_up_deliveries_to_live_ones()
|
|
||||||
{
|
|
||||||
var token = TestContext.Current.CancellationToken;
|
|
||||||
var (privateKey, publicKey) = Keys.NewKeyPair();
|
|
||||||
var avatar = new Avatar { UserName = $"sender{Guid.NewGuid():N}"[..20], PrivateKey = privateKey, PublicKey = publicKey };
|
|
||||||
await DB.Default.SaveAsync(avatar, token);
|
|
||||||
var local = new LocalActorService(new DbEntities(), new StaticOptions<AppConfiguration>(new AppConfiguration { BackendBaseAddress = "https://privapub.test" }));
|
|
||||||
var sender = local.FromAvatar(avatar);
|
|
||||||
var queue = new JobQueue();
|
|
||||||
var delivery = new DeliveryService(new DbEntities(), queue);
|
|
||||||
_peer.Answer("/dead/inbox", 503, TimeSpan.FromMilliseconds(300));
|
|
||||||
_peer.Answer("/live/inbox", 202);
|
|
||||||
|
|
||||||
var deadInboxes = Enumerable.Range(0, 60).Select(i => $"{_peer.B}/dead/inbox?{i}");
|
|
||||||
await delivery.Enqueue(sender, deadInboxes, new JsonObject { ["id"] = $"https://privapub.test/a/{Guid.NewGuid():N}", ["type"] = "Create" }, token);
|
|
||||||
await delivery.Enqueue(sender, new[] { $"{_peer.A}/live/inbox" }, new JsonObject { ["id"] = $"https://privapub.test/a/{Guid.NewGuid():N}", ["type"] = "Create" }, token);
|
|
||||||
|
|
||||||
var handler = new DeliveryJobHandler(local, Peer.Http(), new HostCircuitBreaker(new MemoryCache(new MemoryCacheOptions())), NullLogger<DeliveryJobHandler>.Instance);
|
|
||||||
using var worker = new JobWorker(queue, new IJobHandler[] { handler }, NullLogger<JobWorker>.Instance);
|
|
||||||
await worker.StartAsync(token);
|
|
||||||
var deadline = DateTime.UtcNow.AddSeconds(5);
|
|
||||||
while (DateTime.UtcNow < deadline && !_peer.Requests.Any(r => r.Path == "/live/inbox"))
|
|
||||||
await Task.Delay(50, token);
|
|
||||||
var live = Assert.Single(_peer.Requests, r => r.Path == "/live/inbox");
|
|
||||||
Assert.True(_peer.Requests.Count(r => r.Path == "/dead/inbox") < 30);
|
|
||||||
while (DateTime.UtcNow < deadline && !await DB.Default.Find<RemoteInstance>().Match(i => i.Host == "localhost").ExecuteAnyAsync(token))
|
|
||||||
await Task.Delay(50, token);
|
|
||||||
await worker.StopAsync(token);
|
|
||||||
|
|
||||||
Assert.Contains($"keyId=\"{sender.KeyId}\"", live.Signature);
|
|
||||||
var instance = await DB.Default.Find<RemoteInstance>().Match(i => i.Host == "localhost").ExecuteFirstAsync(token);
|
|
||||||
Assert.True(instance.ConsecutiveFailures > 0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ using PrivaPub.Tests.Support;
|
|||||||
|
|
||||||
namespace PrivaPub.Tests.Infrastructure
|
namespace PrivaPub.Tests.Infrastructure
|
||||||
{
|
{
|
||||||
|
[Xunit.Collection(nameof(Exclusive))]
|
||||||
[Trait("Category", "Integration")]
|
[Trait("Category", "Integration")]
|
||||||
public class MigrationTests
|
public class MigrationTests
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
|
||||||
<PackageReference Include="xunit.v3" Version="3.2.2" />
|
<PackageReference Include="xunit.v3" Version="3.2.2" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace PrivaPub.Tests.Support
|
||||||
|
{
|
||||||
|
[CollectionDefinition(nameof(Exclusive), DisableParallelization = true)]
|
||||||
|
public sealed class Exclusive
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,8 @@ namespace PrivaPub.Tests.Support
|
|||||||
public const string Host = "privapub.test";
|
public const string Host = "privapub.test";
|
||||||
public const string Base = "https://" + Host;
|
public const string Base = "https://" + Host;
|
||||||
|
|
||||||
|
readonly DateTime _started = Millisecond(DateTime.UtcNow);
|
||||||
|
|
||||||
Harness(Peer peer)
|
Harness(Peer peer)
|
||||||
{
|
{
|
||||||
Peer = peer;
|
Peer = peer;
|
||||||
@@ -50,7 +52,7 @@ namespace PrivaPub.Tests.Support
|
|||||||
Outbox = new OutboxPublisher(Db, Local, Delivery);
|
Outbox = new OutboxPublisher(Db, Local, Delivery);
|
||||||
Quotes = new QuoteService(Db, Remote, RemotePosts, Local, Delivery, Outbox);
|
Quotes = new QuoteService(Db, Remote, RemotePosts, Local, Delivery, Outbox);
|
||||||
Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger<InboxReceiver>.Instance);
|
Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger<InboxReceiver>.Instance);
|
||||||
Processor = new InboxProcessor(Remote, new IActivityHandler[]
|
Handlers = new IActivityHandler[]
|
||||||
{
|
{
|
||||||
new FollowHandler(Db, Local, Remote, Delivery),
|
new FollowHandler(Db, Local, Remote, Delivery),
|
||||||
new AcceptHandler(Db, Local, Quotes),
|
new AcceptHandler(Db, Local, Quotes),
|
||||||
@@ -58,6 +60,7 @@ namespace PrivaPub.Tests.Support
|
|||||||
new UndoHandler(Db, Local, Reactions),
|
new UndoHandler(Db, Local, Reactions),
|
||||||
new LikeHandler(Db, Reactions),
|
new LikeHandler(Db, Reactions),
|
||||||
new EmojiReactHandler(Db, Reactions),
|
new EmojiReactHandler(Db, Reactions),
|
||||||
|
new QuoteRequestHandler(Quotes),
|
||||||
new DislikeHandler(Db),
|
new DislikeHandler(Db),
|
||||||
new JoinHandler(Db, Local, Delivery),
|
new JoinHandler(Db, Local, Delivery),
|
||||||
new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote),
|
new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote),
|
||||||
@@ -65,7 +68,8 @@ namespace PrivaPub.Tests.Support
|
|||||||
new DeleteHandler(Db, Local, Remote, Delivery, Groups, Quotes),
|
new DeleteHandler(Db, Local, Remote, Delivery, Groups, Quotes),
|
||||||
new UpdateHandler(Db, Local, Remote, Groups, Records, Quotes),
|
new UpdateHandler(Db, Local, Remote, Groups, Records, Quotes),
|
||||||
new FlagHandler(Db, Local)
|
new FlagHandler(Db, Local)
|
||||||
}, NullLogger<InboxProcessor>.Instance);
|
};
|
||||||
|
Processor = new InboxProcessor(Remote, Handlers, NullLogger<InboxProcessor>.Instance);
|
||||||
Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance);
|
Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance);
|
||||||
Content = new ContentRenderer(Local, Remote);
|
Content = new ContentRenderer(Local, Remote);
|
||||||
Media = new MediaService(new StaticOptions<MediaOptions>(new MediaOptions { Root = Path.Combine(Path.GetTempPath(), $"privapub-media-{Guid.NewGuid():N}") }),
|
Media = new MediaService(new StaticOptions<MediaOptions>(new MediaOptions { Root = Path.Combine(Path.GetTempPath(), $"privapub-media-{Guid.NewGuid():N}") }),
|
||||||
@@ -85,6 +89,7 @@ namespace PrivaPub.Tests.Support
|
|||||||
public DeliveryService Delivery { get; }
|
public DeliveryService Delivery { get; }
|
||||||
public InboxReceiver Receiver { get; }
|
public InboxReceiver Receiver { get; }
|
||||||
public InboxProcessor Processor { get; }
|
public InboxProcessor Processor { get; }
|
||||||
|
public IReadOnlyList<IActivityHandler> Handlers { get; }
|
||||||
public FollowService Follows { get; }
|
public FollowService Follows { get; }
|
||||||
public ContentRenderer Content { get; }
|
public ContentRenderer Content { get; }
|
||||||
public OutboxPublisher Outbox { get; }
|
public OutboxPublisher Outbox { get; }
|
||||||
@@ -135,13 +140,15 @@ namespace PrivaPub.Tests.Support
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<JsonObject>> Outgoing(string inbox) =>
|
public async Task<List<JsonObject>> Outgoing(string inbox) =>
|
||||||
(await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.Deliver).ExecuteAsync())
|
(await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.Deliver && j.CreatedAt >= _started).ExecuteAsync())
|
||||||
.Select(j => JsonSerializer.Deserialize<DeliveryPayload>(j.Payload))
|
.Select(j => JsonSerializer.Deserialize<DeliveryPayload>(j.Payload))
|
||||||
.Where(p => p.Inbox == inbox)
|
.Where(p => p.Inbox == inbox)
|
||||||
.Select(p => JsonNode.Parse(p.Body)!.AsObject())
|
.Select(p => JsonNode.Parse(p.Body)!.AsObject())
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
public async ValueTask DisposeAsync() => await Peer.DisposeAsync();
|
public async ValueTask DisposeAsync() => await Peer.DisposeAsync();
|
||||||
|
|
||||||
|
static DateTime Millisecond(DateTime time) => new(time.Ticks - time.Ticks % TimeSpan.TicksPerMillisecond, DateTimeKind.Utc);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class KeyLocalizer<T> : IStringLocalizer<T>
|
public sealed class KeyLocalizer<T> : IStringLocalizer<T>
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
using PrivaPub.Infrastructure.Cli;
|
||||||
|
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace PrivaPub.Tests.Support.Host
|
||||||
|
{
|
||||||
|
public sealed record Root(string Id, string UserName, string Password, string Jwt);
|
||||||
|
|
||||||
|
public sealed record Persona(string Id, string UserName, Root Root);
|
||||||
|
|
||||||
|
public static partial class Accounts
|
||||||
|
{
|
||||||
|
public const string Password = "Test-Pass-1!";
|
||||||
|
const string OutOfBand = "urn:ietf:wg:oauth:2.0:oob";
|
||||||
|
|
||||||
|
public static async Task<Root> SignUp(this PrivaPubHost host, string name = "root")
|
||||||
|
{
|
||||||
|
var userName = $"{name}{Guid.NewGuid():N}"[..24];
|
||||||
|
using var client = host.Client();
|
||||||
|
var response = await client.PostAsJsonAsync("/clientapi/user/signup", new { userName, password = Password });
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var jwt = (await response.Content.ReadFromJsonAsync<JsonObject>())!;
|
||||||
|
return new Root(jwt["userId"]!.GetValue<string>(), userName, Password, jwt["token"]!.GetValue<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<Root> LogIn(this PrivaPubHost host, Root root)
|
||||||
|
{
|
||||||
|
using var client = host.Client();
|
||||||
|
var response = await client.PostAsJsonAsync("/clientapi/user/login", new { userName = root.UserName, password = root.Password });
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var jwt = (await response.Content.ReadFromJsonAsync<JsonObject>())!;
|
||||||
|
return root with { Jwt = jwt["token"]!.GetValue<string>() };
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<Root> Admin(this PrivaPubHost host)
|
||||||
|
{
|
||||||
|
var root = await host.SignUp("admin");
|
||||||
|
Assert.Equal(0, await AdminCommands.Run(new[] { "promote", root.UserName }));
|
||||||
|
return await host.LogIn(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<Persona> Persona(this PrivaPubHost host, Root root, string name = "persona")
|
||||||
|
{
|
||||||
|
var userName = $"{name}{Guid.NewGuid():N}"[..20];
|
||||||
|
using var client = host.As(root.Jwt);
|
||||||
|
var response = await client.PostAsJsonAsync("/clientapi/avatar/private/insert", new { userName, name, biography = "testing" });
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var avatar = (await response.Content.ReadFromJsonAsync<JsonObject>())!;
|
||||||
|
return new Persona(avatar["id"]!.GetValue<string>(), userName, root);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<string> MastodonToken(this PrivaPubHost host, Persona persona, string scopes = "read write follow")
|
||||||
|
{
|
||||||
|
using var client = host.Client(cookies: true);
|
||||||
|
var app = await Form(client, "/api/v1/apps", ("client_name", "privapub-tests"), ("redirect_uris", OutOfBand), ("scopes", scopes));
|
||||||
|
var clientId = app["client_id"]!.GetValue<string>();
|
||||||
|
var clientSecret = app["client_secret"]!.GetValue<string>();
|
||||||
|
var query = $"client_id={Uri.EscapeDataString(clientId)}&redirect_uri={Uri.EscapeDataString(OutOfBand)}&response_type=code&scope={Uri.EscapeDataString(scopes)}";
|
||||||
|
|
||||||
|
var code = await Authorize(client, persona, query);
|
||||||
|
var token = await Form(client, "/oauth/token", ("grant_type", "authorization_code"), ("code", code), ("client_id", clientId),
|
||||||
|
("client_secret", clientSecret), ("redirect_uri", OutOfBand));
|
||||||
|
return token["access_token"]!.GetValue<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<string> Authorize(HttpClient client, Persona persona, string query, string decision = "allow")
|
||||||
|
{
|
||||||
|
var returnUrl = "/oauth/authorize?" + query;
|
||||||
|
var login = await client.GetStringAsync("/oauth/login?returnUrl=" + Uri.EscapeDataString(returnUrl));
|
||||||
|
var antiforgery = AntiforgeryToken().Match(login).Groups[1].Value;
|
||||||
|
var signedIn = await client.PostAsync("/oauth/login", new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["returnUrl"] = returnUrl,
|
||||||
|
["__RequestVerificationToken"] = antiforgery,
|
||||||
|
["userName"] = persona.Root.UserName,
|
||||||
|
["password"] = persona.Root.Password
|
||||||
|
}));
|
||||||
|
Assert.Equal(HttpStatusCode.Redirect, signedIn.StatusCode);
|
||||||
|
|
||||||
|
var choose = await client.GetStringAsync(returnUrl + "&signed_in=1");
|
||||||
|
var fields = HiddenInput().Matches(choose).Select(m => new KeyValuePair<string, string>(m.Groups[1].Value, WebUtility.HtmlDecode(m.Groups[2].Value))).ToList();
|
||||||
|
fields.Add(new("avatarId", persona.Id));
|
||||||
|
fields.Add(new("decision", decision));
|
||||||
|
var answer = await client.PostAsync("/oauth/authorize", new FormUrlEncodedContent(fields));
|
||||||
|
var page = await answer.Content.ReadAsStringAsync();
|
||||||
|
return Code().Match(page) is { Success: true } match ? match.Groups[1].Value : default;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static HttpClient As(this PrivaPubHost host, string bearer)
|
||||||
|
{
|
||||||
|
var client = host.Client();
|
||||||
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task<JsonObject> Form(HttpClient client, string path, params (string Key, string Value)[] fields)
|
||||||
|
{
|
||||||
|
var response = await client.PostAsync(path, new FormUrlEncodedContent(fields.Select(f => new KeyValuePair<string, string>(f.Key, f.Value))));
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
Assert.True(response.IsSuccessStatusCode, $"{path} answered {(int)response.StatusCode}: {body}");
|
||||||
|
return JsonNode.Parse(body)!.AsObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex("name=\"__RequestVerificationToken\" type=\"hidden\" value=\"([^\"]*)\"")]
|
||||||
|
private static partial Regex AntiforgeryToken();
|
||||||
|
|
||||||
|
[GeneratedRegex("<input type=\"hidden\" name=\"([^\"]*)\" value=\"([^\"]*)\"")]
|
||||||
|
private static partial Regex HiddenInput();
|
||||||
|
|
||||||
|
[GeneratedRegex("<code>([^<]*)</code>")]
|
||||||
|
private static partial Regex Code();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
using MongoDB.Entities;
|
||||||
|
|
||||||
|
using PrivaPub.Federation.Outbox;
|
||||||
|
using PrivaPub.Infrastructure.Jobs;
|
||||||
|
using PrivaPub.Models.Jobs;
|
||||||
|
|
||||||
|
using System.Linq.Expressions;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace PrivaPub.Tests.Support.Host
|
||||||
|
{
|
||||||
|
public static class Jobs
|
||||||
|
{
|
||||||
|
public static async Task<int> Run(this PrivaPubHost host, Expression<Func<Job, bool>> which, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
var handlers = host.Services.GetServices<IJobHandler>().ToDictionary(h => h.Kind);
|
||||||
|
var queue = host.Get<IJobQueue>();
|
||||||
|
var ran = 0;
|
||||||
|
for (var round = 0; round < 50; round++)
|
||||||
|
{
|
||||||
|
var pending = await DB.Default.Find<Job>().Match(which).Match(j => j.State == JobState.Pending).ExecuteAsync(token);
|
||||||
|
if (pending.Count == 0)
|
||||||
|
return ran;
|
||||||
|
foreach (var candidate in pending)
|
||||||
|
{
|
||||||
|
var job = await DB.Default.UpdateAndGet<Job>()
|
||||||
|
.Match(j => j.ID == candidate.ID && j.State == JobState.Pending)
|
||||||
|
.Modify(j => j.State, JobState.Running)
|
||||||
|
.Modify(j => j.LeasedUntil, DateTime.UtcNow + JobQueue.LeaseTime)
|
||||||
|
.Modify(b => b.Inc(j => j.Attempts, 1))
|
||||||
|
.ExecuteAsync(token);
|
||||||
|
if (job == default)
|
||||||
|
continue;
|
||||||
|
var handler = handlers[job.Kind];
|
||||||
|
JobOutcome outcome;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
outcome = await handler.Handle(job, token);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
outcome = JobOutcome.Dead(ex.GetType().Name + ": " + ex.Message);
|
||||||
|
}
|
||||||
|
await queue.Finish(job, outcome, handler.MaxAttempts, token);
|
||||||
|
ran++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ran;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Task<int> RunInbox(this PrivaPubHost host, string activityId, CancellationToken token = default) =>
|
||||||
|
host.Run(j => j.Kind == JobKind.ProcessInbox && j.DedupeKey == "inbox|" + activityId, token);
|
||||||
|
|
||||||
|
public static async Task<List<JsonObject>> Deliveries(string inbox, DateTime since, CancellationToken token = default) =>
|
||||||
|
(await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.Deliver && j.CreatedAt >= since).ExecuteAsync(token))
|
||||||
|
.Select(j => JsonSerializer.Deserialize<DeliveryPayload>(j.Payload))
|
||||||
|
.Where(p => p.Inbox == inbox)
|
||||||
|
.Select(p => JsonNode.Parse(p.Body)!.AsObject())
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using Microsoft.AspNetCore.TestHost;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
|
||||||
|
using PrivaPub.Api.Mastodon.Auth;
|
||||||
|
using PrivaPub.Domain.Media;
|
||||||
|
using PrivaPub.Infrastructure.Jobs;
|
||||||
|
|
||||||
|
using System.Net;
|
||||||
|
|
||||||
|
namespace PrivaPub.Tests.Support.Host
|
||||||
|
{
|
||||||
|
public class PrivaPubHost : WebApplicationFactory<Program>
|
||||||
|
{
|
||||||
|
public const string Host = "privapub.test";
|
||||||
|
public const string Base = "https://" + Host;
|
||||||
|
public const string ClientHeader = "X-Test-Client";
|
||||||
|
|
||||||
|
static readonly SemaphoreSlim Boot = new(1, 1);
|
||||||
|
static readonly Type[] Unwanted = { typeof(JobWorker), typeof(MediaJanitor), typeof(OAuthPruner) };
|
||||||
|
static PrivaPubHost _shared;
|
||||||
|
|
||||||
|
readonly string _mediaRoot = Path.Combine(Path.GetTempPath(), $"privapub-tests-{Guid.NewGuid():N}");
|
||||||
|
|
||||||
|
public IReadOnlyList<ServiceDescriptor> Registered { get; private set; }
|
||||||
|
|
||||||
|
public static async Task<PrivaPubHost> Shared()
|
||||||
|
{
|
||||||
|
await Boot.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_shared == default)
|
||||||
|
{
|
||||||
|
var host = new PrivaPubHost();
|
||||||
|
_ = host.Services;
|
||||||
|
_shared = host;
|
||||||
|
}
|
||||||
|
return _shared;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Boot.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected virtual IEnumerable<KeyValuePair<string, string>> Settings() => new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["MongoSettings:ConnectionString"] = MongoFixture.Connection,
|
||||||
|
["MongoSettings:Database"] = MongoFixture.Database,
|
||||||
|
["MongoSettings:LogsDatabase"] = "logs",
|
||||||
|
["AppConfiguration:Version"] = "0.0.0-test",
|
||||||
|
["AppConfiguration:BackendBaseAddress"] = Base,
|
||||||
|
["AppConfiguration:MaxAllowedUploadFiles"] = "3",
|
||||||
|
["AppConfiguration:MaxAllowedFileSize"] = "2097152",
|
||||||
|
["AppConfiguration:SupportedLanguages:0"] = "en",
|
||||||
|
["AppConfiguration:SupportedLanguages:1"] = "it",
|
||||||
|
["AppConfiguration:HashingOptions:Iterations"] = "1000",
|
||||||
|
["AppConfiguration:Jwt:Key"] = "privapub-tests-only-signing-key-0123456789abcdef0123456789abcdef",
|
||||||
|
["AppConfiguration:Jwt:Issuer"] = Base,
|
||||||
|
["AppConfiguration:Jwt:Audience"] = Base,
|
||||||
|
["AppConfiguration:Jwt:HoursTimeout"] = "1",
|
||||||
|
["AppConfiguration:EmailConfiguration:SmtpServer"] = "127.0.0.1",
|
||||||
|
["AppConfiguration:EmailConfiguration:SmtpPort"] = "9",
|
||||||
|
["AppConfiguration:EmailConfiguration:UseSSL"] = "false",
|
||||||
|
["AppConfiguration:EmailConfiguration:SmtpUsername"] = "nobody@privapub.test",
|
||||||
|
["AppConfiguration:EmailConfiguration:SmtpPassword"] = "none",
|
||||||
|
["Federation:AllowPrivateNetworks"] = "true",
|
||||||
|
["Federation:AllowPlainHttp"] = "true",
|
||||||
|
["Federation:FetchLinkPreviews"] = "false",
|
||||||
|
["Media:Root"] = _mediaRoot,
|
||||||
|
["Logging:LogLevel:Default"] = "Warning",
|
||||||
|
["Serilog:MinimumLevel:Default"] = Environment.GetEnvironmentVariable("PRIVAPUB_TEST_LOGS") == "1" ? "Information" : "Fatal"
|
||||||
|
};
|
||||||
|
|
||||||
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||||
|
{
|
||||||
|
builder.UseEnvironment("Testing");
|
||||||
|
foreach (var (key, value) in Settings())
|
||||||
|
builder.UseSetting(key, value);
|
||||||
|
builder.ConfigureTestServices(services =>
|
||||||
|
{
|
||||||
|
foreach (var hosted in services.Where(s => s.ServiceType == typeof(IHostedService) && Unwanted.Contains(s.ImplementationType)).ToList())
|
||||||
|
services.Remove(hosted);
|
||||||
|
services.AddDataProtection().UseEphemeralDataProtectionProvider();
|
||||||
|
services.AddSingleton<IStartupFilter, ClientAddressFilter>();
|
||||||
|
Registered = services.ToList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void ConfigureClient(HttpClient client)
|
||||||
|
{
|
||||||
|
client.BaseAddress = new Uri(Base + "/");
|
||||||
|
client.DefaultRequestHeaders.Add(ClientHeader, $"10.{Random.Shared.Next(256)}.{Random.Shared.Next(256)}.{Random.Shared.Next(1, 255)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public HttpClient Client(bool cookies = false) => CreateClient(new WebApplicationFactoryClientOptions
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri(Base + "/"),
|
||||||
|
AllowAutoRedirect = false,
|
||||||
|
HandleCookies = cookies
|
||||||
|
});
|
||||||
|
|
||||||
|
public T Get<T>() where T : notnull => Services.GetRequiredService<T>();
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
base.Dispose(disposing);
|
||||||
|
if (disposing && Directory.Exists(_mediaRoot))
|
||||||
|
Directory.Delete(_mediaRoot, recursive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class ClientAddressFilter : IStartupFilter
|
||||||
|
{
|
||||||
|
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next) => app =>
|
||||||
|
{
|
||||||
|
app.Use(async (context, call) =>
|
||||||
|
{
|
||||||
|
if (IPAddress.TryParse(context.Request.Headers[ClientHeader].ToString(), out var address))
|
||||||
|
context.Connection.RemoteIpAddress = address;
|
||||||
|
await call(context);
|
||||||
|
});
|
||||||
|
next(app);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SecureModeHost : PrivaPubHost
|
||||||
|
{
|
||||||
|
static readonly SemaphoreSlim Boot = new(1, 1);
|
||||||
|
static SecureModeHost _shared;
|
||||||
|
|
||||||
|
public static new async Task<SecureModeHost> Shared()
|
||||||
|
{
|
||||||
|
await PrivaPubHost.Shared();
|
||||||
|
await Boot.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_shared == default)
|
||||||
|
{
|
||||||
|
var host = new SecureModeHost();
|
||||||
|
_ = host.Services;
|
||||||
|
_shared = host;
|
||||||
|
}
|
||||||
|
return _shared;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Boot.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override IEnumerable<KeyValuePair<string, string>> Settings() =>
|
||||||
|
base.Settings().Append(new KeyValuePair<string, string>("Federation:SecureMode", "true"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,15 +13,24 @@ namespace PrivaPub.Tests.Support
|
|||||||
public sealed class MongoFixture : IAsyncLifetime
|
public sealed class MongoFixture : IAsyncLifetime
|
||||||
{
|
{
|
||||||
public const string Skip = "set PRIVAPUB_TEST_MONGOD=1 (and optionally PRIVAPUB_TEST_MONGO) to run the tests that need a mongod";
|
public const string Skip = "set PRIVAPUB_TEST_MONGOD=1 (and optionally PRIVAPUB_TEST_MONGO) to run the tests that need a mongod";
|
||||||
|
const int ProductionPort = 27022;
|
||||||
|
const int DefaultPort = 27017;
|
||||||
|
|
||||||
public static bool Enabled => Environment.GetEnvironmentVariable("PRIVAPUB_TEST_MONGOD") == "1";
|
public static bool Enabled => Environment.GetEnvironmentVariable("PRIVAPUB_TEST_MONGOD") == "1";
|
||||||
|
static bool Required => Environment.GetEnvironmentVariable("PRIVAPUB_TEST_REQUIRE_MONGOD") == "1";
|
||||||
|
static bool InCi => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")) || !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITEA_ACTIONS"));
|
||||||
|
|
||||||
public string Database { get; } = $"PrivaPubTests_{Guid.NewGuid():N}";
|
public static string Connection { get; } = Environment.GetEnvironmentVariable("PRIVAPUB_TEST_MONGO") ?? $"mongodb://127.0.0.1:{DefaultPort}";
|
||||||
|
public static string Database { get; } = $"PrivaPubTests_{Guid.NewGuid():N}";
|
||||||
|
|
||||||
public async ValueTask InitializeAsync()
|
public async ValueTask InitializeAsync()
|
||||||
{
|
{
|
||||||
|
if (Required && !Enabled)
|
||||||
|
throw new InvalidOperationException("PRIVAPUB_TEST_REQUIRE_MONGOD=1 but PRIVAPUB_TEST_MONGOD is not 1: the integration tests would be skipped");
|
||||||
if (!Enabled)
|
if (!Enabled)
|
||||||
return;
|
return;
|
||||||
|
var settings = MongoClientSettings.FromConnectionString(Connection);
|
||||||
|
Refuse(settings);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard));
|
BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard));
|
||||||
@@ -29,9 +38,10 @@ namespace PrivaPub.Tests.Support
|
|||||||
catch (BsonSerializationException)
|
catch (BsonSerializationException)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
var connection = Environment.GetEnvironmentVariable("PRIVAPUB_TEST_MONGO") ?? "mongodb://127.0.0.1:27017";
|
await DB.InitAsync(Database, settings);
|
||||||
await DB.InitAsync(Database, MongoClientSettings.FromConnectionString(connection));
|
await RefuseProductionData();
|
||||||
EntityMaps.Warm();
|
EntityMaps.Warm();
|
||||||
|
await DB.Default.MigrateAsync<Program>();
|
||||||
await Indexes.Create();
|
await Indexes.Create();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,5 +50,32 @@ namespace PrivaPub.Tests.Support
|
|||||||
if (Enabled)
|
if (Enabled)
|
||||||
await DB.Default.Database().Client.DropDatabaseAsync(Database);
|
await DB.Default.Database().Client.DropDatabaseAsync(Database);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void Refuse(MongoClientSettings settings)
|
||||||
|
{
|
||||||
|
var ports = settings.Servers.Select(s => s.Port).ToList();
|
||||||
|
if (ports.Contains(ProductionPort))
|
||||||
|
throw new InvalidOperationException($"the tests never run against port {ProductionPort}: that is production's mongod");
|
||||||
|
if (InCi && (Environment.GetEnvironmentVariable("PRIVAPUB_TEST_MONGO") == default || ports.Contains(DefaultPort)))
|
||||||
|
throw new InvalidOperationException("in CI the tests run only against a throwaway mongod: use tools/ci/with-test-mongod.sh");
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task RefuseProductionData()
|
||||||
|
{
|
||||||
|
BsonDocument options;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
options = await DB.Default.Database().Client.GetDatabase("admin").RunCommandAsync<BsonDocument>(new BsonDocument("getCmdLineOpts", 1));
|
||||||
|
}
|
||||||
|
catch (MongoCommandException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var dbPath = options.GetValue("parsed", new BsonDocument()).AsBsonDocument
|
||||||
|
.GetValue("storage", new BsonDocument()).AsBsonDocument
|
||||||
|
.GetValue("dbPath", BsonNull.Value);
|
||||||
|
if (dbPath.IsString && dbPath.AsString.StartsWith("/var/lib/privapub", StringComparison.Ordinal))
|
||||||
|
throw new InvalidOperationException($"the mongod at {Connection} keeps its data in {dbPath.AsString}: that is production's");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -17,7 +17,7 @@ namespace PrivaPub.Tests.Support
|
|||||||
public sealed class Peer : IAsyncDisposable
|
public sealed class Peer : IAsyncDisposable
|
||||||
{
|
{
|
||||||
readonly WebApplication _app;
|
readonly WebApplication _app;
|
||||||
readonly ConcurrentDictionary<string, string> _documents = new();
|
readonly ConcurrentDictionary<string, (string Text, string ContentType)> _documents = new();
|
||||||
readonly ConcurrentDictionary<string, (int Status, TimeSpan Delay)> _answers = new();
|
readonly ConcurrentDictionary<string, (int Status, TimeSpan Delay)> _answers = new();
|
||||||
readonly ConcurrentDictionary<string, (byte[] Bytes, string ContentType)> _files = new();
|
readonly ConcurrentDictionary<string, (byte[] Bytes, string ContentType)> _files = new();
|
||||||
|
|
||||||
@@ -40,12 +40,15 @@ namespace PrivaPub.Tests.Support
|
|||||||
Peer peer = default;
|
Peer peer = default;
|
||||||
app.Run(async context =>
|
app.Run(async context =>
|
||||||
{
|
{
|
||||||
peer.Requests.Enqueue(new(context.Request.Method, context.Request.Path, context.Request.Headers["Signature"].ToString()));
|
var body = context.Request.ContentLength > 0 || context.Request.Headers.TransferEncoding.Count > 0
|
||||||
|
? await new StreamReader(context.Request.Body).ReadToEndAsync()
|
||||||
|
: default;
|
||||||
|
peer.Requests.Enqueue(new(context.Request.Method, context.Request.Path, context.Request.Headers["Signature"].ToString(), body,
|
||||||
|
context.Request.Headers.ToDictionary(h => h.Key, h => h.Value.ToString(), StringComparer.OrdinalIgnoreCase)));
|
||||||
var key = context.Request.Path.Value;
|
var key = context.Request.Path.Value;
|
||||||
if (peer._files.TryGetValue(key, out var file))
|
if (peer._files.TryGetValue(key, out var file))
|
||||||
{
|
{
|
||||||
context.Response.ContentType = file.ContentType;
|
await Results.Bytes(file.Bytes, file.ContentType, enableRangeProcessing: true).ExecuteAsync(context);
|
||||||
await context.Response.Body.WriteAsync(file.Bytes);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (peer._answers.TryGetValue(key, out var answer))
|
if (peer._answers.TryGetValue(key, out var answer))
|
||||||
@@ -60,15 +63,17 @@ namespace PrivaPub.Tests.Support
|
|||||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
context.Response.ContentType = "application/activity+json";
|
context.Response.ContentType = document.ContentType;
|
||||||
await context.Response.WriteAsync(document.Replace("{A}", peer.A).Replace("{B}", peer.B));
|
await context.Response.WriteAsync(document.Text.Replace("{A}", peer.A).Replace("{B}", peer.B));
|
||||||
});
|
});
|
||||||
await app.StartAsync();
|
await app.StartAsync();
|
||||||
peer = new Peer(app, new Uri(app.Urls.First()).Port);
|
peer = new Peer(app, new Uri(app.Urls.First()).Port);
|
||||||
return peer;
|
return peer;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Serve(string path, string json) => _documents[path] = json;
|
public void Serve(string path, string json) => _documents[path] = (json, "application/activity+json");
|
||||||
|
|
||||||
|
public void ServeText(string path, string text, string contentType) => _documents[path] = (text, contentType);
|
||||||
|
|
||||||
public void ServeFile(string path, byte[] bytes, string contentType) => _files[path] = (bytes, contentType);
|
public void ServeFile(string path, byte[] bytes, string contentType) => _files[path] = (bytes, contentType);
|
||||||
|
|
||||||
@@ -95,7 +100,8 @@ namespace PrivaPub.Tests.Support
|
|||||||
public Task Reload(CancellationToken token) => Task.CompletedTask;
|
public Task Reload(CancellationToken token) => Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record HttpRequestRecord(string Method, string Path, string Signature);
|
public sealed record HttpRequestRecord(string Method, string Path, string Signature, string Body = default,
|
||||||
|
IReadOnlyDictionary<string, string> Headers = default);
|
||||||
|
|
||||||
public sealed class StaticOptions<T> : IOptionsMonitor<T>
|
public sealed class StaticOptions<T> : IOptionsMonitor<T>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -44,18 +44,46 @@ namespace PrivaPub.Tests.Support
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public HttpRequestMessage SignedGet(string path, string host = "privapub.test")
|
||||||
|
{
|
||||||
|
var date = DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture);
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Get, path);
|
||||||
|
request.Headers.TryAddWithoutValidation("Date", date);
|
||||||
|
request.Headers.TryAddWithoutValidation("Accept", "application/activity+json");
|
||||||
|
request.Headers.TryAddWithoutValidation("Signature", Signature($"(request-target): get {path}\nhost: {host}\ndate: {date}", "(request-target) host date"));
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
public HttpRequestMessage SignedPost(string path, JsonNode activity, string host = "privapub.test", string date = default)
|
||||||
|
{
|
||||||
|
var body = Encoding.UTF8.GetBytes(activity.ToJsonString());
|
||||||
|
date ??= DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture);
|
||||||
|
var digest = HttpSignatures.Digest(body);
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Post, path) { Content = new ByteArrayContent(body) };
|
||||||
|
request.Content.Headers.TryAddWithoutValidation("Content-Type", "application/activity+json");
|
||||||
|
request.Headers.TryAddWithoutValidation("Date", date);
|
||||||
|
request.Headers.TryAddWithoutValidation("Digest", digest);
|
||||||
|
request.Headers.TryAddWithoutValidation("Signature",
|
||||||
|
Signature($"(request-target): post {path}\nhost: {host}\ndate: {date}\ndigest: {digest}", "(request-target) host date digest"));
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
string Signature(string signingString, string headers)
|
||||||
|
{
|
||||||
|
var signature = Convert.ToBase64String(_key.SignData(Encoding.UTF8.GetBytes(signingString), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
|
||||||
|
return $"keyId=\"{KeyId}\",algorithm=\"rsa-sha256\",headers=\"{headers}\",signature=\"{signature}\"";
|
||||||
|
}
|
||||||
|
|
||||||
public HttpRequest Get(string host, string path)
|
public HttpRequest Get(string host, string path)
|
||||||
{
|
{
|
||||||
var date = DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture);
|
var date = DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture);
|
||||||
var signingString = $"(request-target): get {path}\nhost: {host}\ndate: {date}";
|
|
||||||
var signature = Convert.ToBase64String(_key.SignData(Encoding.UTF8.GetBytes(signingString), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
|
|
||||||
var context = new DefaultHttpContext();
|
var context = new DefaultHttpContext();
|
||||||
context.Request.Method = "GET";
|
context.Request.Method = "GET";
|
||||||
context.Request.Host = new HostString(host);
|
context.Request.Host = new HostString(host);
|
||||||
context.Request.Path = path;
|
context.Request.Path = path;
|
||||||
context.Features.Get<IHttpRequestFeature>().RawTarget = path;
|
context.Features.Get<IHttpRequestFeature>().RawTarget = path;
|
||||||
context.Request.Headers["Date"] = date;
|
context.Request.Headers["Date"] = date;
|
||||||
context.Request.Headers["Signature"] = $"keyId=\"{KeyId}\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date\",signature=\"{signature}\"";
|
context.Request.Headers["Signature"] = Signature($"(request-target): get {path}\nhost: {host}\ndate: {date}", "(request-target) host date");
|
||||||
return context.Request;
|
return context.Request;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,9 +92,6 @@ namespace PrivaPub.Tests.Support
|
|||||||
var body = Encoding.UTF8.GetBytes(activity.ToJsonString());
|
var body = Encoding.UTF8.GetBytes(activity.ToJsonString());
|
||||||
var date = DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture);
|
var date = DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture);
|
||||||
var digest = HttpSignatures.Digest(body);
|
var digest = HttpSignatures.Digest(body);
|
||||||
var signingString = $"(request-target): post {path}\nhost: {host}\ndate: {date}\ndigest: {digest}";
|
|
||||||
var signature = Convert.ToBase64String(_key.SignData(Encoding.UTF8.GetBytes(signingString), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
|
|
||||||
|
|
||||||
var context = new DefaultHttpContext();
|
var context = new DefaultHttpContext();
|
||||||
context.Request.Method = "POST";
|
context.Request.Method = "POST";
|
||||||
context.Request.Host = new HostString(host);
|
context.Request.Host = new HostString(host);
|
||||||
@@ -75,7 +100,7 @@ namespace PrivaPub.Tests.Support
|
|||||||
context.Request.Headers["Date"] = date;
|
context.Request.Headers["Date"] = date;
|
||||||
context.Request.Headers["Digest"] = digest;
|
context.Request.Headers["Digest"] = digest;
|
||||||
context.Request.Headers["Content-Type"] = "application/activity+json";
|
context.Request.Headers["Content-Type"] = "application/activity+json";
|
||||||
context.Request.Headers["Signature"] = $"keyId=\"{KeyId}\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest\",signature=\"{signature}\"";
|
context.Request.Headers["Signature"] = Signature($"(request-target): post {path}\nhost: {host}\ndate: {date}\ndigest: {digest}", "(request-target) host date digest");
|
||||||
context.Request.Body = new MemoryStream(body);
|
context.Request.Body = new MemoryStream(body);
|
||||||
context.Request.ContentLength = body.Length;
|
context.Request.ContentLength = body.Length;
|
||||||
return context.Request;
|
return context.Request;
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
|||||||
avatar.Settings.DefaultSensitive = sensitive;
|
avatar.Settings.DefaultSensitive = sensitive;
|
||||||
if (Params.Has("source[language]"))
|
if (Params.Has("source[language]"))
|
||||||
avatar.Settings.DefaultLanguage = Params.Get("source[language]");
|
avatar.Settings.DefaultLanguage = Params.Get("source[language]");
|
||||||
|
if (Params.Get("source[quote_policy]") is { } quotePolicy && QuotePolicies.IsKnown(quotePolicy))
|
||||||
|
avatar.Settings.QuotePolicy = quotePolicy;
|
||||||
var fields = new Dictionary<string, string>();
|
var fields = new Dictionary<string, string>();
|
||||||
for (var i = 0; i < 4; i++)
|
for (var i = 0; i < 4; i++)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ using PrivaPub.Api.Mastodon.Mappers;
|
|||||||
using PrivaPub.Domain.Privacy;
|
using PrivaPub.Domain.Privacy;
|
||||||
using PrivaPub.Domain.Statuses;
|
using PrivaPub.Domain.Statuses;
|
||||||
using PrivaPub.Models.Post;
|
using PrivaPub.Models.Post;
|
||||||
|
using PrivaPub.Models.User;
|
||||||
|
using PrivaPub.Federation.Rendering;
|
||||||
|
using PrivaPub.Federation.Outbox;
|
||||||
using PrivaPub.StaticServices;
|
using PrivaPub.StaticServices;
|
||||||
|
|
||||||
using PostEntity = PrivaPub.Models.Post.Post;
|
using PostEntity = PrivaPub.Models.Post.Post;
|
||||||
@@ -24,9 +27,14 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
|||||||
readonly MastodonMapper _mapper;
|
readonly MastodonMapper _mapper;
|
||||||
readonly DbEntities _dbEntities;
|
readonly DbEntities _dbEntities;
|
||||||
readonly IMemoryCache _cache;
|
readonly IMemoryCache _cache;
|
||||||
|
readonly IQuoteService _quotes;
|
||||||
|
readonly IOutboxPublisher _outbox;
|
||||||
|
|
||||||
public StatusesController(IStatusService statuses, MastodonMapper mapper, DbEntities dbEntities, IMemoryCache cache)
|
public StatusesController(IStatusService statuses, MastodonMapper mapper, DbEntities dbEntities, IMemoryCache cache, IQuoteService quotes,
|
||||||
|
IOutboxPublisher outbox)
|
||||||
{
|
{
|
||||||
|
_quotes = quotes;
|
||||||
|
_outbox = outbox;
|
||||||
_statuses = statuses;
|
_statuses = statuses;
|
||||||
_mapper = mapper;
|
_mapper = mapper;
|
||||||
_dbEntities = dbEntities;
|
_dbEntities = dbEntities;
|
||||||
@@ -55,6 +63,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
|||||||
Language = Params.Get("language") ?? Me.Settings.DefaultLanguage,
|
Language = Params.Get("language") ?? Me.Settings.DefaultLanguage,
|
||||||
MediaIds = Params.List("media_ids"),
|
MediaIds = Params.List("media_ids"),
|
||||||
QuotedStatusId = Params.Get("quoted_status_id"),
|
QuotedStatusId = Params.Get("quoted_status_id"),
|
||||||
|
QuotePolicy = Params.Get("quote_approval_policy"),
|
||||||
Poll = Params.Has("poll[options]")
|
Poll = Params.Has("poll[options]")
|
||||||
? new PollDraft(Params.List("poll[options]"), Params.Int("poll[expires_in]") ?? 0, Params.Bool("poll[multiple]") == true,
|
? new PollDraft(Params.List("poll[options]"), Params.Int("poll[expires_in]") ?? 0, Params.Bool("poll[multiple]") == true,
|
||||||
Params.Bool("poll[hide_totals]") == true)
|
Params.Bool("poll[hide_totals]") == true)
|
||||||
@@ -242,6 +251,32 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
|||||||
return Json(await _mapper.Statuses(visible, MyId, token));
|
return Json(await _mapper.Statuses(visible, MyId, token));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("/api/v1/statuses/{id}/quotes/{quotingId}/revoke"), Scope("write:statuses")]
|
||||||
|
public async Task<IActionResult> RevokeQuote(string id, string quotingId, CancellationToken token)
|
||||||
|
{
|
||||||
|
var quoted = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
|
||||||
|
var quoting = quoted == default ? default : await _dbEntities.Posts.Match(p => p.ID == quotingId && p.QuotedPostId == quoted.ID).ExecuteFirstAsync(token);
|
||||||
|
if (quoting == default || !await _quotes.RevokeLicence(quoted, quoting, token))
|
||||||
|
return NotFoundError();
|
||||||
|
return Json(await _mapper.Status(await _dbEntities.Posts.MatchID(quoting.ID).ExecuteFirstAsync(token), MyId, token));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("/api/v1/statuses/{id}/interaction_policy"), Scope("write:statuses")]
|
||||||
|
public async Task<IActionResult> InteractionPolicy(string id, CancellationToken token)
|
||||||
|
{
|
||||||
|
var post = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
|
||||||
|
if (post == default)
|
||||||
|
return NotFoundError();
|
||||||
|
var policy = Params.Get("quote_approval_policy");
|
||||||
|
if (!QuotePolicies.IsKnown(policy))
|
||||||
|
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Unknown quote approval policy");
|
||||||
|
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);
|
||||||
|
return Json(await _mapper.Status(post, MyId, token));
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("/api/v1/statuses/{id}/bookmark"), Scope("write:bookmarks")]
|
[HttpPost("/api/v1/statuses/{id}/bookmark"), Scope("write:bookmarks")]
|
||||||
public async Task<IActionResult> Bookmark(string id, CancellationToken token)
|
public async Task<IActionResult> Bookmark(string id, CancellationToken token)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ namespace PrivaPub.Api.Mastodon.Entities
|
|||||||
public List<Field> Fields { get; set; } = new();
|
public List<Field> Fields { get; set; } = new();
|
||||||
public int FollowRequestsCount { get; set; }
|
public int FollowRequestsCount { get; set; }
|
||||||
public string AttributionDomains { get; set; }
|
public string AttributionDomains { get; set; }
|
||||||
|
public string QuotePolicy { get; set; } = "public";
|
||||||
}
|
}
|
||||||
|
|
||||||
public class PrivaPubStatus
|
public class PrivaPubStatus
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace PrivaPub.Api.Mastodon.Infrastructure
|
||||||
|
{
|
||||||
|
public static class MastodonErrors
|
||||||
|
{
|
||||||
|
public static IApplicationBuilder UseMastodonErrorBodies(this IApplicationBuilder app) =>
|
||||||
|
app.UseWhen(context => context.Request.Path.StartsWithSegments("/api"), api => api.UseStatusCodePages(async pages =>
|
||||||
|
{
|
||||||
|
var response = pages.HttpContext.Response;
|
||||||
|
response.ContentType = "application/json; charset=utf-8";
|
||||||
|
await response.WriteAsync(JsonSerializer.Serialize(new { error = Message(response.StatusCode) }, MastodonJson.Options));
|
||||||
|
}));
|
||||||
|
|
||||||
|
public static string Message(int status) => status switch
|
||||||
|
{
|
||||||
|
StatusCodes.Status401Unauthorized => "The access token is invalid",
|
||||||
|
StatusCodes.Status403Forbidden => "This action is outside the authorized scopes",
|
||||||
|
StatusCodes.Status404NotFound => "Record not found",
|
||||||
|
StatusCodes.Status405MethodNotAllowed => "Method not allowed",
|
||||||
|
StatusCodes.Status413PayloadTooLarge => "Payload too large",
|
||||||
|
StatusCodes.Status415UnsupportedMediaType => "Unsupported media type",
|
||||||
|
StatusCodes.Status422UnprocessableEntity => "Validation failed",
|
||||||
|
StatusCodes.Status429TooManyRequests => "Too many requests",
|
||||||
|
>= 500 => "Something went wrong.",
|
||||||
|
_ => "Bad request"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -93,6 +93,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
|||||||
Privacy = actor.Settings.DefaultVisibility ?? "public",
|
Privacy = actor.Settings.DefaultVisibility ?? "public",
|
||||||
Sensitive = actor.Settings.DefaultSensitive,
|
Sensitive = actor.Settings.DefaultSensitive,
|
||||||
Language = actor.Settings.DefaultLanguage,
|
Language = actor.Settings.DefaultLanguage,
|
||||||
|
QuotePolicy = actor.Settings.QuotePolicy ?? QuotePolicies.Public,
|
||||||
Note = actor.Summary ?? string.Empty,
|
Note = actor.Summary ?? string.Empty,
|
||||||
Fields = actor.Fields.Select(f => new Field { Name = f.Key, Value = f.Value }).ToList(),
|
Fields = actor.Fields.Select(f => new Field { Name = f.Key, Value = f.Value }).ToList(),
|
||||||
FollowRequestsCount = (int)await DB.Default.CountAsync<Follower>(f => f.LocalActorId == actor.Id && !f.IsAccepted, token)
|
FollowRequestsCount = (int)await DB.Default.CountAsync<Follower>(f => f.LocalActorId == actor.Id && !f.IsAccepted, token)
|
||||||
@@ -238,6 +239,10 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
|||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var localAuthors = all.Where(p => !p.IsFederatedCopy && p.GroupUserId != default).Select(p => p.GroupUserId).Distinct().ToList();
|
||||||
|
var followsAuthor = viewerId == default || localAuthors.Count == 0
|
||||||
|
? new HashSet<string>()
|
||||||
|
: await FollowedLocalAuthors(viewerId, localAuthors, token);
|
||||||
var quotedIds = nested ? new List<string>() : all.Where(p => p.QuoteState == QuoteState.Accepted && p.QuotedPostId != default).Select(p => p.QuotedPostId).Distinct().ToList();
|
var quotedIds = nested ? new List<string>() : all.Where(p => p.QuoteState == QuoteState.Accepted && p.QuotedPostId != default).Select(p => p.QuotedPostId).Distinct().ToList();
|
||||||
var quotedPosts = new List<PostEntity>();
|
var quotedPosts = new List<PostEntity>();
|
||||||
foreach (var quotedPost in quotedIds.Count == 0 ? new List<PostEntity>() : await _dbEntities.Posts.Match(p => quotedIds.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token))
|
foreach (var quotedPost in quotedIds.Count == 0 ? new List<PostEntity>() : await _dbEntities.Posts.Match(p => quotedIds.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token))
|
||||||
@@ -250,7 +255,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
|||||||
void Quote(Status status, PostEntity post)
|
void Quote(Status status, PostEntity post)
|
||||||
{
|
{
|
||||||
status.QuotesCount = post.QuotesCount;
|
status.QuotesCount = post.QuotesCount;
|
||||||
status.QuoteApproval = Approval(post, viewerId);
|
status.QuoteApproval = post.IsFederatedCopy ? Approval(post, viewerId) : LocalApproval(post, viewerId, followsAuthor.Contains(post.GroupUserId));
|
||||||
if (post.QuoteState == QuoteState.None)
|
if (post.QuoteState == QuoteState.None)
|
||||||
return;
|
return;
|
||||||
var state = post.QuoteState.ToString().ToLowerInvariant();
|
var state = post.QuoteState.ToString().ToLowerInvariant();
|
||||||
@@ -300,6 +305,27 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
|||||||
return document.Body!.InnerHtml;
|
return document.Body!.InnerHtml;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async Task<HashSet<string>> FollowedLocalAuthors(string viewerId, List<string> authorIds, CancellationToken token)
|
||||||
|
{
|
||||||
|
var uris = new Dictionary<string, string>();
|
||||||
|
foreach (var avatar in await _dbEntities.Avatars.Match(a => authorIds.Contains(a.ID)).ExecuteAsync(token))
|
||||||
|
uris[_localActors.FromAvatar(avatar).Uri] = avatar.ID;
|
||||||
|
var targets = uris.Keys.ToList();
|
||||||
|
return (await _dbEntities.Followings.Match(f => f.AvatarId == viewerId && targets.Contains(f.TargetActorURI) && f.State == FollowState.Accepted).ExecuteAsync(token))
|
||||||
|
.Select(f => uris[f.TargetActorURI]).ToHashSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
static QuoteApprovalEntity LocalApproval(PostEntity post, string viewerId, bool viewerFollows)
|
||||||
|
{
|
||||||
|
var policy = ActivityPubRenderer.QuotableBy(post);
|
||||||
|
var everyone = policy switch { QuotePolicies.Public => new List<string> { "public" }, QuotePolicies.Followers => new List<string> { "followers" }, _ => new List<string>() };
|
||||||
|
var current = viewerId == default ? "unknown"
|
||||||
|
: viewerId == post.GroupUserId && post.Visibility is PostVisibility.Public or PostVisibility.Unlisted ? "automatic"
|
||||||
|
: policy == QuotePolicies.Public || policy == QuotePolicies.Followers && viewerFollows ? "automatic"
|
||||||
|
: "denied";
|
||||||
|
return new QuoteApprovalEntity { Automatic = everyone, CurrentUser = current };
|
||||||
|
}
|
||||||
|
|
||||||
static QuoteApprovalEntity Approval(PostEntity post, string viewerId)
|
static QuoteApprovalEntity Approval(PostEntity post, string viewerId)
|
||||||
{
|
{
|
||||||
if (!post.IsFederatedCopy || post.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted))
|
if (!post.IsFederatedCopy || post.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted))
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ namespace PrivaPub.Domain.Statuses
|
|||||||
Task Resolve(PostEntity post, NoteDocument note, CancellationToken token);
|
Task Resolve(PostEntity post, NoteDocument note, CancellationToken token);
|
||||||
Task Revoke(string stampUri, CancellationToken token);
|
Task Revoke(string stampUri, CancellationToken token);
|
||||||
Task<bool> Verified(string stampUri, string quotingUri, PostEntity quoted, CancellationToken token);
|
Task<bool> Verified(string stampUri, string quotingUri, PostEntity quoted, CancellationToken token);
|
||||||
QuotePermission Permission(PostEntity quoted, LocalActor author);
|
Task<QuotePermission> Permission(PostEntity quoted, LocalActor author, CancellationToken token);
|
||||||
|
Task<string> Grant(LocalActor author, PostEntity quoted, string quotingUri, string quoterUri, CancellationToken token);
|
||||||
|
Task ReceiveRequest(JsonNode request, ForeignAvatar actor, CancellationToken token);
|
||||||
|
Task<bool> RevokeLicence(PostEntity quoted, PostEntity quoting, CancellationToken token);
|
||||||
Task Request(LocalActor author, PostEntity post, PostEntity quoted, JsonObject note, CancellationToken token);
|
Task Request(LocalActor author, PostEntity post, PostEntity quoted, JsonObject note, CancellationToken token);
|
||||||
Task<bool> Answered(JsonNode answer, ForeignAvatar actor, bool accepted, CancellationToken token);
|
Task<bool> Answered(JsonNode answer, ForeignAvatar actor, bool accepted, CancellationToken token);
|
||||||
}
|
}
|
||||||
@@ -59,12 +62,115 @@ namespace PrivaPub.Domain.Statuses
|
|||||||
|
|
||||||
const string RequestPrefix = "quote-request-";
|
const string RequestPrefix = "quote-request-";
|
||||||
|
|
||||||
public QuotePermission Permission(PostEntity quoted, LocalActor author)
|
const string LicencePath = "/parrot-licences/";
|
||||||
|
|
||||||
|
public static string LicenceUri(LocalActor author, string licenceId) => author.Uri + LicencePath + licenceId;
|
||||||
|
|
||||||
|
async Task<bool> MayQuote(PostEntity quoted, string quoterUri, CancellationToken token)
|
||||||
|
{
|
||||||
|
var author = await _localActors.FindById(LocalActorKind.Person, quoted.GroupUserId, token);
|
||||||
|
if (author == default)
|
||||||
|
return false;
|
||||||
|
if (quoterUri == author.Uri)
|
||||||
|
return quoted.Visibility is PostVisibility.Public or PostVisibility.Unlisted;
|
||||||
|
return ActivityPubRenderer.QuotableBy(quoted) switch
|
||||||
|
{
|
||||||
|
QuotePolicies.Public => true,
|
||||||
|
QuotePolicies.Followers => await Follows(quoterUri, author, token),
|
||||||
|
_ => false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<bool> Follows(string followerUri, LocalActor author, CancellationToken token)
|
||||||
|
{
|
||||||
|
if (await _dbEntities.Followers.Match(f => f.LocalActorId == author.Id && f.ActorURI == followerUri && f.IsAccepted).ExecuteAnyAsync(token))
|
||||||
|
return true;
|
||||||
|
var local = await _localActors.FindByUri(followerUri, token);
|
||||||
|
return local != default
|
||||||
|
&& await _dbEntities.Followings.Match(f => f.AvatarId == local.Id && f.TargetActorURI == author.Uri && f.State == FollowState.Accepted).ExecuteAnyAsync(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> Grant(LocalActor author, PostEntity quoted, string quotingUri, string quoterUri, CancellationToken token)
|
||||||
|
{
|
||||||
|
var existing = await DB.Default.Find<QuoteLicence>().Match(l => l.PostId == quoted.ID && l.QuotingObjectURI == quotingUri && l.RevokedAt == null).ExecuteFirstAsync(token);
|
||||||
|
if (existing != default)
|
||||||
|
return LicenceUri(author, existing.ID);
|
||||||
|
var licence = new QuoteLicence { PostId = quoted.ID, AuthorAvatarId = author.Id, QuotingObjectURI = quotingUri, QuoterActorURI = quoterUri };
|
||||||
|
await DB.Default.SaveAsync(licence, token);
|
||||||
|
return LicenceUri(author, licence.ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ReceiveRequest(JsonNode request, ForeignAvatar actor, CancellationToken token)
|
||||||
|
{
|
||||||
|
var objectUri = Id(request["object"]);
|
||||||
|
var instrument = request["instrument"];
|
||||||
|
var quotingUri = Id(instrument);
|
||||||
|
if (objectUri == default || quotingUri == default || !Origin.Same(quotingUri, actor.ActorURI)
|
||||||
|
|| instrument is JsonObject embedded && Id(embedded["attributedTo"]) is { } by && by != actor.ActorURI)
|
||||||
|
return;
|
||||||
|
var quoted = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
|
||||||
|
var author = quoted == default ? default : await _localActors.FindById(LocalActorKind.Person, quoted.GroupUserId, token);
|
||||||
|
if (author == default || string.IsNullOrEmpty(actor.InboxURL))
|
||||||
|
return;
|
||||||
|
var answer = new JsonObject
|
||||||
|
{
|
||||||
|
["@context"] = ActivityPubRenderer.Context(),
|
||||||
|
["actor"] = author.Uri,
|
||||||
|
["object"] = Id(request),
|
||||||
|
["to"] = new JsonArray(actor.ActorURI)
|
||||||
|
};
|
||||||
|
if (await MayQuote(quoted, actor.ActorURI, token))
|
||||||
|
{
|
||||||
|
answer["type"] = "Accept";
|
||||||
|
answer["result"] = await Grant(author, quoted, quotingUri, actor.ActorURI, token);
|
||||||
|
answer["id"] = author.ActivityUri($"accept-quote-{Guid.NewGuid():N}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
answer["type"] = "Reject";
|
||||||
|
answer["id"] = author.ActivityUri($"reject-quote-{Guid.NewGuid():N}");
|
||||||
|
}
|
||||||
|
await _delivery.Enqueue(author, new[] { actor.InboxURL }, answer, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> RevokeLicence(PostEntity quoted, PostEntity quoting, CancellationToken token)
|
||||||
|
{
|
||||||
|
var licence = await DB.Default.Find<QuoteLicence>().Match(l => l.PostId == quoted.ID && l.QuotingObjectURI == quoting.ObjectURI && l.RevokedAt == null).ExecuteFirstAsync(token);
|
||||||
|
var author = licence == default ? default : await _localActors.FindById(LocalActorKind.Person, quoted.GroupUserId, token);
|
||||||
|
if (author == default)
|
||||||
|
return false;
|
||||||
|
await DB.Default.Update<QuoteLicence>().MatchID(licence.ID).Modify(l => l.RevokedAt, DateTime.UtcNow).ExecuteAsync(token);
|
||||||
|
var uri = LicenceUri(author, licence.ID);
|
||||||
|
await Revoke(uri, token);
|
||||||
|
var quoter = quoting.IsFederatedCopy ? await _dbEntities.ForeignAvatars.Match(f => f.ActorURI == quoting.ActorURI).ExecuteFirstAsync(token) : default;
|
||||||
|
var inboxes = (await _delivery.FollowerInboxes(author, token)).Append(quoter?.InboxURL).Where(i => !string.IsNullOrEmpty(i)).Distinct().ToList();
|
||||||
|
if (inboxes.Count > 0)
|
||||||
|
await _delivery.Enqueue(author, inboxes, new JsonObject
|
||||||
|
{
|
||||||
|
["@context"] = ActivityPubRenderer.Context(),
|
||||||
|
["id"] = author.ActivityUri($"revoke-quote-{licence.ID}"),
|
||||||
|
["type"] = "Delete",
|
||||||
|
["actor"] = author.Uri,
|
||||||
|
["object"] = uri,
|
||||||
|
["to"] = new JsonArray(Addressing.Public)
|
||||||
|
}, token);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<bool> OurLicence(string stampUri, PostEntity quoting, PostEntity quoted, CancellationToken token)
|
||||||
|
{
|
||||||
|
var marker = stampUri?.LastIndexOf(LicencePath, StringComparison.Ordinal) ?? -1;
|
||||||
|
if (marker < 0 || !stampUri.StartsWith(_localActors.BaseAddress + "/", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return false;
|
||||||
|
var id = stampUri[(marker + LicencePath.Length)..];
|
||||||
|
return await DB.Default.Find<QuoteLicence>().Match(l => l.ID == id && l.PostId == quoted.ID && l.QuotingObjectURI == quoting.ObjectURI && l.RevokedAt == null)
|
||||||
|
.ExecuteAnyAsync(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<QuotePermission> Permission(PostEntity quoted, LocalActor author, CancellationToken token)
|
||||||
{
|
{
|
||||||
if (!quoted.IsFederatedCopy)
|
if (!quoted.IsFederatedCopy)
|
||||||
return quoted.GroupUserId == author.Id && quoted.Visibility is PostVisibility.Public or PostVisibility.Unlisted
|
return await MayQuote(quoted, author.Uri, token) ? QuotePermission.Granted : QuotePermission.Denied;
|
||||||
? QuotePermission.Granted
|
|
||||||
: QuotePermission.Denied;
|
|
||||||
if (quoted.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted))
|
if (quoted.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted))
|
||||||
return QuotePermission.Denied;
|
return QuotePermission.Denied;
|
||||||
if (quoted.QuotePolicy is not { } policy)
|
if (quoted.QuotePolicy is not { } policy)
|
||||||
@@ -140,7 +246,9 @@ namespace PrivaPub.Domain.Statuses
|
|||||||
var state = note.QuoteDeleted ? QuoteState.Deleted
|
var state = note.QuoteDeleted ? QuoteState.Deleted
|
||||||
: quoted == default ? QuoteState.Pending
|
: quoted == default ? QuoteState.Pending
|
||||||
: note.QuoteAuthorization != default
|
: note.QuoteAuthorization != default
|
||||||
? !quoted.IsFederatedCopy || !await Verified(note.QuoteAuthorization, post.ObjectURI, quoted, token) ? QuoteState.Unauthorized : QuoteState.Accepted
|
? (quoted.IsFederatedCopy ? await Verified(note.QuoteAuthorization, post.ObjectURI, quoted, token) : await OurLicence(note.QuoteAuthorization, post, quoted, token))
|
||||||
|
? QuoteState.Accepted
|
||||||
|
: QuoteState.Unauthorized
|
||||||
: note.QuotesByConsent ? QuoteState.Pending
|
: note.QuotesByConsent ? QuoteState.Pending
|
||||||
: quoted.Visibility is PostVisibility.Public or PostVisibility.Unlisted ? QuoteState.Accepted
|
: quoted.Visibility is PostVisibility.Public or PostVisibility.Unlisted ? QuoteState.Accepted
|
||||||
: QuoteState.Unauthorized;
|
: QuoteState.Unauthorized;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ using PrivaPub.Models.Federation;
|
|||||||
using PrivaPub.Models.Group;
|
using PrivaPub.Models.Group;
|
||||||
using PrivaPub.Models.Media;
|
using PrivaPub.Models.Media;
|
||||||
using PrivaPub.Models.Post;
|
using PrivaPub.Models.Post;
|
||||||
|
using PrivaPub.Models.User;
|
||||||
using PrivaPub.Models.Social;
|
using PrivaPub.Models.Social;
|
||||||
using PrivaPub.StaticServices;
|
using PrivaPub.StaticServices;
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ namespace PrivaPub.Domain.Statuses
|
|||||||
public double? RangeKm { get; init; }
|
public double? RangeKm { get; init; }
|
||||||
public PollDraft Poll { get; init; }
|
public PollDraft Poll { get; init; }
|
||||||
public string QuotedStatusId { get; init; }
|
public string QuotedStatusId { get; init; }
|
||||||
|
public string QuotePolicy { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record StatusOutcome(PostEntity Post, int Status = StatusCodes.Status200OK, string Error = default)
|
public sealed record StatusOutcome(PostEntity Post, int Status = StatusCodes.Status200OK, string Error = default)
|
||||||
@@ -125,7 +127,7 @@ namespace PrivaPub.Domain.Statuses
|
|||||||
quoted = await _dbEntities.Posts.Match(p => p.ID == draft.QuotedStatusId && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token);
|
quoted = await _dbEntities.Posts.Match(p => p.ID == draft.QuotedStatusId && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token);
|
||||||
if (quoted == default || !await VisibilityPolicy.CanSee(quoted, author.Id, token))
|
if (quoted == default || !await VisibilityPolicy.CanSee(quoted, author.Id, token))
|
||||||
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
|
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
|
||||||
quotePermission = _quotes.Permission(quoted, author);
|
quotePermission = await _quotes.Permission(quoted, author, token);
|
||||||
if (quotePermission == QuotePermission.Denied)
|
if (quotePermission == QuotePermission.Denied)
|
||||||
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: This post cannot be quoted");
|
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: This post cannot be quoted");
|
||||||
}
|
}
|
||||||
@@ -190,6 +192,10 @@ namespace PrivaPub.Domain.Statuses
|
|||||||
post.ID = (string)post.GenerateNewID();
|
post.ID = (string)post.GenerateNewID();
|
||||||
post.ObjectURI = author.PostUri(post.ID);
|
post.ObjectURI = author.PostUri(post.ID);
|
||||||
post.Url = author.PostHtmlUrl(post.ID);
|
post.Url = author.PostHtmlUrl(post.ID);
|
||||||
|
post.LocalQuotePolicy = QuotePolicies.IsKnown(draft.QuotePolicy) ? draft.QuotePolicy : author.Settings.QuotePolicy ?? QuotePolicies.Public;
|
||||||
|
if (quoted is { IsFederatedCopy: false } && post.QuoteState == QuoteState.Accepted
|
||||||
|
&& await _localActors.FindById(LocalActorKind.Person, quoted.GroupUserId, token) is { } quotedAuthor)
|
||||||
|
post.QuoteAuthorizationURI = await _quotes.Grant(quotedAuthor, quoted, post.ObjectURI, author.Uri, token);
|
||||||
if (located)
|
if (located)
|
||||||
{
|
{
|
||||||
post.Geo = new Coordinates2D(Math.Round(draft.Longitude.Value, 2), Math.Round(draft.Latitude.Value, 2));
|
post.Geo = new Coordinates2D(Math.Round(draft.Longitude.Value, 2), Math.Round(draft.Latitude.Value, 2));
|
||||||
|
|||||||
@@ -199,6 +199,33 @@ namespace PrivaPub.Federation.Controllers
|
|||||||
return Activity(note);
|
return Activity(note);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet, Route("{actor}/parrot-licences/{licenceId}")]
|
||||||
|
public async Task<IActionResult> ParrotLicence(string actor, string licenceId, CancellationToken token)
|
||||||
|
{
|
||||||
|
var author = await _localActors.FindByUserName(actor, token);
|
||||||
|
var licence = author == default ? default : await DB.Default.Find<QuoteLicence>().Match(l => l.ID == licenceId && l.AuthorAvatarId == author.Id).ExecuteFirstAsync(token);
|
||||||
|
var quoted = licence == default ? default : await _dbEntities.Posts.MatchID(licence.PostId).ExecuteFirstAsync(token);
|
||||||
|
if (quoted == default)
|
||||||
|
return NotFound();
|
||||||
|
var uri = Domain.Statuses.QuoteService.LicenceUri(author, licence.ID);
|
||||||
|
if (licence.RevokedAt.HasValue || quoted.DeletedAt.HasValue)
|
||||||
|
return new ContentResult
|
||||||
|
{
|
||||||
|
Content = new JsonObject { ["@context"] = ActivityPubRenderer.ActivityStreams, ["id"] = uri, ["type"] = "Tombstone", ["formerType"] = "QuoteAuthorization" }.ToJsonString(),
|
||||||
|
ContentType = ActivityContentType,
|
||||||
|
StatusCode = StatusCodes.Status410Gone
|
||||||
|
};
|
||||||
|
return Activity(new JsonObject
|
||||||
|
{
|
||||||
|
["@context"] = ActivityPubRenderer.Context(),
|
||||||
|
["id"] = uri,
|
||||||
|
["type"] = "QuoteAuthorization",
|
||||||
|
["attributedTo"] = author.Uri,
|
||||||
|
["interactingObject"] = licence.QuotingObjectURI,
|
||||||
|
["interactionTarget"] = quoted.ObjectURI
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet, Route("{actor}/grunts/{activityId}")]
|
[HttpGet, Route("{actor}/grunts/{activityId}")]
|
||||||
public async Task<IActionResult> Grunt(string actor, string activityId, CancellationToken token)
|
public async Task<IActionResult> Grunt(string actor, string activityId, CancellationToken token)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using PrivaPub.Domain.Statuses;
|
||||||
|
using PrivaPub.Models.User;
|
||||||
|
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace PrivaPub.Federation.Inbox.Handlers
|
||||||
|
{
|
||||||
|
public class QuoteRequestHandler : IActivityHandler
|
||||||
|
{
|
||||||
|
readonly IQuoteService _quotes;
|
||||||
|
|
||||||
|
public QuoteRequestHandler(IQuoteService quotes)
|
||||||
|
{
|
||||||
|
_quotes = quotes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Type => "QuoteRequest";
|
||||||
|
|
||||||
|
public Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token) => _quotes.ReceiveRequest(activity, actor, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,9 +44,11 @@ namespace PrivaPub.Federation.Moderation
|
|||||||
|
|
||||||
public bool IsSuspended(string host) => Find(host)?.Severity == DomainBlockSeverity.Suspend;
|
public bool IsSuspended(string host) => Find(host)?.Severity == DomainBlockSeverity.Suspend;
|
||||||
|
|
||||||
public async Task Reload(CancellationToken token)
|
public async Task Reload(CancellationToken token) =>
|
||||||
|
Load(await DB.Default.Find<DomainBlock>().ExecuteAsync(token));
|
||||||
|
|
||||||
|
public void Load(IEnumerable<DomainBlock> blocks)
|
||||||
{
|
{
|
||||||
var blocks = await DB.Default.Find<DomainBlock>().ExecuteAsync(token);
|
|
||||||
_blocks = blocks.Where(b => !string.IsNullOrEmpty(b.Domain))
|
_blocks = blocks.Where(b => !string.IsNullOrEmpty(b.Domain))
|
||||||
.GroupBy(b => Normalise(b.Domain))
|
.GroupBy(b => Normalise(b.Domain))
|
||||||
.ToDictionary(g => g.Key, g => g.First());
|
.ToDictionary(g => g.Key, g => g.First());
|
||||||
|
|||||||
@@ -43,11 +43,18 @@ namespace PrivaPub.Federation.Rendering
|
|||||||
["quoteAuthorization"] = new JsonObject { ["@id"] = "https://w3id.org/fep/044f#quoteAuthorization", ["@type"] = "@id" },
|
["quoteAuthorization"] = new JsonObject { ["@id"] = "https://w3id.org/fep/044f#quoteAuthorization", ["@type"] = "@id" },
|
||||||
["QuoteRequest"] = "https://w3id.org/fep/044f#QuoteRequest",
|
["QuoteRequest"] = "https://w3id.org/fep/044f#QuoteRequest",
|
||||||
["QuoteAuthorization"] = "https://w3id.org/fep/044f#QuoteAuthorization",
|
["QuoteAuthorization"] = "https://w3id.org/fep/044f#QuoteAuthorization",
|
||||||
|
["interactingObject"] = new JsonObject { ["@id"] = "gts:interactingObject", ["@type"] = "@id" },
|
||||||
|
["interactionTarget"] = new JsonObject { ["@id"] = "gts:interactionTarget", ["@type"] = "@id" },
|
||||||
["misskey"] = "https://misskey-hub.net/ns#",
|
["misskey"] = "https://misskey-hub.net/ns#",
|
||||||
["_misskey_quote"] = "misskey:_misskey_quote",
|
["_misskey_quote"] = "misskey:_misskey_quote",
|
||||||
["fedibird"] = "http://fedibird.com/ns#",
|
["fedibird"] = "http://fedibird.com/ns#",
|
||||||
["quoteUri"] = "fedibird:quoteUri",
|
["quoteUri"] = "fedibird:quoteUri",
|
||||||
["quoteUrl"] = "as:quoteUrl",
|
["quoteUrl"] = "as:quoteUrl",
|
||||||
|
["gts"] = "https://gotosocial.org/ns#",
|
||||||
|
["interactionPolicy"] = new JsonObject { ["@id"] = "gts:interactionPolicy", ["@type"] = "@id" },
|
||||||
|
["canQuote"] = new JsonObject { ["@id"] = "gts:canQuote", ["@type"] = "@id" },
|
||||||
|
["automaticApproval"] = new JsonObject { ["@id"] = "gts:automaticApproval", ["@type"] = "@id" },
|
||||||
|
["manualApproval"] = new JsonObject { ["@id"] = "gts:manualApproval", ["@type"] = "@id" },
|
||||||
["litepub"] = "http://litepub.social/ns#",
|
["litepub"] = "http://litepub.social/ns#",
|
||||||
["EmojiReact"] = "litepub:EmojiReact",
|
["EmojiReact"] = "litepub:EmojiReact",
|
||||||
["focalPoint"] = new JsonObject { ["@container"] = "@list", ["@id"] = "toot:focalPoint" },
|
["focalPoint"] = new JsonObject { ["@container"] = "@list", ["@id"] = "toot:focalPoint" },
|
||||||
@@ -188,6 +195,9 @@ namespace PrivaPub.Federation.Rendering
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static string QuotableBy(PostEntity post) =>
|
||||||
|
post.Visibility is PostVisibility.Public or PostVisibility.Unlisted ? post.LocalQuotePolicy ?? Models.User.QuotePolicies.Nobody : Models.User.QuotePolicies.Nobody;
|
||||||
|
|
||||||
static void AddQuote(JsonObject note, PostEntity post, ref string content)
|
static void AddQuote(JsonObject note, PostEntity post, ref string content)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(post.QuoteURI))
|
if (string.IsNullOrEmpty(post.QuoteURI))
|
||||||
@@ -262,6 +272,19 @@ namespace PrivaPub.Federation.Rendering
|
|||||||
.ToArray())
|
.ToArray())
|
||||||
};
|
};
|
||||||
AddQuote(note, post, ref content);
|
AddQuote(note, post, ref content);
|
||||||
|
if (!post.IsFederatedCopy)
|
||||||
|
note["interactionPolicy"] = new JsonObject
|
||||||
|
{
|
||||||
|
["canQuote"] = new JsonObject
|
||||||
|
{
|
||||||
|
["automaticApproval"] = new JsonArray((JsonNode)(QuotableBy(post) switch
|
||||||
|
{
|
||||||
|
Models.User.QuotePolicies.Public => Objects.Addressing.Public,
|
||||||
|
Models.User.QuotePolicies.Followers => author.Followers,
|
||||||
|
_ => author.Uri
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
};
|
||||||
note["content"] = content;
|
note["content"] = content;
|
||||||
if (!string.IsNullOrEmpty(post.Language))
|
if (!string.IsNullOrEmpty(post.Language))
|
||||||
note["contentMap"] = new JsonObject { [post.Language] = content };
|
note["contentMap"] = new JsonObject { [post.Language] = content };
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ namespace PrivaPub.Infrastructure.Data
|
|||||||
await DB.Default.Index<Reaction>().Key(r => r.PostId, KeyType.Ascending).Key(r => r.ActorURI, KeyType.Ascending).Key(r => r.Emoji, KeyType.Ascending)
|
await DB.Default.Index<Reaction>().Key(r => r.PostId, KeyType.Ascending).Key(r => r.ActorURI, KeyType.Ascending).Key(r => r.Emoji, KeyType.Ascending)
|
||||||
.Option(o => o.Unique = true).CreateAsync(token);
|
.Option(o => o.Unique = true).CreateAsync(token);
|
||||||
await Plain<Reaction>(token, r => r.ActivityURI);
|
await Plain<Reaction>(token, r => r.ActivityURI);
|
||||||
|
await DB.Default.Index<QuoteLicence>().Key(l => l.PostId, KeyType.Ascending).Key(l => l.QuotingObjectURI, KeyType.Ascending).CreateAsync(token);
|
||||||
await Unique<Domain.Content.LinkPreview>(p => p.Url, Builders<Domain.Content.LinkPreview>.Filter.Type(p => p.Url, BsonType.String), token);
|
await Unique<Domain.Content.LinkPreview>(p => p.Url, Builders<Domain.Content.LinkPreview>.Filter.Type(p => p.Url, BsonType.String), token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using MongoDB.Entities;
|
|||||||
using PrivaPub.Models.Jobs;
|
using PrivaPub.Models.Jobs;
|
||||||
|
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
|
using System.Linq.Expressions;
|
||||||
|
|
||||||
namespace PrivaPub.Infrastructure.Jobs
|
namespace PrivaPub.Infrastructure.Jobs
|
||||||
{
|
{
|
||||||
@@ -39,6 +40,13 @@ namespace PrivaPub.Infrastructure.Jobs
|
|||||||
|
|
||||||
readonly string _owner = $"{Environment.MachineName}:{Environment.ProcessId}";
|
readonly string _owner = $"{Environment.MachineName}:{Environment.ProcessId}";
|
||||||
readonly ConcurrentDictionary<JobKind, SemaphoreSlim> _signals = new();
|
readonly ConcurrentDictionary<JobKind, SemaphoreSlim> _signals = new();
|
||||||
|
readonly FilterDefinition<Job> _scope = Builders<Job>.Filter.Empty;
|
||||||
|
|
||||||
|
public JobQueue()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public JobQueue(Expression<Func<Job, bool>> scope) => _scope = Builders<Job>.Filter.Where(scope);
|
||||||
|
|
||||||
public async Task<bool> Enqueue(JobKind kind, string payload, string host, string dedupeKey, CancellationToken token) =>
|
public async Task<bool> Enqueue(JobKind kind, string payload, string host, string dedupeKey, CancellationToken token) =>
|
||||||
await EnqueueMany(new[] { new Job { Kind = kind, Payload = payload, Host = host, DedupeKey = dedupeKey } }, token) == 1;
|
await EnqueueMany(new[] { new Job { Kind = kind, Payload = payload, Host = host, DedupeKey = dedupeKey } }, token) == 1;
|
||||||
@@ -66,7 +74,7 @@ namespace PrivaPub.Infrastructure.Jobs
|
|||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var busy = busyHosts?.ToList() ?? new List<string>();
|
var busy = busyHosts?.ToList() ?? new List<string>();
|
||||||
return await DB.Default.UpdateAndGet<Job>()
|
return await DB.Default.UpdateAndGet<Job>()
|
||||||
.Match(j => j.Kind == kind && j.State == JobState.Pending && j.RunAt <= now && !busy.Contains(j.Host))
|
.Match(f => f.Where(j => j.Kind == kind && j.State == JobState.Pending && j.RunAt <= now && !busy.Contains(j.Host)) & _scope)
|
||||||
.Modify(j => j.State, JobState.Running)
|
.Modify(j => j.State, JobState.Running)
|
||||||
.Modify(j => j.LeasedUntil, now + LeaseTime)
|
.Modify(j => j.LeasedUntil, now + LeaseTime)
|
||||||
.Modify(j => j.LeaseOwner, _owner)
|
.Modify(j => j.LeaseOwner, _owner)
|
||||||
@@ -106,7 +114,7 @@ namespace PrivaPub.Infrastructure.Jobs
|
|||||||
{
|
{
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var result = await DB.Default.Update<Job>()
|
var result = await DB.Default.Update<Job>()
|
||||||
.Match(j => j.State == JobState.Running && j.LeasedUntil < now)
|
.Match(f => f.Where(j => j.State == JobState.Running && j.LeasedUntil < now) & _scope)
|
||||||
.Modify(j => j.State, JobState.Pending)
|
.Modify(j => j.State, JobState.Pending)
|
||||||
.Modify(j => j.RunAt, now)
|
.Modify(j => j.RunAt, now)
|
||||||
.Modify(j => j.LeasedUntil, null)
|
.Modify(j => j.LeasedUntil, null)
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ namespace PrivaPub.Middleware
|
|||||||
.AddSingleton<IActivityHandler, LikeHandler>()
|
.AddSingleton<IActivityHandler, LikeHandler>()
|
||||||
.AddSingleton<IActivityHandler, DislikeHandler>()
|
.AddSingleton<IActivityHandler, DislikeHandler>()
|
||||||
.AddSingleton<IActivityHandler, EmojiReactHandler>()
|
.AddSingleton<IActivityHandler, EmojiReactHandler>()
|
||||||
|
.AddSingleton<IActivityHandler, QuoteRequestHandler>()
|
||||||
.AddSingleton<IReactions, Reactions>()
|
.AddSingleton<IReactions, Reactions>()
|
||||||
.AddSingleton<LinkPreviews>()
|
.AddSingleton<LinkPreviews>()
|
||||||
.AddSingleton<ILinkPreviews>(services => services.GetRequiredService<LinkPreviews>())
|
.AddSingleton<ILinkPreviews>(services => services.GetRequiredService<LinkPreviews>())
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using MongoDB.Entities;
|
||||||
|
|
||||||
|
namespace PrivaPub.Models.Federation
|
||||||
|
{
|
||||||
|
public class QuoteLicence : Entity
|
||||||
|
{
|
||||||
|
public string PostId { get; set; }//the quoted local post
|
||||||
|
public string AuthorAvatarId { get; set; }
|
||||||
|
public string QuotingObjectURI { get; set; }
|
||||||
|
public string QuoterActorURI { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime? RevokedAt { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,7 +60,8 @@ namespace PrivaPub.Models.Post
|
|||||||
public string QuotedPostId { get; set; }//our copy of the quoted post, once fetched
|
public string QuotedPostId { get; set; }//our copy of the quoted post, once fetched
|
||||||
public QuoteState QuoteState { get; set; }
|
public QuoteState QuoteState { get; set; }
|
||||||
public string QuoteAuthorizationURI { get; set; }//FEP-044f: the quoted author's stamp
|
public string QuoteAuthorizationURI { get; set; }//FEP-044f: the quoted author's stamp
|
||||||
public bool QuoteByConsent { get; set; }//our quote of a post whose server asks for consent (FEP-044f), so `quote` is sent
|
public bool QuoteByConsent { get; set; }
|
||||||
|
public string LocalQuotePolicy { get; set; }//who may quote this local post: QuotePolicies.Public, Followers or Nobody//our quote of a post whose server asks for consent (FEP-044f), so `quote` is sent
|
||||||
public int QuotesCount { get; set; }
|
public int QuotesCount { get; set; }
|
||||||
[BsonIgnoreIfNull]
|
[BsonIgnoreIfNull]
|
||||||
public InteractionRule QuotePolicy { get; set; }//a remote post's interactionPolicy.canQuote; null when it states none
|
public InteractionRule QuotePolicy { get; set; }//a remote post's interactionPolicy.canQuote; null when it states none
|
||||||
|
|||||||
@@ -110,6 +110,16 @@ namespace PrivaPub.Models.User
|
|||||||
public string DefaultVisibility { get; set; } = "public";
|
public string DefaultVisibility { get; set; } = "public";
|
||||||
public bool DefaultSensitive { get; set; }
|
public bool DefaultSensitive { get; set; }
|
||||||
public string DefaultLanguage { get; set; }
|
public string DefaultLanguage { get; set; }
|
||||||
|
public string QuotePolicy { get; set; } = QuotePolicies.Public;//owner decision: anyone may quote a public post, automatically
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class QuotePolicies
|
||||||
|
{
|
||||||
|
public const string Public = "public";
|
||||||
|
public const string Followers = "followers";
|
||||||
|
public const string Nobody = "nobody";
|
||||||
|
|
||||||
|
public static bool IsKnown(string value) => value is Public or Followers or Nobody;
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum AvatarServer
|
public enum AvatarServer
|
||||||
|
|||||||
+3
-1
@@ -12,6 +12,7 @@ using Serilog;
|
|||||||
using PrivaPub.Data;
|
using PrivaPub.Data;
|
||||||
using PrivaPub.Extensions;
|
using PrivaPub.Extensions;
|
||||||
using PrivaPub.Api.Mastodon.Auth;
|
using PrivaPub.Api.Mastodon.Auth;
|
||||||
|
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||||
using PrivaPub.Infrastructure;
|
using PrivaPub.Infrastructure;
|
||||||
using PrivaPub.Infrastructure.Cli;
|
using PrivaPub.Infrastructure.Cli;
|
||||||
using PrivaPub.Infrastructure.Data;
|
using PrivaPub.Infrastructure.Data;
|
||||||
@@ -78,7 +79,7 @@ try
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard));
|
BsonSerializer.TryRegisterSerializer(new GuidSerializer(GuidRepresentation.Standard));
|
||||||
var mongoSettings = builder.Configuration.GetSection(nameof(MongoSettings)).Get<MongoSettings>();
|
var mongoSettings = builder.Configuration.GetSection(nameof(MongoSettings)).Get<MongoSettings>();
|
||||||
await DB.InitAsync(mongoSettings.Database, MongoClientSettings.FromConnectionString(mongoSettings.ConnectionString));
|
await DB.InitAsync(mongoSettings.Database, MongoClientSettings.FromConnectionString(mongoSettings.ConnectionString));
|
||||||
EntityMaps.Warm();
|
EntityMaps.Warm();
|
||||||
@@ -128,6 +129,7 @@ try
|
|||||||
|
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
app.UseCors("DefaultCORS");
|
app.UseCors("DefaultCORS");
|
||||||
|
app.UseMastodonErrorBodies();
|
||||||
|
|
||||||
app.UseStaticFiles();
|
app.UseStaticFiles();
|
||||||
var mediaRoot = app.Services.GetRequiredService<PrivaPub.Domain.Media.IMediaService>().Root;
|
var mediaRoot = app.Services.GetRequiredService<PrivaPub.Domain.Media.IMediaService>().Root;
|
||||||
|
|||||||
@@ -14,8 +14,11 @@ namespace PrivaPub.Services
|
|||||||
ILogger<JwtEvents> _logger { get; set; }
|
ILogger<JwtEvents> _logger { get; set; }
|
||||||
const string contentType = "application/json";
|
const string contentType = "application/json";
|
||||||
|
|
||||||
public override async Task AuthenticationFailed(AuthenticationFailedContext context)
|
public override async Task Challenge(JwtBearerChallengeContext context)
|
||||||
{
|
{
|
||||||
|
context.HandleResponse();
|
||||||
|
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||||
|
context.Response.Headers.WWWAuthenticate = context.AuthenticateFailure == default ? "Bearer" : "Bearer error=\"invalid_token\"";
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var localizer = context.HttpContext.RequestServices.GetRequiredService<IStringLocalizer<GenericRes>>();
|
var localizer = context.HttpContext.RequestServices.GetRequiredService<IStringLocalizer<GenericRes>>();
|
||||||
@@ -26,7 +29,7 @@ namespace PrivaPub.Services
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<JwtEvents>>();
|
_logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<JwtEvents>>();
|
||||||
_logger.LogError(ex, "Error at AuthenticationFailed()");
|
_logger.LogError(ex, "Error at Challenge()");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +45,7 @@ namespace PrivaPub.Services
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<JwtEvents>>();
|
_logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<JwtEvents>>();
|
||||||
_logger.LogError(ex, "Error at AuthenticationFailed()");
|
_logger.LogError(ex, "Error at Forbidden()");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-3
@@ -11,7 +11,8 @@ Written 2026-10-01 from the original 2023 code, the decePubClient UI, a federati
|
|||||||
- [x] P3 Social features: v1.5.0, deployed 2026-10-01; media, proxy, blocks, mutes, bookmarks, pins and reports verified by tests and locally (no upload on production yet)
|
- [x] P3 Social features: v1.5.0, deployed 2026-10-01; media, proxy, blocks, mutes, bookmarks, pins and reports verified by tests and locally (no upload on production yet)
|
||||||
- [x] P4 Groups and privacy features: v1.6.0, deployed 2026-10-01; communities, circles and local-only located posts verified by tests. v1.6.1 adds the pasture (`tools/pasture/`): live interop with GoToSocial 0.22.1 passes all 25 checks, three runs in a row. Lemmy and a live Mastodon circle member are not run yet; the pasture has GoToSocial only
|
- [x] P4 Groups and privacy features: v1.6.0, deployed 2026-10-01; communities, circles and local-only located posts verified by tests. v1.6.1 adds the pasture (`tools/pasture/`): live interop with GoToSocial 0.22.1 passes all 25 checks, three runs in a row. Lemmy and a live Mastodon circle member are not run yet; the pasture has GoToSocial only
|
||||||
- [x] P5 Lose nothing: v1.7.0 to v1.9.1, deployed 2026-10-01; parsing, typed details, provenance, downvotes, tombstones, federated blocks, all checked live against GoToSocial. Book reviews and forum threads keep their raw form only (typed in P7 and P8)
|
- [x] P5 Lose nothing: v1.7.0 to v1.9.1, deployed 2026-10-01; parsing, typed details, provenance, downvotes, tombstones, federated blocks, all checked live against GoToSocial. Book reviews and forum threads keep their raw form only (typed in P7 and P8)
|
||||||
- [ ] P6 Emoji, polls, quotes, reactions, cards, players
|
- [x] P6 Emoji, polls, quotes, reactions, cards, players: v1.10.0 to v1.15.0, deployed 2026-10-01; polls, link cards, ranged video streaming and quote policies checked live against GoToSocial
|
||||||
|
- [ ] T/M Test sweep, the full pasture and the interaction ledger (see "Sweep and statistics" below)
|
||||||
- [ ] P7 Threads, communities, moderation, the social graph
|
- [ ] P7 Threads, communities, moderation, the social graph
|
||||||
- [ ] P8 Signatures, discovery, the long tail
|
- [ ] P8 Signatures, discovery, the long tail
|
||||||
|
|
||||||
@@ -128,6 +129,22 @@ and circles (see Owner decisions).
|
|||||||
| PeerTube views | **Never sent.** The remote video file is still downloaded through our proxy when it is played; that cannot be avoided without pre-downloading video. |
|
| PeerTube views | **Never sent.** The remote video file is still downloaded through our proxy when it is played; that cannot be avoided without pre-downloading video. |
|
||||||
| Bluesky bridging (Bridgy Fed) | **Allowed per persona**, with a clear warning that the posts become far more widely copied. Leaving the bridge must work, through a federated `Block` sent to the bridge. Persona creation dates are moved back by a random number of days, so personas created the same day no longer share a date. |
|
| Bluesky bridging (Bridgy Fed) | **Allowed per persona**, with a clear warning that the posts become far more widely copied. Leaving the bridge must work, through a federated `Block` sent to the bridge. Persona creation dates are moved back by a random number of days, so personas created the same day no longer share a date. |
|
||||||
| Reactions and votes | **Public, with a one-time notice.** The client tells each persona once, before its first reaction or vote, that these are public and visible as that persona. |
|
| Reactions and votes | **Public, with a one-time notice.** The client tells each persona once, before its first reaction or vote, that these are public and visible as that persona. |
|
||||||
|
| Who may quote a persona (default) | **Anyone, automatically**, as on Mastodon, for public and unlisted posts only. Each persona can change its default (followers only, or nobody) and each post can be changed; a granted quote can be revoked. Followers-only posts and DMs can never be quoted. |
|
||||||
|
| Quote permission address | `/peasants/{name}/parrot-licences/{id}` |
|
||||||
|
|
||||||
|
### Owner decisions on statistics (2026-10-03)
|
||||||
|
|
||||||
|
| Question | Decision |
|
||||||
|
|---|---|
|
||||||
|
| Fediverse statistics | **Recorded now, published later as per-server aggregates only.** The following are each kept for 90 days as an event that names the remote *server*, never a remote account or a local persona: every inbox answer, every processed activity, every delivery attempt and every outbound request. Each day folds into per-server counters kept indefinitely, plus weekly server snapshots. A future public page, `/stargazing`, shows per-server aggregates for educational use, never per account and never per persona. |
|
||||||
|
| Remote accounts in statistics | **Never stored.** Distinct accounts per server per day are counted with a keyed hash. Its key is made for that day, kept only until the day's rollup and then destroyed, so the hashes can be neither reversed nor linked across days. |
|
||||||
|
| Local side in statistics | **Only the kind of local actor** (person, group, application), **and only on public and unlisted traffic.** DMs, followers-only and circle traffic are one "private" class, never broken out per server in public. Circles are never named, whether as a kind or as a reason. Fetches of our own documents are counted per day, never per server. |
|
||||||
|
| Reading-driven traffic | **Counted per day, never logged per event:** the media proxy, lookups a client asks for, and the client API per endpoint group (admin only). Client app names are not recorded. |
|
||||||
|
| Describing servers | **Every server we exchange activities with is described weekly**, from its NodeInfo (including the user counts it publishes) and its Mastodon instance API, never its contact account. These requests are unsigned, because they are not ActivityPub documents. Never on read. |
|
||||||
|
| Server locations | **City and network (ASN) from the offline DB-IP Lite databases** (CC BY 4.0, attributed), downloaded monthly outside the app. The location comes from the address we connected to; an inbound sender's address is never recorded, and no address is stored. In public: city and network only for servers reporting at least 10 users and not behind a CDN; the country otherwise; only the CDN's name for CDN-fronted servers. The admin sees everything. |
|
||||||
|
| Crawler | **Off by default** (`Statistics:Crawler:Enabled`). When on, it identifies as `PrivaPub-Stargazer/<version> (+https://privapub.thepra.dev/stargazing)`, where `/stargazing` explains it and how to opt out. It honours robots.txt (an unreachable robots.txt means "keep out") and domain blocks. It visits one server a minute, each at most weekly, and at most 5000 servers. It reads only robots.txt, NodeInfo, the instance API and the peers list, never accounts, posts or directories. Crawled servers stay marked as crawled. |
|
||||||
|
| A remote account deletes itself | **Its posts are kept but hidden everywhere** (`Post.AuthorGone`): from timelines, profiles, search and lookups by id. Its follows and timeline rows go, as before. |
|
||||||
|
| Signed-in smoke check in production | **An undiscoverable persona**, whose read-only token is the Gitea secret `PRIVAPUB_SMOKE_TOKEN`; the deploy checks `verify_credentials`, home and notifications with it. The owner creates both. |
|
||||||
|
|
||||||
## Libraries (researched; no maintained .NET ActivityPub library exists, so Letterbook and Iceshrimp.NET both wrote their own)
|
## Libraries (researched; no maintained .NET ActivityPub library exists, so Letterbook and Iceshrimp.NET both wrote their own)
|
||||||
|
|
||||||
@@ -171,6 +188,7 @@ were agreed on 2026-10-01.
|
|||||||
| Group members | `/peasants/{group}/flock` | *new* |
|
| Group members | `/peasants/{group}/flock` | *new* |
|
||||||
| Group moderators | `/peasants/{group}/wardens` | *new* |
|
| Group moderators | `/peasants/{group}/wardens` | *new* |
|
||||||
| DM conversation context | `/peasants/{name}/whispers/{id}` | *new*, replaces `/conversations/{id}` |
|
| DM conversation context | `/peasants/{name}/whispers/{id}` | *new*, replaces `/conversations/{id}` |
|
||||||
|
| Quote permission (FEP-044f stamp) | `/peasants/{name}/parrot-licences/{id}` | chosen by the owner, 2026-10-01 |
|
||||||
|
|
||||||
Routes outside the actor namespace stay conventional, because other software looks for them by name:
|
Routes outside the actor namespace stay conventional, because other software looks for them by name:
|
||||||
- `/.well-known/*` and `/nodeinfo/*`;
|
- `/.well-known/*` and `/nodeinfo/*`;
|
||||||
@@ -456,8 +474,9 @@ it, raw where it doesn't.
|
|||||||
- read every key; verify `QuoteAuthorization` on all of its fields; handle revocation (done in v1.13.0);
|
- read every key; verify `QuoteAuthorization` on all of its fields; handle revocation (done in v1.13.0);
|
||||||
- personas quote others: `quoted_status_id`, `QuoteRequest`, `Accept{result}` verified, `Update` with
|
- personas quote others: `quoted_status_id`, `QuoteRequest`, `Accept{result}` verified, `Update` with
|
||||||
`quoteAuthorization`; legacy quotes for posts that state no policy (done in v1.13.0); `api_versions.mastodon = 7`;
|
`quoteAuthorization`; legacy quotes for posts that state no policy (done in v1.13.0); `api_versions.mastodon = 7`;
|
||||||
- still open: be quotable (`canQuote` on our posts, answer `QuoteRequest`, serve and revoke stamps). It needs a themed
|
- be quotable (done in v1.15.0): `canQuote` on our posts (owner's default: anyone, automatically), `QuoteRequest`
|
||||||
route name for stamps, agreed with the owner, and a default quote policy.
|
answered with a parrot-licence or `Reject`, licences served and revoked; personas set `source[quote_policy]`, posts
|
||||||
|
`quote_approval_policy`; `POST /statuses/:id/quotes/:quoting_id/revoke`, `PUT /statuses/:id/interaction_policy`.
|
||||||
- **Emoji reactions** in all three inbound forms, plus outbound `EmojiReact`, exposed as `emoji_reactions` (done in
|
- **Emoji reactions** in all three inbound forms, plus outbound `EmojiReact`, exposed as `emoji_reactions` (done in
|
||||||
v1.11.0; Pleroma's `/api/v1/pleroma/statuses/:id/reactions` endpoints and `pleroma:emoji_reaction` notifications).
|
v1.11.0; Pleroma's `/api/v1/pleroma/statuses/:id/reactions` endpoints and `pleroma:emoji_reaction` notifications).
|
||||||
- **Link cards** (done in v1.12.0):
|
- **Link cards** (done in v1.12.0):
|
||||||
@@ -517,6 +536,25 @@ it, raw where it doesn't.
|
|||||||
- Pixelfed `place` (display only, never re-federated);
|
- Pixelfed `place` (display only, never re-federated);
|
||||||
- Bluesky bridging per persona.
|
- Bluesky bridging per persona.
|
||||||
|
|
||||||
|
### Sweep and statistics (T and M, planned 2026-10-03, between P6 and P7)
|
||||||
|
|
||||||
|
Two tracks, interleaved so that the test host exists before the ledger, and the ledger starts collecting early, because
|
||||||
|
statistics gain value with every day recorded.
|
||||||
|
|
||||||
|
| Step | Content | Tag |
|
||||||
|
|---|---|---|
|
||||||
|
| T1–T4 | Tests stop sharing state they don't own. CI runs every test against a throwaway mongod. The whole server runs under test (`WebApplicationFactory`). Nothing answers 500. | v1.15.1 |
|
||||||
|
| M1–M6 | The interaction ledger: inbox answers, handler verdicts, delivery attempts, outbound requests, served and client traffic. Provenance fixes (fetched records, stored extensions, group-wrapped Update/Delete). | v1.16.0 |
|
||||||
|
| T5–T8 | Coverage sweep over HTTP: OAuth, `/clientapi`, the Mastodon API, federation GETs, inbox gaps, jobs, migrations, pages. | v1.17.0 |
|
||||||
|
| M7–M10 | Daily rollups (`InstanceDay`, `ServerDay`). Every touched server described weekly (NodeInfo usage, instance API, snapshots). Geolocation (DB-IP Lite city and ASN). Admin statistics API under `/clientapi/admin/statistics`. | v1.18.0 |
|
||||||
|
| T9–T14 | The pasture as plugins. GoToSocial gaps, then Mastodon, Misskey/Sharkey, Akkoma and Lemmy 1.0, each run also checking that peer's statistics. | v1.18.x |
|
||||||
|
| M11 | The opt-in crawler (`PrivaPub-Stargazer`) and the `/stargazing` explainer. | v1.19.0 |
|
||||||
|
|
||||||
|
Later, and not part of these steps: the public `/stargazing` statistics. It is anonymous, cached and rate-limited, and
|
||||||
|
covers per-server software, self-published counts, location as projected by the rule above, availability buckets and
|
||||||
|
the public activity mix. Per-server activity is shown only as order-of-magnitude buckets, and only for servers that
|
||||||
|
report at least 10 users; smaller ones fold into one "small servers" aggregate.
|
||||||
|
|
||||||
### Cut or deferred (deliberately)
|
### Cut or deferred (deliberately)
|
||||||
- **Cut:**
|
- **Cut:**
|
||||||
- Link-preview cards *fetched from the linked page* by default. Cards built from the object, or from FEP-8967
|
- Link-preview cards *fetched from the linked page* by default. Cards built from the object, or from FEP-8967
|
||||||
|
|||||||
Executable
+57
@@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Runs a command (normally `dotnet test`) with a throwaway mongod: a fresh data directory on a random localhost port,
|
||||||
|
# stopped and deleted when the command ends. The box's own mongods (27017 shared, 27022 PrivaPub's) are never touched.
|
||||||
|
# usage: tools/ci/with-test-mongod.sh dotnet test PrivaPub.sln -c Release
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
root="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/privapub-test-mongod"
|
||||||
|
mkdir -p "$root"
|
||||||
|
|
||||||
|
# Leftovers of a cancelled run: anything here older than two hours.
|
||||||
|
find "$root" -mindepth 1 -maxdepth 1 -type d -mmin +120 -print0 2>/dev/null | while IFS= read -r -d '' old; do
|
||||||
|
[ -f "$old/pid" ] && kill "$(cat "$old/pid")" 2>/dev/null || true
|
||||||
|
[ -f "$old/container" ] && podman rm -f "$(cat "$old/container")" >/dev/null 2>&1 || true
|
||||||
|
rm -rf "$old"
|
||||||
|
done
|
||||||
|
|
||||||
|
listening() { (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null; }
|
||||||
|
|
||||||
|
port=""
|
||||||
|
for _ in $(seq 1 20); do
|
||||||
|
candidate=$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')
|
||||||
|
case "$candidate" in 27017|27022) continue ;; esac
|
||||||
|
listening "$candidate" && continue
|
||||||
|
port=$candidate; break
|
||||||
|
done
|
||||||
|
[ -n "$port" ] || { echo "::error::no free port for the test mongod"; exit 1; }
|
||||||
|
|
||||||
|
dir=$(mktemp -d "$root/run-XXXXXX")
|
||||||
|
cleanup() {
|
||||||
|
status=$?
|
||||||
|
[ -f "$dir/pid" ] && kill "$(cat "$dir/pid")" 2>/dev/null || true
|
||||||
|
[ -f "$dir/container" ] && podman rm -f "$(cat "$dir/container")" >/dev/null 2>&1 || true
|
||||||
|
for _ in $(seq 1 20); do listening "$port" || break; sleep 0.5; done
|
||||||
|
rm -rf "$dir"
|
||||||
|
exit $status
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
if command -v mongod >/dev/null 2>&1; then
|
||||||
|
mkdir -p "$dir/db"
|
||||||
|
timeout 7200 mongod --dbpath "$dir/db" --port "$port" --bind_ip 127.0.0.1 --noauth \
|
||||||
|
--wiredTigerCacheSizeGB 0.25 --quiet --logpath "$dir/mongod.log" &
|
||||||
|
echo $! > "$dir/pid"
|
||||||
|
how="mongod $(mongod --version | head -1)"
|
||||||
|
elif command -v podman >/dev/null 2>&1; then
|
||||||
|
podman run -d --rm -p "127.0.0.1:$port:27017" docker.io/library/mongo:8 --quiet > "$dir/container"
|
||||||
|
how="podman mongo:8"
|
||||||
|
else
|
||||||
|
echo "::error::neither mongod nor podman is available for the test mongod"; exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for _ in $(seq 1 60); do listening "$port" && break; sleep 0.5; done
|
||||||
|
listening "$port" || { echo "::error::the test mongod did not start"; cat "$dir/mongod.log" 2>/dev/null | tail -20; exit 1; }
|
||||||
|
echo "test mongod: $how on 127.0.0.1:$port"
|
||||||
|
|
||||||
|
export PRIVAPUB_TEST_MONGOD=1 PRIVAPUB_TEST_REQUIRE_MONGOD=1 PRIVAPUB_TEST_MONGO="mongodb://127.0.0.1:$port"
|
||||||
|
"$@"
|
||||||
Reference in new issue
Block a user