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 File(LocalActor reporter, string targetAccountId, IReadOnlyList 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 File(LocalActor reporter, string targetAccountId, IReadOnlyList 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() : 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().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 ServiceReportTakers = new(StringComparer.Ordinal) { "lemmy" }; static async Task 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().Match(i => i.Host == host).ExecuteFirstAsync(token); return instance?.Software is { } software && ServiceReportTakers.Contains(software.ToLowerInvariant()); } } }