From b795d07ed7a1b705b72b171f8e8de267a720223b Mon Sep 17 00:00:00 2001 From: Raphael Kabo Date: Sat, 7 Oct 2023 14:30:24 +0100 Subject: refactor: event form and api routes --- src/app.ts | 12 +- src/lib/activitypub.ts | 9 + src/lib/config.ts | 5 +- src/lib/email.ts | 151 +++++++++ src/lib/handlebars.ts | 23 ++ src/lib/process.ts | 4 + src/routes.js | 757 +--------------------------------------------- src/routes/activitypub.ts | 174 +++++++++++ src/routes/event.ts | 519 +++++++++++++++++++++++++++++++ src/routes/frontend.ts | 9 + src/util/config.ts | 2 + src/util/generator.ts | 24 ++ src/util/validation.ts | 191 ++++++++++++ 13 files changed, 1123 insertions(+), 757 deletions(-) create mode 100644 src/lib/activitypub.ts create mode 100644 src/lib/email.ts create mode 100644 src/lib/handlebars.ts create mode 100644 src/lib/process.ts create mode 100644 src/routes/activitypub.ts create mode 100644 src/routes/event.ts create mode 100644 src/util/generator.ts create mode 100644 src/util/validation.ts (limited to 'src') diff --git a/src/app.ts b/src/app.ts index 5b01b3c..30cf02d 100755 --- a/src/app.ts +++ b/src/app.ts @@ -3,9 +3,15 @@ import hbs from "express-handlebars"; import routes from "./routes.js"; import frontend from "./routes/frontend.js"; +import activitypub from "./routes/activitypub.js"; +import event from "./routes/event.js"; + +import { initEmailService } from "./lib/email.js"; const app = express(); +app.locals.sendEmails = initEmailService(); + // View engine // const hbsInstance = hbs.create({ defaultLayout: "main", @@ -37,11 +43,15 @@ app.set("hbsInstance", hbsInstance); app.use(express.static("public")); // Body parser // -app.use(express.json({ type: "application/activity+json" })); // support json encoded bodies +app.use(express.json({ type: "application/activity+json" })); +app.use(express.json({ type: "application/ld+json" })); +app.use(express.json({ type: "application/json" })); app.use(express.urlencoded({ extended: true })); // Router // app.use("/", frontend); +app.use("/", activitypub); +app.use("/", event); app.use("/", routes); export default app; diff --git a/src/lib/activitypub.ts b/src/lib/activitypub.ts new file mode 100644 index 0000000..0a3db7b --- /dev/null +++ b/src/lib/activitypub.ts @@ -0,0 +1,9 @@ +import { Request } from "express"; + +export const acceptsActivityPub = (req: Request) => { + return ( + req.headers.accept && + (req.headers.accept.includes("application/activity+json") || + req.headers.accept.includes("application/ld+json")) + ); +}; diff --git a/src/lib/config.ts b/src/lib/config.ts index 9577fd6..7b35b98 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -1,5 +1,6 @@ import fs from "fs"; import toml from "toml"; +import { exitWithError } from "./process.js"; interface GathioConfig { general: { @@ -46,8 +47,8 @@ export const getConfig = (): GathioConfig => { ) as GathioConfig; return config; } catch { - console.error( - "\x1b[31mConfiguration file not found! Have you renamed './config/config-example.toml' to './config/config.toml'?", + exitWithError( + "Configuration file not found! Have you renamed './config/config-example.toml' to './config/config.toml'?", ); return process.exit(1); } diff --git a/src/lib/email.ts b/src/lib/email.ts new file mode 100644 index 0000000..f1dc1ae --- /dev/null +++ b/src/lib/email.ts @@ -0,0 +1,151 @@ +import { Request } from "express"; +import sgMail from "@sendgrid/mail"; +import nodemailer, { TransportOptions } from "nodemailer"; +import { getConfig } from "./config.js"; +import SMTPTransport from "nodemailer/lib/smtp-transport/index.js"; +import { exitWithError } from "./process.js"; +import { renderTemplate } from "./handlebars.js"; +const config = getConfig(); + +type EmailTemplate = + | "addEventAttendee" + | "addEventComment" + | "createEvent" + | "createEventGroup" + | "deleteEvent" + | "editEvent" + | "eventGroupUpdated" + | "subscribed" + | "unattendEvent"; + +export const initEmailService = async (): Promise => { + if (process.env.CYPRESS || process.env.CI) { + console.log( + "Running in Cypress or CI, not initializing email service.", + ); + return false; + } + switch (config.general.mail_service) { + case "sendgrid": + if (!config.sendgrid?.api_key) { + return exitWithError( + "Sendgrid is configured as the email service, but no API key is provided. Please provide an API key in the config file.", + ); + } + sgMail.setApiKey(config.sendgrid.api_key); + console.log("Sendgrid is ready to send emails."); + return true; + case "nodemailer": + if ( + !config.nodemailer?.smtp_server || + !config.nodemailer?.smtp_port || + !config.nodemailer?.smtp_username || + !config.nodemailer?.smtp_password + ) { + return exitWithError( + "Nodemailer is configured as the email service, but not all required fields are provided. Please provide all required fields in the config file.", + ); + } + const nodemailerConfig = { + host: config.nodemailer?.smtp_server, + port: Number(config.nodemailer?.smtp_port) || 587, + auth: { + user: config.nodemailer?.smtp_username, + pass: config.nodemailer?.smtp_password, + }, + } as SMTPTransport.Options; + const nodemailerTransporter = + nodemailer.createTransport(nodemailerConfig); + const nodemailerVerified = await nodemailerTransporter.verify(); + if (nodemailerVerified) { + console.log("Nodemailer is ready to send emails."); + return true; + } else { + return exitWithError( + "Error verifying Nodemailer transporter. Please check your Nodemailer configuration.", + ); + } + default: + console.warn( + "You have not configured this Gathio instance to send emails! This means that event creators will not receive emails when their events are created, which means they may end up locked out of editing events. Consider setting up an email service.", + ); + return false; + } +}; + +export const sendEmail = async ( + to: string, + subject: string, + text: string, + html?: string, +): Promise => { + switch (config.general.mail_service) { + case "sendgrid": + try { + await sgMail.send({ + to, + from: config.general.email, + subject: `${config.general.site_name}: ${subject}`, + text, + html, + }); + return true; + } catch (e: any) { + if (e.response) { + console.error(e.response.body); + } else { + console.error(e); + } + return false; + } + case "nodemailer": + try { + const nodemailerConfig = { + host: config.nodemailer?.smtp_server, + port: Number(config.nodemailer?.smtp_port) || 587, + auth: { + user: config.nodemailer?.smtp_username, + pass: config.nodemailer?.smtp_password, + }, + } as SMTPTransport.Options; + const nodemailerTransporter = + nodemailer.createTransport(nodemailerConfig); + await nodemailerTransporter.sendMail({ + from: config.general.email, + to, + subject, + text, + html, + }); + return true; + } catch (e) { + console.error(e); + return false; + } + default: + return false; + } +}; + +export const sendEmailFromTemplate = async ( + to: string, + subject: string, + template: EmailTemplate, + templateData: Record, + req: Request, +): Promise => { + const html = await renderTemplate(req, `${template}/${template}Html`, { + siteName: config.general.site_name, + siteLogo: config.general.email_logo_url, + domain: config.general.domain, + cache: true, + layout: "email.handlebars", + ...templateData, + }); + const text = await renderTemplate( + req, + `${template}/${template}Text`, + templateData, + ); + return await sendEmail(to, subject, text, html); +}; diff --git a/src/lib/handlebars.ts b/src/lib/handlebars.ts new file mode 100644 index 0000000..d5a8b6e --- /dev/null +++ b/src/lib/handlebars.ts @@ -0,0 +1,23 @@ +import { Request } from "express"; + +export const renderTemplate = async ( + req: Request, + templateName: string, + data: Record, +): Promise => { + return new Promise((resolve, reject) => { + req.app + .get("hbsInstance") + .renderView( + `./views/emails/${templateName}.handlebars`, + data, + (err: any, html: string) => { + if (err) { + console.error(err); + reject(err); + } + resolve(html); + }, + ); + }); +}; diff --git a/src/lib/process.ts b/src/lib/process.ts new file mode 100644 index 0000000..d43b3c7 --- /dev/null +++ b/src/lib/process.ts @@ -0,0 +1,4 @@ +export const exitWithError = (message: string) => { + console.error(`\x1b[31m${message}`); + process.exit(1); +}; diff --git a/src/routes.js b/src/routes.js index 7257bdb..94b7477 100755 --- a/src/routes.js +++ b/src/routes.js @@ -6,7 +6,6 @@ import { getConfig } from "./lib/config.js"; import { addToLog, exportIcal } from "./helpers.js"; import moment from "moment-timezone"; import { marked } from "marked"; -import generateRSAKeypair from "generate-rsa-keypair"; import crypto from "crypto"; import request from "request"; import niceware from "niceware"; @@ -17,21 +16,14 @@ import fileUpload from "express-fileupload"; import Jimp from "jimp"; import schedule from "node-schedule"; import { - createActivityPubActor, - createActivityPubEvent, - createFeaturedPost, - createWebfinger, - updateActivityPubActor, - updateActivityPubEvent, broadcastCreateMessage, - broadcastUpdateMessage, broadcastDeleteMessage, - sendDirectMessage, processInbox, } from "./activitypub.js"; import Event from "./models/Event.js"; import EventGroup from "./models/EventGroup.js"; import path from "path"; +import { renderPlain } from "./util/markdown.js"; const config = getConfig(); const domain = config.general.domain; @@ -40,7 +32,6 @@ const siteName = config.general.site_name; const mailService = config.general.mail_service; const siteLogo = config.general.email_logo_url; const isFederated = config.general.is_federated || true; -const showKofi = config.general.show_kofi; // This alphabet (used to generate all event, group, etc. IDs) is missing '-' // because ActivityPub doesn't like it in IDs @@ -193,180 +184,6 @@ schedule.scheduleJob("59 23 * * *", function (fireDate) { // old (they're not going to become active) }); -// return the JSON for the featured/pinned post for this event -router.get("/:eventID/featured", (req, res) => { - if (!isFederated) return res.sendStatus(404); - const { eventID } = req.params; - const guidObject = crypto.randomBytes(16).toString("hex"); - const featured = { - "@context": "https://www.w3.org/ns/activitystreams", - id: `https://${domain}/${eventID}/featured`, - type: "OrderedCollection", - orderedItems: [createFeaturedPost(eventID)], - }; - if ( - req.headers.accept && - (req.headers.accept.includes("application/activity+json") || - req.headers.accept.includes("application/ld+json")) - ) { - 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", (req, res) => { - if (!isFederated) return res.sendStatus(404); - const { hash, eventID } = req.params; - const id = `https://${domain}/${eventID}/m/${hash}`; - - Event.findOne({ - id: eventID, - }) - .then((event) => { - if (!event) { - res.status(404); - res.render("404", { url: req.url }); - } else { - const message = event.activityPubMessages.find( - (el) => el.id === id, - ); - if (message) { - if ( - req.headers.accept && - (req.headers.accept.includes( - "application/activity+json", - ) || - req.headers.accept.includes("application/ld+json")) - ) { - 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 { - res.status(404); - return res.render("404", { url: req.url }); - } - } - }) - .catch((err) => { - addToLog( - "getActivityPubMessage", - "error", - "Attempt to get Activity Pub Message for " + - id + - " failed with error: " + - err, - ); - res.status(404); - res.render("404", { url: req.url }); - return; - }); -}); - -// return the webfinger record required for the initial activitypub handshake -router.get("/.well-known/webfinger", (req, res) => { - if (!isFederated) return res.sendStatus(404); - let resource = req.query.resource; - 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(/@.*/, ""); - Event.findOne({ - id: eventID, - }) - .then((event) => { - if (!event) { - res.status(404); - res.render("404", { url: req.url }); - } else { - if ( - req.headers.accept && - (req.headers.accept.includes( - "application/activity+json", - ) || - req.headers.accept.includes("application/ld+json")) - ) { - res.header( - "Content-Type", - "application/activity+json", - ).send(createWebfinger(eventID, domain)); - } else { - res.header("Content-Type", "application/json").send( - createWebfinger(eventID, domain), - ); - } - } - }) - .catch((err) => { - addToLog( - "renderWebfinger", - "error", - "Attempt to render webfinger for " + - req.params.eventID + - " failed with error: " + - err, - ); - res.status(404); - res.render("404", { url: req.url }); - return; - }); - } -}); - -router.get("/:eventID/followers", (req, res) => { - if (!isFederated) return res.sendStatus(404); - const eventID = req.params.eventID; - Event.findOne({ - id: eventID, - }).then((event) => { - if (event) { - const followers = event.followers.map((el) => el.actorId); - let followersCollection = { - type: "OrderedCollection", - totalItems: followers.length, - id: `https://${domain}/${eventID}/followers`, - first: { - type: "OrderedCollectionPage", - totalItems: followers.length, - partOf: `https://${domain}/${eventID}/followers`, - orderedItems: followers, - id: `https://${domain}/${eventID}/followers?page=1`, - }, - "@context": ["https://www.w3.org/ns/activitystreams"], - }; - if ( - req.headers.accept && - (req.headers.accept.includes("application/activity+json") || - req.headers.accept.includes("application/ld+json")) - ) { - 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."); - } - }); -}); - router.get("/group/:eventGroupID", (req, res) => { EventGroup.findOne({ id: req.params.eventGroupID, @@ -467,7 +284,7 @@ router.get("/group/:eventGroupID", (req, res) => { title: eventGroup.name, description: marked .parse(eventGroup.description, { - renderer: render_plain(), + renderer: renderPlain(), }) .split(" ") .splice(0, 40) @@ -603,243 +420,6 @@ router.get("/exportgroup/:eventGroupID", (req, res) => { }); // BACKEND ROUTES - -router.post("/newevent", async (req, res) => { - let eventID = nanoid(); - let editToken = randomstring.generate(); - let eventImageFilename = ""; - let isPartOfEventGroup = false; - if (req.files && Object.keys(req.files).length !== 0) { - let eventImageBuffer = req.files.imageUpload.data; - eventImageFilename = await Jimp.read(eventImageBuffer) - .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, - ); - }); - } - let startUTC = moment.tz( - req.body.eventStart, - "D MMMM YYYY, hh:mm a", - req.body.timezone, - ); - let endUTC = moment.tz( - req.body.eventEnd, - "D MMMM YYYY, hh:mm a", - req.body.timezone, - ); - let eventGroup; - if (req.body.eventGroupCheckbox) { - eventGroup = await EventGroup.findOne({ - id: req.body.eventGroupID, - editToken: req.body.eventGroupEditToken, - }); - if (eventGroup) { - isPartOfEventGroup = true; - } - } - - // generate RSA keypair for ActivityPub - let pair = generateRSAKeypair(); - - const event = new Event({ - id: eventID, - type: "public", // This is for backwards compatibility - name: req.body.eventName, - location: req.body.eventLocation, - start: startUTC, - end: endUTC, - timezone: req.body.timezone, - description: req.body.eventDescription, - image: eventImageFilename, - creatorEmail: req.body.creatorEmail, - url: req.body.eventURL, - hostName: req.body.hostName, - viewPassword: req.body.viewPassword, - editPassword: req.body.editPassword, - editToken: editToken, - eventGroup: isPartOfEventGroup ? eventGroup._id : null, - usersCanAttend: req.body.joinCheckbox ? true : false, - showUsersList: req.body.guestlistCheckbox ? true : false, - usersCanComment: req.body.interactionCheckbox ? true : false, - maxAttendees: req.body.maxAttendees, - firstLoad: true, - activityPubActor: createActivityPubActor( - eventID, - domain, - pair.public, - marked.parse(req.body.eventDescription), - req.body.eventName, - req.body.eventLocation, - eventImageFilename, - startUTC, - endUTC, - req.body.timezone, - ), - activityPubEvent: createActivityPubEvent( - req.body.eventName, - startUTC, - endUTC, - req.body.timezone, - req.body.eventDescription, - req.body.eventLocation, - ), - activityPubMessages: [ - { - id: `https://${domain}/${eventID}/m/featuredPost`, - content: JSON.stringify( - createFeaturedPost( - eventID, - req.body.eventName, - startUTC, - endUTC, - req.body.timezone, - req.body.eventDescription, - req.body.eventLocation, - ), - ), - }, - ], - publicKey: pair.public, - privateKey: pair.private, - }); - event - .save() - .then((event) => { - addToLog("createEvent", "success", "Event " + eventID + "created"); - // Send email with edit link - if (req.body.creatorEmail && sendEmails) { - req.app.get("hbsInstance").renderView( - "./views/emails/createevent.handlebars", - { - eventID, - editToken, - siteName, - siteLogo, - domain, - cache: true, - layout: "email.handlebars", - }, - function (err, html) { - const msg = { - to: req.body.creatorEmail, - from: { - name: siteName, - email: contactEmail, - address: contactEmail, - }, - subject: `${siteName}: ${req.body.eventName}`, - html, - }; - switch (mailService) { - case "sendgrid": - sgMail.send(msg).catch((e) => { - console.error(e.toString()); - res.status(500).end(); - }); - break; - case "nodemailer": - nodemailerTransporter - .sendMail(msg) - .catch((e) => { - console.error(e.toString()); - res.status(500).end(); - }); - break; - } - }, - ); - } - // If the event was added to a group, send an email to any group - // subscribers - if (event.eventGroup && sendEmails) { - EventGroup.findOne({ _id: event.eventGroup._id }).then( - (eventGroup) => { - const subscribers = eventGroup.subscribers.reduce( - (acc, current) => { - if (acc.includes(current.email)) { - return acc; - } - return [current.email, ...acc]; - }, - [], - ); - subscribers.forEach((emailAddress) => { - req.app.get("hbsInstance").renderView( - "./views/emails/eventgroupupdated.handlebars", - { - siteName, - siteLogo, - domain, - eventID: req.params.eventID, - eventGroupName: eventGroup.name, - eventName: event.name, - eventID: event.id, - eventGroupID: eventGroup.id, - emailAddress: - encodeURIComponent(emailAddress), - cache: true, - layout: "email.handlebars", - }, - function (err, html) { - const msg = { - to: emailAddress, - from: { - name: siteName, - email: contactEmail, - }, - subject: `${siteName}: New event in ${eventGroup.name}`, - html, - }; - switch (mailService) { - case "sendgrid": - sgMail.send(msg).catch((e) => { - console.error(e.toString()); - res.status(500).end(); - }); - break; - case "nodemailer": - nodemailerTransporter - .sendMail(msg) - .catch((e) => { - console.error(e.toString()); - res.status(500).end(); - }); - break; - } - }, - ); - }); - }, - ); - } - res.writeHead(302, { - Location: "/" + eventID + "?e=" + editToken, - }); - res.end(); - }) - .catch((err) => { - console.error(err); - res.status(500).send( - "Database error, please try again :( - " + err, - ); - addToLog( - "createEvent", - "error", - "Attempt to create event failed with error: " + err, - ); - }); -}); - router.post("/importevent", (req, res) => { let eventID = nanoid(); let editToken = randomstring.generate(); @@ -1071,338 +651,6 @@ router.post("/verifytoken/group/:eventGroupID", (req, res) => { }); }); -router.post("/editevent/:eventID/:editToken", (req, res) => { - let submittedEditToken = req.params.editToken; - Event.findOne({ - id: req.params.eventID, - }) - .then(async (event) => { - if (event.editToken === submittedEditToken) { - // Token matches - - // If there is a new image, upload that first - let eventID = req.params.eventID; - let eventImageFilename = event.image; - if (req.files && Object.keys(req.files).length !== 0) { - let eventImageBuffer = req.files.imageUpload.data; - Jimp.read(eventImageBuffer, (err, img) => { - if (err) throw err; - img.resize(920, Jimp.AUTO) // resize - .quality(80) // set JPEG - .write("./public/events/" + eventID + ".jpg"); // save - }); - eventImageFilename = eventID + ".jpg"; - } - let startUTC = moment.tz( - req.body.eventStart, - "D MMMM YYYY, hh:mm a", - req.body.timezone, - ); - let endUTC = moment.tz( - req.body.eventEnd, - "D MMMM YYYY, hh:mm a", - req.body.timezone, - ); - - let isPartOfEventGroup = false; - let eventGroup; - if (req.body.eventGroupCheckbox) { - eventGroup = await EventGroup.findOne({ - id: req.body.eventGroupID, - editToken: req.body.eventGroupEditToken, - }); - if (eventGroup) { - isPartOfEventGroup = true; - } - } - const updatedEvent = { - name: req.body.eventName, - location: req.body.eventLocation, - start: startUTC, - end: endUTC, - timezone: req.body.timezone, - description: req.body.eventDescription, - url: req.body.eventURL, - hostName: req.body.hostName, - image: eventImageFilename, - usersCanAttend: req.body.joinCheckbox ? true : false, - showUsersList: req.body.guestlistCheckbox ? true : false, - usersCanComment: req.body.interactionCheckbox - ? true - : false, - maxAttendees: req.body.maxAttendeesCheckbox - ? req.body.maxAttendees - : null, - eventGroup: isPartOfEventGroup ? eventGroup._id : null, - activityPubActor: event.activityPubActor - ? updateActivityPubActor( - JSON.parse(event.activityPubActor), - req.body.eventDescription, - req.body.eventName, - req.body.eventLocation, - eventImageFilename, - startUTC, - endUTC, - req.body.timezone, - ) - : null, - activityPubEvent: event.activityPubEvent - ? updateActivityPubEvent( - JSON.parse(event.activityPubEvent), - req.body.eventName, - req.body.startUTC, - req.body.endUTC, - req.body.timezone, - ) - : null, - }; - let diffText = - "

This event was just updated with new information.

    "; - let displayDate; - if (event.name !== updatedEvent.name) { - diffText += `
  • the event name changed to ${updatedEvent.name}
  • `; - } - if (event.location !== updatedEvent.location) { - diffText += `
  • the location changed to ${updatedEvent.location}
  • `; - } - if ( - event.start.toISOString() !== - updatedEvent.start.toISOString() - ) { - displayDate = moment - .tz(updatedEvent.start, updatedEvent.timezone) - .format("dddd D MMMM YYYY h:mm a"); - diffText += `
  • the start time changed to ${displayDate}
  • `; - } - if ( - event.end.toISOString() !== updatedEvent.end.toISOString() - ) { - displayDate = moment - .tz(updatedEvent.end, updatedEvent.timezone) - .format("dddd D MMMM YYYY h:mm a"); - diffText += `
  • the end time changed to ${displayDate}
  • `; - } - if (event.timezone !== updatedEvent.timezone) { - diffText += `
  • the time zone changed to ${updatedEvent.timezone}
  • `; - } - if (event.description !== updatedEvent.description) { - diffText += `
  • the event description changed
  • `; - } - diffText += `
`; - Event.findOneAndUpdate( - { id: req.params.eventID }, - updatedEvent, - function (err, raw) { - if (err) { - addToLog( - "editEvent", - "error", - "Attempt to edit event " + - req.params.eventID + - " failed with error: " + - err, - ); - res.send(err); - } - }, - ) - .then(() => { - addToLog( - "editEvent", - "success", - "Event " + req.params.eventID + " edited", - ); - // send update to ActivityPub subscribers - Event.findOne( - { id: req.params.eventID }, - function (err, event) { - if (!event) return; - let attendees = event.attendees.filter( - (el) => el.id, - ); - if (!err) { - // 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://${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: https://${domain}/${req.params.eventID}`, - }; - 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 - for (const attendee of attendees) { - const jsonObject = { - "@context": - "https://www.w3.org/ns/activitystreams", - name: `RSVP to ${event.name}`, - type: "Note", - content: `@${attendee.name} ${diffText} See here: https://${domain}/${req.params.eventID}`, - 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 (sendEmails) { - Event.findOne({ id: req.params.eventID }).then( - (event) => { - const attendeeEmails = event.attendees - .filter( - (o) => - o.status === "attending" && - o.email, - ) - .map((o) => o.email); - if (attendeeEmails.length) { - console.log( - "Sending emails to: " + - attendeeEmails, - ); - req.app.get("hbsInstance").renderView( - "./views/emails/editevent.handlebars", - { - diffText, - eventID: req.params.eventID, - siteName, - siteLogo, - domain, - cache: true, - layout: "email.handlebars", - }, - function (err, html) { - const msg = { - to: attendeeEmails, - from: { - name: siteName, - email: contactEmail, - address: contactEmail, - }, - subject: `${siteName}: ${event.name} was just edited`, - html, - }; - switch (mailService) { - case "sendgrid": - sgMail - .sendMultiple(msg) - .catch((e) => { - console.error( - e.toString(), - ); - res.status( - 500, - ).end(); - }); - break; - case "nodemailer": - nodemailerTransporter - .sendMail(msg) - .catch((e) => { - console.error( - e.toString(), - ); - res.status( - 500, - ).end(); - }); - break; - } - }, - ); - } else { - console.log("Nothing to send!"); - } - }, - ); - } - res.writeHead(302, { - Location: - "/" + - req.params.eventID + - "?e=" + - req.params.editToken, - }); - res.end(); - }) - .catch((err) => { - console.error(err); - res.send("Sorry! Something went wrong!"); - addToLog( - "editEvent", - "error", - "Attempt to edit event " + - req.params.eventID + - " failed with error: " + - err, - ); - }); - } else { - // Token doesn't match - res.send("Sorry! Something went wrong"); - addToLog( - "editEvent", - "error", - "Attempt to edit event " + - req.params.eventID + - " failed with error: token does not match", - ); - } - }) - .catch((err) => { - console.error(err); - res.send("Sorry! Something went wrong!"); - addToLog( - "editEvent", - "error", - "Attempt to edit event " + - req.params.eventID + - " failed with error: " + - err, - ); - }); -}); - router.post("/editeventgroup/:eventGroupID/:editToken", (req, res) => { let submittedEditToken = req.params.editToken; EventGroup.findOne({ @@ -1506,6 +754,7 @@ router.post("/editeventgroup/:eventGroupID/:editToken", (req, res) => { router.post("/deleteimage/:eventID/:editToken", (req, res) => { let submittedEditToken = req.params.editToken; + let eventImage; Event.findOne({ id: req.params.eventID, }).then((event) => { 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 = + "

This event was just updated with new information.

    "; + let displayDate; + if (event.name !== updatedEvent.name) { + diffText += `
  • the event name changed to ${updatedEvent.name}
  • `; + } + if (event.location !== updatedEvent.location) { + diffText += `
  • the location changed to ${updatedEvent.location}
  • `; + } + if ( + event.start.toISOString() !== updatedEvent.start.toISOString() + ) { + displayDate = moment + .tz(updatedEvent.start, updatedEvent.timezone) + .format("dddd D MMMM YYYY h:mm a"); + diffText += `
  • the start time changed to ${displayDate}
  • `; + } + if (event.end.toISOString() !== updatedEvent.end.toISOString()) { + displayDate = moment + .tz(updatedEvent.end, updatedEvent.timezone) + .format("dddd D MMMM YYYY h:mm a"); + diffText += `
  • the end time changed to ${displayDate}
  • `; + } + if (event.timezone !== updatedEvent.timezone) { + diffText += `
  • the time zone changed to ${updatedEvent.timezone}
  • `; + } + if (event.description !== updatedEvent.description) { + diffText += `
  • the event description changed
  • `; + } + diffText += `
`; + 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: https://${config.general.domain}/${req.params.eventID}`, + }; + 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: `@${attendee.name} ${diffText} See here: https://${config.general.domain}/${req.params.eventID}`, + 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, diff --git a/src/util/config.ts b/src/util/config.ts index c65fdb0..d1fd05b 100644 --- a/src/util/config.ts +++ b/src/util/config.ts @@ -7,6 +7,7 @@ interface FrontendConfig { email: string; siteName: string; showKofi: boolean; + isFederated: boolean; } export const frontendConfig = (): FrontendConfig => ({ @@ -14,4 +15,5 @@ export const frontendConfig = (): FrontendConfig => ({ email: config.general.email, siteName: config.general.site_name, showKofi: config.general.show_kofi, + isFederated: config.general.is_federated, }); diff --git a/src/util/generator.ts b/src/util/generator.ts new file mode 100644 index 0000000..c3712c1 --- /dev/null +++ b/src/util/generator.ts @@ -0,0 +1,24 @@ +import crypto from "crypto"; + +const generateAlphanumericString = (length: number) => { + return Array(length) + .fill(0) + .map((x) => Math.random().toString(36).charAt(2)) + .join(""); +}; + +export const generateEditToken = () => generateAlphanumericString(32); + +export const generateRSAKeypair = () => { + return crypto.generateKeyPairSync("rsa", { + modulusLength: 4096, + publicKeyEncoding: { + type: "spki", + format: "pem", + }, + privateKeyEncoding: { + type: "pkcs8", + format: "pem", + }, + }); +}; diff --git a/src/util/validation.ts b/src/util/validation.ts new file mode 100644 index 0000000..f51769e --- /dev/null +++ b/src/util/validation.ts @@ -0,0 +1,191 @@ +import moment from "moment-timezone"; + +type Error = { + message?: string; + field?: string; +}; + +type ValidationResponse = { + data?: ValidatedEventData; + errors?: Error[]; +}; + +interface EventData { + eventName: string; + eventLocation: string; + eventStart: string; + eventEnd: string; + timezone: string; + eventDescription: string; + eventURL: string; + imagePath: string; + hostName: string; + creatorEmail: string; + eventGroupCheckbox: string; + eventGroupID: string; + eventGroupEditToken: string; + interactionCheckbox: string; + joinCheckbox: string; + maxAttendeesCheckbox: string; + maxAttendees: number; +} + +// EventData without the 'checkbox' fields +export type ValidatedEventData = Omit< + EventData, + | "eventGroupCheckbox" + | "interactionCheckbox" + | "joinCheckbox" + | "maxAttendeesCheckbox" +> & { + eventGroupBoolean: boolean; + interactionBoolean: boolean; + joinBoolean: boolean; + maxAttendeesBoolean: boolean; +}; + +const validateEmail = (email: string) => { + if (!email || email.length === 0 || typeof email !== "string") { + return false; + } + var re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return re.test(email); +}; + +export const validateEventTime = (start: Date, end: Date): Error | boolean => { + if (moment(start).isAfter(moment(end))) { + return { + message: "Start time must be before end time.", + field: "eventStart", + }; + } + if (moment(start).isBefore(moment())) { + return { + message: "Start time must be in the future.", + field: "eventStart", + }; + } + if (moment(end).isBefore(moment())) { + return { + message: "End time must be in the future.", + field: "eventEnd", + }; + } + // Duration cannot be longer than 1 year + if (moment(end).diff(moment(start), "years") > 1) { + return { + message: "Event duration cannot be longer than 1 year.", + field: "eventEnd", + }; + } + return true; +}; + +export const validateEventData = (eventData: EventData): ValidationResponse => { + const validatedData: ValidatedEventData = { + eventName: eventData.eventName, + eventLocation: eventData.eventLocation, + eventStart: eventData.eventStart, + eventEnd: eventData.eventEnd, + timezone: eventData.timezone, + eventDescription: eventData.eventDescription, + eventURL: eventData.eventURL, + imagePath: eventData.imagePath, + hostName: eventData.hostName, + creatorEmail: eventData.creatorEmail, + eventGroupBoolean: eventData.eventGroupCheckbox === "true", + interactionBoolean: eventData.interactionCheckbox === "true", + joinBoolean: eventData.joinCheckbox === "true", + maxAttendeesBoolean: eventData.maxAttendeesCheckbox === "true", + eventGroupID: eventData.eventGroupID, + eventGroupEditToken: eventData.eventGroupEditToken, + maxAttendees: eventData.maxAttendees, + }; + const errors: Error[] = []; + if (!validatedData.eventName) { + errors.push({ + message: "Event name is required.", + field: "eventName", + }); + } + if (!validatedData.eventLocation) { + errors.push({ + message: "Event location is required.", + field: "eventLocation", + }); + } + if (!validatedData.eventStart) { + errors.push({ + message: "Event start time is required.", + field: "eventStart", + }); + } + if (!validatedData.eventEnd) { + errors.push({ + message: "Event end time is required.", + field: "eventEnd", + }); + } + const timeValidation = validateEventTime( + new Date(validatedData.eventStart), + new Date(validatedData.eventEnd), + ); + if (timeValidation !== true && timeValidation !== false) { + errors.push({ + message: timeValidation.message, + }); + } + if (!validatedData.timezone) { + errors.push({ + message: "Event timezone is required.", + field: "timezone", + }); + } + if (!validatedData.eventDescription) { + errors.push({ + message: "Event description is required.", + field: "eventDescription", + }); + } + if (validatedData.eventGroupBoolean) { + if (!validatedData.eventGroupID) { + errors.push({ + message: "Event group ID is required.", + field: "eventGroupID", + }); + } + if (!validatedData.eventGroupEditToken) { + errors.push({ + message: "Event group edit token is required.", + field: "eventGroupEditToken", + }); + } + } + if (validatedData.maxAttendeesBoolean) { + if (!validatedData.maxAttendees) { + errors.push({ + message: "Max number of attendees is required.", + field: "maxAttendees", + }); + } + if (isNaN(validatedData.maxAttendees)) { + errors.push({ + message: "Max number of attendees must be a number.", + field: "maxAttendees", + }); + } + } + if (validatedData.creatorEmail) { + if (!validateEmail(validatedData.creatorEmail)) { + errors.push({ + message: "Email address is invalid.", + field: "creatorEmail", + }); + } + } + + return { + data: validatedData, + errors: errors, + }; +}; -- cgit v1.2.3 From 499da92303ff1ad66807f373b06ad3040c774eaa Mon Sep 17 00:00:00 2001 From: Raphael Kabo Date: Sat, 7 Oct 2023 18:28:57 +0100 Subject: Move nanoid generator to lib --- src/routes/event.ts | 16 ++++++---------- src/util/generator.ts | 10 ++++++++++ 2 files changed, 16 insertions(+), 10 deletions(-) (limited to 'src') diff --git a/src/routes/event.ts b/src/routes/event.ts index c418893..375871b 100644 --- a/src/routes/event.ts +++ b/src/routes/event.ts @@ -1,10 +1,13 @@ 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 { + generateEditToken, + generateEventID, + generateRSAKeypair, +} from "../util/generator.js"; import { validateEventData } from "../util/validation.js"; import { addToLog } from "../helpers.js"; import Event from "../models/Event.js"; @@ -25,13 +28,6 @@ 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({ @@ -67,7 +63,7 @@ router.post( }); } - let eventID = nanoid(); + let eventID = generateEventID(); let editToken = generateEditToken(); let eventImageFilename; let isPartOfEventGroup = false; diff --git a/src/util/generator.ts b/src/util/generator.ts index c3712c1..882f114 100644 --- a/src/util/generator.ts +++ b/src/util/generator.ts @@ -1,5 +1,13 @@ import crypto from "crypto"; +import { customAlphabet } from "nanoid"; +// 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 generateAlphanumericString = (length: number) => { return Array(length) .fill(0) @@ -7,6 +15,8 @@ const generateAlphanumericString = (length: number) => { .join(""); }; +export const generateEventID = () => nanoid(); + export const generateEditToken = () => generateAlphanumericString(32); export const generateRSAKeypair = () => { -- cgit v1.2.3 From d83652f6f4f8aa6a2dfa141e2ab841150652b090 Mon Sep 17 00:00:00 2001 From: Raphael Kabo Date: Sat, 7 Oct 2023 18:29:20 +0100 Subject: Linting --- src/util/generator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/util/generator.ts b/src/util/generator.ts index 882f114..596110d 100644 --- a/src/util/generator.ts +++ b/src/util/generator.ts @@ -1,6 +1,6 @@ import crypto from "crypto"; - import { customAlphabet } from "nanoid"; + // This alphabet (used to generate all event, group, etc. IDs) is missing '-' // because ActivityPub doesn't like it in IDs const nanoid = customAlphabet( -- cgit v1.2.3 From b8e424a8602b586bbf346cd27171ede570a54973 Mon Sep 17 00:00:00 2001 From: Raphael Kabo Date: Sat, 7 Oct 2023 18:48:08 +0100 Subject: refactor: frontend event group routes --- src/helpers.ts | 2 +- src/routes.js | 239 +------------------------------------------------ src/routes/frontend.ts | 183 ++++++++++++++++++++++++++++++++++++- 3 files changed, 183 insertions(+), 241 deletions(-) (limited to 'src') diff --git a/src/helpers.ts b/src/helpers.ts index 72bbd17..6eda3d0 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -21,7 +21,7 @@ export function addToLog(process: string, status: string, message: string) { }); } -export function exportIcal(events: IEvent[], calendarName: string) { +export function exportICal(events: IEvent[], calendarName: string) { if (!events || events.length < 1) return; // Create a new icalGenerator... generator diff --git a/src/routes.js b/src/routes.js index 94b7477..e4ef3cb 100755 --- a/src/routes.js +++ b/src/routes.js @@ -3,9 +3,8 @@ import express from "express"; import { customAlphabet } from "nanoid"; import randomstring from "randomstring"; import { getConfig } from "./lib/config.js"; -import { addToLog, exportIcal } from "./helpers.js"; +import { addToLog } from "./helpers.js"; import moment from "moment-timezone"; -import { marked } from "marked"; import crypto from "crypto"; import request from "request"; import niceware from "niceware"; @@ -23,7 +22,6 @@ import { import Event from "./models/Event.js"; import EventGroup from "./models/EventGroup.js"; import path from "path"; -import { renderPlain } from "./util/markdown.js"; const config = getConfig(); const domain = config.general.domain; @@ -184,241 +182,6 @@ schedule.scheduleJob("59 23 * * *", function (fireDate) { // old (they're not going to become active) }); -router.get("/group/:eventGroupID", (req, res) => { - EventGroup.findOne({ - id: req.params.eventGroupID, - }) - .lean() // Required, see: https://stackoverflow.com/questions/59690923/handlebars-access-has-been-denied-to-resolve-the-property-from-because-it-is - .then(async (eventGroup) => { - if (eventGroup) { - let parsedDescription = marked.parse(eventGroup.description); - let eventGroupEditToken = eventGroup.editToken; - - let escapedName = eventGroup.name.replace(/\s+/g, "+"); - - let eventGroupHasCoverImage = false; - if (eventGroup.image) { - eventGroupHasCoverImage = true; - } else { - eventGroupHasCoverImage = false; - } - let eventGroupHasHost = false; - if (eventGroup.hostName) { - eventGroupHasHost = true; - } else { - eventGroupHasHost = false; - } - - let events = await Event.find({ eventGroup: eventGroup._id }) - .lean() - .sort("start"); - - events.map((event) => { - if ( - moment - .tz(event.end, event.timezone) - .isSame(event.start, "day") - ) { - // Happening during one day - event.displayDate = moment - .tz(event.start, event.timezone) - .format("D MMM YYYY"); - } else { - event.displayDate = - moment - .tz(event.start, event.timezone) - .format("D MMM YYYY") + - moment - .tz(event.end, event.timezone) - .format(" - D MMM YYYY"); - } - if ( - moment - .tz(event.end, event.timezone) - .isBefore(moment.tz(event.timezone)) - ) { - event.eventHasConcluded = true; - } else { - event.eventHasConcluded = false; - } - return (({ id, name, displayDate, eventHasConcluded }) => ({ - id, - name, - displayDate, - eventHasConcluded, - }))(event); - }); - - let upcomingEventsExist = false; - if (events.some((e) => e.eventHasConcluded === false)) { - upcomingEventsExist = true; - } - - let firstLoad = false; - if (eventGroup.firstLoad === true) { - firstLoad = true; - EventGroup.findOneAndUpdate( - { id: req.params.eventGroupID }, - { firstLoad: false }, - function (err, raw) { - if (err) { - res.send(err); - } - }, - ); - } - let editingEnabled = false; - if (Object.keys(req.query).length !== 0) { - if (!req.query.e) { - editingEnabled = false; - console.log("No edit token set"); - } else { - if (req.query.e === eventGroupEditToken) { - editingEnabled = true; - } else { - editingEnabled = false; - } - } - } - let metadata = { - title: eventGroup.name, - description: marked - .parse(eventGroup.description, { - renderer: renderPlain(), - }) - .split(" ") - .splice(0, 40) - .join(" ") - .trim(), - image: eventGroupHasCoverImage - ? `https://${domain}/events/` + eventGroup.image - : null, - url: `https://${domain}/` + req.params.eventID, - }; - res.set("X-Robots-Tag", "noindex"); - res.render("eventgroup", { - domain: domain, - title: eventGroup.name, - eventGroupData: eventGroup, - escapedName: escapedName, - events: events, - upcomingEventsExist: upcomingEventsExist, - parsedDescription: parsedDescription, - editingEnabled: editingEnabled, - eventGroupHasCoverImage: eventGroupHasCoverImage, - eventGroupHasHost: eventGroupHasHost, - firstLoad: firstLoad, - metadata: metadata, - }); - } else { - res.status(404); - res.render("404", { url: req.url }); - } - }) - .catch((err) => { - addToLog( - "displayEventGroup", - "error", - "Attempt to display event group " + - req.params.eventGroupID + - " failed with error: " + - err, - ); - console.log(err); - res.status(404); - res.render("404", { url: req.url }); - return; - }); -}); - -router.get("/group/:eventGroupID/feed.ics", (req, res) => { - EventGroup.findOne({ - id: req.params.eventGroupID, - }) - .lean() // Required, see: https://stackoverflow.com/questions/59690923/handlebars-access-has-been-denied-to-resolve-the-property-from-because-it-is - .then(async (eventGroup) => { - if (eventGroup) { - let events = await Event.find({ eventGroup: eventGroup._id }) - .lean() - .sort("start"); - const string = exportIcal(events, eventGroup.name); - res.set("Content-Type", "text/calendar"); - return 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); - res.render("404", { url: req.url }); - return; - }); -}); - -router.get("/exportevent/:eventID", (req, res) => { - Event.findOne({ - id: req.params.eventID, - }) - .populate("eventGroup") - .then((event) => { - if (event) { - const string = exportIcal([event]); - 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); - res.render("404", { url: req.url }); - return; - }); -}); - -router.get("/exportgroup/:eventGroupID", (req, res) => { - EventGroup.findOne({ - id: req.params.eventGroupID, - }) - .lean() // Required, see: https://stackoverflow.com/questions/59690923/handlebars-access-has-been-denied-to-resolve-the-property-from-because-it-is - .then(async (eventGroup) => { - if (eventGroup) { - let events = await Event.find({ eventGroup: eventGroup._id }) - .lean() - .sort("start"); - const string = exportIcal(events); - 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); - res.render("404", { url: req.url }); - return; - }); -}); - // BACKEND ROUTES router.post("/importevent", (req, res) => { let eventID = nanoid(); diff --git a/src/routes/frontend.ts b/src/routes/frontend.ts index d24210f..56ce4db 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 from "../models/EventGroup.js"; const config = getConfig(); @@ -230,4 +231,182 @@ 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, + }); + } 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; -- cgit v1.2.3 From 6b220e094f215c488eb5102e25506f5b3d371245 Mon Sep 17 00:00:00 2001 From: Raphael Kabo Date: Sun, 8 Oct 2023 12:11:36 +0100 Subject: Refactor: event group form and API, extract JS --- src/app.ts | 5 ++ src/routes.js | 197 ---------------------------------------- src/routes/event.ts | 5 +- src/routes/frontend.ts | 37 +++++++- src/routes/group.ts | 240 +++++++++++++++++++++++++++++++++++++++++++++++++ src/util/validation.ts | 51 ++++++++--- 6 files changed, 321 insertions(+), 214 deletions(-) create mode 100644 src/routes/group.ts (limited to 'src') diff --git a/src/app.ts b/src/app.ts index 30cf02d..c43f31d 100755 --- a/src/app.ts +++ b/src/app.ts @@ -5,6 +5,7 @@ import routes from "./routes.js"; import frontend from "./routes/frontend.js"; import activitypub from "./routes/activitypub.js"; import event from "./routes/event.js"; +import group from "./routes/group.js"; import { initEmailService } from "./lib/email.js"; @@ -33,6 +34,9 @@ const hbsInstance = hbs.create({ match[1] + (match[3] || "s") ); // Plural case: 'bagel(s)' or 'bagel' --> bagels }, + json: function (context: any) { + return JSON.stringify(context); + }, }, }); app.engine("handlebars", hbsInstance.engine); @@ -52,6 +56,7 @@ app.use(express.urlencoded({ extended: true })); app.use("/", frontend); app.use("/", activitypub); app.use("/", event); +app.use("/", group); app.use("/", routes); export default app; diff --git a/src/routes.js b/src/routes.js index e4ef3cb..96420c7 100755 --- a/src/routes.js +++ b/src/routes.js @@ -298,102 +298,6 @@ router.post("/importevent", (req, res) => { } }); -router.post("/neweventgroup", (req, res) => { - let eventGroupID = nanoid(); - let editToken = randomstring.generate(); - let eventGroupImageFilename = ""; - if (req.files && Object.keys(req.files).length !== 0) { - let eventImageBuffer = req.files.imageUpload.data; - Jimp.read(eventImageBuffer, (err, img) => { - if (err) - addToLog( - "Jimp", - "error", - "Attempt to edit image failed with error: " + err, - ); - img.resize(920, Jimp.AUTO) // resize - .quality(80) // set JPEG quality - .write("./public/events/" + eventGroupID + ".jpg"); // save - }); - eventGroupImageFilename = eventGroupID + ".jpg"; - } - const eventGroup = new EventGroup({ - id: eventGroupID, - name: req.body.eventGroupName, - description: req.body.eventGroupDescription, - image: eventGroupImageFilename, - creatorEmail: req.body.creatorEmail, - url: req.body.eventGroupURL, - hostName: req.body.hostName, - editToken: editToken, - firstLoad: true, - }); - eventGroup - .save() - .then(() => { - addToLog( - "createEventGroup", - "success", - "Event group " + eventGroupID + " created", - ); - // Send email with edit link - if (req.body.creatorEmail && sendEmails) { - req.app.get("hbsInstance").renderView( - "./views/emails/createeventgroup.handlebars", - { - eventGroupID, - editToken, - siteName, - siteLogo, - domain, - cache: true, - layout: "email.handlebars", - }, - function (err, html) { - const msg = { - to: req.body.creatorEmail, - from: { - name: siteName, - email: contactEmail, - address: contactEmail, - }, - subject: `${siteName}: ${req.body.eventGroupName}`, - html, - }; - switch (mailService) { - case "sendgrid": - sgMail.send(msg).catch((e) => { - console.error(e.toString()); - res.status(500).end(); - }); - break; - case "nodemailer": - nodemailerTransporter - .sendMail(msg) - .catch((e) => { - console.error(e.toString()); - res.status(500).end(); - }); - break; - } - }, - ); - } - res.writeHead(302, { - Location: "/group/" + eventGroupID + "?e=" + editToken, - }); - res.end(); - }) - .catch((err) => { - res.send("Database error, please try again :( - " + err); - addToLog( - "createEvent", - "error", - "Attempt to create event failed with error: " + err, - ); - }); -}); - router.post("/verifytoken/event/:eventID", (req, res) => { Event.findOne({ id: req.params.eventID, @@ -414,107 +318,6 @@ router.post("/verifytoken/group/:eventGroupID", (req, res) => { }); }); -router.post("/editeventgroup/:eventGroupID/:editToken", (req, res) => { - let submittedEditToken = req.params.editToken; - EventGroup.findOne({ - id: req.params.eventGroupID, - }) - .then((eventGroup) => { - if (eventGroup.editToken === submittedEditToken) { - // Token matches - - // If there is a new image, upload that first - let eventGroupID = req.params.eventGroupID; - let eventGroupImageFilename = eventGroup.image; - if (req.files && Object.keys(req.files).length !== 0) { - let eventImageBuffer = req.files.eventGroupImageUpload.data; - Jimp.read(eventImageBuffer, (err, img) => { - if (err) throw err; - img.resize(920, Jimp.AUTO) // resize - .quality(80) // set JPEG - .write("./public/events/" + eventGroupID + ".jpg"); // save - }); - eventGroupImageFilename = eventGroupID + ".jpg"; - } - const updatedEventGroup = { - name: req.body.eventGroupName, - description: req.body.eventGroupDescription, - url: req.body.eventGroupURL, - hostName: req.body.hostName, - image: eventGroupImageFilename, - }; - EventGroup.findOneAndUpdate( - { id: req.params.eventGroupID }, - updatedEventGroup, - function (err, raw) { - if (err) { - addToLog( - "editEventGroup", - "error", - "Attempt to edit event group " + - req.params.eventGroupID + - " failed with error: " + - err, - ); - res.send(err); - } - }, - ) - .then(() => { - addToLog( - "editEventGroup", - "success", - "Event group " + - req.params.eventGroupID + - " edited", - ); - res.writeHead(302, { - Location: - "/group/" + - req.params.eventGroupID + - "?e=" + - req.params.editToken, - }); - res.end(); - }) - .catch((err) => { - console.error(err); - res.send("Sorry! Something went wrong!"); - addToLog( - "editEventGroup", - "error", - "Attempt to edit event group " + - req.params.eventGroupID + - " failed with error: " + - err, - ); - }); - } else { - // Token doesn't match - res.send("Sorry! Something went wrong"); - addToLog( - "editEventGroup", - "error", - "Attempt to edit event group " + - req.params.eventGroupID + - " failed with error: token does not match", - ); - } - }) - .catch((err) => { - console.error(err); - res.send("Sorry! Something went wrong!"); - addToLog( - "editEventGroup", - "error", - "Attempt to edit event group " + - req.params.eventGroupID + - " failed with error: " + - err, - ); - }); -}); - router.post("/deleteimage/:eventID/:editToken", (req, res) => { let submittedEditToken = req.params.editToken; let eventImage; diff --git a/src/routes/event.ts b/src/routes/event.ts index 375871b..be27fd4 100644 --- a/src/routes/event.ts +++ b/src/routes/event.ts @@ -74,8 +74,7 @@ router.post( img.resize(920, Jimp.AUTO) // resize .quality(80) // set JPEG quality .write("./public/events/" + eventID + ".jpg"); // save - const filename = eventID + ".jpg"; - return filename; + return eventID + ".jpg"; }) .catch((err) => { addToLog( @@ -280,8 +279,8 @@ router.put( }); } - let submittedEditToken = req.body.editToken; try { + const submittedEditToken = req.body.editToken; const event = await Event.findOne({ id: req.params.eventID, }); diff --git a/src/routes/frontend.ts b/src/routes/frontend.ts index 56ce4db..c9594ef 100644 --- a/src/routes/frontend.ts +++ b/src/routes/frontend.ts @@ -6,7 +6,7 @@ import { renderPlain } from "../util/markdown.js"; import getConfig from "../lib/config.js"; import { addToLog, exportICal } from "../helpers.js"; import Event from "../models/Event.js"; -import EventGroup from "../models/EventGroup.js"; +import EventGroup, { IEventGroup } from "../models/EventGroup.js"; const config = getConfig(); @@ -215,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) { @@ -321,6 +346,16 @@ router.get("/group/:eventGroupID", async (req: Request, res: Response) => { 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( 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; diff --git a/src/util/validation.ts b/src/util/validation.ts index f51769e..732fbf3 100644 --- a/src/util/validation.ts +++ b/src/util/validation.ts @@ -44,6 +44,14 @@ export type ValidatedEventData = Omit< maxAttendeesBoolean: boolean; }; +interface EventGroupData { + eventGroupName: string; + eventGroupDescription: string; + eventGroupURL: string; + hostName: string; + creatorEmail: string; +} + const validateEmail = (email: string) => { if (!email || email.length === 0 || typeof email !== "string") { return false; @@ -83,23 +91,11 @@ export const validateEventTime = (start: Date, end: Date): Error | boolean => { export const validateEventData = (eventData: EventData): ValidationResponse => { const validatedData: ValidatedEventData = { - eventName: eventData.eventName, - eventLocation: eventData.eventLocation, - eventStart: eventData.eventStart, - eventEnd: eventData.eventEnd, - timezone: eventData.timezone, - eventDescription: eventData.eventDescription, - eventURL: eventData.eventURL, - imagePath: eventData.imagePath, - hostName: eventData.hostName, - creatorEmail: eventData.creatorEmail, + ...eventData, eventGroupBoolean: eventData.eventGroupCheckbox === "true", interactionBoolean: eventData.interactionCheckbox === "true", joinBoolean: eventData.joinCheckbox === "true", maxAttendeesBoolean: eventData.maxAttendeesCheckbox === "true", - eventGroupID: eventData.eventGroupID, - eventGroupEditToken: eventData.eventGroupEditToken, - maxAttendees: eventData.maxAttendees, }; const errors: Error[] = []; if (!validatedData.eventName) { @@ -189,3 +185,32 @@ export const validateEventData = (eventData: EventData): ValidationResponse => { errors: errors, }; }; + +export const validateGroupData = (groupData: EventGroupData) => { + const errors: Error[] = []; + if (!groupData.eventGroupName) { + errors.push({ + message: "Event group name is required.", + field: "eventGroupName", + }); + } + if (!groupData.eventGroupDescription) { + errors.push({ + message: "Event group description is required.", + field: "eventGroupDescription", + }); + } + if (groupData.creatorEmail) { + if (!validateEmail(groupData.creatorEmail)) { + errors.push({ + message: "Email address is invalid.", + field: "creatorEmail", + }); + } + } + + return { + data: groupData, + errors: errors, + }; +}; -- cgit v1.2.3 From 6ab556eb9adc0f02a279b8f89bc9309734525522 Mon Sep 17 00:00:00 2001 From: Raphael Kabo Date: Sun, 8 Oct 2023 12:37:48 +0100 Subject: Refactor: import event form --- src/routes.js | 115 ----------------------------------------------- src/routes/event.ts | 126 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 115 deletions(-) (limited to 'src') diff --git a/src/routes.js b/src/routes.js index 96420c7..65a2934 100755 --- a/src/routes.js +++ b/src/routes.js @@ -183,121 +183,6 @@ schedule.scheduleJob("59 23 * * *", function (fireDate) { }); // BACKEND ROUTES -router.post("/importevent", (req, res) => { - let eventID = nanoid(); - let editToken = randomstring.generate(); - if (req.files && Object.keys(req.files).length !== 0) { - let iCalObject = ical.parseICS( - req.files.icsImportControl.data.toString("utf8"), - ); - let importedEventData = iCalObject[Object.keys(iCalObject)]; - - let creatorEmail; - if (req.body.creatorEmail) { - creatorEmail = req.body.creatorEmail; - } else if (importedEventData.organizer) { - creatorEmail = importedEventData.organizer.val.replace( - "MAILTO:", - "", - ); - } - - const event = new Event({ - id: eventID, - type: "public", - name: importedEventData.summary, - location: importedEventData.location, - start: importedEventData.start, - end: importedEventData.end, - timezone: - typeof importedEventData.start.tz !== "undefined" - ? importedEventData.start.tz - : "Etc/UTC", - description: importedEventData.description, - image: "", - creatorEmail: creatorEmail, - url: "", - hostName: importedEventData.organizer - ? importedEventData.organizer.params.CN.replace(/["]+/g, "") - : "", - viewPassword: "", - editPassword: "", - editToken: editToken, - usersCanAttend: false, - showUsersList: false, - usersCanComment: false, - firstLoad: true, - }); - event - .save() - .then(() => { - addToLog( - "createEvent", - "success", - "Event " + eventID + " created", - ); - // Send email with edit link - if (creatorEmail && sendEmails) { - req.app.get("hbsInstance").renderView( - "./views/emails/createevent.handlebars", - { - eventID, - editToken, - siteName, - siteLogo, - domain, - cache: true, - layout: "email.handlebars", - }, - function (err, html) { - const msg = { - to: req.body.creatorEmail, - from: { - name: siteName, - email: contactEmail, - address: contactEmail, - }, - subject: `${siteName}: ${importedEventData.summary}`, - html, - }; - switch (mailService) { - case "sendgrid": - sgMail.send(msg).catch((e) => { - console.error(e.toString()); - res.status(500).end(); - }); - break; - case "nodemailer": - nodemailerTransporter - .sendMail(msg) - .catch((e) => { - console.error(e.toString()); - res.status(500).end(); - }); - break; - } - }, - ); - } - res.writeHead(302, { - Location: "/" + eventID + "?e=" + editToken, - }); - res.end(); - }) - .catch((err) => { - res.send("Database error, please try again :("); - addToLog( - "createEvent", - "error", - "Attempt to create event failed with error: " + err, - ); - }); - } else { - console.log("Files array is empty!"); - res.status(500).end(); - } -}); - router.post("/verifytoken/event/:eventID", (req, res) => { Event.findOne({ id: req.params.eventID, diff --git a/src/routes/event.ts b/src/routes/event.ts index be27fd4..2245009 100644 --- a/src/routes/event.ts +++ b/src/routes/event.ts @@ -25,6 +25,7 @@ import { import getConfig from "../lib/config.js"; import { sendEmailFromTemplate } from "../lib/email.js"; import crypto from "crypto"; +import ical from "ical"; const config = getConfig(); @@ -42,6 +43,17 @@ const upload = multer({ 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(); @@ -84,6 +96,7 @@ router.post( ); }); } + const startUTC = moment.tz(eventData.eventStart, eventData.timezone); const endUTC = moment.tz(eventData.eventEnd, eventData.timezone); let eventGroup; @@ -511,4 +524,117 @@ router.put( }, ); +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; -- cgit v1.2.3 From c22a5d94e75aab1665655bf5c632077fbd24e802 Mon Sep 17 00:00:00 2001 From: Raphael Kabo Date: Sun, 8 Oct 2023 12:41:32 +0100 Subject: Fix legacy emailers to work with new templates --- src/routes.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) (limited to 'src') diff --git a/src/routes.js b/src/routes.js index 65a2934..5371e0e 100755 --- a/src/routes.js +++ b/src/routes.js @@ -352,7 +352,7 @@ router.post("/deleteevent/:eventID/:editToken", (req, res) => { attendeeEmails, ); req.app.get("hbsInstance").renderView( - "./views/emails/deleteevent.handlebars", + "./views/emails/deleteEvent/deleteEventHtml.handlebars", { siteName, siteLogo, @@ -699,7 +699,7 @@ router.post("/attendevent/:eventID", async (req, res) => { if (sendEmails) { if (req.body.attendeeEmail) { req.app.get("hbsInstance").renderView( - "./views/emails/addeventattendee.handlebars", + "./views/emails/addEventAttendee/addEventAttendeeHtml.handlebars", { eventID: req.params.eventID, siteName, @@ -775,7 +775,7 @@ router.post("/unattendevent/:eventID", (req, res) => { if (sendEmails) { if (req.body.attendeeEmail) { req.app.get("hbsInstance").renderView( - "./views/emails/unattendevent.handlebars", + "./views/emails/unattendEvent/unattendEventHtml.handlebars", { eventID: req.params.eventID, siteName, @@ -857,7 +857,7 @@ router.get("/oneclickunattendevent/:eventID/:attendeeID", (req, res) => { // currently this is never called because we don't have the email address if (req.body.attendeeEmail) { req.app.get("hbsInstance").renderView( - "./views/emails/removeeventattendee.handlebars", + "./views/emails/removeEventAttendee/removeEventAttendeeHtml.handlebars", { eventName: req.params.eventName, siteName, @@ -929,7 +929,7 @@ router.post("/removeattendee/:eventID/:attendeeID", (req, res) => { // currently this is never called because we don't have the email address if (req.body.attendeeEmail) { req.app.get("hbsInstance").renderView( - "./views/emails/removeeventattendee.handlebars", + "./views/emails/removeEventAttendee/removeEventAttendeeHtml.handlebars", { eventName: req.params.eventName, siteName, @@ -1008,7 +1008,7 @@ router.post("/subscribe/:eventGroupID", (req, res) => { eventGroup.save(); if (sendEmails) { req.app.get("hbsInstance").renderView( - "./views/emails/subscribed.handlebars", + "./views/emails/subscribed/subscribedHtml.handlebars", { eventGroupName: eventGroup.name, eventGroupID: eventGroup.id, @@ -1151,7 +1151,7 @@ router.post("/post/comment/:eventID", (req, res) => { "Sending emails to: " + attendeeEmails, ); req.app.get("hbsInstance").renderView( - "./views/emails/addeventcomment.handlebars", + "./views/emails/addEventComment/addEventCommentHtml.handlebars", { siteName, siteLogo, @@ -1283,7 +1283,7 @@ router.post("/post/reply/:eventID/:commentID", (req, res) => { "Sending emails to: " + attendeeEmails, ); req.app.get("hbsInstance").renderView( - "./views/emails/addeventcomment.handlebars", + "./views/emails/addEventComment/addEventCommentHtml.handlebars", { siteName, siteLogo, -- cgit v1.2.3