diff options
Diffstat (limited to 'src')
-rwxr-xr-x | src/app.ts | 2 | ||||
-rw-r--r-- | src/lib/config.ts | 1 | ||||
-rw-r--r-- | src/lib/email.ts | 1 | ||||
-rw-r--r-- | src/lib/middleware.ts | 51 | ||||
-rw-r--r-- | src/models/MagicLink.ts | 35 | ||||
-rw-r--r-- | src/routes/event.ts | 3 | ||||
-rw-r--r-- | src/routes/frontend.ts | 32 | ||||
-rw-r--r-- | src/routes/group.ts | 2 | ||||
-rw-r--r-- | src/routes/magicLink.ts | 70 | ||||
-rw-r--r-- | src/util/generator.ts | 2 |
10 files changed, 199 insertions, 0 deletions
@@ -7,6 +7,7 @@ import activitypub from "./routes/activitypub.js"; import event from "./routes/event.js"; import group from "./routes/group.js"; import staticPages from "./routes/static.js"; +import magicLink from "./routes/magicLink.js"; import { initEmailService } from "./lib/email.js"; import { @@ -63,6 +64,7 @@ app.use("/", frontend); app.use("/", activitypub); app.use("/", event); app.use("/", group); +app.use("/", magicLink); app.use("/", routes); export default app; diff --git a/src/lib/config.ts b/src/lib/config.ts index 6f142e5..93c04df 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -18,6 +18,7 @@ interface GathioConfig { email_logo_url: string; show_kofi: boolean; mail_service: "nodemailer" | "sendgrid"; + creator_email_addresses: string[]; }; database: { mongodb_url: string; diff --git a/src/lib/email.ts b/src/lib/email.ts index f1dc1ae..8a215a9 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -12,6 +12,7 @@ type EmailTemplate = | "addEventComment" | "createEvent" | "createEventGroup" + | "createEventMagicLink" | "deleteEvent" | "editEvent" | "eventGroupUpdated" diff --git a/src/lib/middleware.ts b/src/lib/middleware.ts new file mode 100644 index 0000000..0594e90 --- /dev/null +++ b/src/lib/middleware.ts @@ -0,0 +1,51 @@ +import { Request, Response } from "express"; +import MagicLink from "../models/MagicLink.js"; +import getConfig from "../lib/config.js"; + +const config = getConfig(); + +export const checkMagicLink = async ( + req: Request, + res: Response, + next: any, +) => { + if (!config.general.creator_email_addresses?.length) { + // No creator email addresses are configured, so skip the magic link check + return next(); + } + if (!req.body.magicLinkToken) { + return res.status(400).json({ + errors: [ + { + message: "No magic link token was provided.", + }, + ], + }); + } + if (!req.body.creatorEmail) { + return res.status(400).json({ + errors: [ + { + message: "No creator email was provided.", + }, + ], + }); + } + const magicLink = await MagicLink.findOne({ + token: req.body.magicLinkToken, + email: req.body.creatorEmail, + expiryTime: { $gt: new Date() }, + permittedActions: "createEvent", + }); + if (!magicLink || magicLink.email !== req.body.creatorEmail) { + return res.status(400).json({ + errors: [ + { + message: + "Magic link is invalid or has expired. Get a new one <a href='/new'>here</a>.", + }, + ], + }); + } + next(); +}; diff --git a/src/models/MagicLink.ts b/src/models/MagicLink.ts new file mode 100644 index 0000000..fa24c33 --- /dev/null +++ b/src/models/MagicLink.ts @@ -0,0 +1,35 @@ +import mongoose from "mongoose"; + +export type MagicLinkAction = "createEvent"; + +export interface MagicLink { + id: string; + email: string; + token: string; + expiryTime: Date; + permittedActions: MagicLinkAction[]; +} + +const MagicLinkSchema = new mongoose.Schema({ + email: { + type: String, + trim: true, + required: true, + }, + token: { + type: String, + trim: true, + required: true, + }, + expiryTime: { + type: Date, + trim: true, + required: true, + }, + permittedActions: { + type: [String], + required: true, + }, +}); + +export default mongoose.model<MagicLink>("MagicLink", MagicLinkSchema); diff --git a/src/routes/event.ts b/src/routes/event.ts index cfd877e..fb9d8c7 100644 --- a/src/routes/event.ts +++ b/src/routes/event.ts @@ -26,6 +26,7 @@ import { sendEmailFromTemplate } from "../lib/email.js"; import crypto from "crypto"; import ical from "ical"; import { markdownToSanitizedHTML } from "../util/markdown.js"; +import { checkMagicLink } from "../lib/middleware.js"; const config = getConfig(); @@ -60,6 +61,7 @@ const router = Router(); router.post( "/event", upload.single("imageUpload"), + checkMagicLink, async (req: Request, res: Response) => { const { data: eventData, errors } = validateEventData(req.body); if (errors && errors.length > 0) { @@ -527,6 +529,7 @@ router.put( router.post( "/import/event", icsUpload.single("icsImportControl"), + checkMagicLink, async (req: Request, res: Response) => { if (!req.file) { return res.status(400).json({ diff --git a/src/routes/frontend.ts b/src/routes/frontend.ts index 8ddfbf6..0d8793a 100644 --- a/src/routes/frontend.ts +++ b/src/routes/frontend.ts @@ -10,6 +10,7 @@ import { acceptsActivityPub, activityPubContentType, } from "../lib/activitypub.js"; +import MagicLink from "../models/MagicLink.js"; const config = getConfig(); @@ -19,9 +20,40 @@ router.get("/", (_: Request, res: Response) => { }); router.get("/new", (_: Request, res: Response) => { + if (config.general.creator_email_addresses?.length) { + return res.render("createEventMagicLink", frontendConfig()); + } + return res.render("newevent", { + title: "New event", + ...frontendConfig(), + }); +}); + +router.get("/new/:magicLinkToken", async (req: Request, res: Response) => { + // If we don't have any creator email addresses, we don't need to check the magic link + // so we can just redirect to the new event page + if (!config.general.creator_email_addresses?.length) { + return res.redirect("/new"); + } + const magicLink = await MagicLink.findOne({ + token: req.params.magicLinkToken, + expiryTime: { $gt: new Date() }, + permittedActions: "createEvent", + }); + if (!magicLink) { + return res.render("createEventMagicLink", { + ...frontendConfig(), + message: { + type: "danger", + text: "This magic link is invalid or has expired. Please request a new one here.", + }, + }); + } res.render("newevent", { title: "New event", ...frontendConfig(), + magicLinkToken: req.params.magicLinkToken, + creatorEmail: magicLink.email, }); }); diff --git a/src/routes/group.ts b/src/routes/group.ts index 40dcccb..34377b0 100644 --- a/src/routes/group.ts +++ b/src/routes/group.ts @@ -9,6 +9,7 @@ import EventGroup from "../models/EventGroup.js"; import { sendEmailFromTemplate } from "../lib/email.js"; import { marked } from "marked"; import { renderPlain } from "../util/markdown.js"; +import { checkMagicLink } from "../lib/middleware.js"; const config = getConfig(); @@ -32,6 +33,7 @@ const router = Router(); router.post( "/group", upload.single("imageUpload"), + checkMagicLink, async (req: Request, res: Response) => { const { data: groupData, errors } = validateGroupData(req.body); if (errors && errors.length > 0) { diff --git a/src/routes/magicLink.ts b/src/routes/magicLink.ts new file mode 100644 index 0000000..24f0667 --- /dev/null +++ b/src/routes/magicLink.ts @@ -0,0 +1,70 @@ +import { Router, Request, Response } from "express"; +import getConfig, { frontendConfig } from "../lib/config.js"; +import { sendEmailFromTemplate } from "../lib/email.js"; +import { generateMagicLinkToken } from "../util/generator.js"; +import MagicLink from "../models/MagicLink.js"; + +const router = Router(); +const config = getConfig(); + +router.post("/magic-link/event/create", async (req: Request, res: Response) => { + const { email } = req.body; + if (!email) { + res.render("createEventMagicLink", { + ...frontendConfig(), + message: { + type: "danger", + text: "Please provide an email address.", + }, + }); + return; + } + const allowedEmails = config.general.creator_email_addresses; + if (!allowedEmails?.length) { + // No creator email addresses are configured, so skip the magic link check + return res.redirect("/new"); + } + if (!allowedEmails.includes(email)) { + res.render("createEventMagicLink", { + ...frontendConfig(), + message: { + type: "success", + text: "Thanks! If this email address can create events, you should receive an email with a magic link.", + }, + }); + return; + } + const token = generateMagicLinkToken(); + const magicLink = new MagicLink({ + email, + token, + expiryTime: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours + permittedActions: ["createEvent"], + }); + await magicLink.save(); + + // Take this opportunity to delete any expired magic links + await MagicLink.deleteMany({ expiryTime: { $lt: new Date() } }); + + sendEmailFromTemplate( + email, + `Magic link to create an event`, + "createEventMagicLink", + { + token, + siteName: config.general.site_name, + siteLogo: config.general.email_logo_url, + domain: config.general.domain, + }, + req, + ); + res.render("createEventMagicLink", { + ...frontendConfig(), + message: { + type: "success", + text: "Thanks! If this email address can create events, you should receive an email with a magic link.", + }, + }); +}); + +export default router; diff --git a/src/util/generator.ts b/src/util/generator.ts index 596110d..d959145 100644 --- a/src/util/generator.ts +++ b/src/util/generator.ts @@ -19,6 +19,8 @@ export const generateEventID = () => nanoid(); export const generateEditToken = () => generateAlphanumericString(32); +export const generateMagicLinkToken = () => generateAlphanumericString(32); + export const generateRSAKeypair = () => { return crypto.generateKeyPairSync("rsa", { modulusLength: 4096, |