Blocks, mutes, bookmarks, pins and reports
- Blocks are per persona and never federate (a Block activity would tell
the other server who blocked whom): the blocked account is removed as a
follower, with a Reject{Follow} if it is remote, unfollowed, cleared from
home and notifications, and refused with Reject if it follows again.
- Mutes (optionally timed, optionally sparing notifications) and per-
persona domain blocks keep authors out of home timelines, notifications
and every status list the API returns; the boosts of a hidden author's
posts are hidden too.
- Bookmarks and pins (at most five, public or unlisted, own posts) with
their Mastodon endpoints and flags; pinned posts are the actor's
`featured` collection at /trophies, and `featuredTags` points at
/tattoos.
- Reports: /api/v1/reports stores the report and, when forwarding to a
remote account, sends Flag from the instance actor, so the reporting
persona is never named to the other server. An inbound Flag about a local
persona or its posts becomes a report; moderators list and resolve them
under /clientapi/moderator/reports.
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
525b5368a1
commit
8f75317050
20 files changed
+813
-21
No files matched your search
@@ -42,6 +42,8 @@ namespace PrivaPub.Federation.Actors
|
||||
public string Outbox => $"{Uri}/anus";
|
||||
public string Followers => $"{Uri}/groupies";
|
||||
public string Following => $"{Uri}/stalking";
|
||||
public string Featured => $"{Uri}/trophies";
|
||||
public string FeaturedTags => $"{Uri}/tattoos";
|
||||
public string SharedInbox => $"{BaseAddress}/human-centipede";
|
||||
public string Domain => new Uri(BaseAddress).Authority;
|
||||
public string Handle => $"{UserName}@{Domain}";
|
||||
|
||||
@@ -5,6 +5,7 @@ using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
@@ -112,6 +113,34 @@ namespace PrivaPub.Federation.Controllers
|
||||
return local is not { IsFederated: true } ? NotFound() : Activity(ActivityPubRenderer.OrderedCollection(local.Following, 0, default));
|
||||
}
|
||||
|
||||
[HttpGet, Route("{actor}/trophies")]
|
||||
public async Task<IActionResult> Featured(string actor, CancellationToken token)
|
||||
{
|
||||
var local = await _localActors.FindByUserName(actor, token);
|
||||
if (local is not { IsFederated: true })
|
||||
return NotFound();
|
||||
var pinIds = (await DB.Default.Find<Pin>().Match(p => p.AvatarId == local.Id).Sort(p => p.ID, Order.Descending).ExecuteAsync(token))
|
||||
.Select(p => p.PostId).ToList();
|
||||
var posts = pinIds.Count == 0
|
||||
? new List<PostEntity>()
|
||||
: await _dbEntities.Posts.Match(p => pinIds.Contains(p.ID) && p.GroupUserId == local.Id && p.ReblogOfPostId == null)
|
||||
.Match(VisibilityPolicy.IsPublic).ExecuteAsync(token);
|
||||
var notes = new List<JsonNode>();
|
||||
foreach (var id in pinIds)
|
||||
if (posts.FirstOrDefault(p => p.ID == id) is { } post)
|
||||
notes.Add(ActivityPubRenderer.Note(post, local, default, post.InReplyToURI));
|
||||
return Activity(ActivityPubRenderer.OrderedCollection(local.Featured, notes.Count, notes));
|
||||
}
|
||||
|
||||
[HttpGet, Route("{actor}/tattoos")]
|
||||
public async Task<IActionResult> FeaturedTags(string actor, CancellationToken token)
|
||||
{
|
||||
var local = await _localActors.FindByUserName(actor, token);
|
||||
return local is not { IsFederated: true }
|
||||
? NotFound()
|
||||
: Activity(ActivityPubRenderer.OrderedCollection(local.FeaturedTags, 0, Enumerable.Empty<JsonNode>()));
|
||||
}
|
||||
|
||||
[HttpGet, Route("{actor}/scribbles/{postId}")]
|
||||
public async Task<IActionResult> Post(string actor, string postId, CancellationToken token)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Social;
|
||||
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 FlagHandler : IActivityHandler
|
||||
{
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly ILocalActorService _localActors;
|
||||
|
||||
public FlagHandler(DbEntities dbEntities, ILocalActorService localActors)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
_localActors = localActors;
|
||||
}
|
||||
|
||||
public string Type => "Flag";
|
||||
|
||||
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
|
||||
{
|
||||
var objects = activity["object"] switch
|
||||
{
|
||||
JsonArray array => array.Select(Id).Where(i => i != default).ToList(),
|
||||
JsonNode single when Id(single) is { } id => new List<string> { id },
|
||||
_ => new List<string>()
|
||||
};
|
||||
|
||||
LocalActor target = default;
|
||||
var postUris = new List<string>();
|
||||
foreach (var uri in objects.Take(50))
|
||||
{
|
||||
var local = await _localActors.FindByUri(uri, token);
|
||||
if (local is { Kind: LocalActorKind.Person } && uri.TrimEnd('/') == local.Uri)
|
||||
target ??= local;
|
||||
else
|
||||
postUris.Add(uri);
|
||||
}
|
||||
var posts = postUris.Count == 0
|
||||
? new List<Models.Post.Post>()
|
||||
: await _dbEntities.Posts.Match(p => postUris.Contains(p.ObjectURI) && !p.IsFederatedCopy).ExecuteAsync(token);
|
||||
if (target == default && posts.Count > 0)
|
||||
target = await _localActors.FindById(LocalActorKind.Person, posts[0].GroupUserId, token);
|
||||
if (target == default)
|
||||
return;
|
||||
|
||||
await DB.Default.SaveAsync(new Report
|
||||
{
|
||||
ReporterActorURI = actor.ActorURI,
|
||||
TargetAccountId = target.Id,
|
||||
TargetActorURI = target.Uri,
|
||||
PostIds = posts.Where(p => p.GroupUserId == target.Id).Select(p => p.ID).ToList(),
|
||||
ObjectURIs = posts.Where(p => p.GroupUserId == target.Id).Select(p => p.ObjectURI).ToList(),
|
||||
Comment = Value(activity, "content")
|
||||
}, token);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,20 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
var target = await _localActors.FindByUri(Id(follow["object"]), token);
|
||||
if (target is not { IsFederated: true } || target.Kind == LocalActorKind.Application)
|
||||
return;
|
||||
if (target.Kind == LocalActorKind.Person
|
||||
&& await DB.Default.Find<Models.Social.Block>().Match(b => b.AvatarId == target.Id && b.TargetActorURI == follower.ActorURI).ExecuteAnyAsync(token))
|
||||
{
|
||||
var reject = new JsonObject
|
||||
{
|
||||
["@context"] = ActivityPubRenderer.ActivityStreams,
|
||||
["id"] = target.ActivityUri($"reject-{Guid.NewGuid():N}"),
|
||||
["type"] = "Reject",
|
||||
["actor"] = target.Uri,
|
||||
["object"] = follow.DeepClone()
|
||||
};
|
||||
await _delivery.Enqueue(target, new[] { follower.InboxURL }, reject, token);
|
||||
return;
|
||||
}
|
||||
|
||||
var existing = await _dbEntities.Followers
|
||||
.Match(f => f.LocalActorId == target.Id && f.LocalActorKind == target.Kind && f.ActorURI == follower.ActorURI)
|
||||
|
||||
@@ -76,6 +76,8 @@ namespace PrivaPub.Federation.Rendering
|
||||
["outbox"] = actor.Outbox,
|
||||
["followers"] = actor.Followers,
|
||||
["following"] = actor.Following,
|
||||
["featured"] = actor.Featured,
|
||||
["featuredTags"] = actor.FeaturedTags,
|
||||
["published"] = Day(actor.Published),
|
||||
["manuallyApprovesFollowers"] = actor.ManuallyApprovesFollowers,
|
||||
["discoverable"] = actor.Discoverable,
|
||||
|
||||
Reference in new issue
Block a user