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, |