Files
SocialPub/PrivaPub/Domain/Relationships/ReportService.cs
T
thepraandClaude Opus 5.5 f105d1f7ea
Build / Build (push) Failing after 7m51s
Deploy / privapub.thepra.dev (push) Successful in 7m58s
PieFed and Mbin take reports from the reporter too
Both speak Lemmy's shapes and joined the servers that get reports in that shape only once the pasture showed each keeps
one with its reason. By their source, PieFed dropped the instance actor's report (it makes a user only of a Person or a
Service) and Mbin kept it without the persona's words (it reads the reason from `summary` alone). With them in
`ServiceReportTakers`, each keeps the reports of a thread and of a comment from "Reports from privapub.test" with
alice's words, and none names her (piefed.sh and mbin.sh, 65 checks with the full sweep's 876). A report of a PieFed
account alone, which PieFed would take from the reporter, is not sent yet; INTEROP says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 19:24:09 +02:00

128 lines
5.4 KiB
C#

using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
namespace PrivaPub.Domain.Relationships
{
public interface IReportService
{
Task<Report> File(LocalActor reporter, string targetAccountId, IReadOnlyList<string> postIds, string comment, string category, bool forward,
CancellationToken token);
}
public class ReportService : IReportService
{
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IDeliveryService _delivery;
public ReportService(DbEntities dbEntities, ILocalActorService localActors, IDeliveryService delivery)
{
_dbEntities = dbEntities;
_localActors = localActors;
_delivery = delivery;
}
public async Task<Report> File(LocalActor reporter, string targetAccountId, IReadOnlyList<string> postIds, string comment, string category,
bool forward, CancellationToken token)
{
var localTarget = await _dbEntities.Avatars.MatchID(targetAccountId).ExecuteFirstAsync(token);
var remoteTarget = localTarget == default ? await _dbEntities.ForeignAvatars.MatchID(targetAccountId).ExecuteFirstAsync(token) : default;
if (localTarget == default && remoteTarget == default)
return default;
var targetUri = localTarget != default ? _localActors.FromAvatar(localTarget).Uri : remoteTarget.ActorURI;
var posts = postIds.Count == 0
? new List<Models.Post.Post>()
: await _dbEntities.Posts.Match(p => postIds.Contains(p.ID) && p.ActorURI == targetUri).ExecuteAsync(token);
var report = new Report
{
ReporterAvatarId = reporter.Id,
TargetAccountId = targetAccountId,
TargetActorURI = targetUri,
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"
};
await DB.Default.SaveAsync(report, token);
if (!forward || remoteTarget == default)
return report;
// 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
{
["@context"] = ActivityPubRenderer.ActivityStreams,
["id"] = instance.ActivityUri($"flag-{report.ID}"),
["type"] = "Flag",
["actor"] = instance.Uri,
["content"] = report.Comment ?? string.Empty,
["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. PieFed (which drops a report from an Application) and Mbin (which reads the reason from summary alone) did on
// 2026-10-06. NodeInfo names them: they speak Lemmy's shapes, and nothing else tells them apart.
static readonly HashSet<string> ServiceReportTakers = new(StringComparer.Ordinal) { "lemmy", "piefed", "mbin" };
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());
}
}
}