Reports reach Lemmy's moderators, from an anonymous reporter

Lemmy takes a report only from a person or a service, about one post or comment, addressed to its community, and it
answered PrivaPub's Flag (the instance actor's, an Application, with no `to` and the account and posts as its object)
400. A report of a post or comment in a community on a server whose NodeInfo names Lemmy now leaves from
`privapub_reports`, a Service with its own key that names nobody: one Flag per post, `to` the community (its own
audience, else its thread's), with the persona's words, or the category, in `summary` and `content`, sent to the
community's inbox. This is the second exception to "a server's software is for display" (owner decision 2026-10-06,
`ReportService.ServiceReportTakers`). Every other server keeps the instance actor's report. An account alone is not
reported to Lemmy, which takes no such report, and `forwarded` now says whether anything left.

The reporter is read unsigned in SecureMode and answers WebFinger like the instance actor. Nobody follows or mentions
it, the Mastodon API has no account for it, and a migration reserves its name. Checked live: Lemmy 1.0 and 0.19 keep the
reports of a thread and of a comment, with alice's words, from "Reports from privapub.test", and none names her (69
checks). A sweep of every scenario with this and the next commit: 876 checks pass; Ghost's Network feed listed alice's
post too late once, and Ghost passes alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-06 19:23:17 +02:00
1 parent 10ff4b3d2a
commit f66c280b0b
33 files changed
+561 -46

No files matched your search

@@ -115,7 +115,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
if (parts.Length == 1 || parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase))
{
var local = await _localActors.FindByUserName(parts[0], token);
return local is { IsFederated: true, IsCircle: false, Kind: not LocalActorKind.Application } ? Json(await _mapper.Local(local, false, token)) : NotFoundError();
return local is { IsFederated: true, IsCircle: false, IsServerActor: false } ? Json(await _mapper.Local(local, false, token)) : NotFoundError();
}
var userName = parts[0];
var domain = parts[1].ToLowerInvariant();
+1 -1
View File
@@ -122,7 +122,7 @@ namespace PrivaPub.Domain.Content
if (parts.Length == 1 || parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase))
{
var local = await _localActors.FindByUserName(parts[0], token);
return local is { IsFederated: true, IsCircle: false, Kind: not LocalActorKind.Application }
return local is { IsFederated: true, IsCircle: false, IsServerActor: false }
? new ResolvedMention(local.Uri, local.UserName, local.Domain, local.Uri, true, local.Id, local.Inbox)
: default;
}
+54 -3
View File
@@ -1,6 +1,7 @@
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Social;
@@ -50,12 +51,41 @@ namespace PrivaPub.Domain.Relationships
PostIds = posts.Select(p => p.ID).ToList(),
ObjectURIs = posts.Select(p => p.ObjectURI).ToList(),
Comment = comment?.Trim(),
Category = category is "spam" or "legal" or "violation" ? category : "other",
Forwarded = forward && remoteTarget != default
Category = category is "spam" or "legal" or "violation" ? category : "other"
};
await DB.Default.SaveAsync(report, token);
if (!forward || remoteTarget == default)
return report;
if (report.Forwarded)
// to the community of each post made in one on a server that takes reports only in Lemmy's shape
var flagged = 0;
var reason = string.IsNullOrEmpty(report.Comment) ? report.Category : report.Comment;
foreach (var post in posts)
{
var community = await Communities.Of(post, _dbEntities, token);
if (community == default || !await TakesServiceReports(community, token))
continue;
var group = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == community).ExecuteFirstAsync(token);
var inbox = string.IsNullOrEmpty(group?.SharedInboxURL) ? group?.InboxURL : group.SharedInboxURL;
if (string.IsNullOrEmpty(inbox))
continue;
var serviceReporter = await _localActors.GetReporterActor(token);
await _delivery.Enqueue(serviceReporter, new[] { inbox }, new JsonObject
{
["@context"] = ActivityPubRenderer.ActivityStreams,
["id"] = serviceReporter.ActivityUri($"flag-{report.ID}-{++flagged}"),
["type"] = "Flag",
["actor"] = serviceReporter.Uri,
["to"] = new JsonArray(community),
["audience"] = community,
["object"] = post.ObjectURI,
["summary"] = reason,
["content"] = reason
}, token);
}
// every other server, the author's, as before: an account alone, or posts outside such communities, go nowhere when
// the author's server takes reports only in Lemmy's shape
if (!await TakesServiceReports(targetUri, token))
{
var instance = await _localActors.GetInstanceActor(token);
var flag = new JsonObject
@@ -68,8 +98,29 @@ namespace PrivaPub.Domain.Relationships
["object"] = new JsonArray(report.ObjectURIs.Prepend(targetUri).Select(u => (JsonNode)u).ToArray())
};
await _delivery.Enqueue(instance, new[] { remoteTarget.SharedInboxURL ?? remoteTarget.InboxURL }, flag, token);
flagged++;
}
if (flagged > 0)
{
report.Forwarded = true;
await DB.Default.Update<Report>().MatchID(report.ID).Modify(r => r.Forwarded, true).ExecuteAsync(token);
}
return report;
}
// The second place PrivaPub decides by a server's software (owner decision 2026-10-06): Lemmy takes a report only from a
// person or a service, about one post or comment, addressed to its community, so a report for it leaves in that shape
// from the server's anonymous reporter; a server joins only once the pasture shows it stores such a report with its
// reason. NodeInfo names it: PieFed and Mbin speak Lemmy's shapes, and nothing else tells them apart.
static readonly HashSet<string> ServiceReportTakers = new(StringComparer.Ordinal) { "lemmy" };
static async Task<bool> TakesServiceReports(string actorUri, CancellationToken token)
{
if (!Uri.TryCreate(actorUri, UriKind.Absolute, out var uri))
return false;
var host = uri.Host;
var instance = await DB.Default.Find<Models.Jobs.RemoteInstance>().Match(i => i.Host == host).ExecuteFirstAsync(token);
return instance?.Software is { } software && ServiceReportTakers.Contains(software.ToLowerInvariant());
}
}
}
+1 -1
View File
@@ -96,7 +96,7 @@ namespace PrivaPub.Domain.Social
var (local, remote) = await ResolveTarget(target, token);
if (local == default && remote == default)
return default;
if (local != default && (local.Id == follower.Id || !local.IsFederated || local.IsCircle || local.Kind == LocalActorKind.Application))
if (local != default && (local.Id == follower.Id || !local.IsFederated || local.IsCircle || local.IsServerActor))
return default;
var targetUri = local?.Uri ?? remote.ActorURI;
@@ -50,6 +50,8 @@ namespace PrivaPub.Federation.Actors
public string FeaturedTags => $"{Uri}/tattoos";
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 string Flock => $"{Uri}/flock";
public string Wardens => $"{Uri}/wardens";
public string SharedInbox => $"{BaseAddress}/human-centipede";
@@ -71,6 +73,7 @@ namespace PrivaPub.Federation.Actors
Task<LocalActor> FindByUri(string actorUri, CancellationToken token);
Task<LocalActor> FindByAddress(string address, CancellationToken token);
Task<LocalActor> GetInstanceActor(CancellationToken token);
Task<LocalActor> GetReporterActor(CancellationToken token);
Task<bool> IsUserNameTaken(string userName, CancellationToken token);
Task<bool> TryReserveUserName(string userName, LocalActorKind kind, string ownerId, CancellationToken token);
LocalActor FromAvatar(Avatar avatar);
@@ -83,16 +86,23 @@ namespace PrivaPub.Federation.Actors
public class LocalActorService : ILocalActorService
{
public const string InstanceUserName = "privapub";
// 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";
public static bool IsServerActorName(string userName) =>
string.Equals(userName, InstanceUserName, StringComparison.OrdinalIgnoreCase) || string.Equals(userName, ReporterUserName, StringComparison.OrdinalIgnoreCase);
static readonly HashSet<string> ReservedByInstance = new(StringComparer.Ordinal)
{
InstanceUserName, "admin", "administrator", "root", "system", "support", "help", "moderator", "mod",
InstanceUserName, ReporterUserName, "admin", "administrator", "root", "system", "support", "help", "moderator", "mod",
"abuse", "postmaster", "webmaster", "hostmaster", "security", "noreply", "no_reply", "null", "undefined"
};
readonly DbEntities _dbEntities;
readonly IOptionsMonitor<AppConfiguration> _appConfiguration;
InstanceActor _instanceActor;
ReporterActor _reporterActor;
public LocalActorService(DbEntities dbEntities, IOptionsMonitor<AppConfiguration> appConfiguration)
{
@@ -109,6 +119,8 @@ namespace PrivaPub.Federation.Actors
userName = userName.ToLowerInvariant();
if (userName == InstanceUserName)
return await GetInstanceActor(token);
if (userName == ReporterUserName)
return await GetReporterActor(token);
var avatar = await _dbEntities.Avatars
.Match(a => a.UserName == userName && !a.DeletionAt.HasValue)
@@ -144,6 +156,8 @@ namespace PrivaPub.Federation.Actors
case LocalActorKind.Group:
var group = await _dbEntities.Groups.MatchID(id).ExecuteFirstAsync(token);
return group == default ? default : FromGroup(group);
case LocalActorKind.Reporter:
return await GetReporterActor(token);
default:
return await GetInstanceActor(token);
}
@@ -187,6 +201,37 @@ namespace PrivaPub.Federation.Actors
};
}
public async Task<LocalActor> GetReporterActor(CancellationToken token)
{
var reporter = _reporterActor ??= await LoadReporterActor(token);
return new LocalActor
{
Id = reporter.ID,
Kind = LocalActorKind.Reporter,
UserName = ReporterUserName,
Name = $"Reports from {new Uri(BaseAddress).Host}",
Summary = "It carries this PrivaPub server's reports to the servers that take them only from a person or a service; "
+ "it never names who made them.",
PrivateKeyPem = reporter.PrivateKey,
PublicKeyPem = reporter.PublicKey,
Discoverable = false,
Published = reporter.CreationDate,
BaseAddress = BaseAddress
};
}
async Task<ReporterActor> LoadReporterActor(CancellationToken token)
{
var reporter = await _dbEntities.ReporterActors.Sort(r => r.CreationDate, Order.Ascending).ExecuteFirstAsync(token);
if (reporter != default)
return reporter;
var (privateKey, publicKey) = Keys.NewKeyPair();
reporter = new ReporterActor { PrivateKey = privateKey, PublicKey = publicKey };
await DB.Default.SaveAsync(reporter, token);
return reporter;
}
async Task<InstanceActor> LoadInstanceActor(CancellationToken token)
{
var instance = await _dbEntities.InstanceActors.Sort(i => i.CreationDate, Order.Ascending).ExecuteFirstAsync(token);
@@ -69,7 +69,7 @@ namespace PrivaPub.Federation.Controllers
};
if (local is not { IsFederated: true })
return NotFound();
if (WantsHtml() && local.Kind != LocalActorKind.Application)
if (WantsHtml() && !local.IsServerActor)
return Redirect(local.HtmlUrl);
return Activity(ActivityPubRenderer.Actor(local));
}
@@ -531,10 +531,11 @@ 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 instance actor, whose key
// peers need first, and except for browsers, which only get redirected to the public pages
// 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
if (_federation.CurrentValue.SecureMode && HttpMethods.IsGet(Request.Method) && !WantsHtml() && Request.Path.Value != "/"
&& !string.Equals(context.RouteData.Values["actor"] as string, LocalActorService.InstanceUserName, StringComparison.OrdinalIgnoreCase)
&& !LocalActorService.IsServerActorName(context.RouteData.Values["actor"] as string)
&& await _fetches.Requester(Request, HttpContext.RequestAborted) == default)
{
context.Result = StatusCode(StatusCodes.Status401Unauthorized);
@@ -74,8 +74,8 @@ namespace PrivaPub.Federation.Controllers
var document = new JsonObject
{
["subject"] = $"acct:{actor.Handle}",
["aliases"] = actor.Kind == LocalActorKind.Application ? new JsonArray(actor.Uri) : new JsonArray(actor.HtmlUrl, actor.Uri),
["links"] = actor.Kind == LocalActorKind.Application
["aliases"] = actor.IsServerActor ? new JsonArray(actor.Uri) : new JsonArray(actor.HtmlUrl, actor.Uri),
["links"] = actor.IsServerActor
? new JsonArray(new JsonObject { ["rel"] = "self", ["type"] = "application/activity+json", ["href"] = actor.Uri })
: new JsonArray(
new JsonObject { ["rel"] = "http://webfinger.net/rel/profile-page", ["type"] = "text/html", ["href"] = actor.HtmlUrl },
+1
View File
@@ -66,6 +66,7 @@ namespace PrivaPub.Federation.Inbox
LocalActorKind.Person => "person",
LocalActorKind.Group when !local.IsCircle => "group",
LocalActorKind.Application => "application",
LocalActorKind.Reporter => "reporter",
_ => default
};
}
+24
View File
@@ -0,0 +1,24 @@
using MongoDB.Entities;
using PrivaPub.StaticServices;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Federation.Inbox
{
public static class Communities
{
// the remote community a post was made in: its own audience, or the first one its thread names on the way up (a
// comment fetched for its thread, or delivered to a persona, may name none)
public static async Task<string> Of(PostEntity post, DbEntities dbEntities, CancellationToken token)
{
for (var depth = 0; post != default && depth < RemotePosts.MaxDepth; depth++)
{
if (!string.IsNullOrEmpty(post.AudienceURI))
return post.AudienceURI;
post = string.IsNullOrEmpty(post.AnsweringToPostId) ? default : await dbEntities.Posts.MatchID(post.AnsweringToPostId).ExecuteFirstAsync(token);
}
return default;
}
}
}
@@ -285,13 +285,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
var post = target == default
? default
: await _dbEntities.Posts.Match(p => p.ObjectURI == target && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
for (var depth = 0; post != default && depth < RemotePosts.MaxDepth; depth++)
{
if (post.AudienceURI == group.ActorURI)
return true;
post = string.IsNullOrEmpty(post.AnsweringToPostId) ? default : await _dbEntities.Posts.MatchID(post.AnsweringToPostId).ExecuteFirstAsync(token);
}
return false;
return post != default && await Communities.Of(post, _dbEntities, token) == group.ActorURI;
}
// A community's moderators lock one of its posts, or unlock it: no more replies, ours included. The community vouches
@@ -44,7 +44,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
var follower = actor;
var target = await _localActors.FindByAddress(Id(follow["object"]), token);
if (target is not { IsFederated: true } || target.Kind == LocalActorKind.Application)
if (target is not { IsFederated: true } || target.IsServerActor)
{
Arrival.Drop("unknown-recipient");
return;
+1 -1
View File
@@ -254,7 +254,7 @@ namespace PrivaPub.Federation.Inbox
case "Follow":
// (by its actor's id, or the profile page Forte names instead)
var target = await _localActors.FindByAddress(Id(inner), token);
if (target is not { IsFederated: true } || target.Kind == LocalActorKind.Application)
if (target is not { IsFederated: true } || target.IsServerActor)
return new(StatusCodes.Status404NotFound, "no such local actor", Reason: "unknown-recipient");
break;
case "Undo" when inner is JsonObject && Id(inner["actor"]) != actorUri:
@@ -181,6 +181,7 @@ namespace PrivaPub.Federation.Outbox
{ IsCircle: true } => default,
{ Kind: LocalActorKind.Person } => "person",
{ Kind: LocalActorKind.Group } => "group",
{ Kind: LocalActorKind.Reporter } => "reporter",
_ => "application"
},
Signature = "cavage:rsa-sha256"
@@ -125,13 +125,14 @@ namespace PrivaPub.Federation.Rendering
{
LocalActorKind.Group => "Group",
LocalActorKind.Application => "Application",
LocalActorKind.Reporter => "Service",
_ when actor.IsBot => "Service",
_ => "Person"
},
["preferredUsername"] = actor.UserName,
["name"] = actor.Name,
["summary"] = Html(actor.Summary),
["url"] = actor.Kind == LocalActorKind.Application ? actor.Uri : actor.HtmlUrl,
["url"] = actor.IsServerActor ? actor.Uri : actor.HtmlUrl,
["inbox"] = actor.Inbox,
["outbox"] = actor.Outbox,
["followers"] = actor.Followers,
@@ -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 anonymous reporter's name (LocalActorService.ReporterUserName) is the server's, as the instance actor's is: a
// persona or group already holding it would be hidden behind the reporter, so the upgrade stops and says so
public class _015_the_reporter_has_its_name : IMigration
{
public async Task UpgradeAsync()
{
var held = await DB.Default.Find<ReservedName>().Match(r => r.Name == LocalActorService.ReporterUserName).ExecuteFirstAsync();
if (held is { OwnerKind: LocalActorKind.Reporter })
return;
if (held != default)
throw new InvalidOperationException(
$"The name {LocalActorService.ReporterUserName} belongs to a {held.OwnerKind} ({held.OwnerId}); rename it before upgrading");
await DB.Default.SaveAsync(new ReservedName { Name = LocalActorService.ReporterUserName, OwnerKind = LocalActorKind.Reporter });
}
}
}
+2 -1
View File
@@ -18,6 +18,7 @@ namespace PrivaPub.Models.Federation
{
Person,
Group,
Application
Application,
Reporter//the anonymous Service that carries reports to Lemmy (LocalActorService.ReporterUserName)
}
}
@@ -0,0 +1,12 @@
using MongoDB.Entities;
namespace PrivaPub.Models.Federation
{
// the server's anonymous reporter (LocalActorService.ReporterUserName), its keys
public class ReporterActor : Entity
{
public string PrivateKey { get; set; }
public string PublicKey { get; set; }
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
}
}
+3 -2
View File
@@ -11,8 +11,9 @@ namespace PrivaPub.Models.Jobs
public DateTime? LastFailureAt { get; set; }
public string LastError { get; set; }
public string Software { get; set; }//NodeInfo software.name: for display, never for deciding behaviour, but for the one
//owner decision (2026-10-05) that a first private message to Lemmy before 1.0 or Mbin is a ChatMessage
public string Software { get; set; }//NodeInfo software.name: for display, never for deciding behaviour, but for two
//owner decisions: a first private message to Lemmy before 1.0 or Mbin is a ChatMessage (2026-10-05), and a report
//to a Lemmy community leaves from the reporter in Lemmy's shape (2026-10-06, ReportService.ServiceReportTakers)
public string SoftwareVersion { get; set; }
public string NodeName { get; set; }
public List<string> Protocols { get; set; } = new();
+1
View File
@@ -33,6 +33,7 @@ namespace PrivaPub.StaticServices
public Find<Follower> Followers { get { return DB.Default.Find<Follower>(); } }
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<Following> Followings { get { return DB.Default.Find<Following>(); } }
public Find<TimelineEntry> TimelineEntries { get { return DB.Default.Find<TimelineEntry>(); } }
+1 -1
View File
@@ -61,7 +61,7 @@ namespace PrivaPub.Web.Pages
public async Task<IActionResult> OnGetAsync(string user, CancellationToken token)
{
Actor = await _localActors.FindByUserName(user, token);
if (Actor is not { IsFederated: true, IsCircle: false } || Actor.Kind == LocalActorKind.Application)
if (Actor is not { IsFederated: true, IsCircle: false } || Actor.IsServerActor)
return NotFound();
if (WantsActivityJson())
return Redirect(Actor.Uri);