Compare commits

...
6 Commits
Author SHA1 Message Date
thepraandClaude Opus 5.5 90a7e38ce9 M6: provenance fixes, stored features, community edits that are real edits
Build / Build (push) Successful in 2m34s
Deploy / privapub.thepra.dev (push) Successful in 2m53s
- A fetched ObjectRecord no longer wears the signature, key, signed headers and @context
  of the activity that caused the fetch, and its ReceivedAt is the fetch's own time. Its
  activity fields now name the trigger. Provenance answers signature null and
  fetchedBy "instance-actor". Migration _008 clears the old fetched records.
- ObjectRecords store their ObjectFeatures extensions and context namespaces (migration
  _009 fills older records in batches), so statistics can count them. Provenance reads the
  stored lists.
- ForeignAvatar.Features records what an actor document uses (ActorFeatures): featured,
  shared inbox, FEP-521a, FEP-8b32, identity proofs, Misskey fields, indexable,
  undiscoverable, locked, key size, and more.
- RemoteEdits.Apply and RemoteDeletes.Remove are shared by the Update, Delete and Announce
  handlers. A community-wrapped Update is now an edit only when newer, refreshes polls
  and media otherwise, revises the ObjectRecord and re-resolves quotes. A
  community-wrapped Delete now tombstones the object, removes its ObjectRecord, reblogs
  and timeline rows, and lowers the reply count. Any deleted quoting post lowers the
  quoted post's quote count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 11:15:21 +02:00
thepraandClaude Opus 5.5 d37999970c M5: outbound requests, previews, the media proxy and served traffic
- HttpScope (AsyncLocal) tags each outbound request with a purpose and a trigger:
  - purpose is set by the caller: actor, key, object, webfinger, context, nodeinfo;
  - trigger is set by the job kind, by "verify" during inbox verification, or defaults
    to "request".
- FederationHttp records every JSON, media and stream fetch: status, time, bytes, hops,
  and an outcome of ok, refused or failed, with a reason: disallowed, remembered,
  bad-redirect, too-many-redirects, content-type, too-large, bad-json, private-address,
  timeout, network, or the status. A fetch a reader caused (trigger "request") is only
  counted per server per day.
- Link previews record a 'preview' event: card, no-card or failed.
- The media proxy counts cache hits.
- TrafficMeter counts the client API per endpoint group, method and status class. It
  counts our served documents (actor, outbox, collection, object, activity, licence,
  webfinger, nodeinfo) by kind, status and whether signed, per day and never per server,
  and never names a circle's collections.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 11:10:42 +02:00
thepraandClaude Opus 5.5 e247001bdb M4: every delivery attempt is recorded
DeliveryJobHandler records each attempt as an 'out' event: the activity, object type and
audience read from the body (ActivityShape, shared with the inbox side), the receiving
server, the status, the time the POST took, the wait since it was queued, the bytes, the
attempt number and the signer's kind (none for circles or private traffic). The outcome is
ok, deferred (429 or 503 with Retry-After, or an open breaker: host-unavailable), retry
(5xx, 408, timeout, network), or dead: another 4xx, private-address, not-deliverable,
signer-gone, or a retry at the last attempt. Until now none of this outlived the job's
seven-day TTL, and the last error was overwritten on success.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 11:06:24 +02:00
thepraandClaude Opus 5.5 ac7cbd136b M3: what each handler did with an activity
Arrival carries a verdict that handlers set with one line before an existing return
(Arrival.Drop, Reject, Accept, About), so no handler signature changes. InboxProcessor
records it as an 'in' event, with the time taken, the wait since the inbox accepted it, the
attempt, the audience (from the post's visibility, or else the activity's addressing), the
local actor kind, the object's age for updates, deletes and reactions, and the FEP features
of a delivered object. It also records types nothing handles (unknown-type), actors that
cannot be loaded (deferred) and handler failures.

Drop reasons: fetch-failed, unparseable, misattributed, duplicate, deleted, not-addressed,
not-followed, not-public, not-visible, not-deleted, unknown-object, unknown-recipient,
cross-origin, unsupported. Accepted sub-reasons: stored, poll-vote, edit, refresh,
actor-refresh, actor-delete, removed, tombstone-only, auto-accepted, pending,
follow-answer, quote-answer, quote-granted, undone, reaction, reported. Rejected: blocked,
ignored, quote-refused.

A circle's traffic is private and kindless, and a stranger posting into one is just
not-addressed, so the event store cannot reveal a circle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 11:04:42 +02:00
thepraandClaude Opus 5.5 4fa53f63bd M2: every inbox answer is recorded
InboxReceiver records each answer once, in a finally, with a reason: too-large, not-json,
not-activity, missing-type-or-actor, no-signature, the signature check's own codes
(headers-unsigned, digest-mismatch, header-unreadable, date-skew, expired,
algorithm-unsupported), signature-invalid, actor-not-key-owner, key-unavailable,
id-cross-origin, undo-foreign, misattributed, unknown-recipient, and for 202s queued,
duplicate, suspended or self-delete-unknown-key. Each event carries the activity and object
type, the inbox, the signature scheme, the bytes and the time taken. A 404 for an unknown
/mouth and a rate-limited inbox (429, from OnRejected) are recorded too.

Until the signature verifies, the host is only claimed, so it is kept only if the server is
already known. A suspended server is recorded under its own name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 10:59:55 +02:00
thepraandClaude Opus 5.5 15cd034b29 M1: the interaction ledger
InteractionEvent records one interaction with a remote server: its channel (recv, in, out,
http, preview, crawl), activity and object type, outcome and reason, status, latency, wait,
bytes, attempt, audience, local actor kind, inbox, signature, features and the object's age.
IInteractionLedger.Record never blocks and never throws: events go into a bounded channel
of 10k, a full channel drops and counts, and a hosted service writes batches of up to 1000
every two seconds.

Privacy, as decided by the owner:
- no persona, root, group or activity id, inbox URL, actor URI or sender IP is stored;
- distinct accounts are counted with an HMAC keyed by a per-day salt (InteractionSalt,
  upserted so restarts agree, never created for a past day);
- the local actor kind survives only on public and unlisted traffic;
- a host claimed by an unverified sender is kept only if it is already known.

Traffic caused by reading is only counted per day (InstanceDay.Reads, ServerDay). Indexes:
a 90-day TTL on events, unique day rows, and a TTL safety net on salts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 10:56:13 +02:00
67 changed files with 2877 additions and 172 deletions

No files matched your search

+15 -2
View File
@@ -248,6 +248,17 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
decided in `docs/ROADMAP.md` ("Owner decisions on what PrivaPub reveals"). Anything new that tells another server decided in `docs/ROADMAP.md` ("Owner decisions on what PrivaPub reveals"). Anything new that tells another server
something about an avatar gets the same treatment: ask, then record it there. something about an avatar gets the same treatment: ask, then record it there.
- **Location-ranged posts never federate.** - **Location-ranged posts never federate.**
- **Statistics name servers, never people** (owner decisions on statistics, 2026-10-03). Every interaction is recorded
through `IInteractionLedger` (`Infrastructure/Statistics`) as an `InteractionEvent` (90 days), but an event never
holds a root, persona or group id, an activity id, an inbox URL, a remote actor URI or a sender IP:
- Distinct remote accounts are counted with `ActorHash`, an HMAC keyed by that day's `InteractionSalt`, which the
day's rollup deletes.
- `LocalKind` survives `Interactions.Sanitize` only on public and unlisted traffic, so a circle can show neither by
presence nor by absence, and no reason code names one.
- Traffic a reader causes (the media proxy, lookups, the client API, fetches of our own documents) is only counted
per day (`Count`, `CountServer`), never logged per event.
- A host claimed by an unverified sender is kept only if it is already a `RemoteInstance`.
- `Record` never blocks and never throws: it writes to a bounded channel, and a full channel drops and counts.
## Data ## Data
@@ -261,13 +272,15 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
activity, inbox, key and signed headers; received time; up to 10 later revisions). Write it right after the post's activity, inbox, key and signed headers; received time; up to 10 later revisions). Write it right after the post's
save: `CreateHandler` (delivered) and `RemotePosts.StoreContext` (fetched) do, and `UpdateHandler` appends a save: `CreateHandler` (delivered) and `RemotePosts.StoreContext` (fetched) do, and `UpdateHandler` appends a
revision. Delivery details reach them through `Arrival.Current`, which `InboxProcessor` sets for the handler's revision. Delivery details reach them through `Arrival.Current`, which `InboxProcessor` sets for the handler's
duration. The raw form lives outside `Post` so timelines never load it. Read through duration. A fetched record has no signature, key or `@context` of the activity that caused the fetch (its activity
fields name that trigger), and every record stores its `Extensions` and `ContextNamespaces` for statistics. The raw form lives outside `Post` so timelines never load it. Read through
`/api/privapub/v1/statuses/:id/provenance` and `/api/privapub/v1/instances/:host`. `/api/privapub/v1/statuses/:id/provenance` and `/api/privapub/v1/instances/:host`.
- **Remote objects are parsed for every shape in `ObjectShapes`:** `url`/`icon`/`image` as a value, an object or an - **Remote objects are parsed for every shape in `ObjectShapes`:** `url`/`icon`/`image` as a value, an object or an
array; Markdown `content`; missing `mediaType`s inferred; thumbnails from any of their five places; and the typed array; Markdown `content`; missing `mediaType`s inferred; thumbnails from any of their five places; and the typed
`Link`, `Video`, `Audio` and `Event` details. A Mastodon API card is built from those, never by fetching the linked page `Link`, `Video`, `Audio` and `Event` details. A Mastodon API card is built from those, never by fetching the linked page
(that fetch is the owner's decision 1, for P6). (that fetch is the owner's decision 1, for P6).
- **An `Update` is an edit only when its `updated` is newer than ours** (`UpdateHandler.IsEdit`). Otherwise it refreshes - **An `Update` is an edit only when its `updated` is newer than ours** (`RemoteEdits.IsEdit`; plain and community-wrapped
Updates both go through `RemoteEdits.Apply`, and deletes through `RemoteDeletes.Remove`). Otherwise it refreshes
the poll, video, audio and event details and nothing else, and leaves no revision: Mastodon and Misskey refresh poll the poll, video, audio and event details and nothing else, and leaves no revision: Mastodon and Misskey refresh poll
counts with bare Updates. counts with bare Updates.
- **A poll vote is a `Note` with a `name`, an `inReplyTo` that is a poll we hold, and no content.** `CreateHandler` - **A poll vote is a `Note` with a `name`, an `inReplyTo` that is a poll we hold, and no content.** `CreateHandler`
@@ -0,0 +1,50 @@
using PrivaPub.Federation.Actors;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
public class ActorFeaturesTests
{
[Fact]
public void A_rich_actor_reports_the_extensions_it_uses()
{
using var rsa = RSA.Create(4096);
var actor = new JsonObject
{
["id"] = "https://social.example/users/a",
["type"] = "Person",
["featured"] = "https://social.example/users/a/featured",
["alsoKnownAs"] = new JsonArray("https://old.example/users/a"),
["endpoints"] = new JsonObject { ["sharedInbox"] = "https://social.example/inbox" },
["assertionMethod"] = new JsonArray(),
["attachment"] = new JsonArray(new JsonObject { ["type"] = "PropertyValue", ["name"] = "x", ["value"] = "y" }),
["manuallyApprovesFollowers"] = true,
["discoverable"] = false,
["indexable"] = true,
["isCat"] = true,
["_misskey_summary"] = "hi",
["publicKey"] = new JsonObject { ["id"] = "https://social.example/users/a#main-key", ["publicKeyPem"] = rsa.ExportSubjectPublicKeyInfoPem() }
};
var features = ActorFeatures.Detect(JsonDocument.Parse(actor.ToJsonString()).RootElement);
Assert.Equal(new[]
{
"featured", "also-known-as", "shared-inbox", "fep-521a-assertion-method", "property-value", "misskey", "is-cat",
"indexable", "undiscoverable", "locked", "key:rsa-4096"
}, features);
}
[Fact]
public void A_bare_actor_reports_nothing_and_junk_is_survived()
{
Assert.Empty(ActorFeatures.Detect(JsonDocument.Parse("{\"id\":\"https://a.example/u\",\"type\":\"Person\"}").RootElement));
Assert.Equal(new[] { "public-key-array" },
ActorFeatures.Detect(JsonDocument.Parse("{\"publicKey\":[{\"publicKeyPem\":\"not a key\"}]}").RootElement));
Assert.Empty(ActorFeatures.Detect(JsonDocument.Parse("[1,2]").RootElement));
}
}
}
@@ -0,0 +1,131 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Social;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
[Trait("Category", "Integration")]
public sealed class CommunityEditsTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority);
async Task<(RemoteActor Community, RemoteActor Poster, JsonObject Note, Post Post)> Announced()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var community = new RemoteActor(_harness.Peer, "cats", type: "Group");
var poster = new RemoteActor(_harness.Peer, "poster");
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = community.Id }, token);
await DB.Default.Update<Following>().Match(f => f.AvatarId == alice.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token);
var note = new JsonObject
{
["id"] = $"{Origin(poster)}/notes/{Guid.NewGuid():N}",
["type"] = "Page",
["name"] = "a title",
["attributedTo"] = poster.Id,
["content"] = "<p>first</p>",
["to"] = new JsonArray(community.Id, Addressing.Public),
["audience"] = community.Id,
["published"] = DateTime.UtcNow.AddMinutes(-5).ToString("O")
};
_harness.Peer.Serve(new Uri(note["id"]!.GetValue<string>()).AbsolutePath, note.ToJsonString());
await Announce(community, new JsonObject { ["id"] = $"{Origin(poster)}/create/{Guid.NewGuid():N}", ["type"] = "Create", ["actor"] = poster.Id, ["object"] = note.DeepClone() });
var post = await DB.Default.Find<Post>().Match(p => p.ObjectURI == note["id"]!.GetValue<string>()).ExecuteSingleAsync(token);
return (community, poster, note, post);
}
Task Announce(RemoteActor community, JsonObject inner) =>
_harness.Deliver(community, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(community)}/announce/{Guid.NewGuid():N}",
["type"] = "Announce",
["actor"] = community.Id,
["to"] = new JsonArray(Addressing.Public),
["object"] = inner
});
[Fact]
public async Task A_community_edit_is_an_edit_only_when_newer_and_keeps_its_history()
{
var token = TestContext.Current.CancellationToken;
var (community, poster, note, post) = await Announced();
var refresh = (JsonObject)note.DeepClone();
_harness.Peer.Serve(new Uri(note["id"]!.GetValue<string>()).AbsolutePath, refresh.ToJsonString());
await Announce(community, new JsonObject { ["id"] = $"{Origin(poster)}/update/{Guid.NewGuid():N}", ["type"] = "Update", ["actor"] = poster.Id, ["object"] = note["id"]!.GetValue<string>() });
var afterRefresh = await DB.Default.Find<Post>().OneAsync(post.ID, token);
var edit = (JsonObject)note.DeepClone();
edit["content"] = "<p>second</p>";
edit["updated"] = DateTime.UtcNow.ToString("O");
_harness.Peer.Serve(new Uri(note["id"]!.GetValue<string>()).AbsolutePath, edit.ToJsonString());
await Announce(community, new JsonObject { ["id"] = $"{Origin(poster)}/update/{Guid.NewGuid():N}", ["type"] = "Update", ["actor"] = poster.Id, ["object"] = note["id"]!.GetValue<string>() });
var afterEdit = await DB.Default.Find<Post>().OneAsync(post.ID, token);
var record = await DB.Default.Find<ObjectRecord>().Match(r => r.ObjectURI == post.ObjectURI).ExecuteSingleAsync(token);
Assert.Empty(afterRefresh.Revisions);
Assert.Null(afterRefresh.EditedAt);
Assert.Equal("<p>second</p>", afterEdit.ContentHtml);
Assert.Equal("a title", afterEdit.Title);
Assert.Single(afterEdit.Revisions);
Assert.NotNull(afterEdit.EditedAt);
Assert.Equal(2, record.Revisions.Count);
Assert.Equal(new[] { "refresh", "edit" }, _harness.Ledger.Of("in").Where(e => e.Activity == "Announce" && e.Object == "Update").Select(e => e.Reason));
}
[Fact]
public async Task A_community_delete_leaves_a_tombstone_and_no_trace()
{
var token = TestContext.Current.CancellationToken;
var (community, poster, note, post) = await Announced();
_harness.Peer.Serve(new Uri(note["id"]!.GetValue<string>()).AbsolutePath, new JsonObject { ["id"] = note["id"]!.GetValue<string>(), ["type"] = "Tombstone" }.ToJsonString());
await Announce(community, new JsonObject { ["id"] = $"{Origin(poster)}/delete/{Guid.NewGuid():N}", ["type"] = "Delete", ["actor"] = poster.Id, ["object"] = note["id"]!.GetValue<string>() });
Assert.False(await DB.Default.Find<Post>().Match(p => p.ID == post.ID).ExecuteAnyAsync(token));
Assert.False(await DB.Default.Find<ObjectRecord>().Match(r => r.ObjectURI == post.ObjectURI).ExecuteAnyAsync(token));
Assert.False(await DB.Default.Find<TimelineEntry>().Match(e => e.PostId == post.ID).ExecuteAnyAsync(token));
Assert.True(await DB.Default.Find<DeletedObject>().Match(d => d.ObjectURI == post.ObjectURI).ExecuteAnyAsync(token));
}
[Fact]
public async Task A_fetched_object_does_not_wear_the_signature_of_the_activity_that_caused_the_fetch()
{
var token = TestContext.Current.CancellationToken;
var (_, _, _, post) = await Announced();
var record = await DB.Default.Find<ObjectRecord>().Match(r => r.ObjectURI == post.ObjectURI).ExecuteSingleAsync(token);
Assert.Equal(ObjectPath.Fetched, record.Path);
Assert.Null(record.KeyId);
Assert.Null(record.SignatureScheme);
Assert.Empty(record.SignedHeaders);
Assert.Null(record.ActivityContext);
Assert.Equal("Announce", record.ActivityType);
Assert.InRange(record.ReceivedAt, DateTime.UtcNow.AddMinutes(-1), DateTime.UtcNow.AddSeconds(1));
Assert.Contains("fep-1b12-audience", record.Extensions);
Assert.NotNull(record.ContextNamespaces);
}
}
}
@@ -32,6 +32,16 @@ namespace PrivaPub.Tests.Federation
await _peer.DisposeAsync(); await _peer.DisposeAsync();
} }
[Fact]
public async Task A_stored_actor_remembers_the_extensions_its_document_uses()
{
var bob = new RemoteActor(_peer, "bob");
var stored = await _service.GetActor(bob.Id, refresh: true, TestContext.Current.CancellationToken);
Assert.Contains("key:rsa-2048", stored.Features);
}
static string Actor(string id, string keyId = default, string owner = default, string pem = default, string type = "Person") => static string Actor(string id, string keyId = default, string owner = default, string pem = default, string type = "Person") =>
new JsonObject new JsonObject
{ {
@@ -1,6 +1,7 @@
using MongoDB.Entities; using MongoDB.Entities;
using PrivaPub.Infrastructure.Data.Migrations; using PrivaPub.Infrastructure.Data.Migrations;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Post; using PrivaPub.Models.Post;
using PrivaPub.Tests.Support; using PrivaPub.Tests.Support;
@@ -46,5 +47,37 @@ namespace PrivaPub.Tests.Infrastructure
Assert.Equal("conversation1", post.ConversationId); Assert.Equal("conversation1", post.ConversationId);
Assert.Equal("a1", post.AuthorAccountId); Assert.Equal("a1", post.AuthorAccountId);
} }
[Fact]
public async Task Fetched_records_lose_the_signature_they_never_had_and_every_record_gets_its_extensions()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
var token = TestContext.Current.CancellationToken;
var fetched = new ObjectRecord
{
ObjectURI = $"https://r.example/{Guid.NewGuid():N}", Path = ObjectPath.Fetched, KeyId = "https://x.example/u#k", SignatureScheme = "draft-cavage",
Algorithm = "rsa-sha256", SignedHeaders = new() { "host" }, ActivityContext = "\"https://www.w3.org/ns/activitystreams\"",
Raw = "{\"type\":\"Note\",\"quoteUrl\":\"https://q.example/1\"}"
};
var delivered = new ObjectRecord
{
ObjectURI = $"https://r.example/{Guid.NewGuid():N}", Path = ObjectPath.Delivered, KeyId = "https://x.example/u#k", SignatureScheme = "draft-cavage",
Raw = "not json"
};
await DB.Default.SaveAsync(new[] { fetched, delivered }, token);
await new _008_fetched_records_lose_the_trigger_signature().UpgradeAsync();
await new _009_object_records_keep_their_extensions().UpgradeAsync();
var afterFetched = await DB.Default.Find<ObjectRecord>().OneAsync(fetched.ID, token);
var afterDelivered = await DB.Default.Find<ObjectRecord>().OneAsync(delivered.ID, token);
Assert.Null(afterFetched.KeyId);
Assert.Null(afterFetched.SignatureScheme);
Assert.Empty(afterFetched.SignedHeaders);
Assert.Null(afterFetched.ActivityContext);
Assert.Equal("https://x.example/u#k", afterDelivered.KeyId);
Assert.Contains("legacy-quote", afterFetched.Extensions);
Assert.Empty(afterDelivered.Extensions);
}
} }
} }
@@ -0,0 +1,111 @@
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.Federation;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.Statistics;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Statistics
{
[Xunit.Collection(nameof(Exclusive))]
[Trait("Category", "Integration")]
public sealed class DeliveryAttemptTests : IAsyncLifetime
{
static readonly string[] PeerHosts = { "localhost", "127.0.0.1" };
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
await DB.Default.DeleteAsync<RemoteInstance>(i => PeerHosts.Contains(i.Host));
}
public async ValueTask DisposeAsync()
{
if (_harness == default)
return;
await _harness.DisposeAsync();
await DB.Default.DeleteAsync<RemoteInstance>(i => PeerHosts.Contains(i.Host));
}
async Task<(JobOutcome Outcome, InteractionEvent Event)> Attempt(string path, JsonObject activity, LocalActor signer = default)
{
var ledger = new MemoryLedger();
var handler = new DeliveryJobHandler(_harness.Local, Peer.Http(), new HostCircuitBreaker(new MemoryCache(new MemoryCacheOptions())),
NullLogger<DeliveryJobHandler>.Instance, ledger);
signer ??= (await _harness.Persona("sender")).Avatar;
var id = activity["id"]!.GetValue<string>();
await _harness.Delivery.Enqueue(signer, new[] { _harness.Peer.A + path }, activity, TestContext.Current.CancellationToken);
var job = await DB.Default.Find<Job>().Match(j => j.DedupeKey == $"{id}|{_harness.Peer.A + path}").ExecuteFirstAsync(TestContext.Current.CancellationToken);
job.Attempts = 1;
var outcome = await handler.Handle(job, TestContext.Current.CancellationToken);
return (outcome, Assert.Single(ledger.Of("out")));
}
static JsonObject Create(string audience) => new()
{
["id"] = $"https://privapub.test/a/{Guid.NewGuid():N}",
["type"] = "Create",
["to"] = new JsonArray(audience),
["object"] = new JsonObject { ["type"] = "Note", ["content"] = "hi" }
};
[Fact]
public async Task A_delivered_public_post_is_ok_with_its_shape_size_and_time()
{
_harness.Peer.Answer("/ok/inbox", 202);
var (outcome, e) = await Attempt("/ok/inbox", Create("https://www.w3.org/ns/activitystreams#Public"));
Assert.Equal(JobResult.Done, outcome.Result);
Assert.Equal(("ok", "Create", "Note", "public", "person"), (e.Outcome, e.Activity, e.Object, e.Audience, e.LocalKind));
Assert.Equal(202, e.Status);
Assert.Equal("127.0.0.1", e.Host);
Assert.Null(e.Reason);
Assert.True(e.Bytes > 0);
Assert.NotNull(e.LatencyMs);
Assert.Equal(1, e.Attempt);
}
[Fact]
public async Task Refusals_and_failures_carry_their_status_and_private_traffic_has_no_kind()
{
_harness.Peer.Answer("/gone/inbox", 410);
_harness.Peer.Answer("/busy/inbox", 429);
_harness.Peer.Answer("/broken/inbox", 500);
var (_, gone) = await Attempt("/gone/inbox", Create("https://elsewhere.example/users/x"));
var (_, busy) = await Attempt("/busy/inbox", Create("https://elsewhere.example/users/x"));
var (_, broken) = await Attempt("/broken/inbox", Create("https://elsewhere.example/users/x"));
Assert.Equal(("dead", "410", 410), (gone.Outcome, gone.Reason, gone.Status!.Value));
Assert.Equal(("retry", "429"), (busy.Outcome, busy.Reason));
Assert.Equal(("retry", "500"), (broken.Outcome, broken.Reason));
Assert.All(new[] { gone, busy, broken }, e => Assert.Equal(("private", (string)null), (e.Audience, e.LocalKind)));
}
[Fact]
public async Task A_signer_that_no_longer_exists_is_dead()
{
var ghost = new LocalActor { Id = "000000000000000000000000", Kind = LocalActorKind.Person, UserName = "ghost", BaseAddress = Harness.Base };
var (outcome, e) = await Attempt("/never/inbox", Create("https://www.w3.org/ns/activitystreams#Public"), ghost);
Assert.Equal(JobResult.Dead, outcome.Result);
Assert.Equal(("dead", "signer-gone"), (e.Outcome, e.Reason));
Assert.Null(e.Status);
}
}
}
@@ -0,0 +1,211 @@
using MongoDB.Entities;
using PrivaPub.Domain.Statuses;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Group;
using PrivaPub.Models.Post;
using PrivaPub.Models.Statistics;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
using GroupEntity = PrivaPub.Models.Group.Group;
namespace PrivaPub.Tests.Statistics
{
public class ArrivalVerdictTests
{
[Fact]
public void The_first_decision_wins_and_a_circle_has_no_kind()
{
var verdict = new ArrivalVerdict();
verdict.Drop("not-addressed");
verdict.Accept("stored");
verdict.About("Note", PostVisibility.Circle, DateTime.UtcNow.AddMinutes(-2), new LocalActor { Kind = LocalActorKind.Group, IsCircle = true });
Assert.Equal(("dropped", "not-addressed"), (verdict.Outcome, verdict.Reason));
Assert.Equal("private", verdict.Audience);
Assert.Null(verdict.LocalKind);
Assert.InRange(verdict.AgeSeconds!.Value, 110, 130);
}
[Theory]
[InlineData(PostVisibility.Public, "public")]
[InlineData(PostVisibility.Unlisted, "unlisted")]
[InlineData(PostVisibility.FollowersOnly, "private")]
[InlineData(PostVisibility.Direct, "private")]
[InlineData(PostVisibility.Circle, "private")]
[InlineData(PostVisibility.LocalGeo, "private")]
public void Every_visibility_but_public_and_unlisted_is_private(PostVisibility visibility, string audience) =>
Assert.Equal(audience, ArrivalVerdict.AudienceOf(visibility));
[Fact]
public void Outside_an_arrival_nothing_happens()
{
Arrival.Current = default;
Arrival.Drop("x");
Arrival.About("Note");
}
}
[Trait("Category", "Integration")]
public sealed class HandlerVerdictTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
InteractionEvent Processed(string activityType) =>
_harness.Ledger.Of("in").Last(e => e.Activity == activityType);
JsonObject Create(RemoteActor author, IEnumerable<string> to, JsonObject extra = default)
{
var origin = new Uri(author.Id).GetLeftPart(UriPartial.Authority);
var note = new JsonObject
{
["id"] = $"{origin}/notes/{Guid.NewGuid():N}",
["type"] = "Note",
["attributedTo"] = author.Id,
["content"] = "<p>hello</p>",
["to"] = new JsonArray(to.Select(t => (JsonNode)t).ToArray())
};
foreach (var (key, value) in extra ?? new JsonObject())
note[key] = value?.DeepClone();
return new JsonObject { ["id"] = $"{origin}/activities/{Guid.NewGuid():N}", ["type"] = "Create", ["actor"] = author.Id, ["object"] = note };
}
[Fact]
public async Task A_direct_message_is_stored_private_and_without_a_local_kind()
{
var (_, alice) = await _harness.Persona("alice");
var bob = new RemoteActor(_harness.Peer, "bob");
await _harness.Deliver(bob, "/human-centipede", Create(bob, new[] { alice.Uri }));
var e = Processed("Create");
Assert.Equal(("accepted", "stored", "private"), (e.Outcome, e.Reason, e.Audience));
Assert.Equal("Note", e.Object);
Assert.Null(e.LocalKind);
Assert.Equal("127.0.0.1", e.Host);
Assert.NotNull(e.Attempt);
Assert.NotNull(e.WaitMs);
Assert.Equal(bob.Id, _harness.Ledger.Events.Last(x => x.Event == e).ActorUri);
}
[Fact]
public async Task A_public_reply_to_a_persona_names_its_kind_and_its_features()
{
var (_, alice) = await _harness.Persona("alice");
var bob = new RemoteActor(_harness.Peer, "bob");
var parent = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "first" }, TestContext.Current.CancellationToken);
await _harness.Deliver(bob, "/human-centipede", Create(bob, new[] { Addressing.Public, alice.Uri },
new JsonObject { ["inReplyTo"] = parent.Post.ObjectURI, ["interactionPolicy"] = new JsonObject(), ["quoteUrl"] = "https://elsewhere.example/1" }));
var e = Processed("Create");
Assert.Equal(("accepted", "public", "person"), (e.Outcome, e.Audience, e.LocalKind));
Assert.Contains("interaction-policy", e.Features);
Assert.Contains("legacy-quote", e.Features);
}
[Fact]
public async Task Unwanted_things_are_dropped_with_a_reason()
{
var stranger = new RemoteActor(_harness.Peer, "stranger");
await _harness.Deliver(stranger, "/human-centipede", Create(stranger, new[] { Addressing.Public }));
var unaddressed = Processed("Create");
await _harness.Deliver(stranger, "/human-centipede", new JsonObject
{
["id"] = new Uri(stranger.Id).GetLeftPart(UriPartial.Authority) + "/likes/" + Guid.NewGuid().ToString("N"),
["type"] = "Like",
["actor"] = stranger.Id,
["object"] = $"{Harness.Base}/peasants/x/scribbles/{Guid.NewGuid():N}"
});
var like = Processed("Like");
await _harness.Deliver(stranger, "/human-centipede", new JsonObject
{
["id"] = new Uri(stranger.Id).GetLeftPart(UriPartial.Authority) + "/x/" + Guid.NewGuid().ToString("N"),
["type"] = "Bite",
["actor"] = stranger.Id,
["object"] = stranger.Id
});
var bite = Processed("Bite");
await _harness.Deliver(stranger, "/human-centipede", new JsonObject
{
["id"] = new Uri(stranger.Id).GetLeftPart(UriPartial.Authority) + "/undo/" + Guid.NewGuid().ToString("N"),
["type"] = "Undo",
["actor"] = stranger.Id,
["object"] = new JsonObject { ["id"] = stranger.Id + "/likes/never", ["type"] = "Like", ["actor"] = stranger.Id, ["object"] = "https://nowhere.example/1" }
});
var undo = Processed("Undo");
Assert.Equal(("dropped", "not-addressed", "public"), (unaddressed.Outcome, unaddressed.Reason, unaddressed.Audience));
Assert.Equal(("dropped", "unknown-object"), (like.Outcome, like.Reason));
Assert.Equal(("dropped", "unknown-type"), (bite.Outcome, bite.Reason));
Assert.Equal(("dropped", "unknown-object", "Like"), (undo.Outcome, undo.Reason, undo.Object));
}
[Fact]
public async Task A_stranger_posting_into_a_circle_never_reveals_the_circle()
{
var (_, owner) = await _harness.Persona("owner");
var (privateKey, publicKey) = Keys.NewKeyPair();
var entity = new GroupEntity
{
UserName = $"circle{Guid.NewGuid():N}"[..20],
Kind = GroupKind.Circle,
PostingPolicy = PostingPolicy.Followers,
PrivateKey = privateKey,
PublicKey = publicKey,
Members = new() { new GroupMember { AvatarId = owner.Id, Role = GroupRole.Owner } }
};
await DB.Default.SaveAsync(entity);
var circle = _harness.Local.FromGroup(entity);
var outsider = new RemoteActor(_harness.Peer, "outsider");
await _harness.Deliver(outsider, "/human-centipede", Create(outsider, new[] { circle.Uri, circle.Flock }));
var e = Processed("Create");
Assert.Equal(("dropped", "not-addressed", "private"), (e.Outcome, e.Reason, e.Audience));
Assert.Null(e.LocalKind);
Assert.DoesNotContain(_harness.Ledger.Events, x => System.Text.Json.JsonSerializer.Serialize(x.Event).Contains("circle", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task A_follow_is_accepted_and_its_undo_is_recorded()
{
var (_, alice) = await _harness.Persona("alice");
var bob = new RemoteActor(_harness.Peer, "bob");
var follow = new JsonObject { ["id"] = bob.Id + "/follows/" + Guid.NewGuid().ToString("N"), ["type"] = "Follow", ["actor"] = bob.Id, ["object"] = alice.Uri };
await _harness.Deliver(bob, "/human-centipede", follow);
var followed = Processed("Follow");
await _harness.Deliver(bob, "/human-centipede", new JsonObject
{
["id"] = bob.Id + "/undo/" + Guid.NewGuid().ToString("N"),
["type"] = "Undo",
["actor"] = bob.Id,
["object"] = follow.DeepClone()
});
var undone = Processed("Undo");
Assert.Equal(("accepted", "auto-accepted", "none"), (followed.Outcome, followed.Reason, followed.Audience));
Assert.Equal(("accepted", "undone", "Follow"), (undone.Outcome, undone.Reason, undone.Object));
}
}
}
@@ -0,0 +1,169 @@
using Microsoft.Extensions.Logging.Abstractions;
using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Moderation;
using PrivaPub.Federation.Signing;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Statistics;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Statistics
{
public class SignatureProblemCodeTests
{
[Theory]
[InlineData("unsupported signature algorithm 'ed25519'", "algorithm-unsupported")]
[InlineData("(request-target) is not signed", "headers-unsigned")]
[InlineData("host is not signed", "headers-unsigned")]
[InlineData("the digest is not signed", "headers-unsigned")]
[InlineData("neither date nor (created) is signed", "headers-unsigned")]
[InlineData("the digest does not match the body", "digest-mismatch")]
[InlineData("unreadable Date header", "header-unreadable")]
[InlineData("unreadable (created)", "header-unreadable")]
[InlineData("unreadable (expires)", "header-unreadable")]
[InlineData("the Date header is outside the allowed window", "date-skew")]
[InlineData("(created) is outside the allowed window", "date-skew")]
[InlineData("the signature has expired", "expired")]
[InlineData("something new", "signature-problem")]
public void Every_signature_problem_has_a_reason_code(string problem, string code) =>
Assert.Equal(code, HttpSignatures.ProblemCode(problem));
}
[Trait("Category", "Integration")]
public sealed class InboxAnswerTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
JsonObject Create(RemoteActor sender, string to)
{
var origin = new Uri(sender.Id).GetLeftPart(UriPartial.Authority);
var id = $"{origin}/notes/{Guid.NewGuid():N}";
return new JsonObject
{
["id"] = id + "/activity",
["type"] = "Create",
["actor"] = sender.Id,
["to"] = new JsonArray(to),
["object"] = new JsonObject { ["id"] = id, ["type"] = "Note", ["attributedTo"] = sender.Id, ["to"] = new JsonArray(to), ["content"] = "hi" }
};
}
(InboxResult Result, InteractionEvent Event, string ActorUri, bool Claimed) Last(InboxResult result)
{
var recorded = _harness.Ledger.Events.Last(e => e.Event.Channel == "recv");
return (result, recorded.Event, recorded.ActorUri, recorded.HostClaimed);
}
[Fact]
public async Task A_verified_delivery_is_queued_once_and_then_a_duplicate()
{
var (_, alice) = await _harness.Persona("alice");
var bob = new RemoteActor(_harness.Peer, "bob");
var activity = Create(bob, alice.Uri);
var first = Last(await _harness.Receiver.Receive(bob.Post(Harness.Host, "/human-centipede", activity), default, CancellationToken.None));
var second = Last(await _harness.Receiver.Receive(bob.Post(Harness.Host, "/human-centipede", activity), default, CancellationToken.None));
Assert.Equal(202, first.Event.Status);
Assert.Equal("queued", first.Event.Reason);
Assert.Equal("queued", first.Event.Outcome);
Assert.Equal("127.0.0.1", first.Event.Host);
Assert.Equal("Create", first.Event.Activity);
Assert.Equal("Note", first.Event.Object);
Assert.Equal("shared", first.Event.Inbox);
Assert.Equal("cavage:rsa-sha256", first.Event.Signature);
Assert.True(first.Event.Bytes > 0);
Assert.Equal(bob.Id, first.ActorUri);
Assert.False(first.Claimed);
Assert.Equal("duplicate", second.Event.Reason);
}
[Fact]
public async Task Refusals_carry_their_reason_and_an_unverified_host_is_only_claimed()
{
var (_, alice) = await _harness.Persona("alice");
var mallory = new RemoteActor(_harness.Peer, "mallory");
var unsigned = mallory.Post(Harness.Host, "/human-centipede", Create(mallory, alice.Uri));
unsigned.Headers.Remove("Signature");
var noSignature = Last(await _harness.Receiver.Receive(unsigned, default, CancellationToken.None));
var tampered = mallory.Post(Harness.Host, "/human-centipede", Create(mallory, alice.Uri));
tampered.Headers["Digest"] = "SHA-256=AAAA";
var badDigest = Last(await _harness.Receiver.Receive(tampered, default, CancellationToken.None));
var forged = mallory.Post(Harness.Host, "/human-centipede", Create(mallory, alice.Uri));
forged.Headers["Signature"] = forged.Headers["Signature"].ToString().Replace("signature=\"", "signature=\"AAAA");
var badSignature = Last(await _harness.Receiver.Receive(forged, default, CancellationToken.None));
var junk = Last(await _harness.Deliver(mallory, "/human-centipede", new JsonArray(1, 2)));
Assert.Equal((401, "no-signature", "none"), (noSignature.Event.Status!.Value, noSignature.Event.Reason, noSignature.Event.Signature));
Assert.Equal((401, "digest-mismatch"), (badDigest.Event.Status!.Value, badDigest.Event.Reason));
Assert.Equal((401, "signature-invalid"), (badSignature.Event.Status!.Value, badSignature.Event.Reason));
Assert.Equal((400, "not-activity"), (junk.Event.Status!.Value, junk.Event.Reason));
foreach (var refused in new[] { noSignature, badDigest, badSignature })
{
Assert.Equal("refused", refused.Event.Outcome);
Assert.True(refused.Claimed);
Assert.Null(refused.ActorUri);
}
}
[Fact]
public async Task A_follow_of_nobody_is_a_404_and_a_self_delete_with_an_unknown_key_is_a_silent_202()
{
var stranger = new RemoteActor(_harness.Peer, "stranger");
var follow = new JsonObject
{
["id"] = stranger.Id + "/follows/" + Guid.NewGuid().ToString("N"),
["type"] = "Follow",
["actor"] = stranger.Id,
["object"] = $"{Harness.Base}/peasants/nobody{Guid.NewGuid():N}"
};
var notFound = Last(await _harness.Receiver.Receive(stranger.Post(Harness.Host, "/human-centipede", follow), default, CancellationToken.None));
var gone = new RemoteActor(_harness.Peer, "gone");
_harness.Peer.Answer(new Uri(gone.Id).AbsolutePath, 410);
var delete = new JsonObject { ["id"] = gone.Id + "#delete", ["type"] = "Delete", ["actor"] = gone.Id, ["object"] = gone.Id };
var deleted = Last(await _harness.Receiver.Receive(gone.Post(Harness.Host, "/human-centipede", delete), default, CancellationToken.None));
Assert.Equal((404, "unknown-recipient"), (notFound.Event.Status!.Value, notFound.Event.Reason));
Assert.Equal((202, "self-delete-unknown-key"), (deleted.Event.Status!.Value, deleted.Event.Reason));
Assert.True(deleted.Claimed);
}
[Fact]
public async Task A_suspended_server_is_recorded_under_its_own_name()
{
var blocks = new DomainBlocks(NullLogger<DomainBlocks>.Instance);
blocks.Load(new[] { new DomainBlock { Domain = "localhost", Severity = DomainBlockSeverity.Suspend } });
var ledger = new MemoryLedger();
var receiver = new InboxReceiver(_harness.Local, _harness.Remote, _harness.Queue, blocks, NullLogger<InboxReceiver>.Instance, ledger);
var (_, alice) = await _harness.Persona("alice");
var spammer = new RemoteActor(_harness.Peer, "spammer", _harness.Peer.B);
var result = await receiver.Receive(spammer.Post(Harness.Host, "/human-centipede", Create(spammer, alice.Uri)), default, CancellationToken.None);
Assert.Equal(202, result.StatusCode);
var (recorded, actorUri, claimed) = Assert.Single(ledger.Events);
Assert.Equal(("localhost", "suspended", "queued"), (recorded.Host, recorded.Reason, recorded.Outcome));
Assert.False(claimed);
Assert.Null(actorUri);
}
}
}
@@ -0,0 +1,105 @@
using Microsoft.Extensions.Logging.Abstractions;
using PrivaPub.Infrastructure.Statistics;
using PrivaPub.Models.Statistics;
using PrivaPub.Tests.Support;
namespace PrivaPub.Tests.Statistics
{
public class InteractionsTests
{
[Fact]
public void The_local_kind_is_kept_only_on_public_and_unlisted_traffic()
{
Assert.Equal("person", Interactions.Sanitize(new InteractionEvent { Audience = "public", LocalKind = "person" }).LocalKind);
Assert.Equal("group", Interactions.Sanitize(new InteractionEvent { Audience = "unlisted", LocalKind = "group" }).LocalKind);
Assert.Null(Interactions.Sanitize(new InteractionEvent { Audience = "private", LocalKind = "group" }).LocalKind);
Assert.Null(Interactions.Sanitize(new InteractionEvent { Audience = "none", LocalKind = "person" }).LocalKind);
Assert.Null(Interactions.Sanitize(new InteractionEvent { LocalKind = "person" }).LocalKind);
Assert.Null(Interactions.Sanitize(new InteractionEvent { Audience = "public", LocalKind = "circle" }).LocalKind);
}
[Fact]
public void Junk_becomes_other_and_hosts_are_normalised()
{
var e = Interactions.Sanitize(new InteractionEvent
{
Host = "Mastodon.Social.",
Channel = "sideways",
Activity = "https://www.w3.org/ns/activitystreams#Create",
Object = "Note",
Outcome = "Dropped!",
Reason = "not-addressed",
Audience = "everyone",
Inbox = "front-door",
Signature = "cavage:rsa-sha256",
Features = new() { "fep-044f", "Bad Feature", "fep-044f", "source:text/markdown" }
});
Assert.Equal("mastodon.social", e.Host);
Assert.Equal("other", e.Channel);
Assert.Equal("other", e.Activity);
Assert.Equal("Note", e.Object);
Assert.Equal("other", e.Outcome);
Assert.Equal("not-addressed", e.Reason);
Assert.Null(e.Audience);
Assert.Null(e.Inbox);
Assert.Equal("cavage:rsa-sha256", e.Signature);
Assert.Equal(new[] { "fep-044f", "other", "source:text/markdown" }, e.Features);
Assert.Equal("-", Interactions.Host("evil host/with path"));
Assert.Equal("-", Interactions.Host(default));
Assert.Equal("social.example", Interactions.HostOf("https://Social.Example:8443/users/x"));
}
[Fact]
public void Latencies_and_waits_fall_into_buckets()
{
Assert.Equal("le50ms", Interactions.Latency(0));
Assert.Equal("le250ms", Interactions.Latency(101));
Assert.Equal("inf", Interactions.Latency(31_000));
Assert.Equal("le1s", Interactions.Wait(900));
Assert.Equal("le60s", Interactions.Wait(59_000));
Assert.Equal("inf", Interactions.Wait(100_000_000));
}
[Fact]
public void An_actor_hash_is_stable_for_a_salt_and_unlinkable_across_salts()
{
var monday = new byte[32];
var tuesday = Enumerable.Repeat((byte)1, 32).ToArray();
const string actor = "https://social.example/users/alice";
var first = InteractionSalts.Hash(monday, actor);
Assert.Equal(first, InteractionSalts.Hash(monday, actor));
Assert.NotEqual(first, InteractionSalts.Hash(tuesday, actor));
Assert.NotEqual(first, InteractionSalts.Hash(monday, actor + "2"));
Assert.Equal(16, first.Length);
Assert.DoesNotContain("alice", first);
Assert.Null(InteractionSalts.Hash(default, actor));
}
[Fact]
public void A_full_ledger_drops_and_counts_but_never_blocks_or_throws()
{
var ledger = new InteractionLedger(new InteractionSalts(), new StaticOptions<StatisticsOptions>(new StatisticsOptions()), NullLogger<InteractionLedger>.Instance);
for (var i = 0; i < 10_005; i++)
ledger.Record(new InteractionEvent { Channel = Interactions.In });
ledger.Record(default);
Assert.Equal(5, ledger.Dropped);
}
[Fact]
public void A_disabled_ledger_records_nothing()
{
var ledger = new InteractionLedger(new InteractionSalts(), new StaticOptions<StatisticsOptions>(new StatisticsOptions { Enabled = false }), NullLogger<InteractionLedger>.Instance);
for (var i = 0; i < 10_005; i++)
ledger.Record(new InteractionEvent { Channel = Interactions.In });
Assert.Equal(0, ledger.Dropped);
}
}
}
@@ -0,0 +1,113 @@
using MongoDB.Bson;
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Infrastructure.Statistics;
using PrivaPub.Models.Statistics;
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
using System.Net;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Statistics
{
[Trait("Category", "Integration")]
public sealed class LedgerOverHttpTests : IAsyncLifetime
{
PrivaPubHost _host;
Peer _peer;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_host = await PrivaPubHost.Shared();
_peer = await Peer.Start();
}
public async ValueTask DisposeAsync()
{
if (_peer != default)
await _peer.DisposeAsync();
}
JsonObject DirectNote(RemoteActor sender, Persona persona, string text)
{
var noteId = $"{_peer.A}/notes/{Guid.NewGuid():N}";
var to = new JsonArray($"{PrivaPubHost.Base}/peasants/{persona.UserName}");
return new JsonObject
{
["id"] = noteId + "/activity",
["type"] = "Create",
["actor"] = sender.Id,
["to"] = to.DeepClone(),
["object"] = new JsonObject { ["id"] = noteId, ["type"] = "Note", ["attributedTo"] = sender.Id, ["to"] = to.DeepClone(), ["content"] = text }
};
}
async Task<List<BsonDocument>> StoredSince(DateTime since, CancellationToken token)
{
await _host.Get<InteractionLedger>().Flush(token);
return await DB.Default.Database().GetCollection<BsonDocument>(nameof(InteractionEvent))
.Find(Builders<BsonDocument>.Filter.Gte(nameof(InteractionEvent.At), since) & Builders<BsonDocument>.Filter.In(nameof(InteractionEvent.Host), new[] { "127.0.0.1", Interactions.Unknown }))
.ToListAsync(token);
}
[Fact]
public async Task A_delivery_and_its_refusals_are_recorded_naming_only_the_server()
{
var token = TestContext.Current.CancellationToken;
var since = DateTime.UtcNow.AddSeconds(-1);
var persona = await _host.Persona(await _host.SignUp(), "ledger");
var bob = new RemoteActor(_peer, "bob");
using var client = _host.Client();
Assert.Equal(HttpStatusCode.Accepted, (await client.SendAsync(bob.SignedPost($"/peasants/{persona.UserName}/mouth", DirectNote(bob, persona, "psst")), token)).StatusCode);
var forged = bob.SignedPost("/human-centipede", DirectNote(bob, persona, "forged"));
forged.Headers.Remove("Signature");
forged.Headers.TryAddWithoutValidation("Signature", bob.SignedPost("/elsewhere", new JsonObject()).Headers.GetValues("Signature").Single());
Assert.Equal(HttpStatusCode.Unauthorized, (await client.SendAsync(forged, token)).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await client.SendAsync(bob.SignedPost($"/peasants/nobody{Guid.NewGuid():N}"[..20] + "/mouth", DirectNote(bob, persona, "lost")), token)).StatusCode);
var stored = await StoredSince(since, token);
var reasons = stored.Select(e => e.GetValue(nameof(InteractionEvent.Reason), BsonNull.Value).ToString()).ToList();
Assert.Contains("queued", reasons);
Assert.Contains("signature-invalid", reasons);
Assert.Contains("unknown-recipient", reasons);
var everything = string.Join("\n", stored.Select(e => e.ToJson()));
Assert.DoesNotContain(persona.UserName, everything);
Assert.DoesNotContain(persona.Id, everything);
Assert.DoesNotContain(persona.Root.Id, everything);
Assert.DoesNotContain(bob.Name, everything);
Assert.DoesNotContain("/notes/", everything);
}
[Fact]
public async Task Client_and_served_traffic_is_counted_per_day_never_per_server()
{
var token = TestContext.Current.CancellationToken;
var persona = await _host.Persona(await _host.SignUp(), "served");
var reader = new RemoteActor(_peer, "reader");
var day = DateTime.UtcNow.Date;
await _host.Get<InteractionLedger>().Flush(token);
var before = await DB.Default.Find<ServerDay>().Match(d => d.Day == day).ExecuteFirstAsync(token) ?? new ServerDay();
using var client = _host.Client();
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/v1/instance", token)).StatusCode);
using (var actorRequest = new HttpRequestMessage(HttpMethod.Get, $"/peasants/{persona.UserName}"))
{
actorRequest.Headers.Accept.ParseAdd("application/activity+json");
Assert.Equal(HttpStatusCode.OK, (await client.SendAsync(actorRequest, token)).StatusCode);
}
Assert.Equal(HttpStatusCode.OK, (await client.SendAsync(reader.SignedGet($"/peasants/{persona.UserName}/anus"), token)).StatusCode);
await _host.Get<InteractionLedger>().Flush(token);
var after = await DB.Default.Find<ServerDay>().Match(d => d.Day == day).ExecuteFirstAsync(token);
long Grew(Dictionary<string, long> now, Dictionary<string, long> then, string key) => now.GetValueOrDefault(key) - then.GetValueOrDefault(key);
Assert.True(Grew(after.Client, before.Client, "api/v1/instance:GET:2xx") >= 1);
Assert.True(Grew(after.Served, before.Served, "actor:200:unsigned") >= 1);
Assert.True(Grew(after.Served, before.Served, "outbox:200:signed") >= 1);
Assert.DoesNotContain(after.Served.Keys, k => k.Contains(persona.UserName));
}
}
}
@@ -0,0 +1,89 @@
using Microsoft.Extensions.Logging.Abstractions;
using MongoDB.Bson;
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Infrastructure.Statistics;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.Statistics;
using PrivaPub.Tests.Support;
namespace PrivaPub.Tests.Statistics
{
[Trait("Category", "Integration")]
public sealed class LedgerStoreTests : IAsyncLifetime
{
readonly string _known = $"known{Guid.NewGuid():N}.example";
readonly string _stranger = $"stranger{Guid.NewGuid():N}.example";
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
await DB.Default.SaveAsync(new RemoteInstance { Host = _known });
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
static InteractionLedger Ledger() =>
new(new InteractionSalts(), new StaticOptions<StatisticsOptions>(new StatisticsOptions()), NullLogger<InteractionLedger>.Instance);
[Fact]
public async Task Events_are_stored_with_a_hash_and_never_the_actor()
{
var token = TestContext.Current.CancellationToken;
var ledger = Ledger();
var actor = $"https://{_known}/users/someone{Guid.NewGuid():N}";
ledger.Record(new InteractionEvent { Host = _known, Channel = Interactions.In, Activity = "Create", Object = "Note", Outcome = Interactions.Accepted }, actor);
ledger.Record(new InteractionEvent { Host = _stranger, Channel = Interactions.Receive, Status = 401, Outcome = Interactions.Refused, Reason = "no-signature" }, hostClaimed: true);
ledger.Record(new InteractionEvent { Host = _known, Channel = Interactions.Receive, Status = 401, Outcome = Interactions.Refused, Reason = "signature-invalid" }, hostClaimed: true);
await ledger.Flush(token);
var stored = await DB.Default.Find<InteractionEvent>().Match(e => e.Host == _known).ExecuteAsync(token);
Assert.Equal(2, stored.Count);
var accepted = Assert.Single(stored, e => e.Channel == Interactions.In);
Assert.NotNull(accepted.ActorHash);
Assert.Equal("signature-invalid", Assert.Single(stored, e => e.Channel == Interactions.Receive).Reason);
Assert.False(await DB.Default.Find<InteractionEvent>().Match(e => e.Host == _stranger).ExecuteAnyAsync(token));
var raw = await DB.Default.Database().GetCollection<BsonDocument>(nameof(InteractionEvent)).Find(new BsonDocument("_id", ObjectId.Parse(accepted.ID))).FirstAsync(token);
Assert.DoesNotContain("someone", raw.ToJson());
Assert.DoesNotContain("/users/", raw.ToJson());
}
[Fact]
public async Task Two_processes_agree_on_a_days_salt_and_no_salt_is_made_for_the_past()
{
var token = TestContext.Current.CancellationToken;
var one = await new InteractionSalts().For(DateTime.UtcNow, token);
var two = await new InteractionSalts().For(DateTime.UtcNow, token);
Assert.Equal(Convert.ToBase64String(one), Convert.ToBase64String(two));
Assert.Null(await new InteractionSalts().For(new DateTime(2001, 1, 1, 0, 0, 0, DateTimeKind.Utc), token));
}
[Fact]
public async Task Counters_add_up_per_day()
{
var token = TestContext.Current.CancellationToken;
var ledger = Ledger();
ledger.Count(_known, "media:hit", 1000);
ledger.Count(_known, "media:hit", 500);
ledger.Count(_known, "media.miss");
ledger.CountServer(ServerSections.Client, $"test{_known}:GET:2xx", 120);
ledger.CountServer(ServerSections.Served, $"test{_known}:200:signed");
await ledger.Flush(token);
var day = await DB.Default.Find<InstanceDay>().Match(d => d.Host == _known && d.Day == DateTime.UtcNow.Date).ExecuteFirstAsync(token);
Assert.Equal(2, day.Reads["media:hit"]);
Assert.Equal(1500, day.Reads["media:hit:bytes"]);
Assert.Equal(1, day.Reads["media_miss"]);
var server = await DB.Default.Find<ServerDay>().Match(d => d.Day == DateTime.UtcNow.Date).ExecuteFirstAsync(token);
Assert.Equal(1, server.Client[$"test{_known.Replace('.', '_')}:GET:2xx"]);
Assert.Equal(1, server.Served[$"test{_known.Replace('.', '_')}:200:signed"]);
Assert.True(server.ClientLatency["le250ms"] >= 1);
}
}
}
@@ -0,0 +1,130 @@
using Microsoft.AspNetCore.Http;
using PrivaPub.Infrastructure.Http;
using PrivaPub.Infrastructure.Statistics;
using PrivaPub.Tests.Support;
namespace PrivaPub.Tests.Statistics
{
public class HttpScopeTests
{
[Fact]
public async Task Scopes_nest_and_restore_across_awaits()
{
Assert.Null(HttpScope.Purpose);
Assert.Equal("request", HttpScope.Trigger);
using (HttpScope.Triggered("deliver"))
{
using (HttpScope.Default("object"))
{
Assert.Equal("object", HttpScope.Purpose);
using (HttpScope.For("actor"))
{
await Task.Yield();
Assert.Equal("actor", HttpScope.Purpose);
using (HttpScope.Default("object"))
Assert.Equal("actor", HttpScope.Purpose);
}
Assert.Equal("object", HttpScope.Purpose);
}
Assert.Null(HttpScope.Purpose);
Assert.Equal("deliver", HttpScope.Trigger);
Assert.False(HttpScope.Crawling);
using (HttpScope.Crawl())
Assert.True(HttpScope.Crawling);
}
Assert.Equal("request", HttpScope.Trigger);
}
}
public class TrafficMeterTests
{
[Theory]
[InlineData("api/v1/accounts/{id}/follow", "api/v1/accounts")]
[InlineData("/api/v1/statuses", "api/v1/statuses")]
[InlineData("/oauth/token", "oauth/token")]
[InlineData("/clientapi/avatar/private/insert", "clientapi/avatar/private")]
[InlineData("api/{x}", "api")]
[InlineData(null, "unmatched")]
public void Client_routes_group_by_their_first_literal_segments(string template, string group) =>
Assert.Equal(group, TrafficMeter.Group(template));
[Theory]
[InlineData("peasants/{actor}", "/peasants/alice", "actor")]
[InlineData("peasants/{actor}/anus", "/peasants/alice/anus", "outbox")]
[InlineData("peasants/{actor}/flock", "/peasants/c/flock", "collection")]
[InlineData("peasants/{actor}/groupies", "/peasants/alice/groupies", "collection")]
[InlineData("peasants/{actor}/scribbles/{postId}", "/peasants/alice/scribbles/1", "object")]
[InlineData("peasants/{actor}/grunts/{activityId}", "/peasants/alice/grunts/1", "activity")]
[InlineData("peasants/{actor}/parrot-licences/{licenceId}", "/peasants/alice/parrot-licences/1", "licence")]
[InlineData("users/{actor}", "/users/alice", "actor")]
[InlineData(".well-known/webfinger", "/.well-known/webfinger", "webfinger")]
[InlineData("nodeinfo/2.1", "/nodeinfo/2.1", "nodeinfo")]
[InlineData("@{user}", "/@alice", null)]
public void Served_documents_are_counted_by_kind_never_by_name(string template, string path, string kind) =>
Assert.Equal(kind, TrafficMeter.Served(template, new PathString(path)));
}
public sealed class OutboundRequestTests : IAsyncLifetime
{
Peer _peer;
public async ValueTask InitializeAsync() => _peer = await Peer.Start();
public async ValueTask DisposeAsync() => await _peer.DisposeAsync();
[Fact]
public async Task A_federation_fetch_is_recorded_with_its_purpose_trigger_and_size()
{
var ledger = new MemoryLedger();
var http = Peer.Http(ledger: ledger);
_peer.Serve("/users/a", "{\"id\":\"{A}/users/a\",\"type\":\"Person\"}");
using (HttpScope.Triggered("processinbox"))
using (HttpScope.For("actor"))
Assert.NotNull(await http.GetJson($"{_peer.A}/users/a", "application/activity+json", default, TestContext.Current.CancellationToken));
var e = Assert.Single(ledger.Of("http"));
Assert.Equal(("127.0.0.1", "actor", "processinbox", "ok", 200), (e.Host, e.Purpose, e.Trigger, e.Outcome, e.Status!.Value));
Assert.True(e.Bytes > 10);
Assert.Equal(0, e.Redirects);
}
[Fact]
public async Task Refusals_are_recorded_and_a_second_try_is_remembered()
{
var ledger = new MemoryLedger();
var http = Peer.Http(ledger: ledger);
_peer.Answer("/gone", 410);
_peer.ServeText("/page", "<html></html>", "text/html");
using (HttpScope.Triggered("fetchancestors"))
{
await http.GetJson($"{_peer.A}/gone", "application/activity+json", default, TestContext.Current.CancellationToken);
await http.GetJson($"{_peer.A}/gone", "application/activity+json", default, TestContext.Current.CancellationToken);
await http.GetJson($"{_peer.A}/page", "application/activity+json", default, TestContext.Current.CancellationToken);
await http.GetJson("ftp://example.org/x", "application/activity+json", default, TestContext.Current.CancellationToken);
}
var events = ledger.Of("http");
Assert.Equal(new[] { ("refused", "410"), ("refused", "remembered"), ("refused", "content-type"), ("refused", "disallowed") },
events.Select(e => (e.Outcome, e.Reason)));
Assert.Equal("object", events[0].Purpose);
}
[Fact]
public async Task A_fetch_a_reader_caused_is_only_counted()
{
var ledger = new MemoryLedger();
var http = Peer.Http(ledger: ledger);
_peer.Serve("/users/b", "{\"id\":\"{A}/users/b\",\"type\":\"Person\"}");
using (HttpScope.For("webfinger"))
await http.GetJson($"{_peer.A}/users/b", "application/activity+json", default, TestContext.Current.CancellationToken);
Assert.Empty(ledger.Events);
Assert.Equal(1, ledger.Counts[("127.0.0.1", "http:webfinger:ok")]);
}
}
}
+4 -3
View File
@@ -51,7 +51,7 @@ namespace PrivaPub.Tests.Support
RemotePosts = new RemotePosts(Db, Local, Remote, new NoBlocks(), Queue, Records, new NoPreviews()); RemotePosts = new RemotePosts(Db, Local, Remote, new NoBlocks(), Queue, Records, new NoPreviews());
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, Ledger);
Handlers = new IActivityHandler[] Handlers = new IActivityHandler[]
{ {
new FollowHandler(Db, Local, Remote, Delivery), new FollowHandler(Db, Local, Remote, Delivery),
@@ -63,13 +63,13 @@ namespace PrivaPub.Tests.Support
new QuoteRequestHandler(Quotes), 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, Records, Quotes),
new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts, Groups, Records, Polls, new NoPreviews(), Quotes), new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts, Groups, Records, Polls, new NoPreviews(), Quotes),
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)
}; };
Processor = new InboxProcessor(Remote, Handlers, NullLogger<InboxProcessor>.Instance); Processor = new InboxProcessor(Remote, Handlers, NullLogger<InboxProcessor>.Instance, Ledger);
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}") }),
@@ -83,6 +83,7 @@ namespace PrivaPub.Tests.Support
public Peer Peer { get; } public Peer Peer { get; }
public DbEntities Db { get; } = new(); public DbEntities Db { get; } = new();
public MemoryLedger Ledger { get; } = new();
public JobQueue Queue { get; } = new(); public JobQueue Queue { get; } = new();
public LocalActorService Local { get; } public LocalActorService Local { get; }
public RemoteActorService Remote { get; } public RemoteActorService Remote { get; }
+27
View File
@@ -0,0 +1,27 @@
using PrivaPub.Infrastructure.Statistics;
using PrivaPub.Models.Statistics;
using System.Collections.Concurrent;
namespace PrivaPub.Tests.Support
{
public sealed class MemoryLedger : IInteractionLedger
{
public ConcurrentQueue<(InteractionEvent Event, string ActorUri, bool HostClaimed)> Events { get; } = new();
public ConcurrentDictionary<(string Host, string Key), long> Counts { get; } = new();
public ConcurrentDictionary<(string Section, string Key), long> ServerCounts { get; } = new();
public long Dropped => 0;
public void Record(InteractionEvent interaction, string actorUri = default, bool hostClaimed = false) =>
Events.Enqueue((Interactions.Sanitize(interaction), actorUri, hostClaimed));
public void Count(string host, string key, long bytes = 0) =>
Counts.AddOrUpdate((Interactions.Host(host), key), 1, (_, count) => count + 1);
public void CountServer(string section, string key, int? latencyMs = default) =>
ServerCounts.AddOrUpdate((section, key), 1, (_, count) => count + 1);
public IReadOnlyList<InteractionEvent> Of(string channel) => Events.Select(e => e.Event).Where(e => e.Channel == channel).ToList();
}
}
+3 -2
View File
@@ -8,6 +8,7 @@ using Microsoft.Extensions.Options;
using PrivaPub.Federation.Moderation; using PrivaPub.Federation.Moderation;
using PrivaPub.Infrastructure.Http; using PrivaPub.Infrastructure.Http;
using PrivaPub.Infrastructure.Statistics;
using PrivaPub.Models.Federation; using PrivaPub.Models.Federation;
using System.Collections.Concurrent; using System.Collections.Concurrent;
@@ -79,7 +80,7 @@ namespace PrivaPub.Tests.Support
public void Answer(string path, int status, TimeSpan delay = default) => _answers[path] = (status, delay); public void Answer(string path, int status, TimeSpan delay = default) => _answers[path] = (status, delay);
public static FederationHttp Http(IMemoryCache cache = default, IDomainBlocks blocks = default) public static FederationHttp Http(IMemoryCache cache = default, IDomainBlocks blocks = default, IInteractionLedger ledger = default)
{ {
var options = new FederationOptions { AllowPrivateNetworks = true, AllowPlainHttp = true }; var options = new FederationOptions { AllowPrivateNetworks = true, AllowPlainHttp = true };
var services = new ServiceCollection(); var services = new ServiceCollection();
@@ -87,7 +88,7 @@ namespace PrivaPub.Tests.Support
.ConfigurePrimaryHttpMessageHandler(() => SafeHttpHandlerFactory.Create(options)); .ConfigurePrimaryHttpMessageHandler(() => SafeHttpHandlerFactory.Create(options));
return new FederationHttp(services.BuildServiceProvider().GetRequiredService<IHttpClientFactory>(), return new FederationHttp(services.BuildServiceProvider().GetRequiredService<IHttpClientFactory>(),
cache ?? new MemoryCache(new MemoryCacheOptions()), new StaticOptions<FederationOptions>(options), cache ?? new MemoryCache(new MemoryCacheOptions()), new StaticOptions<FederationOptions>(options),
blocks ?? new NoBlocks(), NullLogger<FederationHttp>.Instance); blocks ?? new NoBlocks(), NullLogger<FederationHttp>.Instance, ledger);
} }
public async ValueTask DisposeAsync() => await _app.DisposeAsync(); public async ValueTask DisposeAsync() => await _app.DisposeAsync();
@@ -1,3 +1,4 @@
using PrivaPub.Infrastructure.Statistics;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -18,10 +19,13 @@ namespace PrivaPub.Api.Mastodon.Controllers
readonly IMediaService _media; readonly IMediaService _media;
readonly IMediaProxy _proxy; readonly IMediaProxy _proxy;
public MediaController(IMediaService media, IMediaProxy proxy) readonly IInteractionLedger _ledger;
public MediaController(IMediaService media, IMediaProxy proxy, IInteractionLedger ledger = default)
{ {
_media = media; _media = media;
_proxy = proxy; _proxy = proxy;
_ledger = ledger;
} }
[HttpPost("/api/v1/media"), HttpPost("/api/v2/media"), Scope("write:media"), RequestSizeLimit(UploadLimit), [HttpPost("/api/v1/media"), HttpPost("/api/v2/media"), Scope("write:media"), RequestSizeLimit(UploadLimit),
@@ -67,7 +71,10 @@ namespace PrivaPub.Api.Mastodon.Controllers
Response.Headers["Content-Security-Policy"] = "default-src 'none'; sandbox"; Response.Headers["Content-Security-Policy"] = "default-src 'none'; sandbox";
Response.Headers["Cache-Control"] = "public, max-age=604800"; Response.Headers["Cache-Control"] = "public, max-age=604800";
if (_proxy.Cached(url) is { Path: not null } cached) if (_proxy.Cached(url) is { Path: not null } cached)
{
_ledger?.Count(Interactions.HostOf(url), "media:hit");
return PhysicalFile(cached.Path, cached.ContentType, enableRangeProcessing: true); return PhysicalFile(cached.Path, cached.ContentType, enableRangeProcessing: true);
}
if (Request.Headers.Range.Count == 0) if (Request.Headers.Range.Count == 0)
{ {
var (path, contentType) = await _proxy.Fetch(signature, encoded, token); var (path, contentType) = await _proxy.Fetch(signature, encoded, token);
@@ -64,15 +64,16 @@ namespace PrivaPub.Api.Mastodon.Controllers
Id = record.ActivityId, Type = record.ActivityType, Actor = record.ActivityActorURI Id = record.ActivityId, Type = record.ActivityType, Actor = record.ActivityActorURI
}, },
Inbox = record?.Inbox, Inbox = record?.Inbox,
Signature = record?.KeyId == default ? default : new ProvenanceSignature FetchedBy = record?.Path == ObjectPath.Fetched ? "instance-actor" : default,
Signature = record?.KeyId == default || record.Path == ObjectPath.Fetched ? default : new ProvenanceSignature
{ {
Scheme = record.SignatureScheme, KeyId = record.KeyId, Algorithm = record.Algorithm, Headers = record.SignedHeaders Scheme = record.SignatureScheme, KeyId = record.KeyId, Algorithm = record.Algorithm, Headers = record.SignedHeaders
}, },
ReceivedAt = record == default ? default : MastodonJson.Time(record.ReceivedAt), ReceivedAt = record == default ? default : MastodonJson.Time(record.ReceivedAt),
Published = record?.Published is { } published ? MastodonJson.Time(published) : default, Published = record?.Published is { } published ? MastodonJson.Time(published) : default,
Updated = record?.Updated is { } updated ? MastodonJson.Time(updated) : default, Updated = record?.Updated is { } updated ? MastodonJson.Time(updated) : default,
Extensions = ObjectFeatures.Detect(raw), Extensions = record?.Extensions ?? ObjectFeatures.Detect(raw),
ContextNamespaces = ObjectFeatures.Namespaces(raw, record?.ActivityContext), ContextNamespaces = record?.ContextNamespaces ?? ObjectFeatures.Namespaces(raw, record?.ActivityContext),
Raw = raw, Raw = raw,
RawBytes = record?.RawBytes ?? 0, RawBytes = record?.RawBytes ?? 0,
RawHash = record?.RawHash, RawHash = record?.RawHash,
@@ -118,6 +119,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
public bool Refetched { get; set; } public bool Refetched { get; set; }
public ProvenanceActivity Activity { get; set; } public ProvenanceActivity Activity { get; set; }
public string Inbox { get; set; } public string Inbox { get; set; }
public string FetchedBy { get; set; }
public ProvenanceSignature Signature { get; set; } public ProvenanceSignature Signature { get; set; }
public string ReceivedAt { get; set; } public string ReceivedAt { get; set; }
public string Published { get; set; } public string Published { get; set; }
+17 -1
View File
@@ -4,6 +4,8 @@ using Microsoft.Extensions.Options;
using MongoDB.Entities; using MongoDB.Entities;
using PrivaPub.Infrastructure.Statistics;
using PrivaPub.Models.Statistics;
using PrivaPub.Federation.Actors; using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Objects; using PrivaPub.Federation.Objects;
using PrivaPub.Infrastructure.Http; using PrivaPub.Infrastructure.Http;
@@ -46,14 +48,17 @@ namespace PrivaPub.Domain.Content
readonly IJobQueue _queue; readonly IJobQueue _queue;
readonly IOptionsMonitor<FederationOptions> _options; readonly IOptionsMonitor<FederationOptions> _options;
readonly IInteractionLedger _ledger;
public LinkPreviews(DbEntities dbEntities, ILocalActorService localActors, IFederationHttp http, IJobQueue queue, public LinkPreviews(DbEntities dbEntities, ILocalActorService localActors, IFederationHttp http, IJobQueue queue,
IOptionsMonitor<FederationOptions> options) IOptionsMonitor<FederationOptions> options, IInteractionLedger ledger = default)
{ {
_dbEntities = dbEntities; _dbEntities = dbEntities;
_localActors = localActors; _localActors = localActors;
_http = http; _http = http;
_queue = queue; _queue = queue;
_options = options; _options = options;
_ledger = ledger;
} }
public JobKind Kind => JobKind.FetchPreview; public JobKind Kind => JobKind.FetchPreview;
@@ -104,8 +109,19 @@ namespace PrivaPub.Domain.Content
async Task<LinkPreview> Fetch(string url, CancellationToken token) async Task<LinkPreview> Fetch(string url, CancellationToken token)
{ {
var started = System.Diagnostics.Stopwatch.GetTimestamp();
var (finalUri, html) = await _http.GetPage(url, token); var (finalUri, html) = await _http.GetPage(url, token);
var preview = html == default ? new LinkPreview { Url = url, Failed = true } : Read(url, finalUri, html); var preview = html == default ? new LinkPreview { Url = url, Failed = true } : Read(url, finalUri, html);
_ledger?.Record(new InteractionEvent
{
Channel = Interactions.Preview,
Host = Interactions.HostOf(url),
Outcome = html == default ? Interactions.Failed : Interactions.Ok,
Reason = html == default ? default : preview.Title == default && preview.ImageURL == default ? "no-card" : "card",
LatencyMs = (int)System.Diagnostics.Stopwatch.GetElapsedTime(started).TotalMilliseconds,
Bytes = html == default ? default : System.Text.Encoding.UTF8.GetByteCount(html),
Redirects = finalUri == default || finalUri.AbsoluteUri == url ? 0 : 1
});
await DB.Default.Update<LinkPreview>() await DB.Default.Update<LinkPreview>()
.Match(p => p.Url == url) .Match(p => p.Url == url)
.Modify(p => p.Title, preview.Title) .Modify(p => p.Title, preview.Title)
+6
View File
@@ -2,6 +2,7 @@ using MongoDB.Driver;
using MongoDB.Entities; using MongoDB.Entities;
using PrivaPub.Federation.Actors; using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Inbox.Handlers; using PrivaPub.Federation.Inbox.Handlers;
using PrivaPub.Federation.Objects; using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Outbox; using PrivaPub.Federation.Outbox;
@@ -43,7 +44,10 @@ namespace PrivaPub.Domain.Social
public async Task Receive(PostEntity post, ForeignAvatar actor, string content, JsonNode tags, string activityId, CancellationToken token) public async Task Receive(PostEntity post, ForeignAvatar actor, string content, JsonNode tags, string activityId, CancellationToken token)
{ {
if (!LikeHandler.MaySee(post, actor) || Normalise(content, ObjectShapes.Emojis(tags)) is not { } emoji) if (!LikeHandler.MaySee(post, actor) || Normalise(content, ObjectShapes.Emojis(tags)) is not { } emoji)
{
Arrival.Drop(LikeHandler.MaySee(post, actor) ? "unparseable" : "not-visible");
return; return;
}
try try
{ {
await DB.Default.SaveAsync(new Reaction await DB.Default.SaveAsync(new Reaction
@@ -53,8 +57,10 @@ namespace PrivaPub.Domain.Social
} }
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{ {
Arrival.Drop("duplicate");
return; return;
} }
Arrival.Accept("reaction");
if (!post.IsFederatedCopy) if (!post.IsFederatedCopy)
await Notifications.Add(post.GroupUserId, NotificationType.Reaction, actor.ID, actor.ActorURI, post.ID, token, emoji.Name, emoji.Url); await Notifications.Add(post.GroupUserId, NotificationType.Reaction, actor.ID, actor.ActorURI, post.ID, token, emoji.Name, emoji.Url);
} }
+9
View File
@@ -107,11 +107,18 @@ namespace PrivaPub.Domain.Statuses
var quotingUri = Id(instrument); var quotingUri = Id(instrument);
if (objectUri == default || quotingUri == default || !Origin.Same(quotingUri, actor.ActorURI) if (objectUri == default || quotingUri == default || !Origin.Same(quotingUri, actor.ActorURI)
|| instrument is JsonObject embedded && Id(embedded["attributedTo"]) is { } by && by != actor.ActorURI) || instrument is JsonObject embedded && Id(embedded["attributedTo"]) is { } by && by != actor.ActorURI)
{
Arrival.Drop("misattributed");
return; return;
}
var quoted = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); 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); var author = quoted == default ? default : await _localActors.FindById(LocalActorKind.Person, quoted.GroupUserId, token);
if (author == default || string.IsNullOrEmpty(actor.InboxURL)) if (author == default || string.IsNullOrEmpty(actor.InboxURL))
{
Arrival.Drop("unknown-object");
return; return;
}
Arrival.About("Note", quoted.Visibility, quoted.CreationDate);
var answer = new JsonObject var answer = new JsonObject
{ {
["@context"] = ActivityPubRenderer.Context(), ["@context"] = ActivityPubRenderer.Context(),
@@ -121,12 +128,14 @@ namespace PrivaPub.Domain.Statuses
}; };
if (await MayQuote(quoted, actor.ActorURI, token)) if (await MayQuote(quoted, actor.ActorURI, token))
{ {
Arrival.Accept("quote-granted");
answer["type"] = "Accept"; answer["type"] = "Accept";
answer["result"] = await Grant(author, quoted, quotingUri, actor.ActorURI, token); answer["result"] = await Grant(author, quoted, quotingUri, actor.ActorURI, token);
answer["id"] = author.ActivityUri($"accept-quote-{Guid.NewGuid():N}"); answer["id"] = author.ActivityUri($"accept-quote-{Guid.NewGuid():N}");
} }
else else
{ {
Arrival.Reject("quote-refused");
answer["type"] = "Reject"; answer["type"] = "Reject";
answer["id"] = author.ActivityUri($"reject-quote-{Guid.NewGuid():N}"); answer["id"] = author.ActivityUri($"reject-quote-{Guid.NewGuid():N}");
} }
@@ -37,6 +37,7 @@ namespace PrivaPub.Federation.Actors
public List<CustomEmoji> Emojis { get; init; } = new(); public List<CustomEmoji> Emojis { get; init; } = new();
public Dictionary<string, string> Fields { get; init; } = new(); public Dictionary<string, string> Fields { get; init; } = new();
public IReadOnlyList<ActorKey> Keys { get; init; } = Array.Empty<ActorKey>(); public IReadOnlyList<ActorKey> Keys { get; init; } = Array.Empty<ActorKey>();
public List<string> Features { get; init; } = new();
public ActorKey Key(string keyId) => Keys.FirstOrDefault(k => k.Id == keyId); public ActorKey Key(string keyId) => Keys.FirstOrDefault(k => k.Id == keyId);
@@ -54,6 +55,7 @@ namespace PrivaPub.Federation.Actors
{ {
Id = id, Id = id,
Type = type, Type = type,
Features = ActorFeatures.Detect(root),
PreferredUsername = RemoteActorService.Text(root, "preferredUsername"), PreferredUsername = RemoteActorService.Text(root, "preferredUsername"),
Name = RemoteActorService.Text(root, "name"), Name = RemoteActorService.Text(root, "name"),
Summary = RemoteActorService.Text(root, "summary"), Summary = RemoteActorService.Text(root, "summary"),
@@ -0,0 +1,72 @@
using System.Security.Cryptography;
using System.Text.Json;
namespace PrivaPub.Federation.Actors
{
public static class ActorFeatures
{
public static List<string> Detect(JsonElement root)
{
var found = new List<string>();
if (root.ValueKind != JsonValueKind.Object)
return found;
void When(bool condition, string feature)
{
if (condition)
found.Add(feature);
}
bool Has(string name) => root.TryGetProperty(name, out var value) && value.ValueKind is not (JsonValueKind.Null or JsonValueKind.Undefined);
bool True(string name) => root.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.True;
IEnumerable<JsonElement> Items(string name) => !root.TryGetProperty(name, out var value)
? Enumerable.Empty<JsonElement>()
: value.ValueKind == JsonValueKind.Array ? value.EnumerateArray() : new[] { value };
bool Typed(JsonElement item, string type) =>
item.ValueKind == JsonValueKind.Object && item.TryGetProperty("type", out var t) && t.ValueKind == JsonValueKind.String && t.GetString() == type;
When(Has("featured"), "featured");
When(Has("featuredTags"), "featured-tags");
When(Items("alsoKnownAs").Any(), "also-known-as");
When(Has("movedTo"), "moved-to");
When(root.TryGetProperty("endpoints", out var endpoints) && endpoints.ValueKind == JsonValueKind.Object && endpoints.TryGetProperty("sharedInbox", out _), "shared-inbox");
When(Has("assertionMethod"), "fep-521a-assertion-method");
When(Has("proof"), "fep-8b32-proof");
When(Items("attachment").Any(a => Typed(a, "PropertyValue")), "property-value");
When(Items("attachment").Any(a => Typed(a, "VerifiableIdentityStatement") || Typed(a, "IdentityProof")), "fep-c390-identity-proof");
When(Items("tag").Any(t => Typed(t, "Emoji")), "custom-emoji");
When(root.EnumerateObject().Any(p => p.Name.StartsWith("_misskey", StringComparison.Ordinal)), "misskey");
When(True("isCat"), "is-cat");
When(Has("webfinger"), "fep-2c59-webfinger");
When(Has("attributionDomains"), "attribution-domains");
When(Has("searchableBy"), "fep-268d-searchable-by");
When(True("indexable"), "indexable");
When(root.TryGetProperty("discoverable", out var discoverable) && discoverable.ValueKind == JsonValueKind.False, "undiscoverable");
When(True("manuallyApprovesFollowers"), "locked");
When(True("memorial"), "memorial");
When(Has("generator"), "generator");
When(root.TryGetProperty("publicKey", out var keys) && keys.ValueKind == JsonValueKind.Array, "public-key-array");
if (KeyBits(root) is { } bits)
found.Add($"key:rsa-{bits}");
return found;
}
static int? KeyBits(JsonElement root)
{
if (!root.TryGetProperty("publicKey", out var key))
return default;
if (key.ValueKind == JsonValueKind.Array)
key = key.EnumerateArray().FirstOrDefault(k => k.ValueKind == JsonValueKind.Object);
if (key.ValueKind != JsonValueKind.Object || !key.TryGetProperty("publicKeyPem", out var pem) || pem.ValueKind != JsonValueKind.String)
return default;
try
{
using var rsa = RSA.Create();
rsa.ImportFromPem(pem.GetString());
return rsa.KeySize;
}
catch (Exception ex) when (ex is ArgumentException or CryptographicException)
{
return default;
}
}
}
}
@@ -48,6 +48,7 @@ namespace PrivaPub.Federation.Actors
public async Task<FetchedJson> FetchObject(string uri, CancellationToken token) public async Task<FetchedJson> FetchObject(string uri, CancellationToken token)
{ {
using var scope = HttpScope.Default("object");
var signer = await _localActors.GetInstanceActor(token); var signer = await _localActors.GetInstanceActor(token);
var fetched = await Get(uri, signer, token); var fetched = await Get(uri, signer, token);
if (fetched == default) if (fetched == default)
@@ -81,6 +82,7 @@ namespace PrivaPub.Federation.Actors
if (!MayFetch(actorUri)) if (!MayFetch(actorUri))
return cached; return cached;
using var scope = HttpScope.For("actor");
using var fetched = await FetchObject(actorUri, token); using var fetched = await FetchObject(actorUri, token);
var actor = fetched == default ? default : ActorDocument.Parse(fetched.Root); var actor = fetched == default ? default : ActorDocument.Parse(fetched.Root);
if (actor == default) if (actor == default)
@@ -101,6 +103,7 @@ namespace PrivaPub.Federation.Actors
if (!MayFetch(keyId)) if (!MayFetch(keyId))
return cached; return cached;
using var scope = HttpScope.For("key");
using var fetched = await FetchObject(StripFragment(keyId), token); using var fetched = await FetchObject(StripFragment(keyId), token);
if (fetched == default) if (fetched == default)
return cached; return cached;
@@ -129,6 +132,7 @@ namespace PrivaPub.Federation.Actors
if (parts is not { Length: 2 } || string.IsNullOrEmpty(parts[0]) || string.IsNullOrEmpty(parts[1])) if (parts is not { Length: 2 } || string.IsNullOrEmpty(parts[0]) || string.IsNullOrEmpty(parts[1]))
return default; return default;
using var scope = HttpScope.For("webfinger");
var query = $"/.well-known/webfinger?resource={Uri.EscapeDataString($"acct:{parts[0]}@{parts[1]}")}"; var query = $"/.well-known/webfinger?resource={Uri.EscapeDataString($"acct:{parts[0]}@{parts[1]}")}";
using var fetched = await _http.GetJson($"https://{parts[1]}{query}", "application/jrd+json, application/json", sign: default, token) using var fetched = await _http.GetJson($"https://{parts[1]}{query}", "application/jrd+json, application/json", sign: default, token)
?? (_options?.CurrentValue.AllowPlainHttp == true ?? (_options?.CurrentValue.AllowPlainHttp == true
@@ -188,6 +192,7 @@ namespace PrivaPub.Federation.Actors
.Modify(a => a.Published, actor.Published) .Modify(a => a.Published, actor.Published)
.Modify(a => a.Emojis, actor.Emojis) .Modify(a => a.Emojis, actor.Emojis)
.Modify(a => a.Fields, actor.Fields) .Modify(a => a.Fields, actor.Fields)
.Modify(a => a.Features, actor.Features)
.Modify(a => a.AvatarType, Enum.TryParse<AvatarType>(actor.Type, out var type) ? type : AvatarType.Person) .Modify(a => a.AvatarType, Enum.TryParse<AvatarType>(actor.Type, out var type) ? type : AvatarType.Person)
.Modify(a => a.PublicKeyId, key?.Id) .Modify(a => a.PublicKeyId, key?.Id)
.Modify(a => a.PublicKey, key?.Pem) .Modify(a => a.PublicKey, key?.Pem)
@@ -248,7 +248,7 @@ namespace PrivaPub.Federation.Controllers
{ {
var local = await _localActors.FindByUserName(actor, token); var local = await _localActors.FindByUserName(actor, token);
if (local is not { IsFederated: true }) if (local is not { IsFederated: true })
return NotFound(); return Answer(_inbox.NoSuchRecipient(Request));
return Answer(await _inbox.Receive(Request, local, token)); return Answer(await _inbox.Receive(Request, local, token));
} }
@@ -359,7 +359,7 @@ namespace PrivaPub.Federation.Controllers
IActionResult Answer(InboxResult result) IActionResult Answer(InboxResult result)
{ {
if (result.Error != default) if (result.Error != default)
_logger.LogInformation("Inbox refused with {Status}: {Error}", result.StatusCode, result.Error); _logger.LogInformation("Inbox refused with {Status} ({Reason}): {Error}", result.StatusCode, result.Reason, result.Error);
if (result.RetryAfterSeconds is { } seconds) if (result.RetryAfterSeconds is { } seconds)
Response.Headers.RetryAfter = seconds.ToString(System.Globalization.CultureInfo.InvariantCulture); Response.Headers.RetryAfter = seconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
return result.Error == default ? StatusCode(result.StatusCode) : StatusCode(result.StatusCode, result.Error); return result.Error == default ? StatusCode(result.StatusCode) : StatusCode(result.StatusCode, result.Error);
+64
View File
@@ -1,3 +1,8 @@
using PrivaPub.Federation.Actors;
using PrivaPub.Infrastructure.Statistics;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Post;
namespace PrivaPub.Federation.Inbox namespace PrivaPub.Federation.Inbox
{ {
public sealed record Arrival(string ActivityId, string ActivityType, string ActorURI, string Inbox, string KeyId, string Algorithm, public sealed record Arrival(string ActivityId, string ActivityType, string ActorURI, string Inbox, string KeyId, string Algorithm,
@@ -10,5 +15,64 @@ namespace PrivaPub.Federation.Inbox
get => current.Value; get => current.Value;
set => current.Value = value; set => current.Value = value;
} }
public ArrivalVerdict Verdict { get; } = new();
public static void Drop(string reason) => Current?.Verdict.Drop(reason);
public static void Reject(string reason) => Current?.Verdict.Reject(reason);
public static void Accept(string reason) => Current?.Verdict.Accept(reason);
public static void About(string objectType = default, PostVisibility? visibility = default, DateTime? created = default, LocalActor local = default) =>
Current?.Verdict.About(objectType, visibility, created, local);
}
public sealed class ArrivalVerdict
{
public string Outcome { get; private set; }
public string Reason { get; private set; }
public string ObjectType { get; private set; }
public string Audience { get; private set; }
public string LocalKind { get; private set; }
public long? AgeSeconds { get; private set; }
public void Drop(string reason) => Decide(Interactions.Dropped, reason);
public void Reject(string reason) => Decide(Interactions.Rejected, reason);
public void Accept(string reason) => Decide(Interactions.Accepted, reason);
void Decide(string outcome, string reason)
{
if (Outcome != default)
return;
Outcome = outcome;
Reason = reason;
}
public void About(string objectType, PostVisibility? visibility, DateTime? created, LocalActor local)
{
ObjectType = objectType ?? ObjectType;
if (visibility is { } known)
Audience = AudienceOf(known);
if (created is { } at)
AgeSeconds = Math.Max(0, (long)(DateTime.UtcNow - at).TotalSeconds);
if (local != default)
LocalKind = local.Kind switch
{
LocalActorKind.Person => "person",
LocalActorKind.Group when !local.IsCircle => "group",
LocalActorKind.Application => "application",
_ => default
};
}
public static string AudienceOf(PostVisibility visibility) => visibility switch
{
PostVisibility.Public => Interactions.Public,
PostVisibility.Unlisted => Interactions.Unlisted,
_ => Interactions.Private
};
} }
} }
@@ -30,10 +30,18 @@ namespace PrivaPub.Federation.Inbox.Handlers
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token) public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
{ {
if (await _quotes.Answered(activity, actor, accepted: Type == "Accept", token)) if (await _quotes.Answered(activity, actor, accepted: Type == "Accept", token))
{
Arrival.Accept("quote-answer");
return; return;
}
var following = await FindFollowing(activity["object"], actor, _dbEntities, _localActors, token); var following = await FindFollowing(activity["object"], actor, _dbEntities, _localActors, token);
if (following == default) if (following == default)
{
Arrival.Drop("unknown-object");
return; return;
}
Arrival.About("Follow");
Arrival.Accept("follow-answer");
await Apply(following, token); await Apply(following, token);
} }
@@ -1,6 +1,7 @@
using MongoDB.Driver; using MongoDB.Driver;
using MongoDB.Entities; using MongoDB.Entities;
using PrivaPub.Domain.Statuses;
using PrivaPub.Domain.Social; using PrivaPub.Domain.Social;
using PrivaPub.Domain.Timelines; using PrivaPub.Domain.Timelines;
using PrivaPub.Federation.Actors; using PrivaPub.Federation.Actors;
@@ -28,8 +29,14 @@ namespace PrivaPub.Federation.Inbox.Handlers
readonly IFanout _fanout; readonly IFanout _fanout;
readonly IRemoteActorService _remoteActors; readonly IRemoteActorService _remoteActors;
public AnnounceHandler(DbEntities dbEntities, ILocalActorService localActors, IRemotePosts remotePosts, IFanout fanout, IRemoteActorService remoteActors) readonly IObjectRecords _records;
readonly IQuoteService _quotes;
public AnnounceHandler(DbEntities dbEntities, ILocalActorService localActors, IRemotePosts remotePosts, IFanout fanout, IRemoteActorService remoteActors,
IObjectRecords records, IQuoteService quotes)
{ {
_records = records;
_quotes = quotes;
_remoteActors = remoteActors; _remoteActors = remoteActors;
_dbEntities = dbEntities; _dbEntities = dbEntities;
_localActors = localActors; _localActors = localActors;
@@ -50,19 +57,37 @@ namespace PrivaPub.Federation.Inbox.Handlers
var objectUri = Id(inner); var objectUri = Id(inner);
var announceId = Id(activity); var announceId = Id(activity);
if (objectUri == default || announceId == default) if (objectUri == default || announceId == default)
{
Arrival.Drop("unparseable");
return; return;
}
if (await _dbEntities.Posts.Match(p => p.ObjectURI == announceId).ExecuteAnyAsync(token)) if (await _dbEntities.Posts.Match(p => p.ObjectURI == announceId).ExecuteAnyAsync(token))
{
Arrival.Drop("duplicate");
return; return;
}
var isLocal = objectUri.StartsWith(_localActors.BaseAddress + "/", StringComparison.OrdinalIgnoreCase); var isLocal = objectUri.StartsWith(_localActors.BaseAddress + "/", StringComparison.OrdinalIgnoreCase);
var original = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token); var original = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token);
var followed = await _dbEntities.Followings.Match(f => f.TargetActorURI == actor.ActorURI && f.State == FollowState.Accepted).ExecuteAnyAsync(token); var followed = await _dbEntities.Followings.Match(f => f.TargetActorURI == actor.ActorURI && f.State == FollowState.Accepted).ExecuteAnyAsync(token);
if (original is not { IsFederatedCopy: false } && !followed) if (original is not { IsFederatedCopy: false } && !followed)
{
Arrival.Drop("not-followed");
return; return;
}
if (original == default && !isLocal) if (original == default && !isLocal)
original = await _remotePosts.StoreContext(objectUri, 0, token); original = await _remotePosts.StoreContext(objectUri, 0, token);
if (original == default || original.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted)) if (original == default)
{
Arrival.Drop("fetch-failed");
return; return;
}
Arrival.About(original.ObjectType ?? "Note", original.Visibility, original.CreationDate);
if (original.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted))
{
Arrival.Drop("not-public");
return;
}
var to = Strings(activity["to"]); var to = Strings(activity["to"]);
var cc = Strings(activity["cc"]); var cc = Strings(activity["cc"]);
@@ -85,15 +110,21 @@ namespace PrivaPub.Federation.Inbox.Handlers
UpdateDate = published UpdateDate = published
}; };
if (reblog.Visibility == PostVisibility.Direct) if (reblog.Visibility == PostVisibility.Direct)
{
Arrival.Drop("not-public");
return; return;
}
try try
{ {
await DB.Default.SaveAsync(reblog, token); await DB.Default.SaveAsync(reblog, token);
} }
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{ {
Arrival.Drop("duplicate");
return; return;
} }
Arrival.Accept("stored");
Arrival.About(local: original.IsFederatedCopy ? default : await _localActors.FindById(Models.Federation.LocalActorKind.Person, original.GroupUserId, token));
await DB.Default.Update<PostEntity>().MatchID(original.ID).Modify(b => b.Inc(p => p.ReblogsCount, 1)).ExecuteAsync(token); await DB.Default.Update<PostEntity>().MatchID(original.ID).Modify(b => b.Inc(p => p.ReblogsCount, 1)).ExecuteAsync(token);
if (!original.IsFederatedCopy) if (!original.IsFederatedCopy)
@@ -103,18 +134,30 @@ namespace PrivaPub.Federation.Inbox.Handlers
async Task GroupActivity(JsonNode inner, ForeignAvatar group, CancellationToken token) async Task GroupActivity(JsonNode inner, ForeignAvatar group, CancellationToken token)
{ {
Arrival.About(Value(inner, "type"));
if (group.AvatarType != AvatarType.Group if (group.AvatarType != AvatarType.Group
|| !await _dbEntities.Followings.Match(f => f.TargetActorURI == group.ActorURI && f.State == FollowState.Accepted).ExecuteAnyAsync(token)) || !await _dbEntities.Followings.Match(f => f.TargetActorURI == group.ActorURI && f.State == FollowState.Accepted).ExecuteAnyAsync(token))
{
Arrival.Drop("not-followed");
return; return;
}
var objectUri = Id(inner["object"]); var objectUri = Id(inner["object"]);
switch (Value(inner, "type")) switch (Value(inner, "type"))
{ {
case "Create" when objectUri != default: case "Create" when objectUri != default:
if (await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteAnyAsync(token)) if (await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteAnyAsync(token))
{
Arrival.Drop("duplicate");
return; return;
}
var post = await _remotePosts.StoreContext(objectUri, 0, token); var post = await _remotePosts.StoreContext(objectUri, 0, token);
if (post == default) if (post == default)
{
Arrival.Drop("fetch-failed");
return; return;
}
Arrival.Accept("stored");
Arrival.About(visibility: post.Visibility);
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(p => p.AudienceURI, group.ActorURI).ExecuteAsync(token); await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(p => p.AudienceURI, group.ActorURI).ExecuteAsync(token);
post.AudienceURI = group.ActorURI; post.AudienceURI = group.ActorURI;
await _fanout.Distribute(post, token); await _fanout.Distribute(post, token);
@@ -123,40 +166,41 @@ namespace PrivaPub.Federation.Inbox.Handlers
var stored = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.AudienceURI == group.ActorURI && !p.DeletedAt.HasValue) var stored = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.AudienceURI == group.ActorURI && !p.DeletedAt.HasValue)
.ExecuteFirstAsync(token); .ExecuteFirstAsync(token);
if (stored == default) if (stored == default)
{
Arrival.Drop("unknown-object");
return; return;
}
Arrival.About(visibility: stored.Visibility, created: stored.CreationDate);
using (var fetched = await _remoteActors.FetchObject(objectUri, token)) using (var fetched = await _remoteActors.FetchObject(objectUri, token))
{ {
var note = fetched == default ? default : NoteParser.Parse(System.Text.Json.Nodes.JsonNode.Parse(fetched.Root.GetRawText())); var note = fetched == default ? default : NoteParser.Parse(System.Text.Json.Nodes.JsonNode.Parse(fetched.Root.GetRawText()));
if (note == default || note.AttributedTo != stored.ActorURI) if (note == default || note.AttributedTo != stored.ActorURI)
return;
stored.Revisions.Add(new PostRevision
{ {
Title = stored.Title, Arrival.Drop(note == default ? "fetch-failed" : "misattributed");
SpoilerText = stored.SpoilerText, return;
ContentHtml = stored.ContentHtml, }
HasContentWarning = stored.HasContentWarning, Arrival.Accept(await RemoteEdits.Apply(stored, note, Id(inner), _localActors, _records, _quotes, token) ? "edit" : "refresh");
EditedAt = stored.EditedAt ?? stored.CreationDate
});
stored.Title = note.Title;
stored.SpoilerText = note.SpoilerText;
stored.HasContentWarning = note.Sensitive;
stored.Text = note.ContentHtml;
stored.ContentHtml = note.ContentHtml;
stored.Tags = note.Tags.ToList();
stored.Media = note.Attachments.ToList();
stored.EditedAt = note.Updated ?? DateTime.UtcNow;
await DB.Default.SaveAsync(stored, token);
} }
break; break;
case "Delete" when objectUri != default: case "Delete" when objectUri != default:
var deleted = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.AudienceURI == group.ActorURI).ExecuteFirstAsync(token); var deleted = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.AudienceURI == group.ActorURI).ExecuteFirstAsync(token);
if (deleted == default) if (deleted == default)
{
Arrival.Drop("unknown-object");
return; return;
}
Arrival.About(visibility: deleted.Visibility, created: deleted.CreationDate);
using (var check = await _remoteActors.FetchObject(objectUri, token)) using (var check = await _remoteActors.FetchObject(objectUri, token))
if (check != default && Value(System.Text.Json.Nodes.JsonNode.Parse(check.Root.GetRawText()), "type") != "Tombstone") if (check != default && Value(System.Text.Json.Nodes.JsonNode.Parse(check.Root.GetRawText()), "type") != "Tombstone")
{
Arrival.Drop("not-deleted");
return; return;
await DB.Default.DeleteAsync<PostEntity>(deleted.ID); }
await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == deleted.ID); Arrival.Accept("removed");
await RemoteDeletes.Remove(deleted, objectUri, token);
break;
default:
Arrival.Drop("unsupported");
break; break;
} }
} }
@@ -68,26 +68,42 @@ namespace PrivaPub.Federation.Inbox.Handlers
{ {
using var fetched = await _remoteActors.FetchObject(Id(node), token); using var fetched = await _remoteActors.FetchObject(Id(node), token);
node = fetched == default ? default : JsonNode.Parse(fetched.Root.GetRawText()); node = fetched == default ? default : JsonNode.Parse(fetched.Root.GetRawText());
if (node == default)
Arrival.Drop("fetch-failed");
} }
var note = NoteParser.Parse(node); var note = NoteParser.Parse(node);
if (note == default || note.AttributedTo != author.ActorURI) if (note == default || note.AttributedTo != author.ActorURI)
{
Arrival.Drop(note == default ? "unparseable" : "misattributed");
return; return;
}
Arrival.About(note.Type);
if (note.Title != default && note.InReplyTo != default && ObjectShapes.Text(note.ContentHtml, 1) == default) if (note.Title != default && note.InReplyTo != default && ObjectShapes.Text(note.ContentHtml, 1) == default)
{ {
var question = await _dbEntities.Posts.Match(p => p.ObjectURI == note.InReplyTo && p.Poll != null).ExecuteFirstAsync(token); var question = await _dbEntities.Posts.Match(p => p.ObjectURI == note.InReplyTo && p.Poll != null).ExecuteFirstAsync(token);
if (question != default) if (question != default)
{ {
await _polls.Receive(question, author, note.Title, Id(activity), token); await _polls.Receive(question, author, note.Title, Id(activity), token);
Arrival.Accept("poll-vote");
Arrival.About(visibility: question.Visibility);
return; return;
} }
} }
if (await _dbEntities.Posts.Match(p => p.ObjectURI == note.Id).ExecuteAnyAsync(token) if (await _dbEntities.Posts.Match(p => p.ObjectURI == note.Id).ExecuteAnyAsync(token))
|| await DB.Default.Find<DeletedObject>().Match(d => d.ObjectURI == note.Id).ExecuteAnyAsync(token)) {
Arrival.Drop("duplicate");
return; return;
}
if (await DB.Default.Find<DeletedObject>().Match(d => d.ObjectURI == note.Id).ExecuteAnyAsync(token))
{
Arrival.Drop("deleted");
return;
}
var to = note.To.Concat(Strings(activity["to"])).Distinct(StringComparer.Ordinal).ToList(); var to = note.To.Concat(Strings(activity["to"])).Distinct(StringComparer.Ordinal).ToList();
var cc = note.Cc.Concat(Strings(activity["cc"])).Distinct(StringComparer.Ordinal).ToList(); var cc = note.Cc.Concat(Strings(activity["cc"])).Distinct(StringComparer.Ordinal).ToList();
var visibility = Addressing.Classify(to, cc, author.FollowersURL); var visibility = Addressing.Classify(to, cc, author.FollowersURL);
Arrival.About(visibility: visibility);
var addressed = to.Concat(cc) var addressed = to.Concat(cc)
.Concat(Addresses(activity)) .Concat(Addresses(activity))
@@ -111,9 +127,13 @@ namespace PrivaPub.Federation.Inbox.Handlers
var circle = localTargets.FirstOrDefault(t => t is { Kind: LocalActorKind.Group, IsCircle: true }); var circle = localTargets.FirstOrDefault(t => t is { Kind: LocalActorKind.Group, IsCircle: true });
if (circle != default) if (circle != default)
{ {
if (!await IsCircleMember(circle, author.ActorURI, token))
return;
visibility = PostVisibility.Circle; visibility = PostVisibility.Circle;
Arrival.About(visibility: visibility);
if (!await IsCircleMember(circle, author.ActorURI, token))
{
Arrival.Drop("not-addressed");
return;
}
} }
var group = circle ?? (visibility is PostVisibility.Public or PostVisibility.Unlisted var group = circle ?? (visibility is PostVisibility.Public or PostVisibility.Unlisted
? localTargets.FirstOrDefault(t => t is { Kind: LocalActorKind.Group, IsCircle: false }) ? localTargets.FirstOrDefault(t => t is { Kind: LocalActorKind.Group, IsCircle: false })
@@ -126,7 +146,10 @@ namespace PrivaPub.Federation.Inbox.Handlers
.Match(f => f.TargetActorURI == author.ActorURI && f.State == FollowState.Accepted) .Match(f => f.TargetActorURI == author.ActorURI && f.State == FollowState.Accepted)
.ExecuteAnyAsync(token); .ExecuteAnyAsync(token);
if (visibility == PostVisibility.Direct ? persons.Count == 0 : group == default && persons.Count == 0 && !repliesToLocal && !followed) if (visibility == PostVisibility.Direct ? persons.Count == 0 : group == default && persons.Count == 0 && !repliesToLocal && !followed)
{
Arrival.Drop("not-addressed");
return; return;
}
if (parent == default && visibility != PostVisibility.Direct) if (parent == default && visibility != PostVisibility.Direct)
parent = await _remotePosts.Parent(note.InReplyTo, 1, token); parent = await _remotePosts.Parent(note.InReplyTo, 1, token);
@@ -150,8 +173,11 @@ namespace PrivaPub.Federation.Inbox.Handlers
} }
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{ {
Arrival.Drop("duplicate");
return; return;
} }
Arrival.Accept("stored");
Arrival.About(local: group ?? persons.FirstOrDefault());
await _records.Record(note, post, ObjectPath.Delivered, refetched, token); await _records.Record(note, post, ObjectPath.Delivered, refetched, token);
await _previews.Wanted(post, token); await _previews.Wanted(post, token);
await _quotes.Resolve(post, note, token); await _quotes.Resolve(post, note, token);
@@ -49,10 +49,15 @@ namespace PrivaPub.Federation.Inbox.Handlers
var objectUri = Id(delete["object"]); var objectUri = Id(delete["object"]);
if (!Origin.Same(objectUri, actor.ActorURI)) if (!Origin.Same(objectUri, actor.ActorURI))
{
Arrival.Drop("cross-origin");
return; return;
}
if (objectUri == actor.ActorURI) if (objectUri == actor.ActorURI)
{ {
Arrival.Accept("actor-delete");
Arrival.About(actor.AvatarType.ToString(), created: actor.Published);
actor.DeletionAt = DateTime.UtcNow; actor.DeletionAt = DateTime.UtcNow;
actor.AccountState = AvatarAccountState.Deleted; actor.AccountState = AvatarAccountState.Deleted;
await DB.Default.SaveAsync(actor, token); await DB.Default.SaveAsync(actor, token);
@@ -68,25 +73,19 @@ namespace PrivaPub.Federation.Inbox.Handlers
return; return;
} }
await DB.Default.Update<DeletedObject>() await RemoteDeletes.Tombstone(objectUri, token);
.Match(d => d.ObjectURI == objectUri)
.Modify(d => d.DeletedAt, DateTime.UtcNow)
.Option(o => o.IsUpsert = true)
.ExecuteAsync(token);
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI).ExecuteFirstAsync(token); var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI).ExecuteFirstAsync(token);
if (post == default) if (post == default)
{ {
Arrival.Accept("tombstone-only");
await _quotes.Revoke(objectUri, token); await _quotes.Revoke(objectUri, token);
return; return;
} }
await DB.Default.DeleteAsync<PostEntity>(post.ID); Arrival.Accept("removed");
await DB.Default.DeleteAsync<ObjectRecord>(r => r.PostId == post.ID || r.ObjectURI == objectUri); Arrival.About(post.ObjectType, post.Visibility, post.CreationDate);
await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == post.ID || e.ReblogOfPostId == post.ID); await RemoteDeletes.Remove(post, objectUri, token);
if (!string.IsNullOrEmpty(post.GroupId) && await _localActors.FindById(LocalActorKind.Group, post.GroupId, token) is { IsCircle: false } community) if (!string.IsNullOrEmpty(post.GroupId) && await _localActors.FindById(LocalActorKind.Group, post.GroupId, token) is { IsCircle: false } community)
await _groups.Announce(community, activity.AsObject(), post.ObjectURI, isNewPost: false, token); await _groups.Announce(community, activity.AsObject(), post.ObjectURI, isNewPost: false, token);
await DB.Default.DeleteAsync<PostEntity>(p => p.ReblogOfPostId == post.ID);
if (!string.IsNullOrEmpty(post.AnsweringToPostId))
await DB.Default.Update<PostEntity>().MatchID(post.AnsweringToPostId).Modify(b => b.Inc(p => p.RepliesCount, -1)).ExecuteAsync(token);
} }
} }
} }
@@ -31,7 +31,11 @@ namespace PrivaPub.Federation.Inbox.Handlers
? default ? default
: await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token); : await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token);
if (post == default || !LikeHandler.MaySee(post, actor)) if (post == default || !LikeHandler.MaySee(post, actor))
{
Arrival.Drop(post == default ? "unknown-object" : "not-visible");
return; return;
}
Arrival.About(post.ObjectType ?? "Note", post.Visibility, post.CreationDate);
try try
{ {
@@ -39,8 +43,10 @@ namespace PrivaPub.Federation.Inbox.Handlers
} }
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{ {
Arrival.Drop("duplicate");
return; return;
} }
Arrival.Accept("stored");
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(b => b.Inc(p => p.DownvotesCount, 1)).ExecuteAsync(token); await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(b => b.Inc(p => p.DownvotesCount, 1)).ExecuteAsync(token);
} }
} }
@@ -27,8 +27,13 @@ namespace PrivaPub.Federation.Inbox.Handlers
var post = objectUri == default var post = objectUri == default
? default ? default
: await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token); : await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token);
if (post != default) if (post == default)
await _reactions.Receive(post, actor, Value(activity, "content"), activity["tag"], Id(activity), token); {
Arrival.Drop("unknown-object");
return;
}
Arrival.About(post.ObjectType ?? "Note", post.Visibility, post.CreationDate);
await _reactions.Receive(post, actor, Value(activity, "content"), activity["tag"], Id(activity), token);
} }
} }
} }
@@ -50,7 +50,11 @@ namespace PrivaPub.Federation.Inbox.Handlers
if (target == default && posts.Count > 0) if (target == default && posts.Count > 0)
target = await _localActors.FindById(LocalActorKind.Person, posts[0].GroupUserId, token); target = await _localActors.FindById(LocalActorKind.Person, posts[0].GroupUserId, token);
if (target == default) if (target == default)
{
Arrival.Drop("unknown-object");
return; return;
}
Arrival.Accept("reported");
await DB.Default.SaveAsync(new Report await DB.Default.SaveAsync(new Report
{ {
@@ -45,7 +45,10 @@ namespace PrivaPub.Federation.Inbox.Handlers
var target = await _localActors.FindByUri(Id(follow["object"]), token); var target = await _localActors.FindByUri(Id(follow["object"]), token);
if (target is not { IsFederated: true } || target.Kind == LocalActorKind.Application) if (target is not { IsFederated: true } || target.Kind == LocalActorKind.Application)
{
Arrival.Drop("unknown-recipient");
return; return;
}
if (target.Kind == LocalActorKind.Person if (target.Kind == LocalActorKind.Person
&& await DB.Default.Find<Models.Social.Block>().Match(b => b.AvatarId == target.Id && b.TargetActorURI == follower.ActorURI).ExecuteAnyAsync(token)) && await DB.Default.Find<Models.Social.Block>().Match(b => b.AvatarId == target.Id && b.TargetActorURI == follower.ActorURI).ExecuteAnyAsync(token))
{ {
@@ -58,6 +61,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
["object"] = follow.DeepClone() ["object"] = follow.DeepClone()
}; };
await _delivery.Enqueue(target, new[] { follower.InboxURL }, reject, token); await _delivery.Enqueue(target, new[] { follower.InboxURL }, reject, token);
Arrival.Reject("blocked");
return; return;
} }
@@ -75,6 +79,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
record.FollowActivityURI = Id(follow); record.FollowActivityURI = Id(follow);
record.IsAccepted = existing?.IsAccepted == true || !target.ManuallyApprovesFollowers; record.IsAccepted = existing?.IsAccepted == true || !target.ManuallyApprovesFollowers;
await DB.Default.SaveAsync(record, token); await DB.Default.SaveAsync(record, token);
Arrival.Accept(record.IsAccepted ? "auto-accepted" : "pending");
if (target.Kind == LocalActorKind.Person) if (target.Kind == LocalActorKind.Person)
await Notifications.Add(target.Id, record.IsAccepted ? NotificationType.Follow : NotificationType.FollowRequest, await Notifications.Add(target.Id, record.IsAccepted ? NotificationType.Follow : NotificationType.FollowRequest,
@@ -30,11 +30,19 @@ namespace PrivaPub.Federation.Inbox.Handlers
{ {
var objectUri = Id(activity["object"]); var objectUri = Id(activity["object"]);
if (objectUri == default || !objectUri.StartsWith(_localActors.BaseAddress + "/", StringComparison.OrdinalIgnoreCase)) if (objectUri == default || !objectUri.StartsWith(_localActors.BaseAddress + "/", StringComparison.OrdinalIgnoreCase))
{
Arrival.Drop("unknown-object");
return; return;
}
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
var owner = post == default ? default : await _localActors.FindById(LocalActorKind.Person, post.GroupUserId, token); var owner = post == default ? default : await _localActors.FindById(LocalActorKind.Person, post.GroupUserId, token);
if (owner == default || string.IsNullOrEmpty(actor.InboxURL)) if (owner == default || string.IsNullOrEmpty(actor.InboxURL))
{
Arrival.Drop("unknown-object");
return; return;
}
Arrival.About(post.ObjectType ?? "Note", post.Visibility, post.CreationDate);
Arrival.Reject("ignored");
await _delivery.Enqueue(owner, new[] { actor.InboxURL }, new JsonObject await _delivery.Enqueue(owner, new[] { actor.InboxURL }, new JsonObject
{ {
["@context"] = ActivityPubRenderer.ActivityStreams, ["@context"] = ActivityPubRenderer.ActivityStreams,
@@ -35,7 +35,11 @@ namespace PrivaPub.Federation.Inbox.Handlers
? default ? default
: await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token); : await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token);
if (post == default || !MaySee(post, actor)) if (post == default || !MaySee(post, actor))
{
Arrival.Drop(post == default ? "unknown-object" : "not-visible");
return; return;
}
Arrival.About(post.ObjectType ?? "Note", post.Visibility, post.CreationDate);
var content = Value(activity, "_misskey_reaction") ?? Value(activity, "content"); var content = Value(activity, "_misskey_reaction") ?? Value(activity, "content");
if (!Reactions.IsHeart(content)) if (!Reactions.IsHeart(content))
{ {
@@ -49,8 +53,10 @@ namespace PrivaPub.Federation.Inbox.Handlers
} }
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{ {
Arrival.Drop("duplicate");
return; return;
} }
Arrival.Accept("stored");
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(b => b.Inc(p => p.FavouritesCount, 1)).ExecuteAsync(token); await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(b => b.Inc(p => p.FavouritesCount, 1)).ExecuteAsync(token);
if (!post.IsFederatedCopy) if (!post.IsFederatedCopy)
await Notifications.Add(post.GroupUserId, NotificationType.Favourite, actor.ID, actor.ActorURI, post.ID, token); await Notifications.Add(post.GroupUserId, NotificationType.Favourite, actor.ID, actor.ActorURI, post.ID, token);
@@ -37,20 +37,26 @@ namespace PrivaPub.Federation.Inbox.Handlers
var innerType = inner is JsonObject ? Value(inner, "type") : default; var innerType = inner is JsonObject ? Value(inner, "type") : default;
var innerId = Id(inner); var innerId = Id(inner);
Arrival.About(innerType);
var undone = false;
if (innerType is null or "Follow") if (innerType is null or "Follow")
await UndoFollow(inner, innerId, actor, token); undone |= await UndoFollow(inner, innerId, actor, token);
if (innerType is null or "Like") if (innerType is null or "Like")
await UndoLike(inner, innerId, actor, token); undone |= await UndoLike(inner, innerId, actor, token);
if (innerType is null or "Announce") if (innerType is null or "Announce")
await UndoAnnounce(innerId, actor, token); undone |= await UndoAnnounce(innerId, actor, token);
if (innerType is null or "Dislike") if (innerType is null or "Dislike")
await UndoDislike(inner, innerId, actor, token); undone |= await UndoDislike(inner, innerId, actor, token);
if (innerType is null or "Like" or "EmojiReact") if (innerType is null or "Like" or "EmojiReact")
await _reactions.Withdraw(actor, innerId, inner is JsonObject ? Id(inner["object"]) : default, undone |= await _reactions.Withdraw(actor, innerId, inner is JsonObject ? Id(inner["object"]) : default,
inner is JsonObject ? Value(inner, "_misskey_reaction") ?? Value(inner, "content") : default, token); inner is JsonObject ? Value(inner, "_misskey_reaction") ?? Value(inner, "content") : default, token);
if (undone)
Arrival.Accept("undone");
else
Arrival.Drop("unknown-object");
} }
async Task UndoDislike(JsonNode inner, string innerId, ForeignAvatar actor, CancellationToken token) async Task<bool> UndoDislike(JsonNode inner, string innerId, ForeignAvatar actor, CancellationToken token)
{ {
var downvote = innerId == default var downvote = innerId == default
? default ? default
@@ -63,16 +69,18 @@ namespace PrivaPub.Federation.Inbox.Handlers
: await DB.Default.Find<Downvote>().Match(d => d.PostId == post.ID && d.ActorURI == actor.ActorURI).ExecuteFirstAsync(token); : await DB.Default.Find<Downvote>().Match(d => d.PostId == post.ID && d.ActorURI == actor.ActorURI).ExecuteFirstAsync(token);
} }
if (downvote == default) if (downvote == default)
return; return false;
await DB.Default.DeleteAsync<Downvote>(downvote.ID); await DB.Default.DeleteAsync<Downvote>(downvote.ID);
await DB.Default.Update<PostEntity>().MatchID(downvote.PostId).Modify(b => b.Inc(p => p.DownvotesCount, -1)).ExecuteAsync(token); await DB.Default.Update<PostEntity>().MatchID(downvote.PostId).Modify(b => b.Inc(p => p.DownvotesCount, -1)).ExecuteAsync(token);
return true;
} }
async Task UndoFollow(JsonNode inner, string innerId, ForeignAvatar actor, CancellationToken token) async Task<bool> UndoFollow(JsonNode inner, string innerId, ForeignAvatar actor, CancellationToken token)
{ {
var followers = await _dbEntities.Followers.Match(f => f.ActorURI == actor.ActorURI).ExecuteAsync(token); var followers = await _dbEntities.Followers.Match(f => f.ActorURI == actor.ActorURI).ExecuteAsync(token);
var targetUri = inner is JsonObject ? Id(inner["object"]) : default; var targetUri = inner is JsonObject ? Id(inner["object"]) : default;
var target = targetUri == default ? default : await _localActors.FindByUri(targetUri, token); var target = targetUri == default ? default : await _localActors.FindByUri(targetUri, token);
var undone = false;
foreach (var follower in followers) foreach (var follower in followers)
{ {
var matchesActivity = innerId != default && follower.FollowActivityURI == innerId; var matchesActivity = innerId != default && follower.FollowActivityURI == innerId;
@@ -83,10 +91,12 @@ namespace PrivaPub.Federation.Inbox.Handlers
await DB.Default.DeleteAsync<Follower>(follower.ID); await DB.Default.DeleteAsync<Follower>(follower.ID);
if (follower.LocalActorKind == LocalActorKind.Group) if (follower.LocalActorKind == LocalActorKind.Group)
await RemoveForeignMember(follower.LocalActorId, actor.ActorURI, token); await RemoveForeignMember(follower.LocalActorId, actor.ActorURI, token);
undone = true;
} }
return undone;
} }
async Task UndoLike(JsonNode inner, string innerId, ForeignAvatar actor, CancellationToken token) async Task<bool> UndoLike(JsonNode inner, string innerId, ForeignAvatar actor, CancellationToken token)
{ {
var favourite = innerId == default var favourite = innerId == default
? default ? default
@@ -99,23 +109,25 @@ namespace PrivaPub.Federation.Inbox.Handlers
: await _dbEntities.Favourites.Match(f => f.PostId == post.ID && f.ActorURI == actor.ActorURI).ExecuteFirstAsync(token); : await _dbEntities.Favourites.Match(f => f.PostId == post.ID && f.ActorURI == actor.ActorURI).ExecuteFirstAsync(token);
} }
if (favourite == default) if (favourite == default)
return; return false;
await DB.Default.DeleteAsync<Favourite>(favourite.ID); await DB.Default.DeleteAsync<Favourite>(favourite.ID);
await DB.Default.Update<PostEntity>().MatchID(favourite.PostId).Modify(b => b.Inc(p => p.FavouritesCount, -1)).ExecuteAsync(token); await DB.Default.Update<PostEntity>().MatchID(favourite.PostId).Modify(b => b.Inc(p => p.FavouritesCount, -1)).ExecuteAsync(token);
return true;
} }
async Task UndoAnnounce(string innerId, ForeignAvatar actor, CancellationToken token) async Task<bool> UndoAnnounce(string innerId, ForeignAvatar actor, CancellationToken token)
{ {
if (innerId == default) if (innerId == default)
return; return false;
var reblog = await _dbEntities.Posts var reblog = await _dbEntities.Posts
.Match(p => p.ObjectURI == innerId && p.ActorURI == actor.ActorURI && p.ReblogOfPostId != null) .Match(p => p.ObjectURI == innerId && p.ActorURI == actor.ActorURI && p.ReblogOfPostId != null)
.ExecuteFirstAsync(token); .ExecuteFirstAsync(token);
if (reblog == default) if (reblog == default)
return; return false;
await DB.Default.DeleteAsync<PostEntity>(reblog.ID); await DB.Default.DeleteAsync<PostEntity>(reblog.ID);
await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == reblog.ID); await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == reblog.ID);
await DB.Default.Update<PostEntity>().MatchID(reblog.ReblogOfPostId).Modify(b => b.Inc(p => p.ReblogsCount, -1)).ExecuteAsync(token); await DB.Default.Update<PostEntity>().MatchID(reblog.ReblogOfPostId).Modify(b => b.Inc(p => p.ReblogsCount, -1)).ExecuteAsync(token);
return true;
} }
} }
} }
@@ -18,7 +18,6 @@ namespace PrivaPub.Federation.Inbox.Handlers
{ {
public class UpdateHandler : IActivityHandler public class UpdateHandler : IActivityHandler
{ {
const int MaxRevisions = 20;
readonly DbEntities _dbEntities; readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors; readonly ILocalActorService _localActors;
@@ -46,79 +45,34 @@ namespace PrivaPub.Federation.Inbox.Handlers
if (Id(inner) == actor.ActorURI) if (Id(inner) == actor.ActorURI)
{ {
await _remoteActors.GetActor(actor.ActorURI, refresh: true, token); await _remoteActors.GetActor(actor.ActorURI, refresh: true, token);
Arrival.Accept("actor-refresh");
Arrival.About(actor.AvatarType.ToString());
return; return;
} }
if (inner is not JsonObject || !Origin.Same(Id(inner), actor.ActorURI)) if (inner is not JsonObject || !Origin.Same(Id(inner), actor.ActorURI))
{
Arrival.Drop("cross-origin");
return; return;
}
var note = NoteParser.Parse(inner); var note = NoteParser.Parse(inner);
if (note == default || note.AttributedTo != actor.ActorURI) if (note == default || note.AttributedTo != actor.ActorURI)
{
Arrival.Drop(note == default ? "unparseable" : "misattributed");
return; return;
}
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == note.Id && p.ActorURI == actor.ActorURI).ExecuteFirstAsync(token); var post = await _dbEntities.Posts.Match(p => p.ObjectURI == note.Id && p.ActorURI == actor.ActorURI).ExecuteFirstAsync(token);
if (post == default || post.DeletedAt.HasValue) if (post == default || post.DeletedAt.HasValue)
return;
post.Poll = note.Poll ?? post.Poll;
post.QuotePolicy = note.QuotePolicy;
post.Video = note.Video ?? post.Video;
post.Audio = note.Audio ?? post.Audio;
post.Event = note.Event ?? post.Event;
if (!IsEdit(note, post))
{ {
await DB.Default.SaveAsync(post, token); Arrival.Drop("unknown-object");
await _records.Revise(note, Id(activity), token);
await Requote(post, note, token);
return; return;
} }
Arrival.About(note.Type, post.Visibility, post.CreationDate);
post.Revisions.Add(new PostRevision var edited = await RemoteEdits.Apply(post, note, Id(activity), _localActors, _records, _quotes, token);
{ Arrival.Accept(edited ? "edit" : "refresh");
Title = post.Title, if (edited && !string.IsNullOrEmpty(post.GroupId) && await _localActors.FindById(Models.Federation.LocalActorKind.Group, post.GroupId, token) is { IsCircle: false } community)
SpoilerText = post.SpoilerText,
ContentHtml = post.ContentHtml,
HasContentWarning = post.HasContentWarning,
EditedAt = post.EditedAt ?? post.CreationDate
});
if (post.Revisions.Count > MaxRevisions)
post.Revisions.RemoveRange(0, post.Revisions.Count - MaxRevisions);
var mentions = new List<PostMention>();
foreach (var mention in note.Mentions)
{
var local = await _localActors.FindByUri(mention.ActorURI, token);
mentions.Add(new PostMention { ActorURI = mention.ActorURI, Handle = mention.Handle, IsLocal = local != default, AccountId = local?.Id });
}
post.Title = note.Title;
post.SpoilerText = note.SpoilerText;
post.Excerpt = note.Excerpt;
post.Source = note.Source;
post.Emojis = note.Emojis.ToList();
post.CoverURL = note.CoverURL;
post.Link = note.Link;
post.HasContentWarning = note.Sensitive;
post.Text = note.ContentHtml;
post.ContentHtml = note.ContentHtml;
post.Language = note.Language;
post.Tags = note.Tags.ToList();
post.Mentions = mentions;
post.Media = note.Attachments.ToList();
post.EditedAt = note.Updated ?? DateTime.UtcNow;
post.UpdateDate = DateTime.UtcNow;
await DB.Default.SaveAsync(post, token);
await _records.Revise(note, Id(activity), token);
await Requote(post, note, token);
if (!string.IsNullOrEmpty(post.GroupId) && await _localActors.FindById(Models.Federation.LocalActorKind.Group, post.GroupId, token) is { IsCircle: false } community)
await _groups.Announce(community, activity.AsObject(), post.ObjectURI, isNewPost: false, token); await _groups.Announce(community, activity.AsObject(), post.ObjectURI, isNewPost: false, token);
} }
static bool IsEdit(NoteDocument note, PostEntity post) =>
note.Updated is { } updated && updated > (post.EditedAt ?? post.CreationDate);
async Task Requote(PostEntity post, NoteDocument note, CancellationToken token)
{
if (note.QuoteUri != post.QuoteURI || note.QuoteAuthorization != post.QuoteAuthorizationURI || post.QuoteState != QuoteState.Accepted)
await _quotes.Resolve(post, note, token);
}
} }
} }
+48 -2
View File
@@ -1,8 +1,12 @@
using PrivaPub.Federation.Actors; using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Objects;
using PrivaPub.Infrastructure.Jobs; using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Infrastructure.Statistics;
using PrivaPub.Models.Jobs; using PrivaPub.Models.Jobs;
using PrivaPub.Models.Statistics;
using PrivaPub.Models.User; using PrivaPub.Models.User;
using System.Diagnostics;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
@@ -21,12 +25,15 @@ namespace PrivaPub.Federation.Inbox
readonly IRemoteActorService _remoteActors; readonly IRemoteActorService _remoteActors;
readonly IReadOnlyDictionary<string, IActivityHandler> _handlers; readonly IReadOnlyDictionary<string, IActivityHandler> _handlers;
readonly ILogger<InboxProcessor> _logger; readonly ILogger<InboxProcessor> _logger;
readonly IInteractionLedger _ledger;
public InboxProcessor(IRemoteActorService remoteActors, IEnumerable<IActivityHandler> handlers, ILogger<InboxProcessor> logger) public InboxProcessor(IRemoteActorService remoteActors, IEnumerable<IActivityHandler> handlers, ILogger<InboxProcessor> logger,
IInteractionLedger ledger = default)
{ {
_remoteActors = remoteActors; _remoteActors = remoteActors;
_handlers = handlers.ToDictionary(h => h.Type, StringComparer.Ordinal); _handlers = handlers.ToDictionary(h => h.Type, StringComparer.Ordinal);
_logger = logger; _logger = logger;
_ledger = ledger;
} }
public JobKind Kind => JobKind.ProcessInbox; public JobKind Kind => JobKind.ProcessInbox;
@@ -36,28 +43,67 @@ namespace PrivaPub.Federation.Inbox
public async Task<JobOutcome> Handle(Job job, CancellationToken token) public async Task<JobOutcome> Handle(Job job, CancellationToken token)
{ {
var started = Stopwatch.GetTimestamp();
var payload = JsonSerializer.Deserialize<InboxPayload>(job.Payload); var payload = JsonSerializer.Deserialize<InboxPayload>(job.Payload);
var activity = payload == default ? default : JsonNode.Parse(payload.Activity); var activity = payload == default ? default : JsonNode.Parse(payload.Activity);
var type = Value(activity, "type"); var type = Value(activity, "type");
if (type == default || !_handlers.TryGetValue(type, out var handler)) if (type == default || !_handlers.TryGetValue(type, out var handler))
{
Record(job, payload, activity, type, started, new ArrivalVerdict(), Interactions.Dropped, "unknown-type");
return JobOutcome.Done; return JobOutcome.Done;
}
var actor = await _remoteActors.GetActor(payload.ActorURI, refresh: false, token); var actor = await _remoteActors.GetActor(payload.ActorURI, refresh: false, token);
if (actor == default) if (actor == default)
{
Record(job, payload, activity, type, started, new ArrivalVerdict(), Interactions.Deferred, "actor-unavailable");
return JobOutcome.Retry("the actor could not be loaded"); return JobOutcome.Retry("the actor could not be loaded");
}
Arrival.Current = new Arrival(Id(activity), type, actor.ActorURI, payload.Inbox, payload.KeyId, payload.Algorithm, var arrival = new Arrival(Id(activity), type, actor.ActorURI, payload.Inbox, payload.KeyId, payload.Algorithm,
payload.SignedHeaders ?? Array.Empty<string>(), payload.ReceivedAt ?? job.CreatedAt, activity["@context"]?.ToJsonString()); payload.SignedHeaders ?? Array.Empty<string>(), payload.ReceivedAt ?? job.CreatedAt, activity["@context"]?.ToJsonString());
Arrival.Current = arrival;
try try
{ {
await handler.Handle(activity, actor, token); await handler.Handle(activity, actor, token);
} }
catch (Exception ex) when (ex is not OperationCanceledException)
{
Record(job, payload, activity, type, started, arrival.Verdict, Interactions.Failed, ex.GetType().Name.ToLowerInvariant());
throw;
}
finally finally
{ {
Arrival.Current = default; Arrival.Current = default;
} }
Record(job, payload, activity, type, started, arrival.Verdict, default, default);
_logger.LogInformation("Processed {Type} {Id} from {Actor}", type, Id(activity), actor.ActorURI); _logger.LogInformation("Processed {Type} {Id} from {Actor}", type, Id(activity), actor.ActorURI);
return JobOutcome.Done; return JobOutcome.Done;
} }
void Record(Job job, InboxPayload payload, JsonNode activity, string type, long started, ArrivalVerdict verdict, string outcome, string reason)
{
if (_ledger == default || payload == default)
return;
var embedded = activity?["object"] as JsonObject;
_ledger.Record(new InteractionEvent
{
Channel = Interactions.In,
Host = Interactions.HostOf(payload.ActorURI),
Activity = type,
Object = verdict.ObjectType ?? Value(embedded, "type"),
Outcome = outcome ?? verdict.Outcome ?? Interactions.Accepted,
Reason = reason ?? verdict.Reason,
LatencyMs = (int)Stopwatch.GetElapsedTime(started).TotalMilliseconds,
WaitMs = (int)Math.Min(int.MaxValue, Math.Max(0, (DateTime.UtcNow - (payload.ReceivedAt ?? job.CreatedAt)).TotalMilliseconds)),
Attempt = job.Attempts,
Audience = verdict.Audience ?? ActivityShape.Of(activity).Audience,
LocalKind = verdict.LocalKind,
AgeSeconds = verdict.AgeSeconds,
Inbox = payload.Inbox,
Signature = payload.Algorithm == default ? default : "cavage:" + payload.Algorithm,
Features = type is "Create" or "Update" && embedded != default ? ObjectFeatures.Detect(embedded) : default
}, payload.ActorURI);
}
} }
} }
+96 -21
View File
@@ -2,9 +2,12 @@ using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Moderation; using PrivaPub.Federation.Moderation;
using PrivaPub.Federation.Objects; using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Signing; using PrivaPub.Federation.Signing;
using PrivaPub.Infrastructure.Http;
using PrivaPub.Infrastructure.Jobs; using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Infrastructure.Statistics;
using PrivaPub.Models.Federation; using PrivaPub.Models.Federation;
using PrivaPub.Models.Jobs; using PrivaPub.Models.Jobs;
using PrivaPub.Models.Statistics;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
@@ -13,7 +16,7 @@ using static PrivaPub.Federation.Objects.ActivityJson;
namespace PrivaPub.Federation.Inbox namespace PrivaPub.Federation.Inbox
{ {
public sealed record InboxResult(int StatusCode, string Error = default, int? RetryAfterSeconds = default); public sealed record InboxResult(int StatusCode, string Error = default, int? RetryAfterSeconds = default, string Reason = default);
public sealed record InboxPayload(string ActorURI, string Activity, string Inbox = default, string KeyId = default, string Algorithm = default, public sealed record InboxPayload(string ActorURI, string Activity, string Inbox = default, string KeyId = default, string Algorithm = default,
string[] SignedHeaders = default, DateTime? ReceivedAt = default); string[] SignedHeaders = default, DateTime? ReceivedAt = default);
@@ -21,6 +24,7 @@ namespace PrivaPub.Federation.Inbox
public interface IInboxReceiver public interface IInboxReceiver
{ {
Task<InboxResult> Receive(HttpRequest request, LocalActor recipient, CancellationToken token); Task<InboxResult> Receive(HttpRequest request, LocalActor recipient, CancellationToken token);
InboxResult NoSuchRecipient(HttpRequest request);
} }
public class InboxReceiver : IInboxReceiver public class InboxReceiver : IInboxReceiver
@@ -34,27 +38,88 @@ namespace PrivaPub.Federation.Inbox
readonly IJobQueue _queue; readonly IJobQueue _queue;
readonly IDomainBlocks _domainBlocks; readonly IDomainBlocks _domainBlocks;
readonly ILogger<InboxReceiver> _logger; readonly ILogger<InboxReceiver> _logger;
readonly IInteractionLedger _ledger;
public InboxReceiver(ILocalActorService localActors, IRemoteActorService remoteActors, IJobQueue queue, IDomainBlocks domainBlocks, public InboxReceiver(ILocalActorService localActors, IRemoteActorService remoteActors, IJobQueue queue, IDomainBlocks domainBlocks,
ILogger<InboxReceiver> logger) ILogger<InboxReceiver> logger, IInteractionLedger ledger = default)
{ {
_localActors = localActors; _localActors = localActors;
_remoteActors = remoteActors; _remoteActors = remoteActors;
_queue = queue; _queue = queue;
_domainBlocks = domainBlocks; _domainBlocks = domainBlocks;
_logger = logger; _logger = logger;
_ledger = ledger;
}
sealed class Receipt
{
public string Type;
public string ObjectType;
public string ClaimedHost;
public string VerifiedActor;
public string Inbox;
public string Signature = "none";
public long? Bytes;
public bool Blocked;
}
public InboxResult NoSuchRecipient(HttpRequest request)
{
var result = new InboxResult(StatusCodes.Status404NotFound, "no such local actor", Reason: "unknown-recipient");
var keyId = HttpSignatures.Parse(request.Headers["Signature"].ToString())?.KeyId;
Record(new Receipt { ClaimedHost = HostOf(keyId), Inbox = "personal", Bytes = request.ContentLength }, result, 0);
return result;
} }
public async Task<InboxResult> Receive(HttpRequest request, LocalActor recipient, CancellationToken token) public async Task<InboxResult> Receive(HttpRequest request, LocalActor recipient, CancellationToken token)
{
using var scope = HttpScope.Triggered("verify");
var started = System.Diagnostics.Stopwatch.GetTimestamp();
var receipt = new Receipt { Inbox = recipient == default ? "shared" : "personal", Bytes = request.ContentLength };
InboxResult result = default;
try
{
result = await Receive(request, recipient, receipt, token);
return result;
}
finally
{
Record(receipt, result, (int)System.Diagnostics.Stopwatch.GetElapsedTime(started).TotalMilliseconds);
}
}
void Record(Receipt receipt, InboxResult result, int latencyMs)
{
if (_ledger == default || result == default)
return;
var verified = receipt.VerifiedActor != default;
_ledger.Record(new InteractionEvent
{
Channel = Interactions.Receive,
Host = verified ? Interactions.HostOf(receipt.VerifiedActor) : receipt.ClaimedHost,
Activity = receipt.Type,
Object = receipt.ObjectType,
Status = result.StatusCode,
Outcome = result.StatusCode == StatusCodes.Status202Accepted ? Interactions.Queued : Interactions.Refused,
Reason = result.Reason,
LatencyMs = latencyMs,
Bytes = receipt.Bytes,
Inbox = receipt.Inbox,
Signature = receipt.Signature
}, verified ? receipt.VerifiedActor : default, hostClaimed: !verified && !receipt.Blocked);
}
async Task<InboxResult> Receive(HttpRequest request, LocalActor recipient, Receipt receipt, CancellationToken token)
{ {
if (request.ContentLength > MaxBodyBytes) if (request.ContentLength > MaxBodyBytes)
return new(StatusCodes.Status413PayloadTooLarge); return new(StatusCodes.Status413PayloadTooLarge, Reason: "too-large");
using var buffer = new MemoryStream(); using var buffer = new MemoryStream();
await request.Body.CopyToAsync(buffer, token); await request.Body.CopyToAsync(buffer, token);
if (buffer.Length > MaxBodyBytes) if (buffer.Length > MaxBodyBytes)
return new(StatusCodes.Status413PayloadTooLarge); return new(StatusCodes.Status413PayloadTooLarge, Reason: "too-large");
var body = buffer.ToArray(); var body = buffer.ToArray();
receipt.Bytes = body.Length;
JsonNode activity; JsonNode activity;
try try
@@ -63,41 +128,51 @@ namespace PrivaPub.Federation.Inbox
} }
catch (JsonException) catch (JsonException)
{ {
return new(StatusCodes.Status400BadRequest, "the body is not JSON"); return new(StatusCodes.Status400BadRequest, "the body is not JSON", Reason: "not-json");
} }
if (activity is not JsonObject) if (activity is not JsonObject)
return new(StatusCodes.Status400BadRequest, "the body is not an activity"); return new(StatusCodes.Status400BadRequest, "the body is not an activity", Reason: "not-activity");
var type = Value(activity, "type"); var type = Value(activity, "type");
var actorUri = Id(activity["actor"]); var actorUri = Id(activity["actor"]);
receipt.Type = type;
receipt.ObjectType = activity["object"] is JsonObject embedded ? Value(embedded, "type") : default;
receipt.ClaimedHost = HostOf(actorUri);
if (string.IsNullOrEmpty(type) || string.IsNullOrEmpty(actorUri)) if (string.IsNullOrEmpty(type) || string.IsNullOrEmpty(actorUri))
return new(StatusCodes.Status400BadRequest, "type and actor are required"); return new(StatusCodes.Status400BadRequest, "type and actor are required", Reason: "missing-type-or-actor");
var parameters = HttpSignatures.Parse(request.Headers["Signature"].ToString()); var parameters = HttpSignatures.Parse(request.Headers["Signature"].ToString());
if (parameters == default) if (parameters == default)
return new(StatusCodes.Status401Unauthorized, "missing or unreadable Signature header"); return new(StatusCodes.Status401Unauthorized, "missing or unreadable Signature header", Reason: "no-signature");
receipt.Signature = "cavage:" + parameters.Algorithm;
receipt.ClaimedHost ??= HostOf(parameters.KeyId);
if (_domainBlocks.IsSuspended(HostOf(parameters.KeyId)) || _domainBlocks.IsSuspended(HostOf(actorUri))) if (_domainBlocks.IsSuspended(HostOf(parameters.KeyId)) || _domainBlocks.IsSuspended(HostOf(actorUri)))
return new(StatusCodes.Status202Accepted); {
receipt.Blocked = true;
receipt.ClaimedHost = _domainBlocks.IsSuspended(HostOf(actorUri)) ? HostOf(actorUri) : HostOf(parameters.KeyId);
return new(StatusCodes.Status202Accepted, Reason: "suspended");
}
var requestProblem = HttpSignatures.CheckRequest(request, parameters, body); var requestProblem = HttpSignatures.CheckRequest(request, parameters, body);
if (requestProblem != default) if (requestProblem != default)
return new(StatusCodes.Status401Unauthorized, requestProblem); return new(StatusCodes.Status401Unauthorized, requestProblem, Reason: HttpSignatures.ProblemCode(requestProblem));
var signingString = HttpSignatures.SigningString(request, parameters); var signingString = HttpSignatures.SigningString(request, parameters);
var keyOwner = await _remoteActors.GetActorByKeyId(parameters.KeyId, refresh: false, token); var keyOwner = await _remoteActors.GetActorByKeyId(parameters.KeyId, refresh: false, token);
if (keyOwner == default && type == "Delete" && Id(activity["object"]) == actorUri) if (keyOwner == default && type == "Delete" && Id(activity["object"]) == actorUri)
return new(StatusCodes.Status202Accepted); return new(StatusCodes.Status202Accepted, Reason: "self-delete-unknown-key");
if (keyOwner == default || !HttpSignatures.Verify(keyOwner.PublicKey, signingString, parameters.Signature)) if (keyOwner == default || !HttpSignatures.Verify(keyOwner.PublicKey, signingString, parameters.Signature))
{ {
keyOwner = await _remoteActors.GetActorByKeyId(parameters.KeyId, refresh: true, token); keyOwner = await _remoteActors.GetActorByKeyId(parameters.KeyId, refresh: true, token);
if (keyOwner == default && _remoteActors.KeyTemporarilyUnavailable(parameters.KeyId)) if (keyOwner == default && _remoteActors.KeyTemporarilyUnavailable(parameters.KeyId))
return new(StatusCodes.Status503ServiceUnavailable, "the signing key could not be fetched; try again later", KeyRetrySeconds); return new(StatusCodes.Status503ServiceUnavailable, "the signing key could not be fetched; try again later", KeyRetrySeconds, "key-unavailable");
if (keyOwner == default || !HttpSignatures.Verify(keyOwner.PublicKey, signingString, parameters.Signature)) if (keyOwner == default || !HttpSignatures.Verify(keyOwner.PublicKey, signingString, parameters.Signature))
return new(StatusCodes.Status401Unauthorized, "the signature does not verify"); return new(StatusCodes.Status401Unauthorized, "the signature does not verify", Reason: "signature-invalid");
} }
if (!string.Equals(keyOwner.ActorURI, actorUri, StringComparison.Ordinal)) if (!string.Equals(keyOwner.ActorURI, actorUri, StringComparison.Ordinal))
return new(StatusCodes.Status401Unauthorized, "the activity's actor is not the key's owner"); return new(StatusCodes.Status401Unauthorized, "the activity's actor is not the key's owner", Reason: "actor-not-key-owner");
receipt.VerifiedActor = actorUri;
var shapeProblem = await ShapeProblem(type, activity, actorUri, token); var shapeProblem = await ShapeProblem(type, activity, actorUri, token);
if (shapeProblem != default) if (shapeProblem != default)
@@ -106,19 +181,19 @@ namespace PrivaPub.Federation.Inbox
var activityId = Id(activity); var activityId = Id(activity);
var payload = new InboxPayload(actorUri, activity.ToJsonString(), recipient == default ? "shared" : "personal", parameters.KeyId, var payload = new InboxPayload(actorUri, activity.ToJsonString(), recipient == default ? "shared" : "personal", parameters.KeyId,
parameters.Algorithm, parameters.Headers, DateTime.UtcNow); parameters.Algorithm, parameters.Headers, DateTime.UtcNow);
await _queue.Enqueue(JobKind.ProcessInbox, JsonSerializer.Serialize(payload), var queued = await _queue.Enqueue(JobKind.ProcessInbox, JsonSerializer.Serialize(payload),
new Uri(actorUri).Host.ToLowerInvariant(), activityId == default ? default : "inbox|" + activityId, token); new Uri(actorUri).Host.ToLowerInvariant(), activityId == default ? default : "inbox|" + activityId, token);
_logger.LogInformation("Inbox {Recipient}: {Type} from {Actor} queued", recipient?.Handle ?? "shared", type, actorUri); _logger.LogInformation("Inbox {Recipient}: {Type} from {Actor} queued", recipient?.Handle ?? "shared", type, actorUri);
return new(StatusCodes.Status202Accepted); return new(StatusCodes.Status202Accepted, Reason: queued ? "queued" : "duplicate");
} }
static string HostOf(string uri) => Uri.TryCreate(uri, UriKind.Absolute, out var parsed) ? parsed.Host : default; static string HostOf(string uri) => Uri.TryCreate(uri, UriKind.Absolute, out var parsed) ? parsed.Host.ToLowerInvariant() : default;
async Task<InboxResult> ShapeProblem(string type, JsonNode activity, string actorUri, CancellationToken token) async Task<InboxResult> ShapeProblem(string type, JsonNode activity, string actorUri, CancellationToken token)
{ {
var activityId = Id(activity); var activityId = Id(activity);
if (activityId != default && !Origin.Same(activityId, actorUri)) if (activityId != default && !Origin.Same(activityId, actorUri))
return new(StatusCodes.Status400BadRequest, "the activity's id is not on its actor's origin"); return new(StatusCodes.Status400BadRequest, "the activity's id is not on its actor's origin", Reason: "id-cross-origin");
var inner = activity["object"]; var inner = activity["object"];
switch (type) switch (type)
@@ -126,14 +201,14 @@ namespace PrivaPub.Federation.Inbox
case "Follow": case "Follow":
var target = await _localActors.FindByUri(Id(inner), token); var target = await _localActors.FindByUri(Id(inner), token);
if (target is not { IsFederated: true } || target.Kind == LocalActorKind.Application) if (target is not { IsFederated: true } || target.Kind == LocalActorKind.Application)
return new(StatusCodes.Status404NotFound, "no such local actor"); return new(StatusCodes.Status404NotFound, "no such local actor", Reason: "unknown-recipient");
break; break;
case "Undo" when inner is JsonObject && Id(inner["actor"]) != actorUri: case "Undo" when inner is JsonObject && Id(inner["actor"]) != actorUri:
return new(StatusCodes.Status400BadRequest, "an actor can only undo its own activities"); return new(StatusCodes.Status400BadRequest, "an actor can only undo its own activities", Reason: "undo-foreign");
case "Create" or "Update" when inner is JsonObject && Origin.Same(Id(inner), actorUri) case "Create" or "Update" when inner is JsonObject && Origin.Same(Id(inner), actorUri)
&& inner["attributedTo"] != default && Id(inner["attributedTo"]) != actorUri && inner["attributedTo"] != default && Id(inner["attributedTo"]) != actorUri
&& Value(inner, "type") is not ("Person" or "Service" or "Application" or "Group" or "Organization"): && Value(inner, "type") is not ("Person" or "Service" or "Application" or "Group" or "Organization"):
return new(StatusCodes.Status400BadRequest, "the object is not attributed to the actor"); return new(StatusCodes.Status400BadRequest, "the object is not attributed to the actor", Reason: "misattributed");
} }
return default; return default;
} }
+106
View File
@@ -0,0 +1,106 @@
using MongoDB.Entities;
using PrivaPub.Domain.Statuses;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Federation.Inbox
{
public static class RemoteEdits
{
public const int MaxRevisions = 20;
public static bool IsEdit(NoteDocument note, PostEntity post) =>
note.Updated is { } updated && updated > (post.EditedAt ?? post.CreationDate);
public static async Task<bool> Apply(PostEntity post, NoteDocument note, string activityId, ILocalActorService localActors, IObjectRecords records,
IQuoteService quotes, CancellationToken token)
{
post.Poll = note.Poll ?? post.Poll;
post.QuotePolicy = note.QuotePolicy;
post.Video = note.Video ?? post.Video;
post.Audio = note.Audio ?? post.Audio;
post.Event = note.Event ?? post.Event;
if (!IsEdit(note, post))
{
await DB.Default.SaveAsync(post, token);
await records.Revise(note, activityId, token);
await Requote(post, note, quotes, token);
return false;
}
post.Revisions.Add(new PostRevision
{
Title = post.Title,
SpoilerText = post.SpoilerText,
ContentHtml = post.ContentHtml,
HasContentWarning = post.HasContentWarning,
EditedAt = post.EditedAt ?? post.CreationDate
});
if (post.Revisions.Count > MaxRevisions)
post.Revisions.RemoveRange(0, post.Revisions.Count - MaxRevisions);
var mentions = new List<PostMention>();
foreach (var mention in note.Mentions)
{
var local = await localActors.FindByUri(mention.ActorURI, token);
mentions.Add(new PostMention { ActorURI = mention.ActorURI, Handle = mention.Handle, IsLocal = local != default, AccountId = local?.Id });
}
post.Title = note.Title;
post.SpoilerText = note.SpoilerText;
post.Excerpt = note.Excerpt;
post.Source = note.Source;
post.Emojis = note.Emojis.ToList();
post.CoverURL = note.CoverURL;
post.Link = note.Link;
post.HasContentWarning = note.Sensitive;
post.Text = note.ContentHtml;
post.ContentHtml = note.ContentHtml;
post.Language = note.Language;
post.Tags = note.Tags.ToList();
post.Mentions = mentions;
post.Media = note.Attachments.ToList();
post.EditedAt = note.Updated ?? DateTime.UtcNow;
post.UpdateDate = DateTime.UtcNow;
await DB.Default.SaveAsync(post, token);
await records.Revise(note, activityId, token);
await Requote(post, note, quotes, token);
return true;
}
static async Task Requote(PostEntity post, NoteDocument note, IQuoteService quotes, CancellationToken token)
{
if (note.QuoteUri != post.QuoteURI || note.QuoteAuthorization != post.QuoteAuthorizationURI || post.QuoteState != QuoteState.Accepted)
await quotes.Resolve(post, note, token);
}
}
public static class RemoteDeletes
{
public static async Task Tombstone(string objectUri, CancellationToken token) =>
await DB.Default.Update<DeletedObject>()
.Match(d => d.ObjectURI == objectUri)
.Modify(d => d.DeletedAt, DateTime.UtcNow)
.Option(o => o.IsUpsert = true)
.ExecuteAsync(token);
public static async Task Remove(PostEntity post, string objectUri, CancellationToken token)
{
await Tombstone(objectUri, token);
await DB.Default.DeleteAsync<PostEntity>(post.ID);
await DB.Default.DeleteAsync<ObjectRecord>(r => r.PostId == post.ID || r.ObjectURI == objectUri);
await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == post.ID || e.ReblogOfPostId == post.ID);
await DB.Default.DeleteAsync<PostEntity>(p => p.ReblogOfPostId == post.ID);
if (!string.IsNullOrEmpty(post.AnsweringToPostId))
await DB.Default.Update<PostEntity>().MatchID(post.AnsweringToPostId).Modify(b => b.Inc(p => p.RepliesCount, -1)).ExecuteAsync(token);
if (post.QuoteState == QuoteState.Accepted && !string.IsNullOrEmpty(post.QuotedPostId))
await DB.Default.Update<PostEntity>().MatchID(post.QuotedPostId).Modify(b => b.Inc(p => p.QuotesCount, -1)).ExecuteAsync(token);
}
}
}
+2
View File
@@ -1,6 +1,7 @@
using MongoDB.Driver; using MongoDB.Driver;
using MongoDB.Entities; using MongoDB.Entities;
using PrivaPub.Infrastructure.Http;
using PrivaPub.Domain.Content; using PrivaPub.Domain.Content;
using PrivaPub.Federation.Actors; using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Moderation; using PrivaPub.Federation.Moderation;
@@ -123,6 +124,7 @@ namespace PrivaPub.Federation.Inbox
if (existing != default || depth > MaxDepth || await DB.Default.Find<DeletedObject>().Match(d => d.ObjectURI == objectUri).ExecuteAnyAsync(token)) if (existing != default || depth > MaxDepth || await DB.Default.Find<DeletedObject>().Match(d => d.ObjectURI == objectUri).ExecuteAnyAsync(token))
return existing; return existing;
using var scope = HttpScope.For("context");
using var fetched = await _remoteActors.FetchObject(objectUri, token); using var fetched = await _remoteActors.FetchObject(objectUri, token);
var note = fetched == default ? default : NoteParser.Parse(JsonNode.Parse(fetched.Root.GetRawText())); var note = fetched == default ? default : NoteParser.Parse(JsonNode.Parse(fetched.Root.GetRawText()));
if (note == default || !Origin.Same(note.Id, note.AttributedTo)) if (note == default || !Origin.Same(note.Id, note.AttributedTo))
@@ -0,0 +1,48 @@
using PrivaPub.Infrastructure.Statistics;
using System.Text.Json.Nodes;
using static PrivaPub.Federation.Objects.ActivityJson;
namespace PrivaPub.Federation.Objects
{
public sealed record ActivityShape(string Type, string ObjectType, string Audience)
{
public static ActivityShape Of(JsonNode activity)
{
var embedded = activity?["object"] as JsonObject;
return new ActivityShape(Value(activity, "type"), Value(embedded, "type"), AudienceOf(activity));
}
public static ActivityShape Of(string json)
{
try
{
return Of(JsonNode.Parse(json));
}
catch (System.Text.Json.JsonException)
{
return new ActivityShape(default, default, default);
}
}
static string AudienceOf(JsonNode activity)
{
var source = activity?["to"] != default || activity?["cc"] != default ? activity : activity?["object"] as JsonObject;
var to = Addresses(source?["to"]);
var cc = Addresses(source?["cc"]);
if (to.Any(Addressing.IsPublic))
return Interactions.Public;
if (cc.Any(Addressing.IsPublic))
return Interactions.Unlisted;
return to.Count + cc.Count == 0 ? Interactions.None : Interactions.Private;
}
static List<string> Addresses(JsonNode node) => node switch
{
JsonArray array => array.Select(Id).Where(id => id != default).ToList(),
JsonNode single when Id(single) is { } id => new List<string> { id },
_ => new List<string>()
};
}
}
@@ -29,6 +29,7 @@ namespace PrivaPub.Federation.Objects
public async Task<JobOutcome> Handle(Job job, CancellationToken token) public async Task<JobOutcome> Handle(Job job, CancellationToken token)
{ {
var host = job.Payload; var host = job.Payload;
using var scope = HttpScope.For("nodeinfo");
using var links = await _http.GetJson($"https://{host}/.well-known/nodeinfo", "application/json", sign: default, token); using var links = await _http.GetJson($"https://{host}/.well-known/nodeinfo", "application/json", sign: default, token);
var href = links == default ? default : Schemas.Select(schema => LinkFor(links.Root, schema)).FirstOrDefault(link => link != default); var href = links == default ? default : Schemas.Select(schema => LinkFor(links.Root, schema)).FirstOrDefault(link => link != default);
if (href == default) if (href == default)
+12 -8
View File
@@ -34,8 +34,10 @@ namespace PrivaPub.Federation.Objects
public async Task Record(NoteDocument note, PostEntity post, ObjectPath path, bool refetched, CancellationToken token) public async Task Record(NoteDocument note, PostEntity post, ObjectPath path, bool refetched, CancellationToken token)
{ {
var arrival = Arrival.Current; var arrival = Arrival.Current;
var delivered = path == ObjectPath.Delivered;
var raw = Capture(note.Raw); var raw = Capture(note.Raw);
var host = new Uri(note.Id).Host.ToLowerInvariant(); var host = new Uri(note.Id).Host.ToLowerInvariant();
var activityContext = !delivered || note.Raw?.ContainsKey("@context") == true || arrival?.Context?.Length > 16 * 1024 ? default : arrival?.Context;
var record = new ObjectRecord var record = new ObjectRecord
{ {
ObjectURI = note.Id, ObjectURI = note.Id,
@@ -46,20 +48,22 @@ namespace PrivaPub.Federation.Objects
RawHash = raw.Hash, RawHash = raw.Hash,
RawBytes = raw.Bytes, RawBytes = raw.Bytes,
RawTruncated = raw.Truncated, RawTruncated = raw.Truncated,
ActivityContext = note.Raw?.ContainsKey("@context") == true || arrival?.Context?.Length > 16 * 1024 ? default : arrival?.Context, ActivityContext = activityContext,
Path = path, Path = path,
Refetched = refetched, Refetched = refetched,
ActivityId = arrival?.ActivityId, ActivityId = arrival?.ActivityId,
ActivityType = arrival?.ActivityType, ActivityType = arrival?.ActivityType,
ActivityActorURI = arrival?.ActorURI, ActivityActorURI = arrival?.ActorURI,
Inbox = path == ObjectPath.Delivered ? arrival?.Inbox : default, Inbox = delivered ? arrival?.Inbox : default,
SignatureScheme = arrival?.KeyId == default ? default : "draft-cavage", SignatureScheme = !delivered || arrival?.KeyId == default ? default : "draft-cavage",
KeyId = arrival?.KeyId, KeyId = delivered ? arrival?.KeyId : default,
Algorithm = arrival?.Algorithm, Algorithm = delivered ? arrival?.Algorithm : default,
SignedHeaders = arrival?.SignedHeaders?.ToList() ?? new(), SignedHeaders = delivered ? arrival?.SignedHeaders?.ToList() ?? new() : new(),
ReceivedAt = arrival?.ReceivedAt ?? DateTime.UtcNow, ReceivedAt = delivered ? arrival?.ReceivedAt ?? DateTime.UtcNow : DateTime.UtcNow,
Published = note.Published, Published = note.Published,
Updated = note.Updated Updated = note.Updated,
Extensions = ObjectFeatures.Detect(note.Raw),
ContextNamespaces = ObjectFeatures.Namespaces(note.Raw, activityContext)
}; };
try try
{ {
+89 -1
View File
@@ -11,8 +11,12 @@ using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Signing; using PrivaPub.Federation.Signing;
using PrivaPub.Infrastructure.Http; using PrivaPub.Infrastructure.Http;
using PrivaPub.Infrastructure.Jobs; using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Infrastructure.Statistics;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Statistics;
using PrivaPub.Models.Jobs; using PrivaPub.Models.Jobs;
using System.Diagnostics;
using System.Text.Json; using System.Text.Json;
namespace PrivaPub.Federation.Outbox namespace PrivaPub.Federation.Outbox
@@ -81,13 +85,25 @@ namespace PrivaPub.Federation.Outbox
readonly IFederationHttp _http; readonly IFederationHttp _http;
readonly IHostCircuitBreaker _breaker; readonly IHostCircuitBreaker _breaker;
readonly ILogger<DeliveryJobHandler> _logger; readonly ILogger<DeliveryJobHandler> _logger;
readonly IInteractionLedger _ledger;
public DeliveryJobHandler(ILocalActorService actors, IFederationHttp http, IHostCircuitBreaker breaker, ILogger<DeliveryJobHandler> logger) public DeliveryJobHandler(ILocalActorService actors, IFederationHttp http, IHostCircuitBreaker breaker, ILogger<DeliveryJobHandler> logger,
IInteractionLedger ledger = default)
{ {
_actors = actors; _actors = actors;
_http = http; _http = http;
_breaker = breaker; _breaker = breaker;
_logger = logger; _logger = logger;
_ledger = ledger;
}
sealed class Attempt
{
public DeliveryPayload Payload;
public LocalActor Signer;
public int? Status;
public string Reason;
public int? LatencyMs;
} }
public JobKind Kind => JobKind.Deliver; public JobKind Kind => JobKind.Deliver;
@@ -96,28 +112,97 @@ namespace PrivaPub.Federation.Outbox
public int PerHostLimit => 2; public int PerHostLimit => 2;
public async Task<JobOutcome> Handle(Job job, CancellationToken token) public async Task<JobOutcome> Handle(Job job, CancellationToken token)
{
var attempt = new Attempt();
JobOutcome outcome = default;
try
{
outcome = await Deliver(job, attempt, token);
return outcome;
}
finally
{
Record(job, attempt, outcome);
}
}
void Record(Job job, Attempt attempt, JobOutcome outcome)
{
if (_ledger == default)
return;
var shape = attempt.Payload == default ? default : ActivityShape.Of(attempt.Payload.Body);
_ledger.Record(new InteractionEvent
{
Channel = Interactions.Out,
Host = job.Host,
Activity = shape?.Type,
Object = shape?.ObjectType,
Outcome = outcome?.Result switch
{
JobResult.Done => Interactions.Ok,
JobResult.Defer => Interactions.Deferred,
JobResult.Retry when job.Attempts >= MaxAttempts => Interactions.Dead,
JobResult.Retry => Interactions.Retry,
JobResult.Dead => Interactions.Dead,
_ => Interactions.Failed
},
Reason = attempt.Reason,
Status = attempt.Status,
LatencyMs = attempt.LatencyMs,
WaitMs = (int)Math.Min(int.MaxValue, Math.Max(0, (DateTime.UtcNow - job.CreatedAt).TotalMilliseconds)),
Bytes = attempt.Payload?.Body == default ? default : Encoding.UTF8.GetByteCount(attempt.Payload.Body),
Attempt = job.Attempts,
Audience = shape?.Audience,
LocalKind = attempt.Signer switch
{
null => default,
{ IsCircle: true } => default,
{ Kind: LocalActorKind.Person } => "person",
{ Kind: LocalActorKind.Group } => "group",
_ => "application"
},
Signature = "cavage:rsa-sha256"
});
}
async Task<JobOutcome> Deliver(Job job, Attempt attempt, CancellationToken token)
{ {
var payload = JsonSerializer.Deserialize<DeliveryPayload>(job.Payload); var payload = JsonSerializer.Deserialize<DeliveryPayload>(job.Payload);
attempt.Payload = payload;
if (payload == default || !Uri.TryCreate(payload.Inbox, UriKind.Absolute, out var inbox) || !_http.IsAllowed(inbox)) if (payload == default || !Uri.TryCreate(payload.Inbox, UriKind.Absolute, out var inbox) || !_http.IsAllowed(inbox))
{
attempt.Reason = "not-deliverable";
return JobOutcome.Dead("not a deliverable inbox"); return JobOutcome.Dead("not a deliverable inbox");
}
var unavailableUntil = await _breaker.UnavailableUntil(job.Host, token); var unavailableUntil = await _breaker.UnavailableUntil(job.Host, token);
if (unavailableUntil.HasValue) if (unavailableUntil.HasValue)
{
attempt.Reason = "host-unavailable";
return JobOutcome.Defer(unavailableUntil.Value, "the host is unavailable"); return JobOutcome.Defer(unavailableUntil.Value, "the host is unavailable");
}
var signer = await _actors.FindById(payload.SignerKind, payload.SignerId, token); var signer = await _actors.FindById(payload.SignerKind, payload.SignerId, token);
attempt.Signer = signer;
if (signer == default) if (signer == default)
{
attempt.Reason = "signer-gone";
return JobOutcome.Dead("the signing actor no longer exists"); return JobOutcome.Dead("the signing actor no longer exists");
}
var body = Encoding.UTF8.GetBytes(payload.Body); var body = Encoding.UTF8.GetBytes(payload.Body);
using var request = new HttpRequestMessage(HttpMethod.Post, inbox) { Content = new ByteArrayContent(body) }; using var request = new HttpRequestMessage(HttpMethod.Post, inbox) { Content = new ByteArrayContent(body) };
request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(RemoteActorService.ActivityJson); request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(RemoteActorService.ActivityJson);
HttpSignatures.Sign(request, signer, body); HttpSignatures.Sign(request, signer, body);
var started = Stopwatch.GetTimestamp();
try try
{ {
using var response = await _http.Send(request, token); using var response = await _http.Send(request, token);
attempt.LatencyMs = (int)Stopwatch.GetElapsedTime(started).TotalMilliseconds;
var status = (int)response.StatusCode; var status = (int)response.StatusCode;
attempt.Status = status;
attempt.Reason = response.IsSuccessStatusCode ? default : status.ToString(System.Globalization.CultureInfo.InvariantCulture);
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
await _breaker.Succeeded(job.Host, token); await _breaker.Succeeded(job.Host, token);
@@ -142,10 +227,13 @@ namespace PrivaPub.Federation.Outbox
} }
catch (BlockedDestinationException ex) catch (BlockedDestinationException ex)
{ {
attempt.Reason = "private-address";
return JobOutcome.Dead(ex.Message); return JobOutcome.Dead(ex.Message);
} }
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException && !token.IsCancellationRequested) catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException && !token.IsCancellationRequested)
{ {
attempt.LatencyMs = (int)Stopwatch.GetElapsedTime(started).TotalMilliseconds;
attempt.Reason = ex is TaskCanceledException ? "timeout" : "network";
await _breaker.Failed(job.Host, ex.GetType().Name, token); await _breaker.Failed(job.Host, ex.GetType().Name, token);
return JobOutcome.Retry(ex.Message); return JobOutcome.Retry(ex.Message);
} }
@@ -185,6 +185,18 @@ namespace PrivaPub.Federation.Signing
return default; return default;
} }
public static string ProblemCode(string problem) => problem switch
{
null => default,
_ when problem.StartsWith("unsupported signature algorithm", StringComparison.Ordinal) => "algorithm-unsupported",
_ when problem.EndsWith("is not signed", StringComparison.Ordinal) || problem.StartsWith("neither", StringComparison.Ordinal) => "headers-unsigned",
_ when problem.StartsWith("the digest does not match", StringComparison.Ordinal) => "digest-mismatch",
_ when problem.StartsWith("unreadable", StringComparison.Ordinal) => "header-unreadable",
_ when problem.Contains("outside the allowed window", StringComparison.Ordinal) => "date-skew",
"the signature has expired" => "expired",
_ => "signature-problem"
};
static bool IsFresh(DateTimeOffset signedAt, DateTimeOffset now) => static bool IsFresh(DateTimeOffset signedAt, DateTimeOffset now) =>
signedAt >= now - MaxAge && signedAt <= now + MaxClockSkew; signedAt >= now - MaxAge && signedAt <= now + MaxClockSkew;
+9
View File
@@ -7,6 +7,7 @@ using PrivaPub.Models.Group;
using PrivaPub.Models.Jobs; using PrivaPub.Models.Jobs;
using PrivaPub.Models.Post; using PrivaPub.Models.Post;
using PrivaPub.Models.Social; using PrivaPub.Models.Social;
using PrivaPub.Models.Statistics;
using PrivaPub.Models.User; using PrivaPub.Models.User;
namespace PrivaPub.Infrastructure.Data namespace PrivaPub.Infrastructure.Data
@@ -118,6 +119,14 @@ namespace PrivaPub.Infrastructure.Data
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 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);
await DB.Default.Index<InteractionEvent>().Key(e => e.At, KeyType.Ascending).Option(o => o.ExpireAfter = InteractionEvent.Retention).CreateAsync(token);
await Plain<InteractionEvent>(token, e => e.Host, e => e.At);
await DB.Default.Index<InteractionSalt>().Key(s => s.Day, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token);
await DB.Default.Index<InteractionSalt>().Key(s => s.ExpiresAt, KeyType.Ascending).Option(o => o.ExpireAfter = TimeSpan.Zero).CreateAsync(token);
await DB.Default.Index<InstanceDay>().Key(d => d.Day, KeyType.Ascending).Key(d => d.Host, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token);
await Plain<InstanceDay>(token, d => d.Host, d => d.Day);
await DB.Default.Index<ServerDay>().Key(d => d.Day, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token);
} }
static async Task Unique<T>(System.Linq.Expressions.Expression<Func<T, object>> key, FilterDefinition<T> partial, static async Task Unique<T>(System.Linq.Expressions.Expression<Func<T, object>> key, FilterDefinition<T> partial,
@@ -0,0 +1,19 @@
using MongoDB.Entities;
using PrivaPub.Models.Federation;
namespace PrivaPub.Infrastructure.Data.Migrations
{
public class _008_fetched_records_lose_the_trigger_signature : IMigration
{
public async Task UpgradeAsync() =>
await DB.Default.Update<ObjectRecord>()
.Match(r => r.Path == ObjectPath.Fetched)
.Modify(r => r.SignatureScheme, null)
.Modify(r => r.KeyId, null)
.Modify(r => r.Algorithm, null)
.Modify(r => r.SignedHeaders, new List<string>())
.Modify(r => r.ActivityContext, null)
.ExecuteAsync();
}
}
@@ -0,0 +1,41 @@
using MongoDB.Entities;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Federation;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace PrivaPub.Infrastructure.Data.Migrations
{
public class _009_object_records_keep_their_extensions : IMigration
{
public async Task UpgradeAsync()
{
while (true)
{
var batch = await DB.Default.Find<ObjectRecord>()
.Match(r => r.Extensions == null)
.Limit(500)
.ExecuteAsync();
if (batch.Count == 0)
return;
foreach (var record in batch)
{
JsonObject raw = default;
try
{
raw = record.Raw == default ? default : JsonNode.Parse(record.Raw) as JsonObject;
}
catch (JsonException)
{
}
await DB.Default.Update<ObjectRecord>().MatchID(record.ID)
.Modify(r => r.Extensions, ObjectFeatures.Detect(raw))
.Modify(r => r.ContextNamespaces, ObjectFeatures.Namespaces(raw, record.ActivityContext))
.ExecuteAsync();
}
}
}
}
}
+171 -15
View File
@@ -2,6 +2,8 @@ using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using PrivaPub.Federation.Moderation; using PrivaPub.Federation.Moderation;
using PrivaPub.Infrastructure.Statistics;
using PrivaPub.Models.Statistics;
using System.Net; using System.Net;
using System.Text.Json; using System.Text.Json;
@@ -49,17 +51,71 @@ namespace PrivaPub.Infrastructure.Http
readonly IOptionsMonitor<FederationOptions> _options; readonly IOptionsMonitor<FederationOptions> _options;
readonly IDomainBlocks _domainBlocks; readonly IDomainBlocks _domainBlocks;
readonly ILogger<FederationHttp> _logger; readonly ILogger<FederationHttp> _logger;
readonly IInteractionLedger _ledger;
public FederationHttp(IHttpClientFactory httpClientFactory, IMemoryCache cache, IOptionsMonitor<FederationOptions> options, public FederationHttp(IHttpClientFactory httpClientFactory, IMemoryCache cache, IOptionsMonitor<FederationOptions> options,
IDomainBlocks domainBlocks, ILogger<FederationHttp> logger) IDomainBlocks domainBlocks, ILogger<FederationHttp> logger, IInteractionLedger ledger = default)
{ {
_httpClientFactory = httpClientFactory; _httpClientFactory = httpClientFactory;
_cache = cache; _cache = cache;
_options = options; _options = options;
_domainBlocks = domainBlocks; _domainBlocks = domainBlocks;
_logger = logger; _logger = logger;
_ledger = ledger;
} }
sealed class Exchange
{
public Exchange(string url, string purpose)
{
Host = Interactions.HostOf(url);
Purpose = purpose;
}
public readonly string Host;
public readonly string Purpose;
public readonly long Started = System.Diagnostics.Stopwatch.GetTimestamp();
public int? Status;
public long? Bytes;
public int Hops;
public string Outcome = Interactions.Ok;
public string Reason;
public void Refused(string reason) => (Outcome, Reason) = (Interactions.Refused, reason);
public void Failed(string reason) => (Outcome, Reason) = (Interactions.Failed, reason);
public void Answered(HttpResponseMessage response) => Status = (int)response.StatusCode;
}
void Record(Exchange exchange)
{
if (_ledger == default)
return;
var trigger = HttpScope.Trigger;
if (trigger == HttpScope.Request)
{
_ledger.Count(exchange.Host, $"http:{exchange.Purpose}:{exchange.Outcome}", exchange.Bytes ?? 0);
return;
}
_ledger.Record(new InteractionEvent
{
Channel = Interactions.Http,
Host = exchange.Host,
Purpose = exchange.Purpose,
Trigger = trigger,
Outcome = exchange.Outcome,
Reason = exchange.Reason,
Status = exchange.Status,
LatencyMs = (int)System.Diagnostics.Stopwatch.GetElapsedTime(exchange.Started).TotalMilliseconds,
Bytes = exchange.Bytes,
Redirects = exchange.Hops,
Crawl = HttpScope.Crawling
});
}
static string StatusReason(HttpResponseMessage response) => ((int)response.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture);
public bool IsAllowed(Uri target) public bool IsAllowed(Uri target)
{ {
if (target is not { IsAbsoluteUri: true } || !string.IsNullOrEmpty(target.UserInfo)) if (target is not { IsAbsoluteUri: true } || !string.IsNullOrEmpty(target.UserInfo))
@@ -79,12 +135,31 @@ namespace PrivaPub.Infrastructure.Http
} }
public async Task<FetchedJson> GetJson(string url, string accept, Action<HttpRequestMessage> sign, CancellationToken token) public async Task<FetchedJson> GetJson(string url, string accept, Action<HttpRequestMessage> sign, CancellationToken token)
{
var exchange = new Exchange(url, HttpScope.Purpose ?? "object");
try
{
return await GetJson(url, accept, sign, exchange, token);
}
finally
{
Record(exchange);
}
}
async Task<FetchedJson> GetJson(string url, string accept, Action<HttpRequestMessage> sign, Exchange exchange, CancellationToken token)
{ {
if (!Uri.TryCreate(url, UriKind.Absolute, out var target) || !IsAllowed(target)) if (!Uri.TryCreate(url, UriKind.Absolute, out var target) || !IsAllowed(target))
{
exchange.Refused("disallowed");
return default; return default;
}
var negativeKey = NegativeKey(target); var negativeKey = NegativeKey(target);
if (_cache.TryGetValue(negativeKey, out _)) if (_cache.TryGetValue(negativeKey, out _))
{
exchange.Refused("remembered");
return default; return default;
}
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(token); using var timeout = CancellationTokenSource.CreateLinkedTokenSource(token);
timeout.CancelAfter(RequestTimeout); timeout.CancelAfter(RequestTimeout);
@@ -98,46 +173,49 @@ namespace PrivaPub.Infrastructure.Http
using var response = await _httpClientFactory.CreateClient(ClientName) using var response = await _httpClientFactory.CreateClient(ClientName)
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token); .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token);
exchange.Answered(response);
if (IsRedirect(response.StatusCode)) if (IsRedirect(response.StatusCode))
{ {
var location = response.Headers.Location; var location = response.Headers.Location;
var next = location == default ? default : location.IsAbsoluteUri ? location : new Uri(target, location); var next = location == default ? default : location.IsAbsoluteUri ? location : new Uri(target, location);
if (!IsAllowed(next)) if (!IsAllowed(next))
return Refuse(negativeKey, url, "a redirect to a disallowed location"); return Refuse(negativeKey, url, "a redirect to a disallowed location", exchange, "bad-redirect");
target = next; target = next;
exchange.Hops++;
continue; continue;
} }
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
return Refuse(negativeKey, url, $"status {(int)response.StatusCode}", return Refuse(negativeKey, url, $"status {(int)response.StatusCode}", exchange, StatusReason(response),
transient: (int)response.StatusCode is >= 500 or 429 or 408); transient: (int)response.StatusCode is >= 500 or 429 or 408);
var mediaType = response.Content.Headers.ContentType?.MediaType; var mediaType = response.Content.Headers.ContentType?.MediaType;
if (mediaType == default || !JsonMediaTypes.Contains(mediaType, StringComparer.OrdinalIgnoreCase)) if (mediaType == default || !JsonMediaTypes.Contains(mediaType, StringComparer.OrdinalIgnoreCase))
return Refuse(negativeKey, url, $"content type '{mediaType}'"); return Refuse(negativeKey, url, $"content type '{mediaType}'", exchange, "content-type");
if (response.Content.Headers.ContentLength > MaxResponseBytes) if (response.Content.Headers.ContentLength > MaxResponseBytes)
return Refuse(negativeKey, url, "a body over the size limit"); return Refuse(negativeKey, url, "a body over the size limit", exchange, "too-large");
var body = await ReadBounded(response.Content, MaxResponseBytes, timeout.Token); var body = await ReadBounded(response.Content, MaxResponseBytes, timeout.Token);
if (body == default) if (body == default)
return Refuse(negativeKey, url, "a body over the size limit"); return Refuse(negativeKey, url, "a body over the size limit", exchange, "too-large");
exchange.Bytes = body.Length;
return new FetchedJson { FinalUri = target, Document = JsonDocument.Parse(body) }; return new FetchedJson { FinalUri = target, Document = JsonDocument.Parse(body) };
} }
return Refuse(negativeKey, url, "too many redirects"); return Refuse(negativeKey, url, "too many redirects", exchange, "too-many-redirects");
} }
catch (OperationCanceledException) when (!token.IsCancellationRequested) catch (OperationCanceledException) when (!token.IsCancellationRequested)
{ {
return Refuse(negativeKey, url, "a timeout", transient: true); return Refuse(negativeKey, url, "a timeout", exchange, "timeout", transient: true);
} }
catch (HttpRequestException ex) catch (HttpRequestException ex)
{ {
return Refuse(negativeKey, url, ex.Message, transient: true); return Refuse(negativeKey, url, ex.Message, exchange, "network", transient: true);
} }
catch (Exception ex) when (ex is JsonException or BlockedDestinationException) catch (Exception ex) when (ex is JsonException or BlockedDestinationException)
{ {
return Refuse(negativeKey, url, ex.Message); return Refuse(negativeKey, url, ex.Message, exchange, ex is JsonException ? "bad-json" : "private-address");
} }
} }
@@ -145,9 +223,28 @@ namespace PrivaPub.Infrastructure.Http
Uri.TryCreate(url, UriKind.Absolute, out var target) && _cache.TryGetValue(NegativeKey(target), out bool transient) && transient; Uri.TryCreate(url, UriKind.Absolute, out var target) && _cache.TryGetValue(NegativeKey(target), out bool transient) && transient;
public async Task<(byte[] Bytes, string ContentType)> GetMedia(string url, long maxBytes, CancellationToken token) public async Task<(byte[] Bytes, string ContentType)> GetMedia(string url, long maxBytes, CancellationToken token)
{
var exchange = new Exchange(url, "media");
try
{
var media = await GetMedia(url, maxBytes, exchange, token);
if (media.Bytes == default && exchange.Outcome == Interactions.Ok)
exchange.Refused("unusable");
return media;
}
finally
{
Record(exchange);
}
}
async Task<(byte[] Bytes, string ContentType)> GetMedia(string url, long maxBytes, Exchange exchange, CancellationToken token)
{ {
if (!Uri.TryCreate(url, UriKind.Absolute, out var target) || !IsAllowed(target)) if (!Uri.TryCreate(url, UriKind.Absolute, out var target) || !IsAllowed(target))
{
exchange.Refused("disallowed");
return default; return default;
}
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(token); using var timeout = CancellationTokenSource.CreateLinkedTokenSource(token);
timeout.CancelAfter(TimeSpan.FromSeconds(60)); timeout.CancelAfter(TimeSpan.FromSeconds(60));
try try
@@ -157,27 +254,52 @@ namespace PrivaPub.Infrastructure.Http
using var request = new HttpRequestMessage(HttpMethod.Get, target); using var request = new HttpRequestMessage(HttpMethod.Get, target);
request.Headers.Accept.ParseAdd("image/*, video/*, audio/*"); request.Headers.Accept.ParseAdd("image/*, video/*, audio/*");
using var response = await _httpClientFactory.CreateClient(ClientName).SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token); using var response = await _httpClientFactory.CreateClient(ClientName).SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token);
exchange.Answered(response);
if (IsRedirect(response.StatusCode)) if (IsRedirect(response.StatusCode))
{ {
var location = response.Headers.Location; var location = response.Headers.Location;
var next = location == default ? default : location.IsAbsoluteUri ? location : new Uri(target, location); var next = location == default ? default : location.IsAbsoluteUri ? location : new Uri(target, location);
if (!IsAllowed(next)) if (!IsAllowed(next))
{
exchange.Refused("bad-redirect");
return default; return default;
}
target = next; target = next;
exchange.Hops++;
continue; continue;
} }
var mediaType = response.Content.Headers.ContentType?.MediaType?.ToLowerInvariant(); var mediaType = response.Content.Headers.ContentType?.MediaType?.ToLowerInvariant();
if (!response.IsSuccessStatusCode || mediaType == default if (!response.IsSuccessStatusCode)
|| !(mediaType.StartsWith("image/") || mediaType.StartsWith("video/") || mediaType.StartsWith("audio/")) {
|| mediaType.Contains("svg") || response.Content.Headers.ContentLength > maxBytes) exchange.Failed(StatusReason(response));
return default; return default;
}
if (mediaType == default || !(mediaType.StartsWith("image/") || mediaType.StartsWith("video/") || mediaType.StartsWith("audio/"))
|| mediaType.Contains("svg"))
{
exchange.Refused("content-type");
return default;
}
if (response.Content.Headers.ContentLength > maxBytes)
{
exchange.Refused("too-large");
return default;
}
var bytes = await ReadBounded(response.Content, (int)Math.Min(maxBytes, int.MaxValue), timeout.Token); var bytes = await ReadBounded(response.Content, (int)Math.Min(maxBytes, int.MaxValue), timeout.Token);
return bytes == default ? default : (bytes, mediaType); if (bytes == default)
{
exchange.Refused("too-large");
return default;
}
exchange.Bytes = bytes.Length;
return (bytes, mediaType);
} }
exchange.Refused("too-many-redirects");
return default; return default;
} }
catch (Exception ex) when (ex is HttpRequestException or BlockedDestinationException or OperationCanceledException && !token.IsCancellationRequested) catch (Exception ex) when (ex is HttpRequestException or BlockedDestinationException or OperationCanceledException && !token.IsCancellationRequested)
{ {
exchange.Failed(ex switch { BlockedDestinationException => "private-address", OperationCanceledException => "timeout", _ => "network" });
_logger.LogInformation("Media {Url} refused: {Reason}", url, ex.Message); _logger.LogInformation("Media {Url} refused: {Reason}", url, ex.Message);
return default; return default;
} }
@@ -231,9 +353,29 @@ namespace PrivaPub.Infrastructure.Http
} }
public async Task<HttpResponseMessage> OpenMedia(string url, System.Net.Http.Headers.RangeHeaderValue range, CancellationToken token) public async Task<HttpResponseMessage> OpenMedia(string url, System.Net.Http.Headers.RangeHeaderValue range, CancellationToken token)
{
var exchange = new Exchange(url, "stream");
try
{
var response = await OpenMedia(url, range, exchange, token);
if (response == default && exchange.Outcome == Interactions.Ok)
exchange.Refused("unusable");
exchange.Bytes = response?.Content.Headers.ContentLength;
return response;
}
finally
{
Record(exchange);
}
}
async Task<HttpResponseMessage> OpenMedia(string url, System.Net.Http.Headers.RangeHeaderValue range, Exchange exchange, CancellationToken token)
{ {
if (!Uri.TryCreate(url, UriKind.Absolute, out var target) || !IsAllowed(target)) if (!Uri.TryCreate(url, UriKind.Absolute, out var target) || !IsAllowed(target))
{
exchange.Refused("disallowed");
return default; return default;
}
try try
{ {
for (var hop = 0; hop <= MaxRedirects; hop++) for (var hop = 0; hop <= MaxRedirects; hop++)
@@ -242,20 +384,29 @@ namespace PrivaPub.Infrastructure.Http
request.Headers.Accept.ParseAdd("video/*, audio/*, image/*"); request.Headers.Accept.ParseAdd("video/*, audio/*, image/*");
request.Headers.Range = range; request.Headers.Range = range;
var response = await _httpClientFactory.CreateClient(ClientName).SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token); var response = await _httpClientFactory.CreateClient(ClientName).SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token);
exchange.Answered(response);
if (IsRedirect(response.StatusCode)) if (IsRedirect(response.StatusCode))
{ {
var location = response.Headers.Location; var location = response.Headers.Location;
response.Dispose(); response.Dispose();
var next = location == default ? default : location.IsAbsoluteUri ? location : new Uri(target, location); var next = location == default ? default : location.IsAbsoluteUri ? location : new Uri(target, location);
if (!IsAllowed(next)) if (!IsAllowed(next))
{
exchange.Refused("bad-redirect");
return default; return default;
}
target = next; target = next;
exchange.Hops++;
continue; continue;
} }
var mediaType = response.Content.Headers.ContentType?.MediaType?.ToLowerInvariant(); var mediaType = response.Content.Headers.ContentType?.MediaType?.ToLowerInvariant();
if (!response.IsSuccessStatusCode || mediaType == default || mediaType.Contains("svg") if (!response.IsSuccessStatusCode || mediaType == default || mediaType.Contains("svg")
|| !(mediaType.StartsWith("video/") || mediaType.StartsWith("audio/") || mediaType.StartsWith("image/") || mediaType == "application/octet-stream")) || !(mediaType.StartsWith("video/") || mediaType.StartsWith("audio/") || mediaType.StartsWith("image/") || mediaType == "application/octet-stream"))
{ {
if (response.IsSuccessStatusCode)
exchange.Refused("content-type");
else
exchange.Failed(StatusReason(response));
response.Dispose(); response.Dispose();
return default; return default;
} }
@@ -265,6 +416,7 @@ namespace PrivaPub.Infrastructure.Http
} }
catch (Exception ex) when (ex is HttpRequestException or BlockedDestinationException or OperationCanceledException && !token.IsCancellationRequested) catch (Exception ex) when (ex is HttpRequestException or BlockedDestinationException or OperationCanceledException && !token.IsCancellationRequested)
{ {
exchange.Failed(ex switch { BlockedDestinationException => "private-address", OperationCanceledException => "timeout", _ => "network" });
_logger.LogInformation("Media stream {Url} refused: {Reason}", url, ex.Message); _logger.LogInformation("Media stream {Url} refused: {Reason}", url, ex.Message);
return default; return default;
} }
@@ -311,8 +463,12 @@ namespace PrivaPub.Infrastructure.Http
static string NegativeKey(Uri target) => "federation-http:refused:" + target.AbsoluteUri; static string NegativeKey(Uri target) => "federation-http:refused:" + target.AbsoluteUri;
FetchedJson Refuse(string negativeKey, string url, string reason, bool transient = false) FetchedJson Refuse(string negativeKey, string url, string reason, Exchange exchange, string code, bool transient = false)
{ {
if (transient)
exchange.Failed(code);
else
exchange.Refused(code);
_cache.Set(negativeKey, transient, NegativeCacheLifetime); _cache.Set(negativeKey, transient, NegativeCacheLifetime);
_logger.LogInformation("GET {Url} refused: {Reason}", url, reason); _logger.LogInformation("GET {Url} refused: {Reason}", url, reason);
return default; return default;
+39
View File
@@ -0,0 +1,39 @@
namespace PrivaPub.Infrastructure.Http
{
public static class HttpScope
{
public const string Request = "request";
sealed record Tags(string Purpose, string Trigger, bool Crawl);
static readonly AsyncLocal<Tags> current = new();
public static string Purpose => current.Value?.Purpose;
public static string Trigger => current.Value?.Trigger ?? Request;
public static bool Crawling => current.Value?.Crawl == true;
public static IDisposable For(string purpose) => Push(tags => tags with { Purpose = purpose });
public static IDisposable Default(string purpose) => Push(tags => tags with { Purpose = tags.Purpose ?? purpose });
public static IDisposable Triggered(string trigger) => Push(tags => tags with { Trigger = trigger });
public static IDisposable Crawl() => Push(tags => tags with { Crawl = true });
static IDisposable Push(Func<Tags, Tags> change)
{
var previous = current.Value;
current.Value = change(previous ?? new Tags(default, default, false));
return new Restore(previous);
}
sealed class Restore : IDisposable
{
readonly Tags _previous;
public Restore(Tags previous) => _previous = previous;
public void Dispose() => current.Value = _previous;
}
}
}
@@ -1,3 +1,4 @@
using PrivaPub.Infrastructure.Http;
using PrivaPub.Models.Jobs; using PrivaPub.Models.Jobs;
using System.Collections.Concurrent; using System.Collections.Concurrent;
@@ -73,6 +74,7 @@ namespace PrivaPub.Infrastructure.Jobs
JobOutcome outcome; JobOutcome outcome;
try try
{ {
using var scope = HttpScope.Triggered(handler.Kind.ToString().ToLowerInvariant());
outcome = await handler.Handle(job, stoppingToken); outcome = await handler.Handle(job, stoppingToken);
} }
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
+21
View File
@@ -2,6 +2,8 @@ using Microsoft.AspNetCore.RateLimiting;
using PrivaPub.Federation.Objects; using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Signing; using PrivaPub.Federation.Signing;
using PrivaPub.Infrastructure.Statistics;
using PrivaPub.Models.Statistics;
using System.Threading.RateLimiting; using System.Threading.RateLimiting;
@@ -16,6 +18,25 @@ namespace PrivaPub.Infrastructure
service.AddRateLimiter(options => service.AddRateLimiter(options =>
{ {
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.OnRejected = (context, _) =>
{
var http = context.HttpContext;
var ledger = http.RequestServices.GetService<IInteractionLedger>();
var policy = http.GetEndpoint()?.Metadata.GetMetadata<EnableRateLimitingAttribute>()?.PolicyName;
if (policy == Inbox)
ledger?.Record(new InteractionEvent
{
Channel = Interactions.Receive,
Host = Interactions.HostOf(SenderOrigin(http.Request)),
Status = StatusCodes.Status429TooManyRequests,
Outcome = Interactions.Refused,
Reason = "rate-limited",
Inbox = http.Request.Path.Value?.EndsWith("/mouth", StringComparison.Ordinal) == true ? "personal" : "shared"
}, hostClaimed: true);
else
ledger?.CountServer(ServerSections.Client, $"{policy ?? "unknown"}:429");
return ValueTask.CompletedTask;
};
options.AddPolicy(Accounts, context => RateLimitPartition.GetFixedWindowLimiter( options.AddPolicy(Accounts, context => RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown", context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions { PermitLimit = 10, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 })); _ => new FixedWindowRateLimiterOptions { PermitLimit = 10, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 }));
@@ -0,0 +1,250 @@
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.Statistics;
using System.Collections.Concurrent;
using System.Threading.Channels;
namespace PrivaPub.Infrastructure.Statistics
{
public interface IInteractionLedger
{
void Record(InteractionEvent interaction, string actorUri = default, bool hostClaimed = false);
void Count(string host, string key, long bytes = 0);
void CountServer(string section, string key, int? latencyMs = default);
long Dropped { get; }
}
public static class ServerSections
{
public const string Client = "client";
public const string Served = "served";
}
public class InteractionLedger : BackgroundService, IInteractionLedger
{
const int Capacity = 10_000;
const int BatchSize = 1000;
static readonly TimeSpan BatchWait = TimeSpan.FromSeconds(2);
static readonly TimeSpan CounterInterval = TimeSpan.FromSeconds(10);
static readonly TimeSpan KnownHostsAge = TimeSpan.FromMinutes(10);
sealed record Pending(InteractionEvent Event, string ActorUri, bool HostClaimed);
readonly Channel<Pending> _channel = Channel.CreateBounded<Pending>(new BoundedChannelOptions(Capacity)
{
FullMode = BoundedChannelFullMode.Wait,
SingleWriter = false,
SingleReader = false
});
readonly InteractionSalts _salts;
readonly IOptionsMonitor<StatisticsOptions> _options;
readonly ILogger<InteractionLedger> _logger;
readonly SemaphoreSlim _flushing = new(1, 1);
readonly ConcurrentDictionary<(DateTime Day, string Host, string Key), long> _reads = new();
readonly ConcurrentDictionary<(DateTime Day, string Field, string Key), long> _server = new();
HashSet<string> _knownHosts = new(StringComparer.Ordinal);
DateTime _knownHostsAt = DateTime.MinValue;
long _dropped;
long _droppedReported;
long _written;
long _failed;
public InteractionLedger(InteractionSalts salts, IOptionsMonitor<StatisticsOptions> options, ILogger<InteractionLedger> logger)
{
_salts = salts;
_options = options;
_logger = logger;
}
public long Dropped => Interlocked.Read(ref _dropped);
public void Record(InteractionEvent interaction, string actorUri = default, bool hostClaimed = false)
{
try
{
if (interaction == default || !_options.CurrentValue.Enabled)
return;
if (!_channel.Writer.TryWrite(new Pending(interaction, actorUri, hostClaimed)))
Interlocked.Increment(ref _dropped);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "An interaction could not be recorded");
}
}
public void Count(string host, string key, long bytes = 0)
{
if (!_options.CurrentValue.Enabled || string.IsNullOrEmpty(key))
return;
var day = DateTime.UtcNow.Date;
host = Interactions.Host(host);
key = Key(key);
_reads.AddOrUpdate((day, host, key), 1, (_, count) => count + 1);
if (bytes > 0)
_reads.AddOrUpdate((day, host, key + ":bytes"), bytes, (_, count) => count + bytes);
}
public void CountServer(string section, string key, int? latencyMs = default)
{
if (!_options.CurrentValue.Enabled || string.IsNullOrEmpty(key))
return;
var day = DateTime.UtcNow.Date;
var field = section == ServerSections.Served ? nameof(ServerDay.Served) : nameof(ServerDay.Client);
_server.AddOrUpdate((day, field, Key(key)), 1, (_, count) => count + 1);
if (latencyMs is { } latency && field == nameof(ServerDay.Client))
_server.AddOrUpdate((day, nameof(ServerDay.ClientLatency), Interactions.Latency(latency)), 1, (_, count) => count + 1);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var countersAt = DateTime.UtcNow;
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var wait = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
wait.CancelAfter(BatchWait);
try
{
await _channel.Reader.WaitToReadAsync(wait.Token);
}
catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested)
{
}
await FlushEvents(stoppingToken);
if (DateTime.UtcNow - countersAt >= CounterInterval)
{
countersAt = DateTime.UtcNow;
await FlushCounters(stoppingToken);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
}
catch (Exception ex)
{
_logger.LogWarning(ex, "The interaction ledger could not flush");
await Task.Delay(BatchWait, CancellationToken.None);
}
}
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
await base.StopAsync(cancellationToken);
using var budget = new CancellationTokenSource(TimeSpan.FromSeconds(5));
try
{
await Flush(budget.Token);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "The interaction ledger could not flush on shutdown");
}
}
public async Task Flush(CancellationToken token)
{
await FlushEvents(token);
await FlushCounters(token);
}
async Task FlushEvents(CancellationToken token)
{
await _flushing.WaitAsync(token);
try
{
while (_channel.Reader.TryPeek(out _))
{
var batch = new List<InteractionEvent>(BatchSize);
while (batch.Count < BatchSize && _channel.Reader.TryRead(out var pending))
batch.Add(await Prepare(pending, token));
if (batch.Count == 0)
return;
try
{
await DB.Default.InsertAsync(batch, token);
Interlocked.Add(ref _written, batch.Count);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Interlocked.Add(ref _failed, batch.Count);
_logger.LogWarning(ex, "{Count} interactions could not be written", batch.Count);
}
}
}
finally
{
_flushing.Release();
}
}
async Task<InteractionEvent> Prepare(Pending pending, CancellationToken token)
{
var interaction = Interactions.Sanitize(pending.Event);
if (pending.HostClaimed && interaction.Host != Interactions.Unknown && !(await KnownHosts(token)).Contains(interaction.Host))
interaction.Host = Interactions.Unknown;
if (pending.ActorUri != default)
interaction.ActorHash = InteractionSalts.Hash(await _salts.For(interaction.At, token), pending.ActorUri);
return interaction;
}
async Task<HashSet<string>> KnownHosts(CancellationToken token)
{
if (DateTime.UtcNow - _knownHostsAt < KnownHostsAge)
return _knownHosts;
var hosts = await DB.Default.Find<RemoteInstance, string>().Project(i => i.Host).ExecuteAsync(token);
_knownHosts = new HashSet<string>(hosts.Where(h => h != default), StringComparer.Ordinal);
_knownHostsAt = DateTime.UtcNow;
return _knownHosts;
}
async Task FlushCounters(CancellationToken token)
{
foreach (var key in _reads.Keys.ToList())
{
if (!_reads.TryRemove(key, out var count) || count == 0)
continue;
await DB.Default.Update<InstanceDay>()
.Match(d => d.Day == key.Day && d.Host == key.Host)
.Modify(b => b.Inc($"{nameof(InstanceDay.Reads)}.{key.Key}", count))
.Option(o => o.IsUpsert = true)
.ExecuteAsync(token);
}
var day = DateTime.UtcNow.Date;
var written = Interlocked.Exchange(ref _written, 0);
var failed = Interlocked.Exchange(ref _failed, 0);
var dropped = Interlocked.Read(ref _dropped);
var newlyDropped = dropped - Interlocked.Exchange(ref _droppedReported, dropped);
var server = _server.Keys.ToList();
if (server.Count == 0 && written == 0 && failed == 0 && newlyDropped == 0)
return;
var update = DB.Default.Update<ServerDay>()
.Match(d => d.Day == day)
.Modify(b => b.Inc(d => d.LedgerWritten, written))
.Modify(b => b.Inc(d => d.LedgerFailed, failed))
.Modify(b => b.Inc(d => d.LedgerDropped, newlyDropped))
.Option(o => o.IsUpsert = true);
await update.ExecuteAsync(token);
foreach (var key in server)
{
if (!_server.TryRemove(key, out var count) || count == 0)
continue;
await DB.Default.Update<ServerDay>()
.Match(d => d.Day == key.Day)
.Modify(b => b.Inc($"{key.Field}.{key.Key}", count))
.Option(o => o.IsUpsert = true)
.ExecuteAsync(token);
}
}
static string Key(string key) => key.Replace('.', '_').Replace('$', '_');
}
}
@@ -0,0 +1,44 @@
using MongoDB.Entities;
using PrivaPub.Models.Statistics;
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
namespace PrivaPub.Infrastructure.Statistics
{
public class InteractionSalts
{
readonly ConcurrentDictionary<DateTime, byte[]> _known = new();
public async Task<byte[]> For(DateTime day, CancellationToken token)
{
day = day.Date;
if (_known.TryGetValue(day, out var cached))
return cached;
InteractionSalt salt;
if (day == DateTime.UtcNow.Date)
salt = await DB.Default.UpdateAndGet<InteractionSalt>()
.Match(s => s.Day == day)
.Modify(b => b.SetOnInsert(s => s.Key, Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))))
.Modify(b => b.SetOnInsert(s => s.ExpiresAt, day.AddHours(30)))
.Option(o => o.IsUpsert = true)
.ExecuteAsync(token);
else
salt = await DB.Default.Find<InteractionSalt>().Match(s => s.Day == day).ExecuteFirstAsync(token);
if (salt?.Key == default)
return default;
var key = Convert.FromBase64String(salt.Key);
foreach (var old in _known.Keys.Where(k => k < day.AddDays(-1)))
_known.TryRemove(old, out _);
_known[day] = key;
return key;
}
public static string Hash(byte[] salt, string actorUri) =>
salt == default || string.IsNullOrEmpty(actorUri)
? default
: Convert.ToBase64String(HMACSHA256.HashData(salt, Encoding.UTF8.GetBytes(actorUri))[..12]).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
}
@@ -0,0 +1,107 @@
using PrivaPub.Models.Statistics;
using System.Text.RegularExpressions;
namespace PrivaPub.Infrastructure.Statistics
{
public static partial class Interactions
{
public const string Receive = "recv";
public const string In = "in";
public const string Out = "out";
public const string Http = "http";
public const string Preview = "preview";
public const string Crawl = "crawl";
public const string Queued = "queued";
public const string Refused = "refused";
public const string Accepted = "accepted";
public const string Dropped = "dropped";
public const string Rejected = "rejected";
public const string Ok = "ok";
public const string Failed = "failed";
public const string Deferred = "deferred";
public const string Retry = "retry";
public const string Dead = "dead";
public const string Public = "public";
public const string Unlisted = "unlisted";
public const string Private = "private";
public const string None = "none";
public const string Unknown = "-";
public const string Other = "other";
static readonly HashSet<string> Channels = new(StringComparer.Ordinal) { Receive, In, Out, Http, Preview, Crawl };
static readonly HashSet<string> Audiences = new(StringComparer.Ordinal) { Public, Unlisted, Private, None };
static readonly HashSet<string> LocalKinds = new(StringComparer.Ordinal) { "person", "group", "application" };
static readonly HashSet<string> Inboxes = new(StringComparer.Ordinal) { "shared", "personal" };
static readonly int[] LatencyBuckets = { 50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000 };
static readonly int[] WaitBuckets = { 1, 10, 60, 600, 3600, 21600, 86400 };
public static InteractionEvent Sanitize(InteractionEvent e)
{
e.Host = Host(e.Host);
e.Channel = Channels.Contains(e.Channel) ? e.Channel : Other;
e.Activity = TypeName(e.Activity);
e.Object = TypeName(e.Object);
e.Purpose = Word(e.Purpose);
e.Trigger = Word(e.Trigger);
e.Outcome = Word(e.Outcome);
e.Reason = Word(e.Reason);
e.Audience = e.Audience != default && Audiences.Contains(e.Audience) ? e.Audience : default;
e.LocalKind = e.Audience is Public or Unlisted && e.LocalKind != default && LocalKinds.Contains(e.LocalKind) ? e.LocalKind : default;
e.Inbox = e.Inbox != default && Inboxes.Contains(e.Inbox) ? e.Inbox : default;
e.Signature = e.Signature == default ? default : SignatureName().IsMatch(e.Signature) ? e.Signature : Other;
e.Features = e.Features?.Select(Feature).Where(f => f != default).Distinct().Take(32).ToList();
if (e.Features?.Count == 0)
e.Features = default;
return e;
}
public static string Host(string host)
{
host = host?.Trim().TrimEnd('.').ToLowerInvariant();
return string.IsNullOrEmpty(host) || host.Length > 253 || !HostName().IsMatch(host) ? Unknown : host;
}
public static string HostOf(string uri) =>
Uri.TryCreate(uri, UriKind.Absolute, out var parsed) ? Host(parsed.Host) : Unknown;
public static string Latency(int milliseconds) => Bucket("le", milliseconds, LatencyBuckets, "ms");
public static string Wait(int milliseconds) => Bucket("le", milliseconds / 1000, WaitBuckets, "s");
static string Bucket(string prefix, int value, int[] limits, string unit)
{
foreach (var limit in limits)
if (value <= limit)
return $"{prefix}{limit}{unit}";
return "inf";
}
static string TypeName(string value) =>
value == default ? default : TypeWord().IsMatch(value) ? value : Other;
static string Word(string value) =>
value == default ? default : KebabWord().IsMatch(value) ? value : Other;
static string Feature(string value) =>
value == default ? default : FeatureName().IsMatch(value) ? value : Other;
[GeneratedRegex("^[A-Za-z][A-Za-z0-9_-]{0,31}$")]
private static partial Regex TypeWord();
[GeneratedRegex("^[a-z0-9][a-z0-9-]{0,39}$")]
private static partial Regex KebabWord();
[GeneratedRegex("^[a-z0-9][a-z0-9:-]{0,39}$")]
private static partial Regex SignatureName();
[GeneratedRegex("^[a-z0-9][a-z0-9:/+.-]{0,63}$")]
private static partial Regex FeatureName();
[GeneratedRegex("^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$")]
private static partial Regex HostName();
}
}
@@ -0,0 +1,9 @@
namespace PrivaPub.Infrastructure.Statistics
{
public class StatisticsOptions
{
public bool Enabled { get; set; } = true;
public string GeoDirectory { get; set; } = "/var/lib/privapub/geo";
public int PublicCityMinUsers { get; set; } = 10;
}
}
@@ -0,0 +1,78 @@
using System.Diagnostics;
namespace PrivaPub.Infrastructure.Statistics
{
public static class TrafficMeter
{
public static IApplicationBuilder UseTrafficMeter(this IApplicationBuilder app) =>
app.Use(async (context, next) =>
{
var started = Stopwatch.GetTimestamp();
try
{
await next(context);
}
finally
{
Count(context, (int)Stopwatch.GetElapsedTime(started).TotalMilliseconds);
}
});
static void Count(HttpContext context, int latencyMs)
{
var ledger = context.RequestServices?.GetService<IInteractionLedger>();
if (ledger == default)
return;
var path = context.Request.Path;
var template = (context.GetEndpoint() as RouteEndpoint)?.RoutePattern.RawText;
var method = context.Request.Method;
var status = context.Response.StatusCode;
if (path.StartsWithSegments("/api") || path.StartsWithSegments("/oauth") || path.StartsWithSegments("/clientapi"))
{
ledger.CountServer(ServerSections.Client, $"{Group(template)}:{method}:{status / 100}xx", latencyMs);
return;
}
if (method != HttpMethods.Get && method != HttpMethods.Head)
return;
var served = Served(template, path);
if (served != default)
ledger.CountServer(ServerSections.Served,
$"{served}:{status}:{(context.Request.Headers.ContainsKey("Signature") || context.Request.Headers.ContainsKey("Signature-Input") ? "signed" : "unsigned")}");
}
public static string Group(string template)
{
if (template == default)
return "unmatched";
var literals = template.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries)
.TakeWhile(segment => !segment.Contains('{'))
.Take(3);
var group = string.Join('/', literals);
return group.Length == 0 ? "unmatched" : group;
}
public static string Served(string template, PathString path)
{
if (path.StartsWithSegments("/.well-known/webfinger"))
return "webfinger";
if (path.StartsWithSegments("/.well-known/nodeinfo") || path.StartsWithSegments("/nodeinfo"))
return "nodeinfo";
if (!path.StartsWithSegments("/peasants") && !path.StartsWithSegments("/users"))
return default;
var segments = (template ?? path.Value ?? string.Empty).Trim('/').Split('/');
return segments.Length switch
{
<= 2 => "actor",
_ => segments[2] switch
{
"anus" => "outbox",
"scribbles" or "whispers" => "object",
"grunts" => "activity",
"parrot-licences" => "licence",
"mouth" or "human-centipede" => default,
_ => "collection"
}
};
}
}
}
@@ -27,6 +27,7 @@ using PrivaPub.Domain.Statuses;
using PrivaPub.Domain.Timelines; using PrivaPub.Domain.Timelines;
using PrivaPub.Infrastructure.Http; using PrivaPub.Infrastructure.Http;
using PrivaPub.Infrastructure.Jobs; using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Infrastructure.Statistics;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace PrivaPub.Middleware namespace PrivaPub.Middleware
@@ -103,6 +104,14 @@ namespace PrivaPub.Middleware
.AddSingleton<IJobHandler, DeliveryJobHandler>() .AddSingleton<IJobHandler, DeliveryJobHandler>()
.AddHostedService<JobWorker>(); .AddHostedService<JobWorker>();
} }
public static IServiceCollection PrivaPubStatisticsConfiguration(this IServiceCollection service, IConfiguration configuration) =>
service
.Configure<StatisticsOptions>(configuration.GetSection("Statistics"))
.AddSingleton<InteractionSalts>()
.AddSingleton<InteractionLedger>()
.AddSingleton<IInteractionLedger>(services => services.GetRequiredService<InteractionLedger>())
.AddHostedService(services => services.GetRequiredService<InteractionLedger>());
public static IServiceCollection PrivaPubAuthServicesConfiguration(this IServiceCollection service, IConfiguration configuration) public static IServiceCollection PrivaPubAuthServicesConfiguration(this IServiceCollection service, IConfiguration configuration)
{ {
return service return service
@@ -30,6 +30,8 @@ namespace PrivaPub.Models.Federation
public DateTime? Published { get; set; } public DateTime? Published { get; set; }
public DateTime? Updated { get; set; } public DateTime? Updated { get; set; }
public List<ObjectRevision> Revisions { get; set; } = new(); public List<ObjectRevision> Revisions { get; set; } = new();
public List<string> Extensions { get; set; }//ObjectFeatures, stored for statistics; null on records older than v1.16
public List<string> ContextNamespaces { get; set; }
} }
public class ObjectRevision public class ObjectRevision
+18
View File
@@ -0,0 +1,18 @@
using MongoDB.Entities;
namespace PrivaPub.Models.Statistics
{
public class InstanceDay : Entity
{
public DateTime Day { get; set; }
public string Host { get; set; }
public Dictionary<string, long> Counters { get; set; } = new();//admin: every key
public Dictionary<string, long> PublicCounters { get; set; } = new();//all a public page may ever read
public Dictionary<string, long> Latency { get; set; } = new();
public Dictionary<string, long> Waits { get; set; } = new();
public Dictionary<string, long> Bytes { get; set; } = new();
public Dictionary<string, long> Reads { get; set; } = new();//caused by reading (media proxy, lookups): counted live, never by the rollup
public int Accounts { get; set; }//distinct remote accounts that day
public DateTime? RolledUpAt { get; set; }
}
}
@@ -0,0 +1,33 @@
using MongoDB.Entities;
namespace PrivaPub.Models.Statistics
{
public class InteractionEvent : Entity
{
public static readonly TimeSpan Retention = TimeSpan.FromDays(90);
public DateTime At { get; set; } = DateTime.UtcNow;
public string Host { get; set; }//the remote server, lowercase; "-" when unverified and unknown
public string Channel { get; set; }//recv | in | out | http | preview | crawl
public string Activity { get; set; }//Create, Follow, ...; "other" when it is no plain type name
public string Object { get; set; }//Note, Article, Person, ...
public string Purpose { get; set; }//http and crawl: actor, key, object, context, webfinger, nodeinfo, ...
public string Trigger { get; set; }//http: what caused the request (inbox, deliver, describe, request, ...)
public string Outcome { get; set; }//queued, refused, accepted, dropped, ok, failed, deferred, retry, dead
public string Reason { get; set; }
public int? Status { get; set; }
public int? LatencyMs { get; set; }
public int? WaitMs { get; set; }//in: since it reached the inbox; out: since it was queued
public long? Bytes { get; set; }
public int? Attempt { get; set; }
public int? Redirects { get; set; }
public string Audience { get; set; }//public | unlisted | private | none
public string LocalKind { get; set; }//person | group | application, only on public and unlisted traffic
public string Inbox { get; set; }//shared | personal
public string Signature { get; set; }//"cavage:rsa-sha256", "none"
public string ActorHash { get; set; }//keyed with that day's salt, never reversible, never linkable across days
public List<string> Features { get; set; }
public long? AgeSeconds { get; set; }//Update and Delete: how old the object was
public bool Crawl { get; set; }
}
}
@@ -0,0 +1,11 @@
using MongoDB.Entities;
namespace PrivaPub.Models.Statistics
{
public class InteractionSalt : Entity
{
public DateTime Day { get; set; }
public string Key { get; set; }
public DateTime ExpiresAt { get; set; }//a safety net: the day's rollup deletes it
}
}
+17
View File
@@ -0,0 +1,17 @@
using MongoDB.Entities;
namespace PrivaPub.Models.Statistics
{
public class ServerDay : Entity
{
public DateTime Day { get; set; }
public Dictionary<string, long> Client { get; set; } = new();//client API per endpoint group, method and status class
public Dictionary<string, long> ClientLatency { get; set; } = new();
public Dictionary<string, long> Served { get; set; } = new();//our own documents fetched, never per server
public long LedgerWritten { get; set; }
public long LedgerDropped { get; set; }
public long LedgerFailed { get; set; }
public int Accounts { get; set; }
public int Hosts { get; set; }
}
}
+1
View File
@@ -66,6 +66,7 @@ namespace PrivaPub.Models.User
public string PictureDescription { get; set; } public string PictureDescription { get; set; }
public string HeaderDescription { get; set; } public string HeaderDescription { get; set; }
public DateTime? Published { get; set; }//the actor's own published public DateTime? Published { get; set; }//the actor's own published
public List<string> Features { get; set; }//protocol extensions its document uses, for statistics (ActorFeatures)
public string ActorURI { get; set; }//id public string ActorURI { get; set; }//id
public string PublicKeyId { get; set; } public string PublicKeyId { get; set; }
+3
View File
@@ -17,6 +17,7 @@ using PrivaPub.Infrastructure;
using PrivaPub.Infrastructure.Cli; using PrivaPub.Infrastructure.Cli;
using PrivaPub.Infrastructure.Data; using PrivaPub.Infrastructure.Data;
using PrivaPub.Infrastructure.Http; using PrivaPub.Infrastructure.Http;
using PrivaPub.Infrastructure.Statistics;
using PrivaPub.Middleware; using PrivaPub.Middleware;
using PrivaPub.Models; using PrivaPub.Models;
using PrivaPub.Services; using PrivaPub.Services;
@@ -56,6 +57,7 @@ try
.PrivaPubDataBaseConfiguration() .PrivaPubDataBaseConfiguration()
.PrivaPubServicesConfiguration() .PrivaPubServicesConfiguration()
.PrivaPubFederationConfiguration(builder.Configuration) .PrivaPubFederationConfiguration(builder.Configuration)
.PrivaPubStatisticsConfiguration(builder.Configuration)
.PrivaPubCORSConfiguration() .PrivaPubCORSConfiguration()
.PrivaPubRateLimiting() .PrivaPubRateLimiting()
.PrivaPubOAuth(builder.Environment) .PrivaPubOAuth(builder.Environment)
@@ -149,6 +151,7 @@ try
app.UseRequestLocalization(await localizationService.Get()); app.UseRequestLocalization(await localizationService.Get());
app.UseRouting(); app.UseRouting();
app.UseTrafficMeter();
app.UseRateLimiter(); app.UseRateLimiter();
app.UseAuthentication(); app.UseAuthentication();
+1 -1
View File
@@ -12,7 +12,7 @@ Written 2026-10-01 from the original 2023 code, the decePubClient UI, a federati
- [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)
- [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 - [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) - [ ] T/M Test sweep, the full pasture and the interaction ledger (see "Sweep and statistics" below). T1–T4: v1.15.1, deployed and verified 2026-10-03 (CI runs all tests on a throwaway mongod; nothing answers 500)
- [ ] 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