Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81de470f64 | ||
|
|
b691c6766d |
No files matched your search
@@ -199,6 +199,10 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
|
|||||||
4. Answer with `Json(...)` (snake_case, explicit nulls) or `Error(status, message)`; page lists with `Page` and `Link`.
|
4. Answer with `Json(...)` (snake_case, explicit nulls) or `Error(status, message)`; page lists with `Page` and `Link`.
|
||||||
5. Unsupported features answer empty lists or 422 with a message, never 404 or 500, so clients degrade.
|
5. Unsupported features answer empty lists or 422 with a message, never 404 or 500, so clients degrade.
|
||||||
6. Advertise `4.2.0 (compatible; PrivaPub)` until grouped notifications exist.
|
6. Advertise `4.2.0 (compatible; PrivaPub)` until grouped notifications exist.
|
||||||
|
7. **What a Mastodon `Status` cannot say goes in `Status.privapub`** (`PrivaPubStatus`): object type, title, excerpt,
|
||||||
|
cover, the author's source, link, video, audio and event details, and up/down votes. Every media URL in it goes
|
||||||
|
through the proxy; only page links (`link.url`, an event's online link) point at the remote site, because following
|
||||||
|
one is the reader's choice.
|
||||||
|
|
||||||
## Media invariants
|
## Media invariants
|
||||||
|
|
||||||
|
|||||||
+9
-2
@@ -87,12 +87,14 @@ Received:
|
|||||||
| `Follow` | follows an avatar or community; `Accept` is sent unless the community approves members by hand |
|
| `Follow` | follows an avatar or community; `Accept` is sent unless the community approves members by hand |
|
||||||
| `Accept{Follow}`, `Reject{Follow}` | completes or ends a follow an avatar requested |
|
| `Accept{Follow}`, `Reject{Follow}` | completes or ends a follow an avatar requested |
|
||||||
| `Undo{Follow, Like, Announce}` | reverses it |
|
| `Undo{Follow, Like, Announce}` | reverses it |
|
||||||
| `Create{Note, Article, Page, Question, …}` | stored when a local avatar follows the author, is addressed or mentioned, when it replies to a local post, or when it is addressed to a community the author follows; a public parent is fetched to complete the thread |
|
| `Create{Note, Article, Page, Question, Video, Audio, Event, ChatMessage, …}` | stored when a local avatar follows the author, is addressed or mentioned, when it replies to a local post, or when it is addressed to a community the author follows; a public parent is fetched to complete the thread |
|
||||||
| `Update{Note}` | replaces the content; the previous version is kept |
|
| `Update{Note}` | replaces the content; the previous version is kept |
|
||||||
| `Update{Person}` | refetches the actor |
|
| `Update{Person}` | refetches the actor |
|
||||||
| `Like` | counted and notified, on posts the liker could see |
|
| `Like` | counted and notified, on posts the liker could see |
|
||||||
|
| `Dislike` | counted as a downvote (Lemmy, PieFed, Mbin, Friendica); `Undo` takes it back |
|
||||||
|
| `Join` | answered with `Ignore`: PrivaPub hosts no events yet (FEP-8a8e) |
|
||||||
| `Announce` | counted and notified for local posts; shown to followers of the announcer, with the original refetched from its origin |
|
| `Announce` | counted and notified for local posts; shown to followers of the announcer, with the original refetched from its origin |
|
||||||
| `Delete` | deletes the object, or the actor and its follows |
|
| `Delete` | deletes the object, or the actor and its follows; a deleted object id is remembered for 90 days, so a late `Create` cannot bring it back |
|
||||||
| `Flag` | becomes a report for this server's moderators |
|
| `Flag` | becomes a report for this server's moderators |
|
||||||
|
|
||||||
Sent: `Follow`, `Undo{Follow}`, `Create{Note}`, `Update{Note}`, `Update{Person}`, `Delete{Tombstone}`, `Accept{Follow}`,
|
Sent: `Follow`, `Undo{Follow}`, `Create{Note}`, `Update{Note}`, `Update{Person}`, `Delete{Tombstone}`, `Accept{Follow}`,
|
||||||
@@ -130,6 +132,11 @@ Posts with a location (shown to nearby users of this server) never leave the ser
|
|||||||
- **Fetching.** All fetches are signed by the instance actor. They go only to public addresses, follow at most three
|
- **Fetching.** All fetches are signed by the instance actor. They go only to public addresses, follow at most three
|
||||||
redirects and read at most 1 MB.
|
redirects and read at most 1 MB.
|
||||||
- **HTML.** Received HTML is sanitised to Mastodon's allowlist.
|
- **HTML.** Received HTML is sanitised to Mastodon's allowlist.
|
||||||
|
- **Keys we cannot fetch for now.** When a sender's key cannot be fetched because its server timed out or answered
|
||||||
|
5xx, the inbox answers 503 with `Retry-After: 300` rather than 401.
|
||||||
|
- **Activity ids.** `Create` and `Announce` ids dereference. `Follow`, `Like`, `Block` and the `Accept`, `Reject` and
|
||||||
|
`Undo` that answer them do not, because serving them would reveal who follows, likes and blocks whom; they are always
|
||||||
|
sent with their object embedded.
|
||||||
- **Delivery.** Failed deliveries are retried with Mastodon's backoff (16 attempts). A host that keeps failing is paused,
|
- **Delivery.** Failed deliveries are retried with Mastodon's backoff (16 attempts). A host that keeps failing is paused,
|
||||||
starting at an hour and growing to a week.
|
starting at an hour and growing to a week.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
using MongoDB.Entities;
|
||||||
|
|
||||||
|
using PrivaPub.ClientModels.Post;
|
||||||
|
using PrivaPub.Federation.Objects;
|
||||||
|
using PrivaPub.Models.Post;
|
||||||
|
using PrivaPub.Tests.Support;
|
||||||
|
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace PrivaPub.Tests.Federation
|
||||||
|
{
|
||||||
|
[Trait("Category", "Integration")]
|
||||||
|
public sealed class InboundRoutingTests : 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);
|
||||||
|
|
||||||
|
static string NewId(RemoteActor actor, string kind) => $"{Origin(actor)}/{kind}/{Guid.NewGuid():N}";
|
||||||
|
|
||||||
|
async Task<Post> LocalPost(string text)
|
||||||
|
{
|
||||||
|
var (root, alice) = await _harness.Persona("alice");
|
||||||
|
await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = text }, TestContext.Current.CancellationToken);
|
||||||
|
return await DB.Default.Find<Post>().Match(p => p.GroupUserId == alice.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_downvote_counts_and_its_undo_takes_it_back()
|
||||||
|
{
|
||||||
|
var token = TestContext.Current.CancellationToken;
|
||||||
|
var post = await LocalPost("vote on me");
|
||||||
|
var lemmy = new RemoteActor(_harness.Peer, "voter");
|
||||||
|
var dislike = new JsonObject { ["id"] = NewId(lemmy, "activities/dislike"), ["type"] = "Dislike", ["actor"] = lemmy.Id, ["object"] = post.ObjectURI };
|
||||||
|
|
||||||
|
await _harness.Deliver(lemmy, "/human-centipede", dislike);
|
||||||
|
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).DownvotesCount);
|
||||||
|
|
||||||
|
await _harness.Deliver(lemmy, "/human-centipede", new JsonObject
|
||||||
|
{
|
||||||
|
["id"] = NewId(lemmy, "activities/undo"), ["type"] = "Undo", ["actor"] = lemmy.Id, ["object"] = dislike.DeepClone()
|
||||||
|
});
|
||||||
|
Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).DownvotesCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_delete_that_arrives_first_keeps_the_post_from_coming_back()
|
||||||
|
{
|
||||||
|
var token = TestContext.Current.CancellationToken;
|
||||||
|
var (_, alice) = await _harness.Persona("alice");
|
||||||
|
var mallory = new RemoteActor(_harness.Peer, "mallory");
|
||||||
|
var noteId = NewId(mallory, "notes");
|
||||||
|
|
||||||
|
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
|
||||||
|
{
|
||||||
|
["id"] = NewId(mallory, "activities/delete"), ["type"] = "Delete", ["actor"] = mallory.Id,
|
||||||
|
["object"] = new JsonObject { ["id"] = noteId, ["type"] = "Tombstone" }
|
||||||
|
});
|
||||||
|
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
|
||||||
|
{
|
||||||
|
["id"] = NewId(mallory, "activities/create"), ["type"] = "Create", ["actor"] = mallory.Id,
|
||||||
|
["object"] = new JsonObject
|
||||||
|
{
|
||||||
|
["id"] = noteId, ["type"] = "Note", ["attributedTo"] = mallory.Id, ["content"] = "<p>late</p>",
|
||||||
|
["to"] = new JsonArray(Addressing.Public), ["cc"] = new JsonArray(alice.Uri),
|
||||||
|
["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@alice" })
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.False(await DB.Default.Find<Post>().Match(p => p.ObjectURI == noteId).ExecuteAnyAsync(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_lemmy_private_message_arrives_as_a_direct_message()
|
||||||
|
{
|
||||||
|
var token = TestContext.Current.CancellationToken;
|
||||||
|
var (_, alice) = await _harness.Persona("alice");
|
||||||
|
var lemmy = new RemoteActor(_harness.Peer, "pm");
|
||||||
|
var messageId = NewId(lemmy, "private_message");
|
||||||
|
|
||||||
|
await _harness.Deliver(lemmy, "/human-centipede", new JsonObject
|
||||||
|
{
|
||||||
|
["id"] = NewId(lemmy, "activities/create"), ["type"] = "Create", ["actor"] = lemmy.Id, ["to"] = new JsonArray(alice.Uri),
|
||||||
|
["object"] = new JsonObject
|
||||||
|
{
|
||||||
|
["id"] = messageId, ["type"] = "ChatMessage", ["attributedTo"] = lemmy.Id, ["to"] = new JsonArray(alice.Uri),
|
||||||
|
["content"] = "<p>psst</p>", ["mediaType"] = "text/html", ["published"] = DateTime.UtcNow.ToString("O")
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var message = await DB.Default.Find<Post>().Match(p => p.ObjectURI == messageId).ExecuteFirstAsync(token);
|
||||||
|
Assert.NotNull(message);
|
||||||
|
Assert.Equal(PostVisibility.Direct, message.Visibility);
|
||||||
|
Assert.NotNull(message.ConversationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task An_unreachable_key_asks_the_sender_to_retry_instead_of_refusing()
|
||||||
|
{
|
||||||
|
var (_, alice) = await _harness.Persona("alice");
|
||||||
|
var offline = new RemoteActor(_harness.Peer, "offline", origin: "http://127.0.0.1:9");
|
||||||
|
|
||||||
|
var result = await _harness.Deliver(offline, "/human-centipede", new JsonObject
|
||||||
|
{
|
||||||
|
["id"] = NewId(offline, "follows"), ["type"] = "Follow", ["actor"] = offline.Id, ["object"] = alice.Uri
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(503, result.StatusCode);
|
||||||
|
Assert.Equal(300, result.RetryAfterSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_join_of_a_local_post_is_answered_with_ignore()
|
||||||
|
{
|
||||||
|
var post = await LocalPost("not an event");
|
||||||
|
var mobilizon = new RemoteActor(_harness.Peer, "joiner");
|
||||||
|
|
||||||
|
await _harness.Deliver(mobilizon, "/human-centipede", new JsonObject
|
||||||
|
{
|
||||||
|
["id"] = NewId(mobilizon, "join"), ["type"] = "Join", ["actor"] = mobilizon.Id, ["object"] = post.ObjectURI
|
||||||
|
});
|
||||||
|
|
||||||
|
var answer = Assert.Single(await _harness.Outgoing(mobilizon.Id + "/inbox"));
|
||||||
|
Assert.Equal("Ignore", answer["type"]!.GetValue<string>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,6 +53,8 @@ namespace PrivaPub.Tests.Support
|
|||||||
new RejectHandler(Db, Local),
|
new RejectHandler(Db, Local),
|
||||||
new UndoHandler(Db, Local),
|
new UndoHandler(Db, Local),
|
||||||
new LikeHandler(Db),
|
new LikeHandler(Db),
|
||||||
|
new DislikeHandler(Db),
|
||||||
|
new JoinHandler(Db, Local, Delivery),
|
||||||
new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote),
|
new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote),
|
||||||
new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts, Groups, Records),
|
new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts, Groups, Records),
|
||||||
new DeleteHandler(Db, Local, Remote, Delivery, Groups),
|
new DeleteHandler(Db, Local, Remote, Delivery, Groups),
|
||||||
|
|||||||
@@ -49,6 +49,106 @@ namespace PrivaPub.Api.Mastodon.Entities
|
|||||||
public string AttributionDomains { get; set; }
|
public string AttributionDomains { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class PrivaPubStatus
|
||||||
|
{
|
||||||
|
public string ObjectType { get; set; }
|
||||||
|
public string Title { get; set; }
|
||||||
|
public string Excerpt { get; set; }
|
||||||
|
public string Cover { get; set; }
|
||||||
|
public PrivaPubSource Source { get; set; }
|
||||||
|
public PrivaPubLink Link { get; set; }
|
||||||
|
public PrivaPubVideo Video { get; set; }
|
||||||
|
public PrivaPubAudio Audio { get; set; }
|
||||||
|
public PrivaPubEvent Event { get; set; }
|
||||||
|
public PrivaPubVotes Votes { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PrivaPubSource
|
||||||
|
{
|
||||||
|
public string Content { get; set; }
|
||||||
|
public string MediaType { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PrivaPubLink
|
||||||
|
{
|
||||||
|
public string Url { get; set; }
|
||||||
|
public string Title { get; set; }
|
||||||
|
public string Description { get; set; }
|
||||||
|
public string Image { get; set; }
|
||||||
|
public bool FromPublisher { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PrivaPubVariant
|
||||||
|
{
|
||||||
|
public string Url { get; set; }
|
||||||
|
public string MediaType { get; set; }
|
||||||
|
public int? Width { get; set; }
|
||||||
|
public int? Height { get; set; }
|
||||||
|
public double? Fps { get; set; }
|
||||||
|
public long? Size { get; set; }
|
||||||
|
public List<string> Streams { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PrivaPubVideo
|
||||||
|
{
|
||||||
|
public double? Duration { get; set; }
|
||||||
|
public List<PrivaPubVariant> Variants { get; set; } = new();
|
||||||
|
public bool IsLive { get; set; }
|
||||||
|
public int? State { get; set; }
|
||||||
|
public long? Views { get; set; }
|
||||||
|
public int? CommentsPolicy { get; set; }
|
||||||
|
public bool? DownloadEnabled { get; set; }
|
||||||
|
public string Licence { get; set; }
|
||||||
|
public string Category { get; set; }
|
||||||
|
public string Channel { get; set; }
|
||||||
|
public List<PrivaPubCaption> Captions { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PrivaPubCaption
|
||||||
|
{
|
||||||
|
public string Language { get; set; }
|
||||||
|
public string Url { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PrivaPubAudio
|
||||||
|
{
|
||||||
|
public double? Duration { get; set; }
|
||||||
|
public List<PrivaPubVariant> Variants { get; set; } = new();
|
||||||
|
public string Album { get; set; }
|
||||||
|
public int? Position { get; set; }
|
||||||
|
public string Licence { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PrivaPubEvent
|
||||||
|
{
|
||||||
|
public string Start { get; set; }
|
||||||
|
public string End { get; set; }
|
||||||
|
public string TimeZone { get; set; }
|
||||||
|
public bool Floating { get; set; }
|
||||||
|
public List<PrivaPubPlace> Places { get; set; } = new();
|
||||||
|
public string OnlineUrl { get; set; }
|
||||||
|
public string JoinMode { get; set; }
|
||||||
|
public int? Participants { get; set; }
|
||||||
|
public int? Capacity { get; set; }
|
||||||
|
public string Status { get; set; }
|
||||||
|
public string Category { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PrivaPubPlace
|
||||||
|
{
|
||||||
|
public string Name { get; set; }
|
||||||
|
public string Address { get; set; }
|
||||||
|
public double? Latitude { get; set; }
|
||||||
|
public double? Longitude { get; set; }
|
||||||
|
public string Url { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PrivaPubVotes
|
||||||
|
{
|
||||||
|
public int Up { get; set; }
|
||||||
|
public int Down { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public class PreviewCard
|
public class PreviewCard
|
||||||
{
|
{
|
||||||
public string Url { get; set; }
|
public string Url { get; set; }
|
||||||
@@ -104,6 +204,7 @@ namespace PrivaPub.Api.Mastodon.Entities
|
|||||||
public string Language { get; set; }
|
public string Language { get; set; }
|
||||||
public string Text { get; set; }
|
public string Text { get; set; }
|
||||||
public List<object> Filtered { get; set; } = new();
|
public List<object> Filtered { get; set; } = new();
|
||||||
|
public PrivaPubStatus Privapub { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class MediaAttachment
|
public class MediaAttachment
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
|||||||
Language = post.Language,
|
Language = post.Language,
|
||||||
MediaAttachments = post.Media.Select(Media).ToList(),
|
MediaAttachments = post.Media.Select(Media).ToList(),
|
||||||
Card = Card(post),
|
Card = Card(post),
|
||||||
|
Privapub = Extension(post),
|
||||||
Mentions = post.Mentions.Select(m => Mention(m, mentionAccounts)).Where(m => m != default).ToList(),
|
Mentions = post.Mentions.Select(m => Mention(m, mentionAccounts)).Where(m => m != default).ToList(),
|
||||||
Tags = post.Tags.Select(t => new StatusTag { Name = t, Url = ActivityPubRenderer.TagUrl(_localActors.BaseAddress, t) }).ToList()
|
Tags = post.Tags.Select(t => new StatusTag { Name = t, Url = ActivityPubRenderer.TagUrl(_localActors.BaseAddress, t) }).ToList()
|
||||||
};
|
};
|
||||||
@@ -269,6 +270,67 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PrivaPubStatus Extension(PostEntity post) => new()
|
||||||
|
{
|
||||||
|
ObjectType = post.ObjectType,
|
||||||
|
Title = post.IsFederatedCopy ? post.Title : default,
|
||||||
|
Excerpt = post.Excerpt,
|
||||||
|
Cover = Proxied(post.CoverURL),
|
||||||
|
Source = post.Source == default ? default : new PrivaPubSource { Content = post.Source.Content, MediaType = post.Source.MediaType },
|
||||||
|
Link = post.Link == default ? default : new PrivaPubLink
|
||||||
|
{
|
||||||
|
Url = post.Link.Href, Title = post.Link.Title, Description = post.Link.Description, Image = Proxied(post.Link.ImageURL), FromPublisher = post.Link.FromPublisher
|
||||||
|
},
|
||||||
|
Video = post.Video == default ? default : new PrivaPubVideo
|
||||||
|
{
|
||||||
|
Duration = post.Video.DurationSeconds,
|
||||||
|
Variants = post.Video.Variants.Select(Variant).ToList(),
|
||||||
|
IsLive = post.Video.IsLive,
|
||||||
|
State = post.Video.State,
|
||||||
|
Views = post.Video.Views,
|
||||||
|
CommentsPolicy = post.Video.CommentsPolicy,
|
||||||
|
DownloadEnabled = post.Video.DownloadEnabled,
|
||||||
|
Licence = post.Video.Licence,
|
||||||
|
Category = post.Video.Category,
|
||||||
|
Channel = post.Video.ChannelURI,
|
||||||
|
Captions = post.Video.Captions.Select(c => new PrivaPubCaption { Language = c.Language, Url = Proxied(c.Url) }).ToList()
|
||||||
|
},
|
||||||
|
Audio = post.Audio == default ? default : new PrivaPubAudio
|
||||||
|
{
|
||||||
|
Duration = post.Audio.DurationSeconds,
|
||||||
|
Variants = post.Audio.Variants.Select(Variant).ToList(),
|
||||||
|
Album = post.Audio.Album,
|
||||||
|
Position = post.Audio.Position,
|
||||||
|
Licence = post.Audio.Licence
|
||||||
|
},
|
||||||
|
Event = post.Event == default ? default : new PrivaPubEvent
|
||||||
|
{
|
||||||
|
Start = post.Event.Start is { } start ? MastodonJson.Time(start) : default,
|
||||||
|
End = post.Event.End is { } end ? MastodonJson.Time(end) : default,
|
||||||
|
TimeZone = post.Event.TimeZone,
|
||||||
|
Floating = post.Event.FloatingTime,
|
||||||
|
Places = post.Event.Places.Select(p => new PrivaPubPlace { Name = p.Name, Address = p.Address, Latitude = p.Latitude, Longitude = p.Longitude, Url = p.Url }).ToList(),
|
||||||
|
OnlineUrl = post.Event.OnlineURL,
|
||||||
|
JoinMode = post.Event.JoinMode,
|
||||||
|
Participants = post.Event.ParticipantCount,
|
||||||
|
Capacity = post.Event.Capacity,
|
||||||
|
Status = post.Event.Status,
|
||||||
|
Category = post.Event.Category
|
||||||
|
},
|
||||||
|
Votes = new PrivaPubVotes { Up = post.FavouritesCount, Down = post.DownvotesCount }
|
||||||
|
};
|
||||||
|
|
||||||
|
PrivaPubVariant Variant(MediaVariant variant) => new()
|
||||||
|
{
|
||||||
|
Url = Proxied(variant.Url),
|
||||||
|
MediaType = variant.MediaType,
|
||||||
|
Width = variant.Width,
|
||||||
|
Height = variant.Height,
|
||||||
|
Fps = variant.Fps,
|
||||||
|
Size = variant.Size,
|
||||||
|
Streams = variant.Streams
|
||||||
|
};
|
||||||
|
|
||||||
PreviewCard Card(PostEntity post)
|
PreviewCard Card(PostEntity post)
|
||||||
{
|
{
|
||||||
if (!post.IsFederatedCopy)
|
if (!post.IsFederatedCopy)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ namespace PrivaPub.Federation.Actors
|
|||||||
Task<FetchedJson> FetchObject(string uri, CancellationToken token);
|
Task<FetchedJson> FetchObject(string uri, CancellationToken token);
|
||||||
Task<ForeignAvatar> GetActor(string actorUri, bool refresh, CancellationToken token);
|
Task<ForeignAvatar> GetActor(string actorUri, bool refresh, CancellationToken token);
|
||||||
Task<ForeignAvatar> GetActorByKeyId(string keyId, bool refresh, CancellationToken token);
|
Task<ForeignAvatar> GetActorByKeyId(string keyId, bool refresh, CancellationToken token);
|
||||||
|
bool KeyTemporarilyUnavailable(string keyId) => false;
|
||||||
Task<string> ResolveHandle(string handle, CancellationToken token);
|
Task<string> ResolveHandle(string handle, CancellationToken token);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +121,8 @@ namespace PrivaPub.Federation.Actors
|
|||||||
return await Upsert(actor, key, token);
|
return await Upsert(actor, key, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool KeyTemporarilyUnavailable(string keyId) => Origin.Of(keyId) != default && _http.FailedTemporarily(StripFragment(keyId));
|
||||||
|
|
||||||
public async Task<string> ResolveHandle(string handle, CancellationToken token)
|
public async Task<string> ResolveHandle(string handle, CancellationToken token)
|
||||||
{
|
{
|
||||||
var parts = handle?.TrimStart('@').Split('@');
|
var parts = handle?.TrimStart('@').Split('@');
|
||||||
|
|||||||
@@ -333,6 +333,8 @@ namespace PrivaPub.Federation.Controllers
|
|||||||
{
|
{
|
||||||
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}: {Error}", result.StatusCode, result.Error);
|
||||||
|
if (result.RetryAfterSeconds is { } seconds)
|
||||||
|
Response.Headers.RetryAfter = seconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||||
return result.Error == default ? StatusCode(result.StatusCode) : StatusCode(result.StatusCode, result.Error);
|
return result.Error == default ? StatusCode(result.StatusCode) : StatusCode(result.StatusCode, result.Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,8 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
|||||||
var note = NoteParser.Parse(node);
|
var note = NoteParser.Parse(node);
|
||||||
if (note == default || note.AttributedTo != author.ActorURI)
|
if (note == default || note.AttributedTo != author.ActorURI)
|
||||||
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))
|
||||||
return;
|
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();
|
||||||
|
|||||||
@@ -65,10 +65,16 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await DB.Default.Update<DeletedObject>()
|
||||||
|
.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)
|
||||||
return;
|
return;
|
||||||
await DB.Default.DeleteAsync<PostEntity>(post.ID);
|
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<TimelineEntry>(e => e.PostId == post.ID || e.ReblogOfPostId == post.ID);
|
||||||
if (!string.IsNullOrEmpty(post.GroupId) && await _localActors.FindById(LocalActorKind.Group, post.GroupId, token) is { IsCircle: false } community)
|
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);
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using MongoDB.Driver;
|
||||||
|
using MongoDB.Entities;
|
||||||
|
|
||||||
|
using PrivaPub.Models.Social;
|
||||||
|
using PrivaPub.Models.User;
|
||||||
|
using PrivaPub.StaticServices;
|
||||||
|
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
using static PrivaPub.Federation.Objects.ActivityJson;
|
||||||
|
|
||||||
|
using PostEntity = PrivaPub.Models.Post.Post;
|
||||||
|
|
||||||
|
namespace PrivaPub.Federation.Inbox.Handlers
|
||||||
|
{
|
||||||
|
public class DislikeHandler : IActivityHandler
|
||||||
|
{
|
||||||
|
readonly DbEntities _dbEntities;
|
||||||
|
|
||||||
|
public DislikeHandler(DbEntities dbEntities)
|
||||||
|
{
|
||||||
|
_dbEntities = dbEntities;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Type => "Dislike";
|
||||||
|
|
||||||
|
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
|
||||||
|
{
|
||||||
|
var objectUri = Id(activity["object"]);
|
||||||
|
var post = objectUri == default
|
||||||
|
? default
|
||||||
|
: await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token);
|
||||||
|
if (post == default || !LikeHandler.MaySee(post, actor))
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await DB.Default.SaveAsync(new Downvote { AccountId = actor.ID, ActorURI = actor.ActorURI, PostId = post.ID, ActivityURI = Id(activity) }, token);
|
||||||
|
}
|
||||||
|
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(b => b.Inc(p => p.DownvotesCount, 1)).ExecuteAsync(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using PrivaPub.Federation.Actors;
|
||||||
|
using PrivaPub.Federation.Outbox;
|
||||||
|
using PrivaPub.Federation.Rendering;
|
||||||
|
using PrivaPub.Models.Federation;
|
||||||
|
using PrivaPub.Models.User;
|
||||||
|
using PrivaPub.StaticServices;
|
||||||
|
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
using static PrivaPub.Federation.Objects.ActivityJson;
|
||||||
|
|
||||||
|
namespace PrivaPub.Federation.Inbox.Handlers
|
||||||
|
{
|
||||||
|
public class JoinHandler : IActivityHandler
|
||||||
|
{
|
||||||
|
readonly DbEntities _dbEntities;
|
||||||
|
readonly ILocalActorService _localActors;
|
||||||
|
readonly IDeliveryService _delivery;
|
||||||
|
|
||||||
|
public JoinHandler(DbEntities dbEntities, ILocalActorService localActors, IDeliveryService delivery)
|
||||||
|
{
|
||||||
|
_dbEntities = dbEntities;
|
||||||
|
_localActors = localActors;
|
||||||
|
_delivery = delivery;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Type => "Join";
|
||||||
|
|
||||||
|
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
|
||||||
|
{
|
||||||
|
var objectUri = Id(activity["object"]);
|
||||||
|
if (objectUri == default || !objectUri.StartsWith(_localActors.BaseAddress + "/", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return;
|
||||||
|
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);
|
||||||
|
if (owner == default || string.IsNullOrEmpty(actor.InboxURL))
|
||||||
|
return;
|
||||||
|
await _delivery.Enqueue(owner, new[] { actor.InboxURL }, new JsonObject
|
||||||
|
{
|
||||||
|
["@context"] = ActivityPubRenderer.ActivityStreams,
|
||||||
|
["id"] = owner.ActivityUri($"ignore-{Guid.NewGuid():N}"),
|
||||||
|
["type"] = "Ignore",
|
||||||
|
["actor"] = owner.Uri,
|
||||||
|
["object"] = activity.DeepClone()
|
||||||
|
}, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,26 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
|||||||
await UndoLike(inner, innerId, actor, token);
|
await UndoLike(inner, innerId, actor, token);
|
||||||
if (innerType is null or "Announce")
|
if (innerType is null or "Announce")
|
||||||
await UndoAnnounce(innerId, actor, token);
|
await UndoAnnounce(innerId, actor, token);
|
||||||
|
if (innerType is null or "Dislike")
|
||||||
|
await UndoDislike(inner, innerId, actor, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task UndoDislike(JsonNode inner, string innerId, ForeignAvatar actor, CancellationToken token)
|
||||||
|
{
|
||||||
|
var downvote = innerId == default
|
||||||
|
? default
|
||||||
|
: await DB.Default.Find<Downvote>().Match(d => d.ActivityURI == innerId && d.ActorURI == actor.ActorURI).ExecuteFirstAsync(token);
|
||||||
|
if (downvote == default && inner is JsonObject && Id(inner["object"]) is { } objectUri)
|
||||||
|
{
|
||||||
|
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteFirstAsync(token);
|
||||||
|
downvote = post == default
|
||||||
|
? default
|
||||||
|
: await DB.Default.Find<Downvote>().Match(d => d.PostId == post.ID && d.ActorURI == actor.ActorURI).ExecuteFirstAsync(token);
|
||||||
|
}
|
||||||
|
if (downvote == default)
|
||||||
|
return;
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task UndoFollow(JsonNode inner, string innerId, ForeignAvatar actor, CancellationToken token)
|
async Task UndoFollow(JsonNode inner, string innerId, ForeignAvatar actor, CancellationToken token)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ using static PrivaPub.Federation.Objects.ActivityJson;
|
|||||||
|
|
||||||
namespace PrivaPub.Federation.Inbox
|
namespace PrivaPub.Federation.Inbox
|
||||||
{
|
{
|
||||||
public sealed record InboxResult(int StatusCode, string Error = default);
|
public sealed record InboxResult(int StatusCode, string Error = default, int? RetryAfterSeconds = 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);
|
||||||
@@ -25,6 +25,8 @@ namespace PrivaPub.Federation.Inbox
|
|||||||
|
|
||||||
public class InboxReceiver : IInboxReceiver
|
public class InboxReceiver : IInboxReceiver
|
||||||
{
|
{
|
||||||
|
const int KeyRetrySeconds = 300;
|
||||||
|
|
||||||
const int MaxBodyBytes = 1024 * 1024;
|
const int MaxBodyBytes = 1024 * 1024;
|
||||||
|
|
||||||
readonly ILocalActorService _localActors;
|
readonly ILocalActorService _localActors;
|
||||||
@@ -88,6 +90,8 @@ namespace PrivaPub.Federation.Inbox
|
|||||||
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))
|
||||||
|
return new(StatusCodes.Status503ServiceUnavailable, "the signing key could not be fetched; try again later", KeyRetrySeconds);
|
||||||
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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ namespace PrivaPub.Federation.Inbox
|
|||||||
public async Task<PostEntity> StoreContext(string objectUri, int depth, CancellationToken token)
|
public async Task<PostEntity> StoreContext(string objectUri, int depth, CancellationToken token)
|
||||||
{
|
{
|
||||||
var existing = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteFirstAsync(token);
|
var existing = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteFirstAsync(token);
|
||||||
if (existing != default || depth > MaxDepth)
|
if (existing != default || depth > MaxDepth || await DB.Default.Find<DeletedObject>().Match(d => d.ObjectURI == objectUri).ExecuteAnyAsync(token))
|
||||||
return existing;
|
return existing;
|
||||||
|
|
||||||
using var fetched = await _remoteActors.FetchObject(objectUri, token);
|
using var fetched = await _remoteActors.FetchObject(objectUri, token);
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ namespace PrivaPub.Federation.Objects
|
|||||||
|
|
||||||
public static class NoteParser
|
public static class NoteParser
|
||||||
{
|
{
|
||||||
static readonly string[] NoteTypes = { "Note", "Article", "Page", "Question", "Event", "Video", "Audio", "Image" };
|
static readonly string[] NoteTypes = { "Note", "Article", "Page", "Question", "Event", "Video", "Audio", "Image", "ChatMessage" };
|
||||||
const int MaxAttachments = 16;
|
const int MaxAttachments = 16;
|
||||||
const int MaxTags = 64;
|
const int MaxTags = 64;
|
||||||
|
|
||||||
|
|||||||
@@ -123,12 +123,13 @@ namespace PrivaPub.Federation.Outbox
|
|||||||
await _breaker.Succeeded(job.Host, token);
|
await _breaker.Succeeded(job.Host, token);
|
||||||
return JobOutcome.Done;
|
return JobOutcome.Done;
|
||||||
}
|
}
|
||||||
if (response.StatusCode == HttpStatusCode.TooManyRequests)
|
if (response.StatusCode == HttpStatusCode.TooManyRequests
|
||||||
|
|| response.StatusCode == HttpStatusCode.ServiceUnavailable && response.Headers.RetryAfter != default)
|
||||||
{
|
{
|
||||||
var retryAfter = response.Headers.RetryAfter?.Delta ?? (response.Headers.RetryAfter?.Date - DateTimeOffset.UtcNow);
|
var retryAfter = response.Headers.RetryAfter?.Delta ?? (response.Headers.RetryAfter?.Date - DateTimeOffset.UtcNow);
|
||||||
return retryAfter > TimeSpan.Zero
|
return retryAfter > TimeSpan.Zero
|
||||||
? JobOutcome.Defer(DateTime.UtcNow + Min(retryAfter.Value, TimeSpan.FromHours(6)), "429")
|
? JobOutcome.Defer(DateTime.UtcNow + Min(retryAfter.Value, TimeSpan.FromHours(6)), $"{status}")
|
||||||
: JobOutcome.Retry("429");
|
: JobOutcome.Retry($"{status}");
|
||||||
}
|
}
|
||||||
if (status is >= 400 and < 500 && status != 408)
|
if (status is >= 400 and < 500 && status != 408)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ namespace PrivaPub.Infrastructure.Data
|
|||||||
{
|
{
|
||||||
public static class Indexes
|
public static class Indexes
|
||||||
{
|
{
|
||||||
|
public static readonly TimeSpan TombstoneLifetime = TimeSpan.FromDays(90);
|
||||||
|
|
||||||
public static async Task Create(CancellationToken token = default)
|
public static async Task Create(CancellationToken token = default)
|
||||||
{
|
{
|
||||||
await Unique<Post>(p => p.ObjectURI, Builders<Post>.Filter.Type(p => p.ObjectURI, BsonType.String), token);
|
await Unique<Post>(p => p.ObjectURI, Builders<Post>.Filter.Type(p => p.ObjectURI, BsonType.String), token);
|
||||||
@@ -104,6 +106,10 @@ namespace PrivaPub.Infrastructure.Data
|
|||||||
await Unique<RemoteInstance>(i => i.Host, Builders<RemoteInstance>.Filter.Type(i => i.Host, BsonType.String), token);
|
await Unique<RemoteInstance>(i => i.Host, Builders<RemoteInstance>.Filter.Type(i => i.Host, BsonType.String), token);
|
||||||
await Unique<ObjectRecord>(r => r.ObjectURI, Builders<ObjectRecord>.Filter.Type(r => r.ObjectURI, BsonType.String), token);
|
await Unique<ObjectRecord>(r => r.ObjectURI, Builders<ObjectRecord>.Filter.Type(r => r.ObjectURI, BsonType.String), token);
|
||||||
await Plain<ObjectRecord>(token, r => r.PostId);
|
await Plain<ObjectRecord>(token, r => r.PostId);
|
||||||
|
await Unique<DeletedObject>(d => d.ObjectURI, Builders<DeletedObject>.Filter.Type(d => d.ObjectURI, BsonType.String), token);
|
||||||
|
await DB.Default.Index<DeletedObject>().Key(d => d.DeletedAt, KeyType.Ascending).Option(o => o.ExpireAfter = TombstoneLifetime).CreateAsync(token);
|
||||||
|
await DB.Default.Index<Downvote>().Key(d => d.PostId, KeyType.Ascending).Key(d => d.ActorURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token);
|
||||||
|
await Plain<Downvote>(token, d => d.ActivityURI);
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ namespace PrivaPub.Infrastructure.Http
|
|||||||
{
|
{
|
||||||
bool IsAllowed(Uri target);
|
bool IsAllowed(Uri target);
|
||||||
Task<FetchedJson> GetJson(string url, string accept, Action<HttpRequestMessage> sign, CancellationToken token);
|
Task<FetchedJson> GetJson(string url, string accept, Action<HttpRequestMessage> sign, CancellationToken token);
|
||||||
|
bool FailedTemporarily(string url);
|
||||||
Task<HttpResponseMessage> Send(HttpRequestMessage request, CancellationToken token);
|
Task<HttpResponseMessage> Send(HttpRequestMessage request, CancellationToken token);
|
||||||
Task<(byte[] Bytes, string ContentType)> GetMedia(string url, long maxBytes, CancellationToken token);
|
Task<(byte[] Bytes, string ContentType)> GetMedia(string url, long maxBytes, CancellationToken token);
|
||||||
}
|
}
|
||||||
@@ -107,7 +108,8 @@ namespace PrivaPub.Infrastructure.Http
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
return Refuse(negativeKey, url, $"status {(int)response.StatusCode}");
|
return Refuse(negativeKey, url, $"status {(int)response.StatusCode}",
|
||||||
|
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))
|
||||||
@@ -125,14 +127,21 @@ namespace PrivaPub.Infrastructure.Http
|
|||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (!token.IsCancellationRequested)
|
catch (OperationCanceledException) when (!token.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
return Refuse(negativeKey, url, "a timeout");
|
return Refuse(negativeKey, url, "a timeout", transient: true);
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (ex is HttpRequestException or JsonException or BlockedDestinationException)
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
return Refuse(negativeKey, url, ex.Message, transient: true);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is JsonException or BlockedDestinationException)
|
||||||
{
|
{
|
||||||
return Refuse(negativeKey, url, ex.Message);
|
return Refuse(negativeKey, url, ex.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool FailedTemporarily(string url) =>
|
||||||
|
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)
|
||||||
{
|
{
|
||||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var target) || !IsAllowed(target))
|
if (!Uri.TryCreate(url, UriKind.Absolute, out var target) || !IsAllowed(target))
|
||||||
@@ -200,9 +209,9 @@ 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)
|
FetchedJson Refuse(string negativeKey, string url, string reason, bool transient = false)
|
||||||
{
|
{
|
||||||
_cache.Set(negativeKey, true, 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ namespace PrivaPub.Middleware
|
|||||||
.AddSingleton<IActivityHandler, RejectHandler>()
|
.AddSingleton<IActivityHandler, RejectHandler>()
|
||||||
.AddSingleton<IActivityHandler, UndoHandler>()
|
.AddSingleton<IActivityHandler, UndoHandler>()
|
||||||
.AddSingleton<IActivityHandler, LikeHandler>()
|
.AddSingleton<IActivityHandler, LikeHandler>()
|
||||||
|
.AddSingleton<IActivityHandler, DislikeHandler>()
|
||||||
|
.AddSingleton<IActivityHandler, JoinHandler>()
|
||||||
.AddSingleton<IActivityHandler, AnnounceHandler>()
|
.AddSingleton<IActivityHandler, AnnounceHandler>()
|
||||||
.AddSingleton<IActivityHandler, FlagHandler>()
|
.AddSingleton<IActivityHandler, FlagHandler>()
|
||||||
.AddSingleton<IActivityHandler, CreateHandler>()
|
.AddSingleton<IActivityHandler, CreateHandler>()
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using MongoDB.Entities;
|
||||||
|
|
||||||
|
namespace PrivaPub.Models.Federation
|
||||||
|
{
|
||||||
|
public class DeletedObject : Entity
|
||||||
|
{
|
||||||
|
public string ObjectURI { get; set; }
|
||||||
|
public DateTime DeletedAt { get; set; } = DateTime.UtcNow;//expires after Indexes.TombstoneLifetime
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,6 +59,7 @@ namespace PrivaPub.Models.Post
|
|||||||
|
|
||||||
public int RepliesCount { get; set; }
|
public int RepliesCount { get; set; }
|
||||||
public int FavouritesCount { get; set; }
|
public int FavouritesCount { get; set; }
|
||||||
|
public int DownvotesCount { get; set; }//Lemmy, PieFed, Mbin and Friendica Dislikes
|
||||||
public int ReblogsCount { get; set; }
|
public int ReblogsCount { get; set; }
|
||||||
public List<PostRevision> Revisions { get; set; } = new();
|
public List<PostRevision> Revisions { get; set; } = new();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using MongoDB.Entities;
|
||||||
|
|
||||||
|
namespace PrivaPub.Models.Social
|
||||||
|
{
|
||||||
|
public class Downvote : Entity
|
||||||
|
{
|
||||||
|
public string AccountId { get; set; }//ForeignAvatar.ID
|
||||||
|
public string ActorURI { get; set; }
|
||||||
|
public string PostId { get; set; }
|
||||||
|
public string ActivityURI { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-7
@@ -10,7 +10,7 @@ Written 2026-10-01 from the original 2023 code, the decePubClient UI, a federati
|
|||||||
- [x] P2 Mastodon client API: v1.4.0, deployed 2026-10-01; OAuth and the anonymous API verified on production, the signed-in API verified locally (no real-client login on production yet)
|
- [x] P2 Mastodon client API: v1.4.0, deployed 2026-10-01; OAuth and the anonymous API verified on production, the signed-in API verified locally (no real-client login on production yet)
|
||||||
- [x] P3 Social features: v1.5.0, deployed 2026-10-01; media, proxy, blocks, mutes, bookmarks, pins and reports verified by tests and locally (no upload on production yet)
|
- [x] P3 Social features: v1.5.0, deployed 2026-10-01; media, proxy, blocks, mutes, bookmarks, pins and reports verified by tests and locally (no upload on production yet)
|
||||||
- [x] P4 Groups and privacy features: v1.6.0, deployed 2026-10-01; communities, circles and local-only located posts verified by tests. v1.6.1 adds the pasture (`tools/pasture/`): live interop with GoToSocial 0.22.1 passes all 25 checks, three runs in a row. Lemmy and a live Mastodon circle member are not run yet; the pasture has GoToSocial only
|
- [x] P4 Groups and privacy features: v1.6.0, deployed 2026-10-01; communities, circles and local-only located posts verified by tests. v1.6.1 adds the pasture (`tools/pasture/`): live interop with GoToSocial 0.22.1 passes all 25 checks, three runs in a row. Lemmy and a live Mastodon circle member are not run yet; the pasture has GoToSocial only
|
||||||
- [ ] P5 Lose nothing: wire tolerance, full objects, raw capture (see `docs/INTEROP.md`)
|
- [x] P5 Lose nothing: v1.7.0 to v1.9.1, deployed 2026-10-01; parsing, typed details, provenance, downvotes, tombstones, federated blocks, all checked live against GoToSocial. Book reviews and forum threads keep their raw form only (typed in P7 and P8)
|
||||||
- [ ] P6 Emoji, polls, quotes, reactions, cards, players
|
- [ ] P6 Emoji, polls, quotes, reactions, cards, players
|
||||||
- [ ] 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
|
||||||
@@ -414,7 +414,7 @@ it, raw where it doesn't.
|
|||||||
- Persona usernames match Mastodon's and Misskey's pattern (groups already did).
|
- Persona usernames match Mastodon's and Misskey's pattern (groups already did).
|
||||||
- Every term we emit is defined in our JSON-LD context.
|
- Every term we emit is defined in our JSON-LD context.
|
||||||
- `Vary: Accept`.
|
- `Vary: Accept`.
|
||||||
- Still open: every activity id dereferences.
|
- Not done on purpose: see "Deliberately not done" below.
|
||||||
- **Owner decisions that were small** (done in v1.7.0): blocks federate (`Block`, `Undo{Block}`); a persona's and a
|
- **Owner decisions that were small** (done in v1.7.0): blocks federate (`Block`, `Undo{Block}`); a persona's and a
|
||||||
group's `published` is a random day up to two weeks before its creation (migration `_007`).
|
group's `published` is a random day up to two weeks before its creation (migration `_007`).
|
||||||
- **Parsing every shape** (done in v1.8.0, `Federation/Objects/ObjectShapes.cs`):
|
- **Parsing every shape** (done in v1.8.0, `Federation/Objects/ObjectShapes.cs`):
|
||||||
@@ -431,13 +431,21 @@ it, raw where it doesn't.
|
|||||||
- **Raw capture for the details view** (done in v1.8.0): the object as received, how it arrived, the signature scheme and
|
- **Raw capture for the details view** (done in v1.8.0): the object as received, how it arrived, the signature scheme and
|
||||||
key, received versus `published`, the extensions detected, and the origin's software (§4.3). Exposed at
|
key, received versus `published`, the extensions detected, and the origin's software (§4.3). Exposed at
|
||||||
`/api/privapub/v1/statuses/:id/provenance` and `/api/privapub/v1/instances/:host`. Checked live against GoToSocial.
|
`/api/privapub/v1/statuses/:id/provenance` and `/api/privapub/v1/instances/:host`. Checked live against GoToSocial.
|
||||||
- **Routing by object type:**
|
- **Routing by object type** (done in v1.9.0):
|
||||||
- `Accept`/`Reject`/`TentativeAccept` routed by what their object is (W6).
|
- `Accept`/`Reject` act only on a Follow we sent and ignore any other object (W6); routing to RSVPs and interaction
|
||||||
|
approvals comes with those features.
|
||||||
- `ChatMessage` in as a direct message.
|
- `ChatMessage` in as a direct message.
|
||||||
- `Dislike` and votes recorded in a ledger.
|
- `Dislike` and its `Undo` in a downvote ledger, shown with favourites as `privapub.votes`.
|
||||||
- A `Join` answered with `Ignore` until RSVP exists.
|
- A `Join` answered with `Ignore` until RSVP exists.
|
||||||
- Friendica's thread-`Follow` refused without an error.
|
- Friendica's thread-`Follow` is ignored without an error.
|
||||||
- **Delivery:** honour 503 with `Retry-After`; answer 503 ourselves when a key fetch fails temporarily.
|
- A deleted object's id is kept for 90 days (W9), and its `ObjectRecord` is removed with the post.
|
||||||
|
- **Typed details reach clients** (done in v1.9.0) as `Status.privapub`.
|
||||||
|
- **Delivery:** a 503 with `Retry-After` is waited out like a 429 and no longer counts against the host (done in v1.9.0).
|
||||||
|
We answer 503 with `Retry-After` ourselves when a sender's key cannot be fetched for a temporary reason (v1.9.1),
|
||||||
|
so Mastodon 4.7 retries instead of switching to RFC 9421.
|
||||||
|
- **Deliberately not done: dereferenceable `Follow`, `Like`, `Block`, `Accept`, `Reject` and `Undo` ids.** Serving them
|
||||||
|
would publish who follows, likes and blocks whom, which our collections deliberately hide. Every one of them is sent
|
||||||
|
with its object embedded, which is all Misskey needs. `Create` and `Announce` ids do dereference.
|
||||||
|
|
||||||
#### P6 What people see: emoji, polls, quotes, reactions, cards, players
|
#### P6 What people see: emoji, polls, quotes, reactions, cards, players
|
||||||
- **Custom emoji** on posts, names, fields and poll options, proxied.
|
- **Custom emoji** on posts, names, fields and poll options, proxied.
|
||||||
|
|||||||
Reference in new issue
Block a user