diff options
-rw-r--r-- | package.json | 1 | ||||
-rw-r--r-- | pnpm-lock.yaml | 7 | ||||
-rw-r--r-- | src/activitypub.js | 189 | ||||
-rw-r--r-- | src/lib/activitypub.ts | 168 | ||||
-rw-r--r-- | src/lib/activitypub/templates.ts | 14 | ||||
-rwxr-xr-x | src/routes.js | 30 | ||||
-rwxr-xr-x | src/start.ts | 1 |
7 files changed, 249 insertions, 161 deletions
diff --git a/package.json b/package.json index d573f7c..60fa957 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "license": "GPL-3.0-or-later", "dependencies": { "@sendgrid/mail": "^6.5.5", + "activitypub-types": "^1.0.3", "cors": "^2.8.5", "dompurify": "^3.0.6", "express": "^4.18.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f303bc0..51126fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ dependencies: '@sendgrid/mail': specifier: ^6.5.5 version: 6.5.5 + activitypub-types: + specifier: ^1.0.3 + version: 1.0.3 cors: specifier: ^2.8.5 version: 2.8.5 @@ -875,6 +878,10 @@ packages: hasBin: true dev: true + /activitypub-types@1.0.3: + resolution: {integrity: sha512-70PzXqhskrXebCcIAxyvKeQAR8myxLlSF5GKcyN/5UkTpTPTlQyzehzd4tXFgR6ZE+Tvsy3/1WWKSPTQPXvFwA==} + dev: false + /agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} diff --git a/src/activitypub.js b/src/activitypub.js index a6f4ada..88e3768 100644 --- a/src/activitypub.js +++ b/src/activitypub.js @@ -10,7 +10,7 @@ const domain = config.general.domain; const siteName = config.general.site_name; const isFederated = config.general.is_federated; import Event from "./models/Event.js"; -import { activityPubContentType, alternateActivityPubContentType } from "./lib/activitypub.js"; +import { handlePollResponse, activityPubContentType, alternateActivityPubContentType, getEventId, getNoteRecipient } from "./lib/activitypub.js"; // This alphabet (used to generate all event, group, etc. IDs) is missing '-' // because ActivityPub doesn't like it in IDs @@ -660,7 +660,7 @@ export function sendAcceptMessage(thebody, eventID, targetDomain, callback) { function _handleFollow(req, res) { const myURL = new URL(req.body.actor); let targetDomain = myURL.hostname; - let eventID = req.body.object.replace(`https://${domain}/`, ""); + let eventID = getEventId(req.body.object); // Add the user to the DB of accounts that follow the account // get the follower's username request( @@ -735,11 +735,22 @@ function _handleFollow(req, res) { "https://www.w3.org/ns/activitystreams", name: `RSVP to ${event.name}`, type: "Question", - content: `<span class=\"h-card\"><a href="${req.body.actor}" class="u-url mention">@<span>${name}</span></a></span> Will you attend ${event.name}? (If you reply "Yes", you'll be listed as an attendee on the event page.)`, + content: `<span class=\"h-card\"><a href="${req.body.actor}" class="u-url mention">@<span>${name}</span></a></span> Will you attend ${event.name}?`, oneOf: [ { type: "Note", - name: "Yes", + name: "Yes, and show me in the public list", + "replies": { "type": "Collection", "totalItems": 0 } + }, + { + type: "Note", + name: "Yes, but hide me from the public list", + "replies": { "type": "Collection", "totalItems": 0 } + }, + { + type: "Note", + name: "No", + "replies": { "type": "Collection", "totalItems": 0 } }, ], endTime: @@ -807,7 +818,7 @@ function _handleFollow(req, res) { }); } else { // this person is already a follower so just say "ok" - return res.status(200); + return res.sendStatus(200); } }, ); @@ -867,11 +878,15 @@ function _handleUndoFollow(req, res) { } function _handleAcceptEvent(req, res) { - let { name, attributedTo, inReplyTo, to, actor } = req.body; - if (Array.isArray(to)) { - to = to[0]; + let { name, attributedTo, inReplyTo, actor } = req.body; + const recipient = getNoteRecipient(req.body); + if (!recipient) { + return res.status(400).send("No recipient found in the object"); + } + const eventID = getEventId(recipient); + if (!eventID) { + return res.status(400).send("No event ID found in the recipient"); } - const eventID = to.replace(`https://${domain}/`, ""); Event.findOne( { id: eventID, @@ -989,7 +1004,7 @@ function _handleUndoAcceptEvent(req, res) { ); if (message) { // it's a match - Event.update( + Event.updateOne( { id: eventID }, { $pull: { attendees: { id: actor } } }, ).then((response) => { @@ -1005,134 +1020,6 @@ function _handleUndoAcceptEvent(req, res) { ); } -function _handleCreateNote(req, res) { - // figure out what this is in reply to -- it should be addressed specifically to us - let { name, attributedTo, inReplyTo, to } = req.body.object; - // if it's an array just grab the first element, since a poll should only broadcast back to the pollster - if (Array.isArray(to)) { - to = to[0]; - } - const eventID = to.replace(`https://${domain}/`, ""); - // make sure this person is actually a follower - Event.findOne( - { - id: eventID, - }, - function (err, event) { - if (!event) return; - // is this even someone who follows us - const indexOfFollower = event.followers.findIndex( - (el) => el.actorId === req.body.object.attributedTo, - ); - if (indexOfFollower !== -1) { - // 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 message = event.activityPubMessages.find((el) => { - const content = JSON.parse(el.content); - return inReplyTo === (content.object && content.object.id); - }); - if (message) { - const content = JSON.parse(message.content); - // check if the message we sent out was sent to the actor this incoming message is attributedTo - if (content.to[0] === attributedTo) { - // it's a match, this is a valid poll response, add RSVP to database - // fetch the profile information of the user - request( - { - url: attributedTo, - headers: { - Accept: activityPubContentType, - "Content-Type": activityPubContentType, - }, - }, - function (error, response, body) { - body = JSON.parse(body); - // if this account is NOT already in our attendees list, add it - if ( - !event.attendees - .map((el) => el.id) - .includes(attributedTo) - ) { - const attendeeName = - body.preferredUsername || - body.name || - attributedTo; - const newAttendee = { - name: attendeeName, - status: "attending", - id: attributedTo, - number: 1, - }; - event.attendees.push(newAttendee); - event - .save() - .then((fullEvent) => { - addToLog( - "addEventAttendee", - "success", - "Attendee added to event " + - req.params.eventID, - ); - // get the new attendee with its hidden id from the full event - let fullAttendee = - fullEvent.attendees.find( - (el) => - el.id === attributedTo, - ); - // 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: `<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 here: <a href="https://${domain}/oneclickunattendevent/${event.id}/${fullAttendee._id}">https://${domain}/oneclickunattendevent/${event.id}/${fullAttendee._id}</a>`, - tag: [ - { - type: "Mention", - href: newAttendee.id, - name: newAttendee.name, - }, - ], - }; - // send direct message to user - sendDirectMessage( - jsonObject, - newAttendee.id, - event.id, - ); - return res.sendStatus(200); - }) - .catch((err) => { - addToLog( - "addEventAttendee", - "error", - "Attempt to add attendee to event " + - req.params.eventID + - " failed with error: " + - err, - ); - return res - .status(500) - .send( - "Database error, please try again :(", - ); - }); - } else { - // it's a duplicate and this person is already rsvped so just say OK - return res - .status(200) - .send( - "Attendee is already registered.", - ); - } - }, - ); - } - } - } - }, - ); -} - function _handleDelete(req, res) { const deleteObjectId = req.body.object.id; // find all events with comments from the author @@ -1221,7 +1108,10 @@ function _handleCreateNoteComment(req, res) { cc.includes("https://www.w3.org/ns/activitystreams#Public")) ) { // figure out which event(s) of ours it was addressing + // Mastodon seems to put the event ID in the to field, Pleroma in the cc field + // This is because ActivityPub is a mess (love you ActivityPub) let ourEvents = cc + .concat(to) .filter((el) => el.includes(`https://${domain}/`)) .map((el) => el.replace(`https://${domain}/`, "")); // comments should only be on one event. if more than one, ignore (spam, probably) @@ -1320,28 +1210,31 @@ export function processInbox(req, res) { try { // if a Follow activity hits the inbox if (typeof req.body.object === "string" && req.body.type === "Follow") { + console.log("Sending to _handleFollow"); _handleFollow(req, res); } // if an Undo activity with a Follow object hits the inbox - if ( + else if ( req.body && req.body.type === "Undo" && req.body.object && req.body.object.type === "Follow" ) { + console.log("Sending to _handleUndoFollow"); _handleUndoFollow(req, res); } // if an Accept activity with the id of the Event we sent out hits the inbox, it is an affirmative RSVP - if ( + else if ( req.body && req.body.type === "Accept" && req.body.object && typeof req.body.object === "string" ) { + console.log("Sending to _handleAcceptEvent"); _handleAcceptEvent(req, res); } // if an Undo activity containing an Accept containing the id of the Event we sent out hits the inbox, it is an undo RSVP - if ( + else if ( req.body && req.body.type === "Undo" && req.body.object && @@ -1349,10 +1242,11 @@ export function processInbox(req, res) { typeof req.body.object.object === "string" && req.body.object.type === "Accept" ) { + console.log("Sending to _handleUndoAcceptEvent"); _handleUndoAcceptEvent(req, res); } // if a Create activity with a Note object hits the inbox, and it's a reply, it might be a vote in a poll - if ( + else if ( req.body && req.body.type === "Create" && req.body.object && @@ -1360,22 +1254,27 @@ export function processInbox(req, res) { req.body.object.inReplyTo && req.body.object.to ) { - _handleCreateNote(req, res); + handlePollResponse(req, res); } // if a Delete activity hits the inbox, it might a deletion of a comment - if (req.body && req.body.type === "Delete") { + else if (req.body && req.body.type === "Delete") { + console.log("Sending to _handleDelete"); _handleDelete(req, res); } // if we are CC'ed on a public or unlisted Create/Note, then this is a comment to us we should boost (Announce) to our followers - if ( + else if ( req.body && req.body.type === "Create" && req.body.object && req.body.object.type === "Note" && req.body.object.to ) { + console.log("Sending to _handleCreateNoteComment"); _handleCreateNoteComment(req, res); } // CC'ed + else { + console.log("No action taken"); + } } catch (e) { console.log("Error in processing inbox:", e); } 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>.`; diff --git a/src/routes.js b/src/routes.js index 360f387..475f5a0 100755 --- a/src/routes.js +++ b/src/routes.js @@ -505,13 +505,12 @@ router.post("/deleteeventgroup/:eventGroupID/:editToken", (req, res) => { }, ); } - Event.update( + Event.updateOne( { _id: { $in: linkedEventIDs } }, { $set: { eventGroup: null } }, { multi: true }, ) .then((response) => { - console.log(response); addToLog( "deleteEventGroup", "success", @@ -762,12 +761,11 @@ router.post("/unattendevent/:eventID", (req, res) => { return res.sendStatus(500); } - Event.update( + Event.updateOne( { id: req.params.eventID }, { $pull: { attendees: { removalPassword } } }, ) .then((response) => { - console.log(response); addToLog( "unattendEvent", "success", @@ -836,15 +834,16 @@ router.post("/unattendevent/:eventID", (req, res) => { // this is a one-click unattend that requires a secret URL that only the person who RSVPed over // activitypub knows router.get("/oneclickunattendevent/:eventID/:attendeeID", (req, res) => { - // Mastodon will "click" links that sent to its users, presumably as a prefetch? + // Mastodon and Pleroma will "click" links that sent to its users, presumably as a prefetch? // Anyway, this ignores the automated clicks that are done without the user's knowledge if ( req.headers["user-agent"] && - req.headers["user-agent"].includes("Mastodon") + (req.headers["user-agent"].toLowerCase().includes("mastodon") || + req.headers["user-agent"].toLowerCase().includes("pleroma")) ) { return res.sendStatus(200); } - Event.update( + Event.updateOne( { id: req.params.eventID }, { $pull: { attendees: { _id: req.params.attendeeID } } }, ) @@ -915,12 +914,11 @@ router.get("/oneclickunattendevent/:eventID/:attendeeID", (req, res) => { }); router.post("/removeattendee/:eventID/:attendeeID", (req, res) => { - Event.update( + Event.updateOne( { id: req.params.eventID }, { $pull: { attendees: { _id: req.params.attendeeID } } }, ) .then((response) => { - console.log(response); addToLog( "removeEventAttendee", "success", @@ -1071,12 +1069,11 @@ router.post("/subscribe/:eventGroupID", (req, res) => { */ router.get("/unsubscribe/:eventGroupID", (req, res) => { const email = req.query.email; - console.log(email); if (!email) { return res.sendStatus(500); } - EventGroup.update( + EventGroup.updateOne( { id: req.params.eventGroupID }, { $pull: { subscribers: { email } } }, ) @@ -1434,7 +1431,10 @@ router.post("/deletecomment/:eventID/:commentID/:editToken", (req, res) => { router.post("/activitypub/inbox", (req, res) => { if (!isFederated) return res.sendStatus(404); // validate the incoming message - const signature = req.get("Signature"); + const signature = req.get("signature"); + if (!signature) { + return res.status(401).send("No signature provided."); + } let signature_header = signature .split(",") .map((pair) => { @@ -1446,7 +1446,6 @@ router.post("/activitypub/inbox", (req, res) => { acc[el[0]] = el[1]; return acc; }, {}); - // get the actor // TODO if this is a Delete for an Actor this won't work request( @@ -1478,11 +1477,10 @@ router.post("/activitypub/inbox", (req, res) => { } }) .join("\n"); - const verifier = crypto.createVerify("RSA-SHA256"); verifier.update(comparison_string, "ascii"); - const publicKeyBuf = new Buffer(publicKey, "ascii"); - const signatureBuf = new Buffer( + const publicKeyBuf = Buffer.from(publicKey, "ascii"); + const signatureBuf = Buffer.from( signature_header.signature, "base64", ); diff --git a/src/start.ts b/src/start.ts index a6399ac..124a2fb 100755 --- a/src/start.ts +++ b/src/start.ts @@ -9,6 +9,7 @@ mongoose.connect(config.database.mongodb_url, { useUnifiedTopology: true, }); mongoose.set("useCreateIndex", true); +mongoose.set("useFindAndModify", false); mongoose.Promise = global.Promise; mongoose.connection .on("connected", () => { |