summaryrefslogtreecommitdiff
path: root/src/routes
diff options
context:
space:
mode:
authorRaphael <mail@raphaelkabo.com>2023-10-08 19:26:04 +0100
committerGitHub <noreply@github.com>2023-10-08 19:26:04 +0100
commit44e150bc7f8391b56b78a0697dbd128a8bf8be7b (patch)
treeef065e69228453d5d49b886157a4a88ed3540474 /src/routes
parent9ef8e220b4fb582d620016d293b340a63ec97cff (diff)
parent608532d24d868d939fd2cef6302d8d5089a81ee4 (diff)
Merge pull request #112 from lowercasename/rk/typescript
Typescript migration
Diffstat (limited to 'src/routes')
-rw-r--r--src/routes/activitypub.ts174
-rw-r--r--src/routes/event.ts640
-rw-r--r--src/routes/frontend.ts227
-rw-r--r--src/routes/group.ts240
4 files changed, 1279 insertions, 2 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..2245009
--- /dev/null
+++ b/src/routes/event.ts
@@ -0,0 +1,640 @@
+import { Router, Response, Request } from "express";
+import multer from "multer";
+import Jimp from "jimp";
+import moment from "moment-timezone";
+import { marked } from "marked";
+import {
+ generateEditToken,
+ generateEventID,
+ 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";
+import ical from "ical";
+
+const config = getConfig();
+
+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 icsUpload = multer({
+ storage: storage,
+ limits: { fileSize: 10 * 1024 * 1024 },
+ fileFilter: function (_, file, cb) {
+ const filetype = "text/calendar";
+ if (file.mimetype !== filetype) {
+ return cb(new Error("Only ICS files 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 = generateEventID();
+ 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
+ return eventID + ".jpg";
+ })
+ .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.",
+ },
+ ],
+ });
+ }
+
+ try {
+ const submittedEditToken = req.body.editToken;
+ 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,
+ },
+ ],
+ });
+ }
+ },
+);
+
+router.post(
+ "/import/event",
+ icsUpload.single("icsImportControl"),
+ async (req: Request, res: Response) => {
+ if (!req.file) {
+ return res.status(400).json({
+ errors: [
+ {
+ message: "No file was provided.",
+ },
+ ],
+ });
+ }
+
+ let eventID = generateEventID();
+ let editToken = generateEditToken();
+
+ let iCalObject = ical.parseICS(req.file.buffer.toString("utf8"));
+
+ let importedEventData = iCalObject[Object.keys(iCalObject)[0]];
+
+ let creatorEmail: string | undefined;
+ if (req.body.creatorEmail) {
+ creatorEmail = req.body.creatorEmail;
+ } else if (importedEventData.organizer) {
+ if (typeof importedEventData.organizer === "string") {
+ creatorEmail = importedEventData.organizer.replace(
+ "MAILTO:",
+ "",
+ );
+ } else {
+ creatorEmail = importedEventData.organizer.val.replace(
+ "MAILTO:",
+ "",
+ );
+ }
+ }
+
+ let hostName: string | undefined;
+ if (importedEventData.organizer) {
+ if (typeof importedEventData.organizer === "string") {
+ hostName = importedEventData.organizer.replace(/["]+/g, "");
+ } else {
+ hostName = importedEventData.organizer.params.CN.replace(
+ /["]+/g,
+ "",
+ );
+ }
+ }
+
+ const event = new Event({
+ id: eventID,
+ type: "public",
+ name: importedEventData.summary,
+ location: importedEventData.location,
+ start: importedEventData.start,
+ end: importedEventData.end,
+ timezone: "Etc/UTC", // TODO: get timezone from ics file
+ description: importedEventData.description,
+ image: "",
+ creatorEmail,
+ url: "",
+ hostName,
+ viewPassword: "",
+ editPassword: "",
+ editToken: editToken,
+ usersCanAttend: false,
+ showUsersList: false,
+ usersCanComment: false,
+ firstLoad: true,
+ });
+ try {
+ await event.save();
+ addToLog("createEvent", "success", `Event ${eventID} created`);
+ // Send email with edit link
+ if (creatorEmail && req.app.locals.sendEmails) {
+ sendEmailFromTemplate(
+ creatorEmail,
+ `${importedEventData.summary}`,
+ "createEvent",
+ {
+ eventID,
+ editToken,
+ siteName: config.general.site_name,
+ siteLogo: config.general.email_logo_url,
+ domain: config.general.domain,
+ },
+ req,
+ );
+ }
+ 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,
+ },
+ ],
+ });
+ }
+ },
+);
+
+export default router;
diff --git a/src/routes/frontend.ts b/src/routes/frontend.ts
index 71984ec..c9594ef 100644
--- a/src/routes/frontend.ts
+++ b/src/routes/frontend.ts
@@ -1,11 +1,12 @@
import { Router, Request, Response } from "express";
-import Event from "../models/Event.js";
import moment from "moment-timezone";
import { marked } from "marked";
import { frontendConfig } from "../util/config.js";
import { renderPlain } from "../util/markdown.js";
import getConfig from "../lib/config.js";
-import { addToLog } from "../helpers.js";
+import { addToLog, exportICal } from "../helpers.js";
+import Event from "../models/Event.js";
+import EventGroup, { IEventGroup } from "../models/EventGroup.js";
const config = getConfig();
@@ -69,6 +70,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 +202,8 @@ router.get("/:eventID", async (req: Request, res: Response) => {
parsedLocation: parsedLocation,
parsedStart: parsedStart,
parsedEnd: parsedEnd,
+ parsedStartForDateInput,
+ parsedEndForDateInput,
displayDate: displayDate,
fromNow: fromNow,
timezone: event.timezone,
@@ -205,6 +215,31 @@ router.get("/:eventID", async (req: Request, res: Response) => {
eventHasConcluded: eventHasConcluded,
eventHasBegun: eventHasBegun,
metadata: metadata,
+ jsonData: {
+ name: event.name,
+ id: event.id,
+ description: event.description,
+ location: event.location,
+ timezone: event.timezone,
+ url: event.url,
+ hostName: event.hostName,
+ creatorEmail: event.creatorEmail,
+ eventGroupID: event.eventGroup
+ ? (event.eventGroup as unknown as IEventGroup).id
+ : null,
+ eventGroupEditToken: event.eventGroup
+ ? (event.eventGroup as unknown as IEventGroup).editToken
+ : null,
+ usersCanAttend: event.usersCanAttend,
+ usersCanComment: event.usersCanComment,
+ maxAttendees: event.maxAttendees,
+ startISO: eventStartISO,
+ endISO: eventEndISO,
+ startForDateInput: parsedStartForDateInput,
+ endForDateInput: parsedEndForDateInput,
+ image: event.image,
+ editToken: editingEnabled ? eventEditToken : null,
+ },
});
}
} catch (err) {
@@ -221,4 +256,192 @@ router.get("/:eventID", async (req: Request, res: Response) => {
}
});
+router.get("/group/:eventGroupID", async (req: Request, res: Response) => {
+ try {
+ const eventGroup = await EventGroup.findOne({
+ id: req.params.eventGroupID,
+ }).lean();
+
+ if (!eventGroup) {
+ return res.status(404).render("404", { url: req.url });
+ }
+ const parsedDescription = marked.parse(eventGroup.description);
+ const eventGroupEditToken = eventGroup.editToken;
+ const escapedName = eventGroup.name.replace(/\s+/g, "+");
+ const eventGroupHasCoverImage = !!eventGroup.image;
+ const eventGroupHasHost = !!eventGroup.hostName;
+
+ const events = await Event.find({ eventGroup: eventGroup._id })
+ .lean()
+ .sort("start");
+
+ const updatedEvents = events.map((event) => {
+ const startMoment = moment.tz(event.start, event.timezone);
+ const endMoment = moment.tz(event.end, event.timezone);
+ const isSameDay = startMoment.isSame(endMoment, "day");
+
+ return {
+ id: event.id,
+ name: event.name,
+ displayDate: isSameDay
+ ? startMoment.format("D MMM YYYY")
+ : `${startMoment.format("D MMM YYYY")} - ${endMoment.format(
+ "D MMM YYYY",
+ )}`,
+ eventHasConcluded: endMoment.isBefore(
+ moment.tz(event.timezone),
+ ),
+ };
+ });
+
+ const upcomingEventsExist = updatedEvents.some(
+ (e) => !e.eventHasConcluded,
+ );
+
+ let firstLoad = false;
+ if (eventGroup.firstLoad === true) {
+ firstLoad = true;
+ await EventGroup.findOneAndUpdate(
+ { id: req.params.eventGroupID },
+ { firstLoad: false },
+ );
+ }
+
+ let editingEnabled = false;
+ if (Object.keys(req.query).length !== 0) {
+ if (!req.query.e) {
+ editingEnabled = false;
+ } else {
+ editingEnabled = req.query.e === eventGroupEditToken;
+ }
+ }
+
+ const metadata = {
+ title: eventGroup.name,
+ description: marked
+ .parse(eventGroup.description, {
+ renderer: renderPlain(),
+ })
+ .split(" ")
+ .splice(0, 40)
+ .join(" ")
+ .trim(),
+ image: eventGroupHasCoverImage
+ ? `https://${config.general.domain}/events/` + eventGroup.image
+ : null,
+ url: `https://${config.general.domain}/` + req.params.eventID,
+ };
+
+ res.set("X-Robots-Tag", "noindex");
+ res.render("eventgroup", {
+ domain: config.general.domain,
+ title: eventGroup.name,
+ eventGroupData: eventGroup,
+ escapedName: escapedName,
+ events: updatedEvents,
+ upcomingEventsExist: upcomingEventsExist,
+ parsedDescription: parsedDescription,
+ editingEnabled: editingEnabled,
+ eventGroupHasCoverImage: eventGroupHasCoverImage,
+ eventGroupHasHost: eventGroupHasHost,
+ firstLoad: firstLoad,
+ metadata: metadata,
+ jsonData: {
+ name: eventGroup.name,
+ id: eventGroup.id,
+ description: eventGroup.description,
+ url: eventGroup.url,
+ hostName: eventGroup.hostName,
+ creatorEmail: eventGroup.creatorEmail,
+ image: eventGroup.image,
+ editToken: editingEnabled ? eventGroupEditToken : null,
+ },
+ });
+ } catch (err) {
+ addToLog(
+ "displayEventGroup",
+ "error",
+ `Attempt to display event group ${req.params.eventGroupID} failed with error: ${err}`,
+ );
+ console.log(err);
+ return res.status(404).render("404", { url: req.url });
+ }
+});
+
+router.get(
+ "/group/:eventGroupID/feed.ics",
+ async (req: Request, res: Response) => {
+ try {
+ const eventGroup = await EventGroup.findOne({
+ id: req.params.eventGroupID,
+ }).lean();
+
+ if (eventGroup) {
+ const events = await Event.find({
+ eventGroup: eventGroup._id,
+ }).sort("start");
+ const string = exportICal(events, eventGroup.name);
+ res.set("Content-Type", "text/calendar");
+ res.send(string);
+ }
+ } catch (err) {
+ addToLog(
+ "eventGroupFeed",
+ "error",
+ `Attempt to display event group feed for ${req.params.eventGroupID} failed with error: ${err}`,
+ );
+ console.log(err);
+ res.status(404).render("404", { url: req.url });
+ }
+ },
+);
+
+router.get("/export/event/:eventID", async (req: Request, res: Response) => {
+ try {
+ const event = await Event.findOne({
+ id: req.params.eventID,
+ }).populate("eventGroup");
+
+ if (event) {
+ const string = exportICal([event], event.name);
+ res.send(string);
+ }
+ } catch (err) {
+ addToLog(
+ "exportEvent",
+ "error",
+ `Attempt to export event ${req.params.eventID} failed with error: ${err}`,
+ );
+ console.log(err);
+ res.status(404).render("404", { url: req.url });
+ }
+});
+
+router.get(
+ "/export/group/:eventGroupID",
+ async (req: Request, res: Response) => {
+ try {
+ const eventGroup = await EventGroup.findOne({
+ id: req.params.eventGroupID,
+ }).lean();
+
+ if (eventGroup) {
+ const events = await Event.find({
+ eventGroup: eventGroup._id,
+ }).sort("start");
+ const string = exportICal(events, eventGroup.name);
+ res.send(string);
+ }
+ } catch (err) {
+ addToLog(
+ "exportEvent",
+ "error",
+ `Attempt to export event group ${req.params.eventGroupID} failed with error: ${err}`,
+ );
+ console.log(err);
+ res.status(404).render("404", { url: req.url });
+ }
+ },
+);
+
export default router;
diff --git a/src/routes/group.ts b/src/routes/group.ts
new file mode 100644
index 0000000..2801248
--- /dev/null
+++ b/src/routes/group.ts
@@ -0,0 +1,240 @@
+import { Router, Response, Request } from "express";
+import getConfig from "../lib/config.js";
+import multer from "multer";
+import { generateEditToken, generateEventID } from "../util/generator.js";
+import { validateGroupData } from "../util/validation.js";
+import Jimp from "jimp";
+import { addToLog } from "../helpers.js";
+import EventGroup from "../models/EventGroup.js";
+import { sendEmailFromTemplate } from "../lib/email.js";
+
+const config = getConfig();
+
+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(
+ "/group",
+ upload.single("imageUpload"),
+ async (req: Request, res: Response) => {
+ const { data: groupData, errors } = validateGroupData(req.body);
+ if (errors && errors.length > 0) {
+ return res.status(400).json({ errors });
+ }
+ if (!groupData) {
+ return res.status(400).json({
+ errors: [
+ {
+ message: "No group data was provided.",
+ },
+ ],
+ });
+ }
+
+ try {
+ const groupID = generateEventID();
+ const editToken = generateEditToken();
+ let groupImageFilename;
+
+ if (req.file?.buffer) {
+ groupImageFilename = await Jimp.read(req.file.buffer)
+ .then((img) => {
+ img.resize(920, Jimp.AUTO) // resize
+ .quality(80) // set JPEG quality
+ .write("./public/events/" + groupID + ".jpg"); // save
+ return groupID + ".jpg";
+ })
+ .catch((err) => {
+ addToLog(
+ "Jimp",
+ "error",
+ "Attempt to edit image failed with error: " + err,
+ );
+ });
+ }
+
+ const eventGroup = new EventGroup({
+ id: groupID,
+ name: groupData.eventGroupName,
+ description: groupData.eventGroupDescription,
+ image: groupImageFilename,
+ creatorEmail: groupData.creatorEmail,
+ url: groupData.eventGroupURL,
+ hostName: groupData.hostName,
+ editToken: editToken,
+ firstLoad: true,
+ });
+
+ await eventGroup.save();
+
+ addToLog(
+ "createEventGroup",
+ "success",
+ "Event group " + groupID + " created",
+ );
+
+ // Send email with edit link
+ if (groupData.creatorEmail && req.app.locals.sendEmails) {
+ sendEmailFromTemplate(
+ groupData.creatorEmail,
+ `${eventGroup.name}`,
+ "createEventGroup",
+ {
+ eventGroupID: eventGroup.id,
+ editToken: eventGroup.editToken,
+ siteName: config.general.site_name,
+ siteLogo: config.general.email_logo_url,
+ domain: config.general.domain,
+ },
+ req,
+ );
+ }
+
+ res.status(200).json({
+ id: groupID,
+ editToken: editToken,
+ url: `/group/${groupID}?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(
+ "/group/:eventGroupID",
+ upload.single("imageUpload"),
+ async (req: Request, res: Response) => {
+ const { data: groupData, errors } = validateGroupData(req.body);
+ if (errors && errors.length > 0) {
+ return res.status(400).json({ errors });
+ }
+ if (!groupData) {
+ return res.status(400).json({
+ errors: [
+ {
+ message: "No group data was provided.",
+ },
+ ],
+ });
+ }
+
+ try {
+ const submittedEditToken = req.body.editToken;
+ const eventGroup = await EventGroup.findOne({
+ id: req.params.eventGroupID,
+ });
+ if (!eventGroup) {
+ return res.status(404).json({
+ errors: [
+ {
+ message: "Event group not found.",
+ },
+ ],
+ });
+ }
+
+ if (eventGroup.editToken !== submittedEditToken) {
+ // Token doesn't match
+ addToLog(
+ "editEventGroup",
+ "error",
+ `Attempt to edit event group ${req.params.eventGroupID} 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 eventGroupID = req.params.eventGroupID;
+ let eventGroupImageFilename = eventGroup.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/${eventGroupID}.jpg`); // save
+ })
+ .catch((err) => {
+ addToLog(
+ "Jimp",
+ "error",
+ "Attempt to edit image failed with error: " + err,
+ );
+ });
+ eventGroupImageFilename = eventGroupID + ".jpg";
+ }
+
+ const updatedEventGroup = {
+ name: req.body.eventGroupName,
+ description: req.body.eventGroupDescription,
+ url: req.body.eventGroupURL,
+ hostName: req.body.hostName,
+ image: eventGroupImageFilename,
+ };
+
+ await EventGroup.findOneAndUpdate(
+ { id: req.params.eventGroupID },
+ updatedEventGroup,
+ );
+
+ addToLog(
+ "editEventGroup",
+ "success",
+ "Event group " + req.params.eventGroupID + " edited",
+ );
+
+ res.sendStatus(200);
+ } catch (err) {
+ console.error(err);
+ addToLog(
+ "editEventGroup",
+ "error",
+ "Attempt to edit event group " +
+ req.params.eventGroupID +
+ " failed with error: " +
+ err,
+ );
+ return res.status(500).json({
+ errors: [
+ {
+ message: err,
+ },
+ ],
+ });
+ }
+ },
+);
+
+export default router;