P7: downvotes go out, and feeds are read through the server's reader
Votes out. A persona's downvote is a Dislike (POST /api/v1/statuses/:id/downvote and /undownvote, the viewer's own as privapub.votes.downvoted). One vote a post: a changed vote is sent as the new vote alone, as Lemmy sends one, since an Undo of the old one beside it could arrive after it and take the new one away; Undo goes only when a vote is taken back. A vote on a post in a remote community goes to the community, which counts it, and to the author only when on another server: one copy a server, since PieFed drops a second copy of an activity it has just seen. Mbin keeps a favourite apart from a vote, so there a favourite stays after a switch to a downvote. Feeds followed (owner decision 2026-10-07: through the server). Following a feed (Lemmy's multi-community, PieFed's feed) keeps a FeedSubscription for the persona and nothing else; the new Service privapub_feeds (LocalActorKind.Reader, reserved by migration _017) follows every community of the feeds read here, reconciled when a persona follows or leaves one, when a feed's list is read again (kept as it was when it cannot be read) and every six hours. Its Following rows carry FollowerKind, so nobody's home gets what it brings in and its unanswered follows are sent again as its own. The persona reads GET /api/v1/timelines/feed/:id (the feed's threads, ours included) and lists its feeds at GET /api/v1/feeds. Checked in the pasture, every scenario: 873 pass. The 14 failures are Hubzilla's (identical on the previous commit: Hubzilla no longer answers a follow in this pasture since its restore) and two activities Smithereen never sent; followsync passes once the pasture's restore is older than the 14-day pause. Live: alice's downvotes count as downvotes on Lemmy 1.0 and PieFed, replacing her upvote; Lemmy 1.0 and PieFed take privapub_feeds' follows and their threads reach the feed timelines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
1 parent
3b3f1f6b93
commit
6fccb6fe35
32 files changed
+577
-45
No files matched your search
@@ -32,10 +32,12 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
readonly IFollowService _follows;
|
||||
readonly IOutboxPublisher _outbox;
|
||||
readonly IRelationshipService _relationships;
|
||||
readonly IFeedFollows _feeds;
|
||||
|
||||
public AccountsController(MastodonMapper mapper, DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors,
|
||||
IFollowService follows, IOutboxPublisher outbox, IRelationshipService relationships)
|
||||
IFollowService follows, IOutboxPublisher outbox, IRelationshipService relationships, IFeedFollows feeds)
|
||||
{
|
||||
_feeds = feeds;
|
||||
_relationships = relationships;
|
||||
_mapper = mapper;
|
||||
_dbEntities = dbEntities;
|
||||
@@ -266,14 +268,27 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
return Json(following.Select(f => accounts.GetValueOrDefault(f.TargetAccountId)).Where(a => a != default).ToList());
|
||||
}
|
||||
|
||||
// PrivaPub's own: the feeds the persona reads (a feed's timeline is /api/v1/timelines/feed/:id)
|
||||
[HttpGet("/api/v1/feeds"), Scope("read:follows")]
|
||||
public async Task<IActionResult> Feeds(CancellationToken token)
|
||||
{
|
||||
var read = await DB.Default.Find<FeedSubscription>().Match(s => s.AvatarId == MyId).Sort(s => s.ID, MongoDB.Entities.Order.Descending).ExecuteAsync(token);
|
||||
var accounts = await _mapper.Accounts(read.Select(s => s.FeedAccountId), token);
|
||||
return Json(read.Select(s => accounts.GetValueOrDefault(s.FeedAccountId)).Where(a => a != default).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/accounts/{id}/follow"), Scope("write:follows")]
|
||||
public async Task<IActionResult> Follow(string id, CancellationToken token)
|
||||
{
|
||||
var (local, remote) = await Find(id, token);
|
||||
if (local == default && remote == default)
|
||||
return NotFoundError();
|
||||
// a feed is read through the server, whose reader follows its communities; the persona follows nothing
|
||||
if (remote is { AvatarType: AvatarType.Feed })
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "A feed is followed through its communities");
|
||||
{
|
||||
await _feeds.Subscribe(Me, remote, true, token);
|
||||
return Json(await Relationship(id, token));
|
||||
}
|
||||
var following = await _follows.FollowAs(Me, local?.Uri ?? remote.ActorURI, Params.Bool("reblogs") != false, token);
|
||||
return following == default ? Error(StatusCodes.Status403Forbidden, "This action is not allowed") : Json(await Relationship(id, token));
|
||||
}
|
||||
@@ -284,7 +299,10 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
var (local, remote) = await Find(id, token);
|
||||
if (local == default && remote == default)
|
||||
return NotFoundError();
|
||||
await _follows.UnfollowAs(Me, local?.Uri ?? remote.ActorURI, token);
|
||||
if (remote is { AvatarType: AvatarType.Feed })
|
||||
await _feeds.Subscribe(Me, remote, false, token);
|
||||
else
|
||||
await _follows.UnfollowAs(Me, local?.Uri ?? remote.ActorURI, token);
|
||||
return Json(await Relationship(id, token));
|
||||
}
|
||||
|
||||
@@ -432,6 +450,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
var (local, remote) = await Find(id, token);
|
||||
var uri = local?.Uri ?? remote?.ActorURI;
|
||||
var following = uri == default ? default : await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.TargetActorURI == uri).ExecuteFirstAsync(token);
|
||||
var reading = remote is { AvatarType: AvatarType.Feed } && await _feeds.IsSubscribed(MyId, remote.ActorURI, token);
|
||||
var followedBy = uri == default ? default : await _dbEntities.Followers.Match(f => f.LocalActorId == MyId && f.ActorURI == uri).ExecuteFirstAsync(token);
|
||||
var blocking = uri != default && await DB.Default.Find<Block>().Match(b => b.AvatarId == MyId && b.TargetActorURI == uri).ExecuteAnyAsync(token);
|
||||
var mute = uri == default ? default : await DB.Default.Find<Mute>().Match(m => m.AvatarId == MyId && m.TargetActorURI == uri).ExecuteFirstAsync(token);
|
||||
@@ -448,7 +467,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
Muting = mute != default && (mute.ExpiresAt == default || mute.ExpiresAt > DateTime.UtcNow),
|
||||
MutingNotifications = mute?.HideNotifications == true,
|
||||
DomainBlocking = domainBlocked,
|
||||
Following = following?.State == FollowState.Accepted,
|
||||
Following = following?.State == FollowState.Accepted || reading,
|
||||
Requested = following?.State == FollowState.Requested,
|
||||
ShowingReblogs = following?.ShowReblogs ?? false,
|
||||
FollowedBy = followedBy?.IsAccepted == true,
|
||||
|
||||
@@ -333,6 +333,13 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
[HttpPost("/api/v1/statuses/{id}/unfavourite"), Scope("write:favourites")]
|
||||
public Task<IActionResult> Unfavourite(string id, CancellationToken token) => Toggle(_statuses.Favourite(Me, id, false, token), id, token);
|
||||
|
||||
// PrivaPub's own, for the threadiverse's downvotes (a favourite is the upvote)
|
||||
[HttpPost("/api/v1/statuses/{id}/downvote"), Scope("write:favourites")]
|
||||
public Task<IActionResult> Downvote(string id, CancellationToken token) => Toggle(_statuses.Downvote(Me, id, true, token), id, token);
|
||||
|
||||
[HttpPost("/api/v1/statuses/{id}/undownvote"), Scope("write:favourites")]
|
||||
public Task<IActionResult> Undownvote(string id, CancellationToken token) => Toggle(_statuses.Downvote(Me, id, false, token), id, token);
|
||||
|
||||
[HttpPost("/api/v1/statuses/{id}/reblog"), Scope("write:statuses")]
|
||||
public async Task<IActionResult> Reblog(string id, CancellationToken token)
|
||||
{
|
||||
|
||||
@@ -64,6 +64,26 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
return await Respond(query, "/api/v1/timelines/public", token);
|
||||
}
|
||||
|
||||
// PrivaPub's own: a feed the persona reads (Lemmy's multi-community, PieFed's feed), its communities' threads,
|
||||
// newest first, as the server's reader of feeds brings them in (owner decision 2026-10-07)
|
||||
[HttpGet("/api/v1/timelines/feed/{id}"), Scope("read:statuses")]
|
||||
public async Task<IActionResult> Feed(string id, CancellationToken token)
|
||||
{
|
||||
var feed = await _dbEntities.ForeignAvatars.MatchID(id).ExecuteFirstAsync(token);
|
||||
if (feed is not { AvatarType: Models.User.AvatarType.Feed }
|
||||
|| !await DB.Default.Find<FeedSubscription>().Match(s => s.AvatarId == MyId && s.FeedActorURI == feed.ActorURI).ExecuteAnyAsync(token))
|
||||
return NotFoundError();
|
||||
var communities = feed.FeedCommunities;
|
||||
var ours = Me.BaseAddress + "/peasants/";
|
||||
var names = communities.Where(c => c.StartsWith(ours, StringComparison.Ordinal)).Select(c => c[ours.Length..]).ToList();
|
||||
var groups = names.Count == 0
|
||||
? new List<string>()
|
||||
: (await _dbEntities.Groups.Match(g => names.Contains(g.UserName) && !g.DeletionAt.HasValue).ExecuteAsync(token)).Select(g => g.ID).ToList();
|
||||
var query = _dbEntities.Posts.Match(p => (communities.Contains(p.AudienceURI) || groups.Contains(p.GroupId))
|
||||
&& p.ReblogOfPostId == null && p.InReplyToURI == null).Match(VisibilityPolicy.IsPublic);
|
||||
return await Respond(query, $"/api/v1/timelines/feed/{id}", token);
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/timelines/tag/{hashtag}"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> Tag(string hashtag, CancellationToken token)
|
||||
{
|
||||
|
||||
@@ -180,6 +180,7 @@
|
||||
{
|
||||
public int Up { get; set; }
|
||||
public int Down { get; set; }
|
||||
public bool? Downvoted { get; set; }//the viewer's own downvote; null with no viewer
|
||||
}
|
||||
|
||||
public class QuoteEntity
|
||||
|
||||
@@ -218,6 +218,9 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
var favourited = viewerId == default
|
||||
? new HashSet<string>()
|
||||
: (await _dbEntities.Favourites.Match(f => f.AccountId == viewerId && ids.Contains(f.PostId)).ExecuteAsync(token)).Select(f => f.PostId).ToHashSet();
|
||||
var downvoted = viewerId == default
|
||||
? new HashSet<string>()
|
||||
: (await DB.Default.Find<Downvote>().Match(d => d.AccountId == viewerId && ids.Contains(d.PostId)).ExecuteAsync(token)).Select(d => d.PostId).ToHashSet();
|
||||
var bookmarked = viewerId == default
|
||||
? new HashSet<string>()
|
||||
: (await DB.Default.Find<Bookmark>().Match(b => b.AvatarId == viewerId && ids.Contains(b.PostId)).ExecuteAsync(token)).Select(b => b.PostId).ToHashSet();
|
||||
@@ -286,7 +289,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
Card = Card(post),
|
||||
Poll = Poll(post, viewerId, ownVotes.GetValueOrDefault(post.ID)),
|
||||
Emojis = Emojis(post.Emojis),
|
||||
Privapub = Extension(post, accounts),
|
||||
Privapub = Extension(post, accounts, viewerId == default ? default(bool?) : downvoted.Contains(post.ID)),
|
||||
EmojiReactions = reactions.GetValueOrDefault(post.ID) ?? new(),
|
||||
Pleroma = new PleromaStatus { EmojiReactions = reactions.GetValueOrDefault(post.ID) ?? new() },
|
||||
Mentions = post.Mentions.Where(m => !m.Silent).Select(m => Mention(m, mentionAccounts)).Where(m => m != default).ToList(),
|
||||
@@ -489,7 +492,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
List<CustomEmojiEntity> Emojis(IEnumerable<CustomEmoji> emojis) =>
|
||||
emojis?.Select(e => new CustomEmojiEntity { Shortcode = e.Shortcode, Url = Proxied(e.URL), StaticUrl = Proxied(e.URL) }).ToList() ?? new();
|
||||
|
||||
PrivaPubStatus Extension(PostEntity post, Dictionary<string, Account> accounts) => new()
|
||||
PrivaPubStatus Extension(PostEntity post, Dictionary<string, Account> accounts, bool? downvoted) => new()
|
||||
{
|
||||
Wall = post.WallOwnerAccountId == default || post.WallOwnerAccountId == AuthorOf(post) ? default : accounts.GetValueOrDefault(post.WallOwnerAccountId),
|
||||
ObjectType = post.ObjectType,
|
||||
@@ -543,7 +546,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
{
|
||||
Name = post.Place.Name, Latitude = post.Place.Latitude, Longitude = post.Place.Longitude, Country = post.Place.Country
|
||||
},
|
||||
Votes = new PrivaPubVotes { Up = post.FavouritesCount, Down = post.DownvotesCount },
|
||||
Votes = new PrivaPubVotes { Up = post.FavouritesCount, Down = post.DownvotesCount, Downvoted = downvoted },
|
||||
Flairs = post.Flairs?.Select(f => new PrivaPubFlair
|
||||
{
|
||||
Id = f.Id, Name = f.Name, Slug = f.Slug, Description = f.Description, Color = f.Color, TextColor = f.TextColor,
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Infrastructure.Jobs;
|
||||
using PrivaPub.Models.Jobs;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
namespace PrivaPub.Domain.Social
|
||||
{
|
||||
public interface IFeedFollows
|
||||
{
|
||||
Task<bool> Subscribe(LocalActor me, ForeignAvatar feed, bool on, CancellationToken token);
|
||||
Task<bool> IsSubscribed(string avatarId, string feedActorUri, CancellationToken token);
|
||||
Task Reconcile(CancellationToken token);
|
||||
}
|
||||
|
||||
// Feeds read through the server (owner decision 2026-10-07): a persona following a feed is kept here alone, and the
|
||||
// server's reader (LocalActorService.ReaderUserName) follows every community in the feeds anyone reads, as Lemmy's own
|
||||
// follower of multi-communities does, and stops following one no feed holds any more. What it follows arrives as any
|
||||
// community's posts do; nobody's home shows them, the feed's timeline does.
|
||||
public class FeedFollows : IFeedFollows
|
||||
{
|
||||
public const int MaxCommunities = 500;
|
||||
static readonly TimeSpan Stale = TimeSpan.FromDays(1);
|
||||
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly ILocalActorService _localActors;
|
||||
readonly IRemoteActorService _remoteActors;
|
||||
readonly IFollowService _follows;
|
||||
readonly IJobQueue _jobs;
|
||||
|
||||
public FeedFollows(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IFollowService follows, IJobQueue jobs)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
_localActors = localActors;
|
||||
_remoteActors = remoteActors;
|
||||
_follows = follows;
|
||||
_jobs = jobs;
|
||||
}
|
||||
|
||||
public static Task<bool> Sync(IJobQueue jobs, CancellationToken token) =>
|
||||
jobs.Enqueue(JobKind.SyncFeeds, string.Empty, default, $"feeds|{Guid.NewGuid():N}", token);
|
||||
|
||||
public async Task<bool> Subscribe(LocalActor me, ForeignAvatar feed, bool on, CancellationToken token)
|
||||
{
|
||||
if (feed is not { AvatarType: AvatarType.Feed })
|
||||
return false;
|
||||
if (on)
|
||||
try
|
||||
{
|
||||
await DB.Default.SaveAsync(new FeedSubscription { AvatarId = me.Id, FeedActorURI = feed.ActorURI, FeedAccountId = feed.ID }, token);
|
||||
}
|
||||
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
else if ((await DB.Default.DeleteAsync<FeedSubscription>(s => s.AvatarId == me.Id && s.FeedActorURI == feed.ActorURI)).DeletedCount == 0)
|
||||
return true;
|
||||
await Sync(_jobs, token);
|
||||
return true;
|
||||
}
|
||||
|
||||
public Task<bool> IsSubscribed(string avatarId, string feedActorUri, CancellationToken token) =>
|
||||
DB.Default.Find<FeedSubscription>().Match(s => s.AvatarId == avatarId && s.FeedActorURI == feedActorUri).ExecuteAnyAsync(token);
|
||||
|
||||
public async Task Reconcile(CancellationToken token)
|
||||
{
|
||||
var read = (await DB.Default.Find<FeedSubscription, string>().Project(s => s.FeedActorURI).ExecuteAsync(token)).Distinct().ToList();
|
||||
var feeds = await _dbEntities.ForeignAvatars.Match(a => read.Contains(a.ActorURI) && a.AvatarType == AvatarType.Feed && !a.DeletionAt.HasValue)
|
||||
.ExecuteAsync(token);
|
||||
// a feed's communities are as its server listed them a day ago at most (AccountCountsJob reads them, then asks
|
||||
// for this again)
|
||||
foreach (var stale in feeds.Where(f => !(DateTime.UtcNow - f.CountedAt < Stale)))
|
||||
if (Uri.TryCreate(stale.ActorURI, UriKind.Absolute, out var uri))
|
||||
await _jobs.Enqueue(JobKind.CountAccount, stale.ActorURI, uri.Host.ToLowerInvariant(), AccountCountsJob.DedupeKey(stale.ActorURI, DateTime.UtcNow), token);
|
||||
|
||||
var ours = _localActors.BaseAddress + "/";
|
||||
var wanted = feeds.SelectMany(f => f.FeedCommunities).Where(c => !c.StartsWith(ours, StringComparison.Ordinal))
|
||||
.Distinct(StringComparer.Ordinal).Take(MaxCommunities).ToHashSet(StringComparer.Ordinal);
|
||||
var reader = await _localActors.GetReaderActor(token);
|
||||
var following = await _dbEntities.Followings.Match(f => f.AvatarId == reader.Id).ExecuteAsync(token);
|
||||
foreach (var gone in following.Where(f => !wanted.Contains(f.TargetActorURI)))
|
||||
await _follows.UnfollowAs(reader, gone.TargetActorURI, token);
|
||||
var followed = following.Select(f => f.TargetActorURI).ToHashSet(StringComparer.Ordinal);
|
||||
foreach (var community in wanted.Where(c => !followed.Contains(c)))
|
||||
// only a community: a feed lists nothing else, and the reader follows nothing else
|
||||
if (await _remoteActors.GetActor(community, refresh: false, token) is { AvatarType: AvatarType.Group })
|
||||
await _follows.FollowAs(reader, community, showReblogs: false, token);
|
||||
}
|
||||
}
|
||||
|
||||
public class FeedSyncJob(IServiceScopeFactory scopes) : IJobHandler
|
||||
{
|
||||
public JobKind Kind => JobKind.SyncFeeds;
|
||||
public int Concurrency => 1;
|
||||
public int MaxAttempts => 2;
|
||||
public int PerHostLimit => 1;
|
||||
|
||||
public async Task<JobOutcome> Handle(Job job, CancellationToken token)
|
||||
{
|
||||
using var scope = scopes.CreateScope();
|
||||
await scope.ServiceProvider.GetRequiredService<IFeedFollows>().Reconcile(token);
|
||||
return JobOutcome.Done;
|
||||
}
|
||||
}
|
||||
|
||||
// brings the reader's follows up to date with the feeds read here, a minute after start and then every six hours
|
||||
public sealed class FeedReader(IServiceScopeFactory scopes, ILogger<FeedReader> logger) : BackgroundService
|
||||
{
|
||||
static readonly TimeSpan Every = TimeSpan.FromHours(6);
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken token)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMinutes(1), token);
|
||||
using var timer = new PeriodicTimer(Every);
|
||||
do
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = scopes.CreateScope();
|
||||
await FeedFollows.Sync(scope.ServiceProvider.GetRequiredService<IJobQueue>(), token);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(ex, "Feeds could not be brought up to date");
|
||||
}
|
||||
}
|
||||
while (await timer.WaitForNextTickAsync(token));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,7 @@ namespace PrivaPub.Domain.Social
|
||||
var following = new Following
|
||||
{
|
||||
AvatarId = follower.Id,
|
||||
FollowerKind = follower.Kind == LocalActorKind.Person ? default(LocalActorKind?) : follower.Kind,
|
||||
TargetActorURI = targetUri,
|
||||
TargetAccountId = local?.Id ?? remote.ID,
|
||||
TargetIsLocal = local != default,
|
||||
@@ -298,7 +299,7 @@ namespace PrivaPub.Domain.Social
|
||||
.ExecuteAsync(token);
|
||||
foreach (var following in pending.Where(f => (f.ResentAt ?? f.CreatedAt) + ResendAfter[f.Resent] <= now))
|
||||
{
|
||||
var follower = await _localActors.FindById(LocalActorKind.Person, following.AvatarId, token);
|
||||
var follower = await _localActors.FindById(following.FollowerKind ?? LocalActorKind.Person, following.AvatarId, token);
|
||||
var inbox = following.TargetInboxURL ?? (await _remoteActors.GetActor(following.TargetActorURI, refresh: false, token))?.InboxURL;
|
||||
if (follower != default && !string.IsNullOrEmpty(inbox))
|
||||
{
|
||||
|
||||
@@ -64,6 +64,7 @@ namespace PrivaPub.Domain.Statuses
|
||||
Task<StatusOutcome> Edit(LocalActor author, string postId, StatusDraft draft, CancellationToken token);
|
||||
Task<StatusOutcome> Remove(LocalActor author, string postId, CancellationToken token);
|
||||
Task<StatusOutcome> Favourite(LocalActor me, string postId, bool on, CancellationToken token);
|
||||
Task<StatusOutcome> Downvote(LocalActor me, string postId, bool on, CancellationToken token);
|
||||
Task<StatusOutcome> Reblog(LocalActor me, string postId, bool on, PostVisibility visibility, CancellationToken token);
|
||||
}
|
||||
|
||||
@@ -438,6 +439,9 @@ namespace PrivaPub.Domain.Statuses
|
||||
var likePermission = on ? await Permission(post, me, InteractionKind.Like, token) : QuotePermission.Granted;
|
||||
if (likePermission == QuotePermission.Denied)
|
||||
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Its author does not take likes from you");
|
||||
// one vote a post: an upvote replaces the downvote, here and, by its Like alone, there
|
||||
if (on)
|
||||
await Forget<Downvote>(d => d.PostId == post.ID && d.ActorURI == me.Uri, post, p => p.DownvotesCount, token);
|
||||
|
||||
// each favourite is a Like of its own, as Mastodon's are: a favourite after an unfavourite is a new Like, which no
|
||||
// server (and no delivery queue) takes for the one already undone; an unfavourite undoes the Like it ends
|
||||
@@ -472,7 +476,7 @@ namespace PrivaPub.Domain.Statuses
|
||||
post.FavouritesCount--;
|
||||
}
|
||||
|
||||
if (post.IsFederatedCopy && await AuthorInbox(post, token) is { } inbox)
|
||||
if (post.IsFederatedCopy && await VoteInboxes(post, token) is { Count: > 0 } inboxes)
|
||||
{
|
||||
var like = new JsonObject
|
||||
{
|
||||
@@ -485,11 +489,95 @@ namespace PrivaPub.Domain.Statuses
|
||||
if (on && favourite.Approval == ApprovalState.Pending)
|
||||
await _approvals.Ask(me, post, InteractionKind.Like, like, favourite.ID, token);
|
||||
else
|
||||
await _delivery.Enqueue(me, new[] { inbox }, on ? like : Undo(me, like, $"undo-like-{favourite.ID}"), token);
|
||||
await _delivery.Enqueue(me, inboxes, on ? like : Undo(me, like, $"undo-like-{favourite.ID}"), token);
|
||||
}
|
||||
return new StatusOutcome(post);
|
||||
}
|
||||
|
||||
// A downvote, as the threadiverse counts them (Lemmy, PieFed): a `Dislike` to the post's author and its community,
|
||||
// `Undo{Dislike}` to take it back. One vote a post: a downvote replaces the upvote by its Dislike alone, as Lemmy
|
||||
// sends a changed vote, since an Undo{Like} sent beside it could arrive after it and take the downvote away (Mbin,
|
||||
// which keeps a favourite apart, keeps it). Public, as a favourite is (owner decision: the client tells each
|
||||
// persona once).
|
||||
public async Task<StatusOutcome> Downvote(LocalActor me, string postId, bool on, CancellationToken token)
|
||||
{
|
||||
var post = await Visible(me, postId, token);
|
||||
if (post?.ReblogOfPostId != default)
|
||||
post = await Visible(me, post.ReblogOfPostId, token);
|
||||
if (post == default)
|
||||
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
|
||||
|
||||
Downvote downvote;
|
||||
if (on)
|
||||
{
|
||||
await Forget<Favourite>(f => f.AccountId == me.Id && f.PostId == post.ID, post, p => p.FavouritesCount, token);
|
||||
downvote = new Downvote { ID = ObjectId.GenerateNewId().ToString(), AccountId = me.Id, ActorURI = me.Uri, PostId = post.ID };
|
||||
downvote.ActivityURI = me.ActivityUri($"dislike-{downvote.ID}");
|
||||
try
|
||||
{
|
||||
await DB.Default.SaveAsync(downvote, token);
|
||||
}
|
||||
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
return new StatusOutcome(post);
|
||||
}
|
||||
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(b => b.Inc(p => p.DownvotesCount, 1)).ExecuteAsync(token);
|
||||
post.DownvotesCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
downvote = await DB.Default.Find<Downvote>().Match(d => d.PostId == post.ID && d.ActorURI == me.Uri).ExecuteFirstAsync(token);
|
||||
if (downvote == default || (await DB.Default.DeleteAsync<Downvote>(downvote.ID)).DeletedCount == 0)
|
||||
return new StatusOutcome(post);
|
||||
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(b => b.Inc(p => p.DownvotesCount, -1)).ExecuteAsync(token);
|
||||
post.DownvotesCount--;
|
||||
}
|
||||
|
||||
if (post.IsFederatedCopy && await VoteInboxes(post, token) is { Count: > 0 } inboxes)
|
||||
{
|
||||
var dislike = new JsonObject
|
||||
{
|
||||
["@context"] = ActivityPubRenderer.ActivityStreams,
|
||||
["id"] = downvote.ActivityURI,
|
||||
["type"] = "Dislike",
|
||||
["actor"] = me.Uri,
|
||||
["object"] = post.ObjectURI
|
||||
};
|
||||
await _delivery.Enqueue(me, inboxes, on ? dislike : Undo(me, dislike, $"undo-dislike-{downvote.ID}"), token);
|
||||
}
|
||||
return new StatusOutcome(post);
|
||||
}
|
||||
|
||||
// the persona's other vote on the post, dropped here alone: the vote that replaces it says so there
|
||||
async Task Forget<TVote>(System.Linq.Expressions.Expression<Func<TVote, bool>> mine, PostEntity post,
|
||||
System.Linq.Expressions.Expression<Func<PostEntity, int>> count, CancellationToken token) where TVote : Entity
|
||||
{
|
||||
var vote = await DB.Default.Find<TVote>().Match(mine).ExecuteFirstAsync(token);
|
||||
if (vote == default || (await DB.Default.DeleteAsync<TVote>(vote.ID)).DeletedCount == 0)
|
||||
return;
|
||||
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(b => b.Inc(count, -1)).ExecuteAsync(token);
|
||||
if (typeof(TVote) == typeof(Favourite))
|
||||
post.FavouritesCount--;
|
||||
else
|
||||
post.DownvotesCount--;
|
||||
}
|
||||
|
||||
// where a vote on a remote post goes: the community it was made in, which counts the votes and passes them on, as
|
||||
// Lemmy sends one; and its author when on another server (a Mastodon account's post in a Lemmy community). One copy a
|
||||
// server: PieFed drops the second it is sent within a minute, though its first may yet fail.
|
||||
async Task<List<string>> VoteInboxes(PostEntity post, CancellationToken token)
|
||||
{
|
||||
var inboxes = new List<string>();
|
||||
if (await Federation.Inbox.Communities.Of(post, _dbEntities, token) is { } communityUri
|
||||
&& await _dbEntities.ForeignAvatars.Match(f => f.ActorURI == communityUri && f.AvatarType == AvatarType.Group).ExecuteFirstAsync(token) is { } community
|
||||
&& !string.IsNullOrEmpty(community.InboxURL))
|
||||
inboxes.Add(community.InboxURL);
|
||||
if (await AuthorInbox(post, token) is { } author
|
||||
&& !inboxes.Any(i => string.Equals(new Uri(i).Authority, new Uri(author).Authority, StringComparison.OrdinalIgnoreCase)))
|
||||
inboxes.Add(author);
|
||||
return inboxes;
|
||||
}
|
||||
|
||||
// how a remote post's interaction policy takes an interaction of ours: at once, once asked, or not at all
|
||||
async Task<QuotePermission> Permission(PostEntity target, LocalActor actor, InteractionKind kind, CancellationToken token) =>
|
||||
target is { IsFederatedCopy: true } && _approvals != default ? await _approvals.Judge(target, actor, kind, token) : QuotePermission.Granted;
|
||||
|
||||
@@ -40,13 +40,14 @@ namespace PrivaPub.Domain.Timelines
|
||||
{
|
||||
case PostVisibility.Public or PostVisibility.Unlisted or PostVisibility.FollowersOnly:
|
||||
var followers = post.IsFederatedCopy
|
||||
? await _dbEntities.Followings.Match(f => f.TargetActorURI == post.ActorURI && f.State == FollowState.Accepted).ExecuteAsync(token)
|
||||
? await _dbEntities.Followings.Match(f => f.TargetActorURI == post.ActorURI && f.State == FollowState.Accepted && f.FollowerKind == null).ExecuteAsync(token)
|
||||
: await _dbEntities.Followings.Match(f => f.TargetAccountId == post.GroupUserId && f.TargetIsLocal && f.State == FollowState.Accepted).ExecuteAsync(token);
|
||||
foreach (var following in followers)
|
||||
if (await Shows(following, post, token))
|
||||
recipients.Add(following.AvatarId);
|
||||
if (!string.IsNullOrEmpty(post.AudienceURI))
|
||||
foreach (var member in await _dbEntities.Followings.Match(f => f.TargetActorURI == post.AudienceURI && f.State == FollowState.Accepted).ExecuteAsync(token))
|
||||
// (the server's reader of feeds follows communities for nobody's home)
|
||||
foreach (var member in await _dbEntities.Followings.Match(f => f.TargetActorURI == post.AudienceURI && f.State == FollowState.Accepted && f.FollowerKind == null).ExecuteAsync(token))
|
||||
recipients.Add(member.AvatarId);
|
||||
// a post on a wall (FEP-400e): its owner, when a persona, and those following the owner
|
||||
if (!string.IsNullOrEmpty(post.WallOwnerURI))
|
||||
|
||||
@@ -19,16 +19,18 @@ namespace PrivaPub.Federation.Actors
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly IFeaturedPosts _featured;
|
||||
readonly ILocalActorService _localActors;
|
||||
readonly IJobQueue _jobs;
|
||||
|
||||
public const int MaxFeedCommunities = 50;
|
||||
|
||||
public AccountCountsJob(IRemoteActorService remoteActors, DbEntities dbEntities, IFeaturedPosts featured = default,
|
||||
ILocalActorService localActors = default)
|
||||
ILocalActorService localActors = default, IJobQueue jobs = default)
|
||||
{
|
||||
_remoteActors = remoteActors;
|
||||
_dbEntities = dbEntities;
|
||||
_featured = featured;
|
||||
_localActors = localActors;
|
||||
_jobs = jobs;
|
||||
}
|
||||
|
||||
public JobKind Kind => JobKind.CountAccount;
|
||||
@@ -44,19 +46,24 @@ namespace PrivaPub.Federation.Actors
|
||||
if (actor == default)
|
||||
return JobOutcome.Done;
|
||||
var feed = actor.AvatarType == AvatarType.Feed;
|
||||
var (following, communities) = await Read(actor, actor.FollowingURL, feed, token);
|
||||
var (following, communities, listed) = await Read(actor, actor.FollowingURL, feed, token);
|
||||
var update = DB.Default.Update<ForeignAvatar>().MatchID(actor.ID)
|
||||
.Modify(a => a.FollowersCount, (await Read(actor, actor.FollowersURL, false, token)).Total)
|
||||
.Modify(a => a.FollowingCount, following)
|
||||
.Modify(a => a.StatusesCount, (await Read(actor, actor.OutboxURL, false, token)).Total)
|
||||
.Modify(a => a.CountedAt, DateTime.UtcNow);
|
||||
if (feed)
|
||||
// (a feed whose list could not be read keeps the one it had: the reader of feeds would unfollow them all)
|
||||
if (feed && listed)
|
||||
update = update.Modify(a => a.FeedCommunities, communities);
|
||||
await update.ExecuteAsync(token);
|
||||
if (feed)
|
||||
if (feed && listed)
|
||||
{
|
||||
foreach (var community in communities)
|
||||
if (_localActors == default || !community.StartsWith(_localActors.BaseAddress + "/", StringComparison.Ordinal))
|
||||
await _remoteActors.GetActor(community, refresh: false, token);
|
||||
if (_jobs != default && await DB.Default.Find<Models.Social.FeedSubscription>().Match(s => s.FeedActorURI == actor.ActorURI).ExecuteAnyAsync(token))
|
||||
await Domain.Social.FeedFollows.Sync(_jobs, token);
|
||||
}
|
||||
if (_featured != default)
|
||||
await _featured.Sync(actor, token);
|
||||
return JobOutcome.Done;
|
||||
@@ -64,31 +71,33 @@ namespace PrivaPub.Federation.Actors
|
||||
|
||||
// only from the actor's own server, as everything we believe about it: its total, and the ids it lists when asked
|
||||
// (inline, or on its first page)
|
||||
async Task<(int? Total, List<string> Items)> Read(ForeignAvatar actor, string collection, bool items, CancellationToken token)
|
||||
async Task<(int? Total, List<string> Items, bool Listed)> Read(ForeignAvatar actor, string collection, bool items, CancellationToken token)
|
||||
{
|
||||
var listed = new List<string>();
|
||||
if (string.IsNullOrEmpty(collection) || !Origin.Same(collection, actor.ActorURI))
|
||||
return (default, listed);
|
||||
return (default, listed, false);
|
||||
using var scope = HttpScope.For("collection");
|
||||
using var fetched = await _remoteActors.FetchObject(collection, token);
|
||||
if (fetched == default || fetched.Root.ValueKind != System.Text.Json.JsonValueKind.Object)
|
||||
return (default, listed);
|
||||
return (default, listed, false);
|
||||
int? total = fetched.Root.TryGetProperty("totalItems", out var counted) && counted.TryGetInt32(out var count) && count >= 0 ? count : default;
|
||||
if (!items)
|
||||
return (total, listed);
|
||||
return (total, listed, true);
|
||||
var document = System.Text.Json.Nodes.JsonNode.Parse(fetched.Root.GetRawText());
|
||||
var page = document["orderedItems"] ?? document["items"] ?? document["first"]?["orderedItems"] ?? document["first"]?["items"];
|
||||
if (page == default && ActivityJson.Id(document["first"]) is { } first && Origin.Same(first, actor.ActorURI))
|
||||
{
|
||||
using var next = await _remoteActors.FetchObject(first, token);
|
||||
var nextPage = next == default ? default : System.Text.Json.Nodes.JsonNode.Parse(next.Root.GetRawText());
|
||||
if (next == default)
|
||||
return (total, listed, false);
|
||||
var nextPage = System.Text.Json.Nodes.JsonNode.Parse(next.Root.GetRawText());
|
||||
page = nextPage?["orderedItems"] ?? nextPage?["items"];
|
||||
}
|
||||
if (page is System.Text.Json.Nodes.JsonArray array)
|
||||
listed = array.Select(ActivityJson.Id)
|
||||
.Where(id => Uri.TryCreate(id, UriKind.Absolute, out var uri) && uri.Scheme is "https" or "http")
|
||||
.Distinct(StringComparer.Ordinal).Take(MaxFeedCommunities).ToList();
|
||||
return (total, listed);
|
||||
return (total, listed, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@ namespace PrivaPub.Federation.Actors
|
||||
public string Wall => $"{Uri}/graffiti";
|
||||
public bool HasWall => Kind == LocalActorKind.Person && !IsCircle && IsFederated;
|
||||
// the server's own actors (the instance actor, the reporter): nobody follows, mentions or looks them up as accounts
|
||||
public bool IsServerActor => Kind is LocalActorKind.Application or LocalActorKind.Reporter;
|
||||
public bool IsServerActor => Kind is LocalActorKind.Application or LocalActorKind.Reporter or LocalActorKind.Reader;
|
||||
public string Flock => $"{Uri}/flock";
|
||||
public string Wardens => $"{Uri}/wardens";
|
||||
public string SharedInbox => $"{BaseAddress}/human-centipede";
|
||||
@@ -74,6 +74,7 @@ namespace PrivaPub.Federation.Actors
|
||||
Task<LocalActor> FindByAddress(string address, CancellationToken token);
|
||||
Task<LocalActor> GetInstanceActor(CancellationToken token);
|
||||
Task<LocalActor> GetReporterActor(CancellationToken token);
|
||||
Task<LocalActor> GetReaderActor(CancellationToken token);
|
||||
Task<bool> IsUserNameTaken(string userName, CancellationToken token);
|
||||
Task<bool> TryReserveUserName(string userName, LocalActorKind kind, string ownerId, CancellationToken token);
|
||||
LocalActor FromAvatar(Avatar avatar);
|
||||
@@ -89,13 +90,17 @@ namespace PrivaPub.Federation.Actors
|
||||
// the anonymous Service that carries this server's reports to Lemmy, which takes no report from an Application
|
||||
// (owner decision 2026-10-06): one actor for the server, never one per persona
|
||||
public const string ReporterUserName = "privapub_reports";
|
||||
// the anonymous Service that follows the communities of the feeds personas read, so no community learns which persona
|
||||
// reads it (owner decision 2026-10-07); a Service, since Lemmy takes a follow from no Application
|
||||
public const string ReaderUserName = "privapub_feeds";
|
||||
|
||||
public static bool IsServerActorName(string userName) =>
|
||||
string.Equals(userName, InstanceUserName, StringComparison.OrdinalIgnoreCase) || string.Equals(userName, ReporterUserName, StringComparison.OrdinalIgnoreCase);
|
||||
string.Equals(userName, InstanceUserName, StringComparison.OrdinalIgnoreCase) || string.Equals(userName, ReporterUserName, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(userName, ReaderUserName, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
static readonly HashSet<string> ReservedByInstance = new(StringComparer.Ordinal)
|
||||
{
|
||||
InstanceUserName, ReporterUserName, "admin", "administrator", "root", "system", "support", "help", "moderator", "mod",
|
||||
InstanceUserName, ReporterUserName, ReaderUserName, "admin", "administrator", "root", "system", "support", "help", "moderator", "mod",
|
||||
"abuse", "postmaster", "webmaster", "hostmaster", "security", "noreply", "no_reply", "null", "undefined"
|
||||
};
|
||||
|
||||
@@ -103,6 +108,7 @@ namespace PrivaPub.Federation.Actors
|
||||
readonly IOptionsMonitor<AppConfiguration> _appConfiguration;
|
||||
InstanceActor _instanceActor;
|
||||
ReporterActor _reporterActor;
|
||||
ReaderActor _readerActor;
|
||||
|
||||
public LocalActorService(DbEntities dbEntities, IOptionsMonitor<AppConfiguration> appConfiguration)
|
||||
{
|
||||
@@ -121,6 +127,8 @@ namespace PrivaPub.Federation.Actors
|
||||
return await GetInstanceActor(token);
|
||||
if (userName == ReporterUserName)
|
||||
return await GetReporterActor(token);
|
||||
if (userName == ReaderUserName)
|
||||
return await GetReaderActor(token);
|
||||
|
||||
var avatar = await _dbEntities.Avatars
|
||||
.Match(a => a.UserName == userName && !a.DeletionAt.HasValue)
|
||||
@@ -158,6 +166,8 @@ namespace PrivaPub.Federation.Actors
|
||||
return group == default ? default : FromGroup(group);
|
||||
case LocalActorKind.Reporter:
|
||||
return await GetReporterActor(token);
|
||||
case LocalActorKind.Reader:
|
||||
return await GetReaderActor(token);
|
||||
default:
|
||||
return await GetInstanceActor(token);
|
||||
}
|
||||
@@ -221,6 +231,36 @@ namespace PrivaPub.Federation.Actors
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<LocalActor> GetReaderActor(CancellationToken token)
|
||||
{
|
||||
var reader = _readerActor ??= await LoadReaderActor(token);
|
||||
|
||||
return new LocalActor
|
||||
{
|
||||
Id = reader.ID,
|
||||
Kind = LocalActorKind.Reader,
|
||||
UserName = ReaderUserName,
|
||||
Name = $"Feeds read on {new Uri(BaseAddress).Host}",
|
||||
Summary = "It follows the communities in the feeds this PrivaPub server's people read; it never names who reads them.",
|
||||
PrivateKeyPem = reader.PrivateKey,
|
||||
PublicKeyPem = reader.PublicKey,
|
||||
Discoverable = false,
|
||||
Published = reader.CreationDate,
|
||||
BaseAddress = BaseAddress
|
||||
};
|
||||
}
|
||||
|
||||
async Task<ReaderActor> LoadReaderActor(CancellationToken token)
|
||||
{
|
||||
var reader = await _dbEntities.ReaderActors.Sort(r => r.CreationDate, Order.Ascending).ExecuteFirstAsync(token);
|
||||
if (reader != default)
|
||||
return reader;
|
||||
var (privateKey, publicKey) = Keys.NewKeyPair();
|
||||
reader = new ReaderActor { PrivateKey = privateKey, PublicKey = publicKey };
|
||||
await DB.Default.SaveAsync(reader, token);
|
||||
return reader;
|
||||
}
|
||||
|
||||
async Task<ReporterActor> LoadReporterActor(CancellationToken token)
|
||||
{
|
||||
var reporter = await _dbEntities.ReporterActors.Sort(r => r.CreationDate, Order.Ascending).ExecuteFirstAsync(token);
|
||||
|
||||
@@ -532,8 +532,8 @@ namespace PrivaPub.Federation.Controllers
|
||||
if (HttpMethods.IsGet(Request.Method))
|
||||
Response.Headers.Vary = "Accept";
|
||||
// SecureMode asks every reader of ActivityPub documents for a signature, except for the server's own actors (the
|
||||
// instance actor, the reporter), whose keys peers need first, and except for browsers, which only get redirected to
|
||||
// the public pages
|
||||
// instance actor, the reporter, the reader of feeds), whose keys peers need first, and except for browsers, which
|
||||
// only get redirected to the public pages
|
||||
if (_federation.CurrentValue.SecureMode && HttpMethods.IsGet(Request.Method) && !WantsHtml() && Request.Path.Value != "/"
|
||||
&& !LocalActorService.IsServerActorName(context.RouteData.Values["actor"] as string)
|
||||
&& await _fetches.Requester(Request, HttpContext.RequestAborted) == default)
|
||||
|
||||
@@ -67,6 +67,7 @@ namespace PrivaPub.Federation.Inbox
|
||||
LocalActorKind.Group when !local.IsCircle => "group",
|
||||
LocalActorKind.Application => "application",
|
||||
LocalActorKind.Reporter => "reporter",
|
||||
LocalActorKind.Reader => "reader",
|
||||
_ => default
|
||||
};
|
||||
}
|
||||
|
||||
@@ -182,6 +182,7 @@ namespace PrivaPub.Federation.Outbox
|
||||
{ Kind: LocalActorKind.Person } => "person",
|
||||
{ Kind: LocalActorKind.Group } => "group",
|
||||
{ Kind: LocalActorKind.Reporter } => "reporter",
|
||||
{ Kind: LocalActorKind.Reader } => "reader",
|
||||
_ => "application"
|
||||
},
|
||||
Signature = "cavage:rsa-sha256"
|
||||
|
||||
@@ -126,7 +126,7 @@ namespace PrivaPub.Federation.Rendering
|
||||
{
|
||||
LocalActorKind.Group => "Group",
|
||||
LocalActorKind.Application => "Application",
|
||||
LocalActorKind.Reporter => "Service",
|
||||
LocalActorKind.Reporter or LocalActorKind.Reader => "Service",
|
||||
_ when actor.IsBot => "Service",
|
||||
_ => "Person"
|
||||
},
|
||||
|
||||
@@ -26,6 +26,7 @@ namespace PrivaPub.Infrastructure.Data
|
||||
await Plain<Post>(token, p => p.WallURI, p => p.ID);//a persona's wall (FEP-400e)
|
||||
await Plain<Post>(token, p => p.ConversationId, p => p.ID);
|
||||
await Plain<Post>(token, p => p.InReplyToURI);
|
||||
await Plain<Post>(token, p => p.AudienceURI, p => p.ID);//a feed's timeline: the posts of its communities
|
||||
// who boosted what: a persona's boosts of the posts a page shows were a scan of every post under load
|
||||
// (tools/pasture/load.sh), and a post's boosts and replies are read for its counts and context
|
||||
await Plain<Post>(token, p => p.AuthorAccountId, p => p.ReblogOfPostId);
|
||||
@@ -167,6 +168,9 @@ namespace PrivaPub.Infrastructure.Data
|
||||
await Plain<ObjectRecord>(token, r => r.PostId);
|
||||
await Unique<DeletedObject>(d => d.ObjectURI, Builders<DeletedObject>.Filter.Gt(d => d.ObjectURI, ""), token);
|
||||
await DB.Default.Index<DeletedObject>().Key(d => d.DeletedAt, KeyType.Ascending).Option(o => o.ExpireAfter = TombstoneLifetime).CreateAsync(token);
|
||||
await DB.Default.Index<FeedSubscription>().Key(s => s.AvatarId, KeyType.Ascending).Key(s => s.FeedActorURI, KeyType.Ascending)
|
||||
.Option(o => o.Unique = true).CreateAsync(token);
|
||||
await Plain<FeedSubscription>(token, s => s.FeedActorURI);
|
||||
await Unique<ThreadCollection>(c => c.URI, Builders<ThreadCollection>.Filter.Gt(c => c.URI, ""), token);
|
||||
await DB.Default.Index<ThreadCollection>().Key(c => c.ReadAt, KeyType.Ascending).Option(o => o.ExpireAfter = ThreadCollectionLifetime).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);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.User;
|
||||
|
||||
namespace PrivaPub.Infrastructure.Data.Migrations
|
||||
{
|
||||
// the feeds reader's name (LocalActorService.ReaderUserName) is the server's, as the reporter's is: a persona or group
|
||||
// already holding it would be hidden behind the reader, so the upgrade stops and says so
|
||||
public class _017_the_reader_has_its_name : IMigration
|
||||
{
|
||||
public async Task UpgradeAsync()
|
||||
{
|
||||
var held = await DB.Default.Find<ReservedName>().Match(r => r.Name == LocalActorService.ReaderUserName).ExecuteFirstAsync();
|
||||
if (held is { OwnerKind: LocalActorKind.Reader })
|
||||
return;
|
||||
if (held != default)
|
||||
throw new InvalidOperationException(
|
||||
$"The name {LocalActorService.ReaderUserName} belongs to a {held.OwnerKind} ({held.OwnerId}); rename it before upgrading");
|
||||
await DB.Default.SaveAsync(new ReservedName { Name = LocalActorService.ReaderUserName, OwnerKind = LocalActorKind.Reader });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,7 @@ namespace PrivaPub.Middleware
|
||||
.AddSingleton<IJobHandler, PollRefreshJob>()
|
||||
.AddSingleton<IJobHandler, RecoveryJob>()
|
||||
.AddSingleton<IJobHandler, Federation.Actors.AccountCountsJob>()
|
||||
.AddSingleton<IJobHandler, Domain.Social.FeedSyncJob>()
|
||||
.AddSingleton<IJobHandler, Federation.Actors.OutboxBackfill>()
|
||||
.AddSingleton<IJobHandler, Federation.Actors.FollowingSynchronization>()
|
||||
.AddSingleton<IJobHandler, PollCloseJob>()
|
||||
@@ -136,6 +137,7 @@ namespace PrivaPub.Middleware
|
||||
.AddSingleton<IJobHandler, DeliveryJobHandler>()
|
||||
.AddHostedService<JobWorker>()
|
||||
.AddHostedService<Domain.Social.FollowResender>()
|
||||
.AddHostedService<Domain.Social.FeedReader>()
|
||||
.AddSingleton<Federation.Relays.IRelays, Federation.Relays.Relays>()
|
||||
.AddHostedService<Federation.Relays.RelaySubscriber>();
|
||||
}
|
||||
@@ -234,6 +236,7 @@ namespace PrivaPub.Middleware
|
||||
.AddTransient<IRelationshipService, RelationshipService>()
|
||||
.AddTransient<IReportService, ReportService>()
|
||||
.AddTransient<IFollowService, FollowService>()
|
||||
.AddTransient<IFeedFollows, FeedFollows>()
|
||||
.AddTransient<ITimelineService, TimelineService>()
|
||||
.AddSingleton<AppConfigurationService>()
|
||||
.AddHttpContextAccessor()
|
||||
|
||||
@@ -19,6 +19,7 @@ namespace PrivaPub.Models.Federation
|
||||
Person,
|
||||
Group,
|
||||
Application,
|
||||
Reporter//the anonymous Service that carries reports to Lemmy (LocalActorService.ReporterUserName)
|
||||
Reporter,//the anonymous Service that carries reports to Lemmy (LocalActorService.ReporterUserName)
|
||||
Reader//the anonymous Service that follows the communities of the feeds personas read (LocalActorService.ReaderUserName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
namespace PrivaPub.Models.Federation
|
||||
{
|
||||
// the server's anonymous reader of feeds (LocalActorService.ReaderUserName), its keys
|
||||
public class ReaderActor : Entity
|
||||
{
|
||||
public string PrivateKey { get; set; }
|
||||
public string PublicKey { get; set; }
|
||||
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,8 @@ namespace PrivaPub.Models.Jobs
|
||||
SynchronizeFollowing,
|
||||
ProcessMedia,
|
||||
ExportArchive,
|
||||
ImportArchive
|
||||
ImportArchive,
|
||||
SyncFeeds
|
||||
}
|
||||
|
||||
public enum JobState
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
namespace PrivaPub.Models.Social
|
||||
{
|
||||
// a persona reading a feed elsewhere (Lemmy's multi-community, PieFed's feed): the server's reader follows the feed's
|
||||
// communities, never the persona (owner decision 2026-10-07)
|
||||
public class FeedSubscription : Entity
|
||||
{
|
||||
public string AvatarId { get; set; }
|
||||
public string FeedActorURI { get; set; }
|
||||
public string FeedAccountId { get; set; }//ForeignAvatar.ID
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,8 @@ namespace PrivaPub.Models.Social
|
||||
{
|
||||
public class Following : Entity
|
||||
{
|
||||
public string AvatarId { get; set; }//the local follower
|
||||
public string AvatarId { get; set; }//the local follower: a persona, or the server's reader of feeds (FollowerKind)
|
||||
public PrivaPub.Models.Federation.LocalActorKind? FollowerKind { get; set; }//null for a persona
|
||||
public string TargetActorURI { get; set; }
|
||||
public string TargetAccountId { get; set; }//Avatar.ID, Group.ID or ForeignAvatar.ID
|
||||
public bool TargetIsLocal { get; set; }
|
||||
|
||||
@@ -69,6 +69,8 @@ namespace PrivaPub.Services
|
||||
await DB.Default.DeleteAsync<Models.Social.PersonaList>(l => l.AvatarId == avatar.ID);
|
||||
await DB.Default.DeleteAsync<Models.Social.PersonaFilter>(f => f.AvatarId == avatar.ID);
|
||||
await DB.Default.DeleteAsync<Models.Social.FollowedTag>(t => t.AvatarId == avatar.ID);
|
||||
// (the reader of feeds stops following what nobody reads any more at its next round)
|
||||
await DB.Default.DeleteAsync<Models.Social.FeedSubscription>(s => s.AvatarId == avatar.ID);
|
||||
// nothing of it publishes later, and none of its files stays served: its posts' media, its pictures and
|
||||
// what its scheduled posts held
|
||||
await DB.Default.DeleteAsync<Models.Social.ScheduledStatus>(s => s.AvatarId == avatar.ID);
|
||||
|
||||
@@ -34,6 +34,7 @@ namespace PrivaPub.StaticServices
|
||||
public Find<Delivery> Deliveries { get { return DB.Default.Find<Delivery>(); } }
|
||||
public Find<InstanceActor> InstanceActors { get { return DB.Default.Find<InstanceActor>(); } }
|
||||
public Find<ReporterActor> ReporterActors { get { return DB.Default.Find<ReporterActor>(); } }
|
||||
public Find<ReaderActor> ReaderActors { get { return DB.Default.Find<ReaderActor>(); } }
|
||||
|
||||
public Find<Following> Followings { get { return DB.Default.Find<Following>(); } }
|
||||
public Find<TimelineEntry> TimelineEntries { get { return DB.Default.Find<TimelineEntry>(); } }
|
||||
|
||||
Reference in new issue
Block a user