2026-10-01 11:39:10 +02:00
|
|
|
using MongoDB.Driver;
|
|
|
|
|
using MongoDB.Entities;
|
|
|
|
|
|
2026-10-03 11:15:21 +02:00
|
|
|
using PrivaPub.Domain.Statuses;
|
2026-10-01 11:39:10 +02:00
|
|
|
using PrivaPub.Domain.Social;
|
|
|
|
|
using PrivaPub.Domain.Timelines;
|
|
|
|
|
using PrivaPub.Federation.Actors;
|
|
|
|
|
using PrivaPub.Federation.Objects;
|
|
|
|
|
using PrivaPub.Infrastructure.Ids;
|
|
|
|
|
using PrivaPub.Models.Post;
|
|
|
|
|
using PrivaPub.Models.Social;
|
|
|
|
|
using PrivaPub.Models.User;
|
|
|
|
|
using PrivaPub.StaticServices;
|
|
|
|
|
|
|
|
|
|
using System.Globalization;
|
|
|
|
|
using System.Text.Json.Nodes;
|
|
|
|
|
|
|
|
|
|
using static PrivaPub.Federation.Objects.ActivityJson;
|
|
|
|
|
|
|
|
|
|
using PostEntity = PrivaPub.Models.Post.Post;
|
|
|
|
|
|
|
|
|
|
namespace PrivaPub.Federation.Inbox.Handlers
|
|
|
|
|
{
|
|
|
|
|
public class AnnounceHandler : IActivityHandler
|
|
|
|
|
{
|
|
|
|
|
readonly DbEntities _dbEntities;
|
|
|
|
|
readonly ILocalActorService _localActors;
|
|
|
|
|
readonly IRemotePosts _remotePosts;
|
|
|
|
|
readonly IFanout _fanout;
|
2026-10-01 12:30:57 +02:00
|
|
|
readonly IRemoteActorService _remoteActors;
|
2026-10-01 11:39:10 +02:00
|
|
|
|
2026-10-03 11:15:21 +02:00
|
|
|
readonly IObjectRecords _records;
|
|
|
|
|
readonly IQuoteService _quotes;
|
|
|
|
|
|
|
|
|
|
public AnnounceHandler(DbEntities dbEntities, ILocalActorService localActors, IRemotePosts remotePosts, IFanout fanout, IRemoteActorService remoteActors,
|
|
|
|
|
IObjectRecords records, IQuoteService quotes)
|
2026-10-01 11:39:10 +02:00
|
|
|
{
|
2026-10-03 11:15:21 +02:00
|
|
|
_records = records;
|
|
|
|
|
_quotes = quotes;
|
2026-10-01 12:30:57 +02:00
|
|
|
_remoteActors = remoteActors;
|
2026-10-01 11:39:10 +02:00
|
|
|
_dbEntities = dbEntities;
|
|
|
|
|
_localActors = localActors;
|
|
|
|
|
_remotePosts = remotePosts;
|
|
|
|
|
_fanout = fanout;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public string Type => "Announce";
|
|
|
|
|
|
|
|
|
|
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
|
|
|
|
|
{
|
|
|
|
|
var inner = activity["object"];
|
|
|
|
|
if (inner is JsonObject && Value(inner, "type") is "Create" or "Update" or "Delete" or "Like" or "Dislike" or "Undo" or "Add" or "Remove" or "Block")
|
2026-10-01 12:30:57 +02:00
|
|
|
{
|
|
|
|
|
await GroupActivity(inner, actor, token);
|
2026-10-01 11:39:10 +02:00
|
|
|
return;
|
2026-10-01 12:30:57 +02:00
|
|
|
}
|
2026-10-01 11:39:10 +02:00
|
|
|
var objectUri = Id(inner);
|
|
|
|
|
var announceId = Id(activity);
|
|
|
|
|
if (objectUri == default || announceId == default)
|
2026-10-03 11:04:42 +02:00
|
|
|
{
|
|
|
|
|
Arrival.Drop("unparseable");
|
2026-10-01 11:39:10 +02:00
|
|
|
return;
|
2026-10-03 11:04:42 +02:00
|
|
|
}
|
2026-10-01 11:39:10 +02:00
|
|
|
if (await _dbEntities.Posts.Match(p => p.ObjectURI == announceId).ExecuteAnyAsync(token))
|
2026-10-03 11:04:42 +02:00
|
|
|
{
|
|
|
|
|
Arrival.Drop("duplicate");
|
2026-10-01 11:39:10 +02:00
|
|
|
return;
|
2026-10-03 11:04:42 +02:00
|
|
|
}
|
2026-10-01 11:39:10 +02:00
|
|
|
|
|
|
|
|
var isLocal = objectUri.StartsWith(_localActors.BaseAddress + "/", StringComparison.OrdinalIgnoreCase);
|
|
|
|
|
var original = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token);
|
|
|
|
|
var followed = await _dbEntities.Followings.Match(f => f.TargetActorURI == actor.ActorURI && f.State == FollowState.Accepted).ExecuteAnyAsync(token);
|
|
|
|
|
if (original is not { IsFederatedCopy: false } && !followed)
|
2026-10-03 11:04:42 +02:00
|
|
|
{
|
|
|
|
|
Arrival.Drop("not-followed");
|
2026-10-01 11:39:10 +02:00
|
|
|
return;
|
2026-10-03 11:04:42 +02:00
|
|
|
}
|
2026-10-01 11:39:10 +02:00
|
|
|
if (original == default && !isLocal)
|
|
|
|
|
original = await _remotePosts.StoreContext(objectUri, 0, token);
|
2026-10-03 11:04:42 +02:00
|
|
|
if (original == default)
|
|
|
|
|
{
|
|
|
|
|
Arrival.Drop("fetch-failed");
|
2026-10-01 11:39:10 +02:00
|
|
|
return;
|
2026-10-03 11:04:42 +02:00
|
|
|
}
|
|
|
|
|
Arrival.About(original.ObjectType ?? "Note", original.Visibility, original.CreationDate);
|
|
|
|
|
if (original.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted))
|
|
|
|
|
{
|
|
|
|
|
Arrival.Drop("not-public");
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-10-01 11:39:10 +02:00
|
|
|
|
|
|
|
|
var to = Strings(activity["to"]);
|
|
|
|
|
var cc = Strings(activity["cc"]);
|
|
|
|
|
var published = DateTimeOffset.TryParse(Value(activity, "published"), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var at)
|
|
|
|
|
? at.UtcDateTime
|
|
|
|
|
: DateTime.UtcNow;
|
|
|
|
|
var reblog = new PostEntity
|
|
|
|
|
{
|
2026-10-01 13:19:16 +02:00
|
|
|
ID = PrivacyIds.Arrived(published),
|
2026-10-01 11:39:10 +02:00
|
|
|
ObjectURI = announceId,
|
|
|
|
|
ActivityURI = announceId,
|
|
|
|
|
ActorURI = actor.ActorURI,
|
|
|
|
|
AuthorAccountId = actor.ID,
|
|
|
|
|
ReblogOfPostId = original.ID,
|
|
|
|
|
Visibility = Addressing.Classify(to, cc, actor.FollowersURL),
|
|
|
|
|
To = to,
|
|
|
|
|
Cc = cc,
|
|
|
|
|
IsFederatedCopy = true,
|
|
|
|
|
CreationDate = published,
|
|
|
|
|
UpdateDate = published
|
|
|
|
|
};
|
|
|
|
|
if (reblog.Visibility == PostVisibility.Direct)
|
2026-10-03 11:04:42 +02:00
|
|
|
{
|
|
|
|
|
Arrival.Drop("not-public");
|
2026-10-01 11:39:10 +02:00
|
|
|
return;
|
2026-10-03 11:04:42 +02:00
|
|
|
}
|
2026-10-01 11:39:10 +02:00
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
await DB.Default.SaveAsync(reblog, token);
|
|
|
|
|
}
|
|
|
|
|
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
|
|
|
|
{
|
2026-10-03 11:04:42 +02:00
|
|
|
Arrival.Drop("duplicate");
|
2026-10-01 11:39:10 +02:00
|
|
|
return;
|
|
|
|
|
}
|
2026-10-03 11:04:42 +02:00
|
|
|
Arrival.Accept("stored");
|
|
|
|
|
Arrival.About(local: original.IsFederatedCopy ? default : await _localActors.FindById(Models.Federation.LocalActorKind.Person, original.GroupUserId, token));
|
2026-10-01 11:39:10 +02:00
|
|
|
|
|
|
|
|
await DB.Default.Update<PostEntity>().MatchID(original.ID).Modify(b => b.Inc(p => p.ReblogsCount, 1)).ExecuteAsync(token);
|
|
|
|
|
if (!original.IsFederatedCopy)
|
|
|
|
|
await Notifications.Add(original.GroupUserId, NotificationType.Reblog, actor.ID, actor.ActorURI, original.ID, token);
|
|
|
|
|
await _fanout.Distribute(reblog, token);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-01 12:30:57 +02:00
|
|
|
async Task GroupActivity(JsonNode inner, ForeignAvatar group, CancellationToken token)
|
|
|
|
|
{
|
2026-10-03 11:04:42 +02:00
|
|
|
Arrival.About(Value(inner, "type"));
|
2026-10-01 12:30:57 +02:00
|
|
|
if (group.AvatarType != AvatarType.Group
|
|
|
|
|
|| !await _dbEntities.Followings.Match(f => f.TargetActorURI == group.ActorURI && f.State == FollowState.Accepted).ExecuteAnyAsync(token))
|
2026-10-03 11:04:42 +02:00
|
|
|
{
|
|
|
|
|
Arrival.Drop("not-followed");
|
2026-10-01 12:30:57 +02:00
|
|
|
return;
|
2026-10-03 11:04:42 +02:00
|
|
|
}
|
2026-10-01 12:30:57 +02:00
|
|
|
var objectUri = Id(inner["object"]);
|
|
|
|
|
switch (Value(inner, "type"))
|
|
|
|
|
{
|
|
|
|
|
case "Create" when objectUri != default:
|
|
|
|
|
if (await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteAnyAsync(token))
|
2026-10-03 11:04:42 +02:00
|
|
|
{
|
|
|
|
|
Arrival.Drop("duplicate");
|
2026-10-01 12:30:57 +02:00
|
|
|
return;
|
2026-10-03 11:04:42 +02:00
|
|
|
}
|
2026-10-01 12:30:57 +02:00
|
|
|
var post = await _remotePosts.StoreContext(objectUri, 0, token);
|
|
|
|
|
if (post == default)
|
2026-10-03 11:04:42 +02:00
|
|
|
{
|
|
|
|
|
Arrival.Drop("fetch-failed");
|
2026-10-01 12:30:57 +02:00
|
|
|
return;
|
2026-10-03 11:04:42 +02:00
|
|
|
}
|
|
|
|
|
Arrival.Accept("stored");
|
|
|
|
|
Arrival.About(visibility: post.Visibility);
|
2026-10-01 12:30:57 +02:00
|
|
|
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(p => p.AudienceURI, group.ActorURI).ExecuteAsync(token);
|
|
|
|
|
post.AudienceURI = group.ActorURI;
|
|
|
|
|
await _fanout.Distribute(post, token);
|
|
|
|
|
break;
|
|
|
|
|
case "Update" when objectUri != default:
|
|
|
|
|
var stored = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.AudienceURI == group.ActorURI && !p.DeletedAt.HasValue)
|
|
|
|
|
.ExecuteFirstAsync(token);
|
|
|
|
|
if (stored == default)
|
2026-10-03 11:04:42 +02:00
|
|
|
{
|
|
|
|
|
Arrival.Drop("unknown-object");
|
2026-10-01 12:30:57 +02:00
|
|
|
return;
|
2026-10-03 11:04:42 +02:00
|
|
|
}
|
|
|
|
|
Arrival.About(visibility: stored.Visibility, created: stored.CreationDate);
|
2026-10-01 12:30:57 +02:00
|
|
|
using (var fetched = await _remoteActors.FetchObject(objectUri, token))
|
|
|
|
|
{
|
|
|
|
|
var note = fetched == default ? default : NoteParser.Parse(System.Text.Json.Nodes.JsonNode.Parse(fetched.Root.GetRawText()));
|
|
|
|
|
if (note == default || note.AttributedTo != stored.ActorURI)
|
2026-10-03 11:04:42 +02:00
|
|
|
{
|
|
|
|
|
Arrival.Drop(note == default ? "fetch-failed" : "misattributed");
|
2026-10-01 12:30:57 +02:00
|
|
|
return;
|
2026-10-03 11:04:42 +02:00
|
|
|
}
|
2026-10-03 11:15:21 +02:00
|
|
|
Arrival.Accept(await RemoteEdits.Apply(stored, note, Id(inner), _localActors, _records, _quotes, token) ? "edit" : "refresh");
|
2026-10-01 12:30:57 +02:00
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
case "Delete" when objectUri != default:
|
|
|
|
|
var deleted = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.AudienceURI == group.ActorURI).ExecuteFirstAsync(token);
|
|
|
|
|
if (deleted == default)
|
2026-10-03 11:04:42 +02:00
|
|
|
{
|
|
|
|
|
Arrival.Drop("unknown-object");
|
2026-10-01 12:30:57 +02:00
|
|
|
return;
|
2026-10-03 11:04:42 +02:00
|
|
|
}
|
|
|
|
|
Arrival.About(visibility: deleted.Visibility, created: deleted.CreationDate);
|
2026-10-01 12:30:57 +02:00
|
|
|
using (var check = await _remoteActors.FetchObject(objectUri, token))
|
|
|
|
|
if (check != default && Value(System.Text.Json.Nodes.JsonNode.Parse(check.Root.GetRawText()), "type") != "Tombstone")
|
2026-10-03 11:04:42 +02:00
|
|
|
{
|
|
|
|
|
Arrival.Drop("not-deleted");
|
2026-10-01 12:30:57 +02:00
|
|
|
return;
|
2026-10-03 11:04:42 +02:00
|
|
|
}
|
|
|
|
|
Arrival.Accept("removed");
|
2026-10-03 11:15:21 +02:00
|
|
|
await RemoteDeletes.Remove(deleted, objectUri, token);
|
2026-10-01 12:30:57 +02:00
|
|
|
break;
|
2026-10-03 11:04:42 +02:00
|
|
|
default:
|
|
|
|
|
Arrival.Drop("unsupported");
|
|
|
|
|
break;
|
2026-10-01 12:30:57 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-01 11:39:10 +02:00
|
|
|
static List<string> Strings(JsonNode node) => node switch
|
|
|
|
|
{
|
|
|
|
|
JsonArray array => array.Select(Id).Where(id => id != default).ToList(),
|
|
|
|
|
JsonNode single when Id(single) is { } id => new List<string> { id },
|
|
|
|
|
_ => new List<string>()
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|