summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rwxr-xr-xsrc/app.ts2
-rw-r--r--src/lib/config.ts1
-rw-r--r--src/lib/email.ts1
-rw-r--r--src/lib/middleware.ts51
-rw-r--r--src/models/MagicLink.ts35
-rw-r--r--src/routes/event.ts3
-rw-r--r--src/routes/frontend.ts32
-rw-r--r--src/routes/group.ts2
-rw-r--r--src/routes/magicLink.ts70
-rw-r--r--src/util/generator.ts2
10 files changed, 199 insertions, 0 deletions
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 <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,