summaryrefslogtreecommitdiff
path: root/src/lib/activitypub.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib/activitypub.ts')
-rw-r--r--src/lib/activitypub.ts168
1 files changed, 168 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.");
+ }
+};