diff options
| author | Raphael Kabo <raphaelkabo@hey.com> | 2023-10-07 14:30:24 +0100 | 
|---|---|---|
| committer | Raphael Kabo <raphaelkabo@hey.com> | 2023-10-07 15:38:47 +0100 | 
| commit | b795d07ed7a1b705b72b171f8e8de267a720223b (patch) | |
| tree | b8ae3df8dbb89f839f29328e817f030dc22b89f8 /src/routes | |
| parent | 9341659fd7a791d77454dd33743e42d952dbd202 (diff) | |
refactor: event form and api routes
Diffstat (limited to 'src/routes')
| -rw-r--r-- | src/routes/activitypub.ts | 174 | ||||
| -rw-r--r-- | src/routes/event.ts | 519 | ||||
| -rw-r--r-- | src/routes/frontend.ts | 9 | 
3 files changed, 702 insertions, 0 deletions
diff --git a/src/routes/activitypub.ts b/src/routes/activitypub.ts new file mode 100644 index 0000000..2c4231a --- /dev/null +++ b/src/routes/activitypub.ts @@ -0,0 +1,174 @@ +import { Router, Request, Response, NextFunction } from "express"; +import { createFeaturedPost, createWebfinger } from "../activitypub.js"; +import { acceptsActivityPub } from "../lib/activitypub.js"; +import getConfig from "../lib/config.js"; +import Event from "../models/Event.js"; +import { addToLog } from "../helpers.js"; + +const config = getConfig(); + +const router = Router(); + +const send404IfNotFederated = ( +    req: Request, +    res: Response, +    next: NextFunction, +) => { +    if (!config.general.is_federated) { +        res.status(404).render("404", { url: req.url }); +        return; +    } +    next(); +}; + +router.use(send404IfNotFederated); + +// return the JSON for the featured/pinned post for this event +router.get("/:eventID/featured", (req: Request, res: Response) => { +    const { eventID } = req.params; +    const featured = { +        "@context": "https://www.w3.org/ns/activitystreams", +        id: `https://${config.general.domain}/${eventID}/featured`, +        type: "OrderedCollection", +        orderedItems: [createFeaturedPost(eventID)], +    }; +    if (acceptsActivityPub(req)) { +        res.header("Content-Type", "application/activity+json").send(featured); +    } else { +        res.header("Content-Type", "application/json").send(featured); +    } +}); + +// return the JSON for a given activitypub message +router.get("/:eventID/m/:hash", async (req: Request, res: Response) => { +    const { hash, eventID } = req.params; +    const id = `https://${config.general.domain}/${eventID}/m/${hash}`; + +    try { +        const event = await Event.findOne({ +            id: eventID, +        }); +        if (!event) { +            return res.status(404).render("404", { url: req.url }); +        } else { +            if (!event.activityPubMessages) { +                return res.status(404).render("404", { url: req.url }); +            } +            const message = event.activityPubMessages.find( +                (el) => el.id === id, +            ); +            if (message) { +                if (acceptsActivityPub(req)) { +                    res.header( +                        "Content-Type", +                        "application/activity+json", +                    ).send(JSON.parse(message.content || "{}")); +                } else { +                    res.header("Content-Type", "application/json").send( +                        JSON.parse(message.content || "{}"), +                    ); +                } +            } else { +                return res.status(404).render("404", { url: req.url }); +            } +        } +    } catch (err) { +        addToLog( +            "getActivityPubMessage", +            "error", +            "Attempt to get Activity Pub Message for " + +                id + +                " failed with error: " + +                err, +        ); +        return res.status(404).render("404", { url: req.url }); +    } +}); + +router.get("/.well-known/webfinger", async (req, res) => { +    let resource = req.query.resource as string; +    if (!resource || !resource.includes("acct:")) { +        return res +            .status(400) +            .send( +                'Bad request. Please make sure "acct:USER@DOMAIN" is what you are sending as the "resource" query parameter.', +            ); +    } else { +        // "foo@domain" +        let activityPubAccount = resource.replace("acct:", ""); +        // "foo" +        let eventID = activityPubAccount.replace(/@.*/, ""); + +        try { +            const event = await Event.findOne({ id: eventID }); + +            if (!event) { +                return res.status(404).render("404", { url: req.url }); +            } else { +                if (acceptsActivityPub(req)) { +                    res.header( +                        "Content-Type", +                        "application/activity+json", +                    ).send(createWebfinger(eventID, config.general.domain)); +                } else { +                    res.header("Content-Type", "application/json").send( +                        createWebfinger(eventID, config.general.domain), +                    ); +                } +            } +        } catch (err) { +            addToLog( +                "renderWebfinger", +                "error", +                `Attempt to render webfinger for ${resource} failed with error: ${err}`, +            ); +            return res.status(404).render("404", { url: req.url }); +        } +    } +}); + +router.get("/:eventID/followers", async (req, res) => { +    const eventID = req.params.eventID; + +    try { +        const event = await Event.findOne({ id: eventID }); + +        if (event && event.followers) { +            const followers = event.followers.map((el) => el.actorId); +            let followersCollection = { +                type: "OrderedCollection", +                totalItems: followers.length, +                id: `https://${config.general.domain}/${eventID}/followers`, +                first: { +                    type: "OrderedCollectionPage", +                    totalItems: followers.length, +                    partOf: `https://${config.general.domain}/${eventID}/followers`, +                    orderedItems: followers, +                    id: `https://${config.general.domain}/${eventID}/followers?page=1`, +                }, +                "@context": ["https://www.w3.org/ns/activitystreams"], +            }; + +            if (acceptsActivityPub(req)) { +                return res +                    .header("Content-Type", "application/activity+json") +                    .send(followersCollection); +            } else { +                return res +                    .header("Content-Type", "application/json") +                    .send(followersCollection); +            } +        } else { +            return res.status(400).send("Bad request."); +        } +    } catch (err) { +        addToLog( +            "renderFollowers", +            "error", +            `Attempt to render followers for ${eventID} failed with error: ${err}`, +        ); +        return res.status(404).render("404", { url: req.url }); +    } +}); + +export default router; diff --git a/src/routes/event.ts b/src/routes/event.ts new file mode 100644 index 0000000..c418893 --- /dev/null +++ b/src/routes/event.ts @@ -0,0 +1,519 @@ +import { Router, Response, Request } from "express"; +import { customAlphabet } from "nanoid"; +import multer from "multer"; +import Jimp from "jimp"; +import moment from "moment-timezone"; +import { marked } from "marked"; +import { generateEditToken, generateRSAKeypair } from "../util/generator.js"; +import { validateEventData } from "../util/validation.js"; +import { addToLog } from "../helpers.js"; +import Event from "../models/Event.js"; +import EventGroup from "../models/EventGroup.js"; +import { +    broadcastCreateMessage, +    broadcastUpdateMessage, +    createActivityPubActor, +    createActivityPubEvent, +    createFeaturedPost, +    sendDirectMessage, +    updateActivityPubActor, +    updateActivityPubEvent, +} from "../activitypub.js"; +import getConfig from "../lib/config.js"; +import { sendEmailFromTemplate } from "../lib/email.js"; +import crypto from "crypto"; + +const config = getConfig(); + +// This alphabet (used to generate all event, group, etc. IDs) is missing '-' +// because ActivityPub doesn't like it in IDs +const nanoid = customAlphabet( +    "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_", +    21, +); + +const storage = multer.memoryStorage(); +// Accept only JPEG, GIF or PNG images, up to 10MB +const upload = multer({ +    storage: storage, +    limits: { fileSize: 10 * 1024 * 1024 }, +    fileFilter: function (_, file, cb) { +        const filetypes = /jpeg|jpg|png|gif/; +        const mimetype = filetypes.test(file.mimetype); +        if (!mimetype) { +            return cb(new Error("Only JPEG, PNG and GIF images are allowed.")); +        } +        cb(null, true); +    }, +}); + +const router = Router(); + +router.post( +    "/event", +    upload.single("imageUpload"), +    async (req: Request, res: Response) => { +        const { data: eventData, errors } = validateEventData(req.body); +        if (errors && errors.length > 0) { +            return res.status(400).json({ errors }); +        } +        if (!eventData) { +            return res.status(400).json({ +                errors: [ +                    { +                        message: "No event data was provided.", +                    }, +                ], +            }); +        } + +        let eventID = nanoid(); +        let editToken = generateEditToken(); +        let eventImageFilename; +        let isPartOfEventGroup = false; + +        if (req.file?.buffer) { +            eventImageFilename = await Jimp.read(req.file.buffer) +                .then((img) => { +                    img.resize(920, Jimp.AUTO) // resize +                        .quality(80) // set JPEG quality +                        .write("./public/events/" + eventID + ".jpg"); // save +                    const filename = eventID + ".jpg"; +                    return filename; +                }) +                .catch((err) => { +                    addToLog( +                        "Jimp", +                        "error", +                        "Attempt to edit image failed with error: " + err, +                    ); +                }); +        } +        const startUTC = moment.tz(eventData.eventStart, eventData.timezone); +        const endUTC = moment.tz(eventData.eventEnd, eventData.timezone); +        let eventGroup; +        if (eventData?.eventGroupBoolean) { +            try { +                eventGroup = await EventGroup.findOne({ +                    id: eventData.eventGroupID, +                    editToken: eventData.eventGroupEditToken, +                }); +                if (eventGroup) { +                    isPartOfEventGroup = true; +                } +            } catch (err) { +                console.error(err); +                addToLog( +                    "createEvent", +                    "error", +                    "Attempt to find event group failed with error: " + err, +                ); +            } +        } + +        // generate RSA keypair for ActivityPub +        let { publicKey, privateKey } = generateRSAKeypair(); + +        const event = new Event({ +            id: eventID, +            type: "public", // This is for backwards compatibility +            name: eventData.eventName, +            location: eventData.eventLocation, +            start: startUTC, +            end: endUTC, +            timezone: eventData.timezone, +            description: eventData.eventDescription, +            image: eventImageFilename, +            creatorEmail: eventData.creatorEmail, +            url: eventData.eventURL, +            hostName: eventData.hostName, +            viewPassword: "", // Backwards compatibility +            editPassword: "", // Backwards compatibility +            editToken: editToken, +            eventGroup: isPartOfEventGroup ? eventGroup?._id : null, +            usersCanAttend: eventData.joinBoolean ? true : false, +            showUsersList: false, // Backwards compatibility +            usersCanComment: eventData.interactionBoolean ? true : false, +            maxAttendees: eventData.maxAttendees, +            firstLoad: true, +            activityPubActor: createActivityPubActor( +                eventID, +                config.general.domain, +                publicKey, +                marked.parse(eventData.eventDescription), +                eventData.eventName, +                eventData.eventLocation, +                eventImageFilename, +                startUTC, +                endUTC, +                eventData.timezone, +            ), +            activityPubEvent: createActivityPubEvent( +                eventData.eventName, +                startUTC, +                endUTC, +                eventData.timezone, +                eventData.eventDescription, +                eventData.eventLocation, +            ), +            activityPubMessages: [ +                { +                    id: `https://${config.general.domain}/${eventID}/m/featuredPost`, +                    content: JSON.stringify( +                        createFeaturedPost( +                            eventID, +                            eventData.eventName, +                            startUTC, +                            endUTC, +                            eventData.timezone, +                            eventData.eventDescription, +                            eventData.eventLocation, +                        ), +                    ), +                }, +            ], +            publicKey, +            privateKey, +        }); +        try { +            const savedEvent = await event.save(); +            addToLog("createEvent", "success", "Event " + eventID + "created"); +            // Send email with edit link +            if (eventData.creatorEmail && req.app.locals.sendEmails) { +                sendEmailFromTemplate( +                    eventData.creatorEmail, +                    `${eventData.eventName}`, +                    "createEvent", +                    { +                        eventID, +                        editToken, +                        siteName: config.general.site_name, +                        siteLogo: config.general.email_logo_url, +                        domain: config.general.domain, +                    }, +                    req, +                ); +            } +            // If the event was added to a group, send an email to any group +            // subscribers +            if (event.eventGroup && req.app.locals.sendEmails) { +                try { +                    const eventGroup = await EventGroup.findOne({ +                        _id: event.eventGroup.toString(), +                    }); +                    if (!eventGroup) { +                        throw new Error( +                            "Event group not found for event " + eventID, +                        ); +                    } +                    const subscribers = eventGroup?.subscribers?.reduce( +                        (acc: string[], current) => { +                            if (current.email && !acc.includes(current.email)) { +                                return [current.email, ...acc]; +                            } +                            return acc; +                        }, +                        [] as string[], +                    ); +                    subscribers?.forEach((emailAddress) => { +                        sendEmailFromTemplate( +                            emailAddress, +                            `New event in ${eventGroup.name}`, +                            "eventGroupUpdated", +                            { +                                siteName: config.general.site_name, +                                siteLogo: config.general.email_logo_url, +                                domain: config.general.domain, +                                eventGroupName: eventGroup.name, +                                eventName: event.name, +                                eventID: event.id, +                                eventGroupID: eventGroup.id, +                                emailAddress: encodeURIComponent(emailAddress), +                            }, +                            req, +                        ); +                    }); +                } catch (err) { +                    console.error(err); +                    addToLog( +                        "createEvent", +                        "error", +                        "Attempt to send event group emails failed with error: " + +                            err, +                    ); +                } +            } +            return res.json({ +                eventID: eventID, +                editToken: editToken, +                url: `/${eventID}?e=${editToken}`, +            }); +        } catch (err) { +            console.error(err); +            addToLog( +                "createEvent", +                "error", +                "Attempt to create event failed with error: " + err, +            ); +            return res.status(500).json({ +                errors: [ +                    { +                        message: err, +                    }, +                ], +            }); +        } +    }, +); + +router.put( +    "/event/:eventID", +    upload.single("imageUpload"), +    async (req: Request, res: Response) => { +        const { data: eventData, errors } = validateEventData(req.body); +        if (errors && errors.length > 0) { +            return res.status(400).json({ errors }); +        } +        if (!eventData) { +            return res.status(400).json({ +                errors: [ +                    { +                        message: "No event data was provided.", +                    }, +                ], +            }); +        } + +        let submittedEditToken = req.body.editToken; +        try { +            const event = await Event.findOne({ +                id: req.params.eventID, +            }); +            if (!event) { +                return res.status(404).json({ +                    errors: [ +                        { +                            message: "Event not found.", +                        }, +                    ], +                }); +            } +            if (event.editToken !== submittedEditToken) { +                // Token doesn't match +                addToLog( +                    "editEvent", +                    "error", +                    `Attempt to edit event ${req.params.eventID} failed with error: token does not match`, +                ); +                return res.status(403).json({ +                    errors: [ +                        { +                            message: "Edit token is invalid.", +                        }, +                    ], +                }); +            } +            // Token matches +            // If there is a new image, upload that first +            let eventID = req.params.eventID; +            let eventImageFilename = event.image; +            if (req.file?.buffer) { +                Jimp.read(req.file.buffer) +                    .then((img) => { +                        img.resize(920, Jimp.AUTO) // resize +                            .quality(80) // set JPEG quality +                            .write(`./public/events/${eventID}.jpg`); // save +                    }) +                    .catch((err) => { +                        addToLog( +                            "Jimp", +                            "error", +                            "Attempt to edit image failed with error: " + err, +                        ); +                    }); +                eventImageFilename = eventID + ".jpg"; +            } + +            const startUTC = moment.tz( +                eventData.eventStart, +                eventData.timezone, +            ); +            const endUTC = moment.tz(eventData.eventEnd, eventData.timezone); + +            let isPartOfEventGroup = false; +            let eventGroup; +            if (eventData.eventGroupBoolean) { +                eventGroup = await EventGroup.findOne({ +                    id: eventData.eventGroupID, +                    editToken: eventData.eventGroupEditToken, +                }); +                if (eventGroup) { +                    isPartOfEventGroup = true; +                } +            } +            const updatedEvent = { +                name: eventData.eventName, +                location: eventData.eventLocation, +                start: startUTC.toDate(), +                end: endUTC.toDate(), +                timezone: eventData.timezone, +                description: eventData.eventDescription, +                url: eventData.eventURL, +                hostName: eventData.hostName, +                image: eventImageFilename, +                usersCanAttend: eventData.joinBoolean, +                showUsersList: false, // Backwards compatibility +                usersCanComment: eventData.interactionBoolean, +                maxAttendees: eventData.maxAttendeesBoolean +                    ? eventData.maxAttendees +                    : undefined, +                eventGroup: isPartOfEventGroup ? eventGroup?._id : null, +                activityPubActor: event.activityPubActor +                    ? updateActivityPubActor( +                          JSON.parse(event.activityPubActor), +                          eventData.eventDescription, +                          eventData.eventName, +                          eventData.eventLocation, +                          eventImageFilename, +                          startUTC, +                          endUTC, +                          eventData.timezone, +                      ) +                    : undefined, +                activityPubEvent: event.activityPubEvent +                    ? updateActivityPubEvent( +                          JSON.parse(event.activityPubEvent), +                          eventData.eventName, +                          startUTC, +                          endUTC, +                          eventData.timezone, +                      ) +                    : undefined, +            }; +            let diffText = +                "<p>This event was just updated with new information.</p><ul>"; +            let displayDate; +            if (event.name !== updatedEvent.name) { +                diffText += `<li>the event name changed to ${updatedEvent.name}</li>`; +            } +            if (event.location !== updatedEvent.location) { +                diffText += `<li>the location changed to ${updatedEvent.location}</li>`; +            } +            if ( +                event.start.toISOString() !== updatedEvent.start.toISOString() +            ) { +                displayDate = moment +                    .tz(updatedEvent.start, updatedEvent.timezone) +                    .format("dddd D MMMM YYYY h:mm a"); +                diffText += `<li>the start time changed to ${displayDate}</li>`; +            } +            if (event.end.toISOString() !== updatedEvent.end.toISOString()) { +                displayDate = moment +                    .tz(updatedEvent.end, updatedEvent.timezone) +                    .format("dddd D MMMM YYYY h:mm a"); +                diffText += `<li>the end time changed to ${displayDate}</li>`; +            } +            if (event.timezone !== updatedEvent.timezone) { +                diffText += `<li>the time zone changed to ${updatedEvent.timezone}</li>`; +            } +            if (event.description !== updatedEvent.description) { +                diffText += `<li>the event description changed</li>`; +            } +            diffText += `</ul>`; +            const updatedEventObject = await Event.findOneAndUpdate( +                { id: req.params.eventID }, +                updatedEvent, +                { new: true }, +            ); +            if (!updatedEventObject) { +                throw new Error("Event not found"); +            } +            addToLog( +                "editEvent", +                "success", +                "Event " + req.params.eventID + " edited", +            ); +            // send update to ActivityPub subscribers +            let attendees = updatedEventObject.attendees?.filter((el) => el.id); +            // broadcast an identical message to all followers, will show in home timeline +            const guidObject = crypto.randomBytes(16).toString("hex"); +            const jsonObject = { +                "@context": "https://www.w3.org/ns/activitystreams", +                id: `https://${config.general.domain}/${req.params.eventID}/m/${guidObject}`, +                name: `RSVP to ${event.name}`, +                type: "Note", +                cc: "https://www.w3.org/ns/activitystreams#Public", +                content: `${diffText} See here: <a href="https://${config.general.domain}/${req.params.eventID}">https://${config.general.domain}/${req.params.eventID}</a>`, +            }; +            broadcastCreateMessage(jsonObject, event.followers, eventID); +            // also broadcast an Update profile message to all followers so that at least Mastodon servers will update the local profile information +            const jsonUpdateObject = JSON.parse(event.activityPubActor || "{}"); +            broadcastUpdateMessage(jsonUpdateObject, event.followers, eventID); +            // also broadcast an Update/Event for any calendar apps that are consuming our Events +            const jsonEventObject = JSON.parse(event.activityPubEvent || "{}"); +            broadcastUpdateMessage(jsonEventObject, event.followers, eventID); + +            // DM to attendees +            if (attendees?.length) { +                for (const attendee of attendees) { +                    const jsonObject = { +                        "@context": "https://www.w3.org/ns/activitystreams", +                        name: `RSVP to ${event.name}`, +                        type: "Note", +                        content: `<span class=\"h-card\"><a href="${attendee.id}" class="u-url mention">@<span>${attendee.name}</span></a></span> ${diffText} See here: <a href="https://${config.general.domain}/${req.params.eventID}">https://${config.general.domain}/${req.params.eventID}</a>`, +                        tag: [ +                            { +                                type: "Mention", +                                href: attendee.id, +                                name: attendee.name, +                            }, +                        ], +                    }; +                    // send direct message to user +                    sendDirectMessage(jsonObject, attendee.id, eventID); +                } +            } +            // Send update to all attendees +            if (req.app.locals.sendEmails) { +                const attendeeEmails = event.attendees +                    ?.filter((o) => o.status === "attending" && o.email) +                    .map((o) => o.email); +                if (attendeeEmails?.length) { +                    sendEmailFromTemplate( +                        attendeeEmails.join(","), +                        `${event.name} was just edited`, +                        "editEvent", +                        { +                            diffText, +                            eventID: req.params.eventID, +                            siteName: config.general.site_name, +                            siteLogo: config.general.email_logo_url, +                            domain: config.general.domain, +                        }, +                        req, +                    ); +                } +            } +            res.sendStatus(200); +        } catch (err) { +            console.error(err); +            addToLog( +                "editEvent", +                "error", +                "Attempt to edit event " + +                    req.params.eventID + +                    " failed with error: " + +                    err, +            ); +            return res.status(500).json({ +                errors: [ +                    { +                        message: err, +                    }, +                ], +            }); +        } +    }, +); + +export default router; diff --git a/src/routes/frontend.ts b/src/routes/frontend.ts index 71984ec..d24210f 100644 --- a/src/routes/frontend.ts +++ b/src/routes/frontend.ts @@ -69,6 +69,13 @@ router.get("/:eventID", async (req: Request, res: Response) => {          let parsedEnd = moment              .tz(event.end, event.timezone)              .format("YYYYMMDD[T]HHmmss"); +        // See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/datetime-local +        const parsedStartForDateInput = moment +            .tz(event.start, event.timezone) +            .format("YYYY-MM-DDTHH:mm"); +        const parsedEndForDateInput = moment +            .tz(event.end, event.timezone) +            .format("YYYY-MM-DDTHH:mm");          let eventHasConcluded = false;          if (              moment @@ -194,6 +201,8 @@ router.get("/:eventID", async (req: Request, res: Response) => {                  parsedLocation: parsedLocation,                  parsedStart: parsedStart,                  parsedEnd: parsedEnd, +                parsedStartForDateInput, +                parsedEndForDateInput,                  displayDate: displayDate,                  fromNow: fromNow,                  timezone: event.timezone,  | 
