diff options
| author | Raphael Kabo <raphaelkabo@hey.com> | 2024-02-25 17:56:25 +0000 | 
|---|---|---|
| committer | Raphael Kabo <raphaelkabo@hey.com> | 2024-02-25 17:56:25 +0000 | 
| commit | cd0f291eb1a608589fcc2c1875fa7099ed8e2c51 (patch) | |
| tree | 05b1d8b1d63baed174883cc96807051e530969a2 /src | |
| parent | b17238eb2840553c69fc2dae168be557afbcee9c (diff) | |
feat: optionally restrict event creation to specific emails
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,  | 
