131 lines
5.8 KiB
C#
131 lines
5.8 KiB
C#
using MongoDB.Bson;
|
|||
|
|
using MongoDB.Driver;
|
||
|
|
using MongoDB.Entities;
|
||
|
|
|
||
|
|
using PrivaPub.Domain.Privacy;
|
||
|
|
using PrivaPub.Federation.Actors;
|
||
|
|
using PrivaPub.Federation.Objects;
|
||
|
|
using PrivaPub.Federation.Outbox;
|
||
|
|
using PrivaPub.Federation.Rendering;
|
||
|
|
using PrivaPub.Models.Social;
|
||
|
|
using PrivaPub.Models.User;
|
||
|
|
using PrivaPub.StaticServices;
|
||
|
|
|
||
|
|
using System.Text.Json.Nodes;
|
||
|
|
|
||
|
|
using static PrivaPub.Federation.Objects.ActivityJson;
|
||
|
|
|
||
|
|
using PostEntity = PrivaPub.Models.Post.Post;
|
||
|
|
|
||
|
|
namespace PrivaPub.Domain.Statuses
|
||
|
|
{
|
||
|
|
// A persona's participation in a remote event (owner decision 2026-10-05: personas may join events): a Join with the
|
||
|
|
// event as its object, sent to the event's server, which answers Accept (Mobilizon takes a free event's participants at
|
||
|
|
// once) or Reject; a Leave takes it back. A participation is public on the event's server: who comes is shown there.
|
||
|
|
public interface IParticipations
|
||
|
|
{
|
||
|
|
Task<StatusOutcome> Join(LocalActor me, string postId, CancellationToken token);
|
||
|
|
Task<StatusOutcome> Leave(LocalActor me, string postId, CancellationToken token);
|
||
|
|
Task<bool> Answered(JsonNode answer, ForeignAvatar actor, bool accepted, CancellationToken token);
|
||
|
|
}
|
||
|
|
|
||
|
|
public class Participations : IParticipations
|
||
|
|
{
|
||
|
|
const string JoinPrefix = "join-";
|
||
|
|
|
||
|
|
readonly DbEntities _dbEntities;
|
||
|
|
readonly ILocalActorService _localActors;
|
||
|
|
readonly IDeliveryService _delivery;
|
||
|
|
|
||
|
|
public Participations(DbEntities dbEntities, ILocalActorService localActors, IDeliveryService delivery)
|
||
|
|
{
|
||
|
|
_dbEntities = dbEntities;
|
||
|
|
_localActors = localActors;
|
||
|
|
_delivery = delivery;
|
||
|
|
}
|
||
|
|
|
||
|
|
async Task<PostEntity> Event(LocalActor me, string postId, CancellationToken token)
|
||
|
|
{
|
||
|
|
var post = await _dbEntities.Posts.Match(p => p.ID == postId && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
|
||
|
|
return post is { IsFederatedCopy: true, Event: not null } && await VisibilityPolicy.CanSee(post, me.Id, token) ? post : default;
|
||
|
|
}
|
||
|
|
|
||
|
|
public async Task<StatusOutcome> Join(LocalActor me, string postId, CancellationToken token)
|
||
|
|
{
|
||
|
|
var post = await Event(me, postId, token);
|
||
|
|
if (post == default)
|
||
|
|
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
|
||
|
|
// an event by invitation, or one that takes its participants on another site, is not joined from here
|
||
|
|
if (post.Event.JoinMode is "invite" or "external")
|
||
|
|
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: This event does not take participants this way");
|
||
|
|
if (await DB.Default.Find<Participation>().Match(p => p.AvatarId == me.Id && p.PostId == post.ID).ExecuteFirstAsync(token) is { State: not ParticipationState.Rejected })
|
||
|
|
return new StatusOutcome(post);
|
||
|
|
await DB.Default.DeleteAsync<Participation>(p => p.AvatarId == me.Id && p.PostId == post.ID);
|
||
|
|
var participation = new Participation { ID = ObjectId.GenerateNewId().ToString(), AvatarId = me.Id, PostId = post.ID };
|
||
|
|
participation.ActivityURI = me.ActivityUri(JoinPrefix + participation.ID);
|
||
|
|
try
|
||
|
|
{
|
||
|
|
await DB.Default.SaveAsync(participation, token);
|
||
|
|
}
|
||
|
|
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||
|
|
{
|
||
|
|
return new StatusOutcome(post);
|
||
|
|
}
|
||
|
|
await Send(me, post, new JsonObject
|
||
|
|
{
|
||
|
|
["@context"] = ActivityPubRenderer.ActivityStreams,
|
||
|
|
["id"] = participation.ActivityURI,
|
||
|
|
["type"] = "Join",
|
||
|
|
["actor"] = me.Uri,
|
||
|
|
["object"] = post.ObjectURI,
|
||
|
|
["to"] = new JsonArray(post.ActorURI)
|
||
|
|
}, token);
|
||
|
|
return new StatusOutcome(post);
|
||
|
|
}
|
||
|
|
|
||
|
|
public async Task<StatusOutcome> Leave(LocalActor me, string postId, CancellationToken token)
|
||
|
|
{
|
||
|
|
var post = await Event(me, postId, token);
|
||
|
|
if (post == default)
|
||
|
|
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
|
||
|
|
var participation = await DB.Default.Find<Participation>().Match(p => p.AvatarId == me.Id && p.PostId == post.ID).ExecuteFirstAsync(token);
|
||
|
|
if (participation == default || (await DB.Default.DeleteAsync<Participation>(participation.ID)).DeletedCount == 0)
|
||
|
|
return new StatusOutcome(post);
|
||
|
|
await Send(me, post, new JsonObject
|
||
|
|
{
|
||
|
|
["@context"] = ActivityPubRenderer.ActivityStreams,
|
||
|
|
["id"] = me.ActivityUri("leave-" + participation.ID),
|
||
|
|
["type"] = "Leave",
|
||
|
|
["actor"] = me.Uri,
|
||
|
|
["object"] = post.ObjectURI,
|
||
|
|
["to"] = new JsonArray(post.ActorURI)
|
||
|
|
}, token);
|
||
|
|
return new StatusOutcome(post);
|
||
|
|
}
|
||
|
|
|
||
|
|
async Task Send(LocalActor me, PostEntity post, JsonObject activity, CancellationToken token)
|
||
|
|
{
|
||
|
|
var author = string.IsNullOrEmpty(post.AuthorAccountId) ? default : await _dbEntities.ForeignAvatars.MatchID(post.AuthorAccountId).ExecuteFirstAsync(token);
|
||
|
|
var inbox = !string.IsNullOrEmpty(author?.SharedInboxURL) ? author.SharedInboxURL : author?.InboxURL;
|
||
|
|
if (!string.IsNullOrEmpty(inbox))
|
||
|
|
await _delivery.Enqueue(me, new[] { inbox }, activity, token);
|
||
|
|
}
|
||
|
|
|
||
|
|
// the event's server answering our Join: Accept or Reject, naming it or carrying it
|
||
|
|
public async Task<bool> Answered(JsonNode answer, ForeignAvatar actor, bool accepted, CancellationToken token)
|
||
|
|
{
|
||
|
|
var joinId = Id(answer["object"]);
|
||
|
|
var marker = joinId?.LastIndexOf("/grunts/" + JoinPrefix, StringComparison.Ordinal) ?? -1;
|
||
|
|
if (marker < 0 || !joinId.StartsWith(_localActors.BaseAddress + "/", StringComparison.OrdinalIgnoreCase))
|
||
|
|
return false;
|
||
|
|
var participation = await DB.Default.Find<Participation>().Match(p => p.ActivityURI == joinId).ExecuteFirstAsync(token);
|
||
|
|
var post = participation == default ? default : await _dbEntities.Posts.MatchID(participation.PostId).ExecuteFirstAsync(token);
|
||
|
|
if (post == default || !Origin.Same(post.ObjectURI, actor.ActorURI))
|
||
|
|
return true;
|
||
|
|
await DB.Default.Update<Participation>().MatchID(participation.ID)
|
||
|
|
.Modify(p => p.State, accepted ? ParticipationState.Accepted : ParticipationState.Rejected)
|
||
|
|
.ExecuteAsync(token);
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|