summaryrefslogtreecommitdiff
path: root/src/routes
diff options
context:
space:
mode:
authorRaphael Kabo <raphaelkabo@hey.com>2023-10-07 14:30:24 +0100
committerRaphael Kabo <raphaelkabo@hey.com>2023-10-07 15:38:47 +0100
commitb795d07ed7a1b705b72b171f8e8de267a720223b (patch)
treeb8ae3df8dbb89f839f29328e817f030dc22b89f8 /src/routes
parent9341659fd7a791d77454dd33743e42d952dbd202 (diff)
refactor: event form and api routes
Diffstat (limited to 'src/routes')
-rw-r--r--src/routes/activitypub.ts174
-rw-r--r--src/routes/event.ts519
-rw-r--r--src/routes/frontend.ts9
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,