2026-10-01 12:13:57 +02:00
|
|
|
using MongoDB.Entities;
|
|
|
|
|
|
|
|
|
|
using PrivaPub.Federation.Actors;
|
2026-10-06 19:23:17 +02:00
|
|
|
using PrivaPub.Federation.Inbox;
|
2026-10-01 12:13:57 +02:00
|
|
|
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(),
|
2026-10-06 19:23:17 +02:00
|
|
|
Category = category is "spam" or "legal" or "violation" ? category : "other"
|
2026-10-01 12:13:57 +02:00
|
|
|
};
|
|
|
|
|
await DB.Default.SaveAsync(report, token);
|
2026-10-06 19:23:17 +02:00
|
|
|
if (!forward || remoteTarget == default)
|
|
|
|
|
return report;
|
2026-10-01 12:13:57 +02:00
|
|
|
|
2026-10-06 19:23:17 +02:00
|
|
|
// 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))
|
2026-10-01 12:13:57 +02:00
|
|
|
{
|
|
|
|
|
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);
|
2026-10-06 19:23:17 +02:00
|
|
|
flagged++;
|
|
|
|
|
}
|
|
|
|
|
if (flagged > 0)
|
|
|
|
|
{
|
|
|
|
|
report.Forwarded = true;
|
|
|
|
|
await DB.Default.Update<Report>().MatchID(report.ID).Modify(r => r.Forwarded, true).ExecuteAsync(token);
|
2026-10-01 12:13:57 +02:00
|
|
|
}
|
|
|
|
|
return report;
|
|
|
|
|
}
|
2026-10-06 19:23:17 +02:00
|
|
|
|
|
|
|
|
// 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());
|
|
|
|
|
}
|
2026-10-01 12:13:57 +02:00
|
|
|
}
|
|
|
|
|
}
|