From cd0f291eb1a608589fcc2c1875fa7099ed8e2c51 Mon Sep 17 00:00:00 2001 From: Raphael Kabo Date: Sun, 25 Feb 2024 17:56:25 +0000 Subject: feat: optionally restrict event creation to specific emails --- config/config.example.toml | 5 ++ public/js/modules/new.js | 12 ++++ src/app.ts | 2 + src/lib/config.ts | 1 + src/lib/email.ts | 1 + src/lib/middleware.ts | 51 ++++++++++++++++ src/models/MagicLink.ts | 35 +++++++++++ src/routes/event.ts | 3 + src/routes/frontend.ts | 32 ++++++++++ src/routes/group.ts | 2 + src/routes/magicLink.ts | 70 ++++++++++++++++++++++ src/util/generator.ts | 2 + views/createEventMagicLink.handlebars | 30 ++++++++++ .../createEventMagicLinkHtml.handlebars | 8 +++ .../createEventMagicLinkText.handlebars | 11 ++++ views/partials/eventForm.handlebars | 5 +- views/partials/eventGroupForm.handlebars | 5 +- views/partials/importeventform.handlebars | 5 +- 18 files changed, 274 insertions(+), 6 deletions(-) create mode 100644 src/lib/middleware.ts create mode 100644 src/models/MagicLink.ts create mode 100644 src/routes/magicLink.ts create mode 100644 views/createEventMagicLink.handlebars create mode 100644 views/emails/createEventMagicLink/createEventMagicLinkHtml.handlebars create mode 100644 views/emails/createEventMagicLink/createEventMagicLinkText.handlebars diff --git a/config/config.example.toml b/config/config.example.toml index 8f5a09d..73e90d0 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -17,6 +17,11 @@ show_kofi = false # 'nodemailer' or 'sendgrid'. Configure settings for this mail # service below. mail_service = "nodemailer" +# An array of email addresses which are permitted to create events. If this is +# empty, anyone can create events. +# For example: +# creator_email_addresses = ["test@test.com", "admin@test.com"] +creator_email_addresses = [] [database] # Set up for a locally running MongoDB connection. Change this to diff --git a/public/js/modules/new.js b/public/js/modules/new.js index 2d880f8..a018087 100644 --- a/public/js/modules/new.js +++ b/public/js/modules/new.js @@ -119,6 +119,10 @@ function newEventForm() { "imageUpload", this.$refs.eventImageUpload.files[0], ); + formData.append( + "magicLinkToken", + this.$refs.magicLinkToken.value, + ); try { const response = await fetch("/event", { method: "POST", @@ -170,6 +174,10 @@ function newEventGroupForm() { "imageUpload", this.$refs.eventGroupImageUpload.files[0], ); + formData.append( + "magicLinkToken", + this.$refs.magicLinkToken.value, + ); try { const response = await fetch("/group", { method: "POST", @@ -218,6 +226,10 @@ function importEventForm() { "icsImportControl", this.$refs.icsImportControl.files[0], ); + formData.append( + "magicLinkToken", + this.$refs.magicLinkToken.value, + ); try { const response = await fetch("/import/event", { method: "POST", diff --git a/src/app.ts b/src/app.ts index f3c99c7..40425a8 100755 --- a/src/app.ts +++ b/src/app.ts @@ -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 here.", + }, + ], + }); + } + 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", 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, diff --git a/views/createEventMagicLink.handlebars b/views/createEventMagicLink.handlebars new file mode 100644 index 0000000..563af82 --- /dev/null +++ b/views/createEventMagicLink.handlebars @@ -0,0 +1,30 @@ +
+ +

Request a link to create a new event

+ +
+

+ The administrator of this instance has limited event creation to a set of specific email addresses. If your email address is allowed to create events, you will be sent a magic link to create an event. If not, you won't receive anything. +

+

+ If you run into any issues, please contact the instance administrator. +

+ {{#if message}} + + {{/if}} +
+ + +
+
+ +
+
+
diff --git a/views/emails/createEventMagicLink/createEventMagicLinkHtml.handlebars b/views/emails/createEventMagicLink/createEventMagicLinkHtml.handlebars new file mode 100644 index 0000000..1379607 --- /dev/null +++ b/views/emails/createEventMagicLink/createEventMagicLinkHtml.handlebars @@ -0,0 +1,8 @@ +

Here's a magic link which will allow you to create an event on {{siteName}}.

+

This link will expire in 24 hours and can be used multiple times before then. Don't share it publicly, because it will allow anyone to create an event on your behalf!

+

https://{{domain}}/new/{{token}}

+

Love,

+

{{siteName}}

+
+

Hold up - I have no idea what this email is about!

+

If you didn't try to create an event on {{siteName}}, someone may have accidentally typed your email instead of theirs. Don't worry - there isn't anything you need to do. Your email address will be deleted after the magic link expires.

diff --git a/views/emails/createEventMagicLink/createEventMagicLinkText.handlebars b/views/emails/createEventMagicLink/createEventMagicLinkText.handlebars new file mode 100644 index 0000000..e3b4f96 --- /dev/null +++ b/views/emails/createEventMagicLink/createEventMagicLinkText.handlebars @@ -0,0 +1,11 @@ +Here's a magic link which will allow you to create an event on {{siteName}}. + +This link will expire in 24 hours and can be used multiple times before then. Don't share it publicly, because it will allow anyone to create an event on your behalf! + +https://{{domain}}/new/{{token}} + +Love, + +{{siteName}} + +If you didn't try to create an event on {{siteName}}, someone may have accidentally typed your email instead of theirs. Don't worry - there isn't anything you need to do. Your email address will be deleted after the magic link expires. diff --git a/views/partials/eventForm.handlebars b/views/partials/eventForm.handlebars index c44a2ef..c2eebc3 100755 --- a/views/partials/eventForm.handlebars +++ b/views/partials/eventForm.handlebars @@ -1,3 +1,4 @@ +
@@ -65,7 +66,7 @@
- + If you provide your email, we will send your secret editing password here, and use it to notify you of updates to the event.
@@ -159,7 +160,7 @@

Please fix these errors:

diff --git a/views/partials/eventGroupForm.handlebars b/views/partials/eventGroupForm.handlebars index e020f4a..258c321 100644 --- a/views/partials/eventGroupForm.handlebars +++ b/views/partials/eventGroupForm.handlebars @@ -1,3 +1,4 @@ +
@@ -18,7 +19,7 @@
- + If you provide your email, we will send your secret editing password here, and use it to notify you of updates to the event.
@@ -40,7 +41,7 @@

Please fix these errors:

diff --git a/views/partials/importeventform.handlebars b/views/partials/importeventform.handlebars index d62b599..ac3c673 100644 --- a/views/partials/importeventform.handlebars +++ b/views/partials/importeventform.handlebars @@ -6,6 +6,7 @@ Image showing the location of the export option on Facebook
+
@@ -17,7 +18,7 @@
- + If you provide your email, we will send your secret editing password here, and use it to notify you of updates to the event.
@@ -31,7 +32,7 @@

Please fix these errors:

-- cgit v1.2.3