Files

131 lines
5.8 KiB
C#
Raw Permalink Normal View History

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;
}
}
}