Files

127 lines
5.4 KiB
C#
Raw Permalink Normal View History

2026-10-01 12:13:57 +02:00
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
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(),
Category = category is "spam" or "legal" or "violation" ? category : "other"
2026-10-01 12:13:57 +02:00
};
await DB.Default.SaveAsync(report, token);
if (!forward || remoteTarget == default)
return report;
2026-10-01 12:13:57 +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);
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;
}
// 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());
}
2026-10-01 12:13:57 +02:00
}
}