Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90a7e38ce9 | ||
|
|
d37999970c | ||
|
|
e247001bdb | ||
|
|
ac7cbd136b | ||
|
|
4fa53f63bd | ||
|
|
15cd034b29 |
No files matched your search
@@ -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")]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,7 +27,12 @@ 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)
|
||||||
|
{
|
||||||
|
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);
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+19
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
var bytes = await ReadBounded(response.Content, (int)Math.Min(maxBytes, int.MaxValue), timeout.Token);
|
|
||||||
return bytes == default ? default : (bytes, mediaType);
|
|
||||||
}
|
}
|
||||||
|
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);
|
||||||
|
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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user