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>.`;  | 
