diff options
author | Raphael <raphaelkabo@gmail.com> | 2024-02-05 09:18:41 +0000 |
---|---|---|
committer | Raphael Kabo <raphaelkabo@hey.com> | 2024-02-05 21:46:59 +0000 |
commit | be317850f203c428f77394f824908ecff500cf78 (patch) | |
tree | 4c16335c590ac01269e0adb3940a3bc21f121ead /src/lib | |
parent | ecff04b132db687f67d9a6cda2d1c13831c45394 (diff) |
Fix Pleroma federation, add hidden RSVP option
Diffstat (limited to 'src/lib')
-rw-r--r-- | src/lib/activitypub.ts | 168 | ||||
-rw-r--r-- | src/lib/activitypub/templates.ts | 14 |
2 files changed, 182 insertions, 0 deletions
diff --git a/src/lib/activitypub.ts b/src/lib/activitypub.ts index 11f0770..31c75ce 100644 --- a/src/lib/activitypub.ts +++ b/src/lib/activitypub.ts @@ -1,4 +1,18 @@ import { Request, Response } from "express"; +import Event from "../models/Event.js"; +import { sendDirectMessage } from "../activitypub.js"; +import { successfulRSVPResponse } from "./activitypub/templates.js"; + +interface APObject { + type: "Note"; + actor?: string; + id: string; + to?: string | string[]; + cc?: string | string[]; + attributedTo: string; + inReplyTo: string; + name: string; +} // From https://www.w3.org/TR/activitypub/#client-to-server-interactions: // "Servers MAY interpret a Content-Type or Accept header of application/activity+json @@ -20,3 +34,157 @@ export const acceptsActivityPub = (req: Request) => { (header) => req.headers.accept?.includes(header), ); }; + +// At least for poll responses, Mastodon stores the recipient (the poll-maker) +// in the 'to' field, while Pleroma stores it in 'cc' +export const getNoteRecipient = (object: APObject): string | null => { + const { to, cc } = object; + if (!to && !cc) { + return ""; + } + if (to && to.length > 0) { + if (Array.isArray(to)) { + return to[0]; + } + if (typeof to === "string") { + return to; + } + return null; + } else if (cc && cc.length > 0) { + if (Array.isArray(cc)) { + return cc[0]; + } + return cc; + } + return null; +}; + +// Returns the event ID from a URL like http://localhost:3000/123abc +// or https://gath.io/123abc +export const getEventId = (url: string): string => { + try { + return new URL(url).pathname.replace("/", ""); + } catch (error) { + // Apparently not a URL so maybe it's just the ID + return url; + } +}; + +export const handlePollResponse = async (req: Request, res: Response) => { + try { + // figure out what this is in reply to -- it should be addressed specifically to us + const { attributedTo, inReplyTo, name } = req.body.object as APObject; + const recipient = getNoteRecipient(req.body.object); + if (!recipient) throw new Error("No recipient found"); + + const eventID = getEventId(recipient); + const event = await Event.findOne({ id: eventID }); + if (!event) throw new Error("Event not found"); + + // make sure this person is actually a follower of the event + const senderAlreadyFollows = event.followers?.some( + (el) => el.actorId === attributedTo, + ); + if (!senderAlreadyFollows) { + throw new Error("Poll response sender does not follow event"); + } + + // compare the inReplyTo to its stored message, if it exists and + // it's going to the right follower then this is a valid reply + const matchingMessage = event.activityPubMessages?.find((el) => { + const content = JSON.parse(el.content || ""); + return inReplyTo === content?.object?.id; + }); + if (!matchingMessage) throw new Error("No matching message found"); + const messageContent = JSON.parse(matchingMessage.content || ""); + // check if the message we sent out was sent to the actor this incoming + // message is attributedTo + const messageRecipient = getNoteRecipient(messageContent.object); + if (!messageRecipient || messageRecipient !== attributedTo) { + throw new Error("Message recipient does not match attributedTo"); + } + + // it's a match, this is a valid poll response, add RSVP to database + + // 'name' is the poll response + // - "Yes, and show me in the public list", + // - "Yes, but hide me from the public list", + // - "No" + if ( + name !== "Yes, and show me in the public list" && + name !== "Yes, but hide me from the public list" && + name !== "No" + ) { + throw new Error("Invalid poll response"); + } + + if (name === "No") { + // Why did you even respond? + return res.status(200).send("Thanks I guess?"); + } + + const visibility = + name === "Yes, and show me in the public list" + ? "public" + : "private"; + + // fetch the profile information of the user + const response = await fetch(attributedTo, { + headers: { + Accept: activityPubContentType, + "Content-Type": activityPubContentType, + }, + }); + if (!response.ok) throw new Error("Actor not found"); + const apActor = await response.json(); + + // If the actor is not already attending the event, add them + if (!event.attendees?.some((el) => el.id === attributedTo)) { + const attendeeName = + apActor.preferredUsername || apActor.name || attributedTo; + const newAttendee = { + name: attendeeName, + status: "attending", + id: attributedTo, + number: 1, + visibility, + }; + const updatedEvent = await Event.findOneAndUpdate( + { id: eventID }, + { $push: { attendees: newAttendee } }, + { new: true }, + ).exec(); + const fullAttendee = updatedEvent?.attendees?.find( + (el) => el.id === attributedTo, + ); + if (!fullAttendee) throw new Error("Full attendee not found"); + + // send a "click here to remove yourself" link back to the user as a DM + const jsonObject = { + "@context": "https://www.w3.org/ns/activitystreams", + name: `RSVP to ${event.name}`, + type: "Note", + content: successfulRSVPResponse({ + event, + newAttendee, + fullAttendee, + }), + tag: [ + { + type: "Mention", + href: newAttendee.id, + name: newAttendee.name, + }, + ], + }; + // send direct message to user + sendDirectMessage(jsonObject, newAttendee.id, event.id); + return res.sendStatus(200); + } else { + return res.status(200).send("Attendee is already registered."); + } + } catch (error) { + console.error(error); + return res.status(500).send("An unexpected error occurred."); + } +}; diff --git a/src/lib/activitypub/templates.ts b/src/lib/activitypub/templates.ts new file mode 100644 index 0000000..cab9ada --- /dev/null +++ b/src/lib/activitypub/templates.ts @@ -0,0 +1,14 @@ +import { IEvent } from "../../models/Event.js"; +import getConfig from "../config.js"; +const config = getConfig(); + +export const successfulRSVPResponse = ({ + event, + newAttendee, + fullAttendee, +}: { + event: IEvent; + newAttendee: { id: string; name: string }; + fullAttendee: { _id: string }; +}) => + `<span class="h-card"><a href="${newAttendee.id}" class="u-url mention">@<span>${newAttendee.name}</span></a></span> Thanks for RSVPing! You can remove yourself from the RSVP list by clicking <a href="https://${config.general.domain}/oneclickunattendevent/${event.id}/${fullAttendee._id}">here</a>.`; |