diff options
Diffstat (limited to 'src')
| -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 | 
5 files changed, 241 insertions, 161 deletions
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", () => {  | 
