summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rwxr-xr-xsrc/app.ts4
-rw-r--r--src/helpers.ts2
-rw-r--r--src/index.d.ts14
-rw-r--r--src/lib/config.ts86
-rw-r--r--src/lib/email.ts2
-rw-r--r--src/lib/middleware.ts29
-rw-r--r--src/models/Event.ts5
-rwxr-xr-xsrc/models/EventGroup.ts5
-rwxr-xr-xsrc/routes.js2
-rw-r--r--src/routes/activitypub.ts59
-rw-r--r--src/routes/event.ts44
-rw-r--r--src/routes/frontend.ts135
-rw-r--r--src/routes/group.ts15
-rw-r--r--src/routes/magicLink.ts20
-rw-r--r--src/routes/static.ts6
-rw-r--r--src/util/object.ts30
-rw-r--r--src/util/validation.ts31
17 files changed, 382 insertions, 107 deletions
diff --git a/src/app.ts b/src/app.ts
index 40425a8..0708081 100755
--- a/src/app.ts
+++ b/src/app.ts
@@ -1,5 +1,6 @@
import express from "express";
import hbs from "express-handlebars";
+import cookieParser from "cookie-parser";
import routes from "./routes.js";
import frontend from "./routes/frontend.js";
@@ -58,6 +59,9 @@ app.use(express.json({ type: activityPubContentType }));
app.use(express.json({ type: "application/json" }));
app.use(express.urlencoded({ extended: true }));
+// Cookies //
+app.use(cookieParser());
+
// Router //
app.use("/", staticPages);
app.use("/", frontend);
diff --git a/src/helpers.ts b/src/helpers.ts
index 6eda3d0..47b380f 100644
--- a/src/helpers.ts
+++ b/src/helpers.ts
@@ -1,6 +1,6 @@
import moment from "moment-timezone";
import icalGenerator from "ical-generator";
-import Log, { ILog } from "./models/Log.js";
+import Log from "./models/Log.js";
import { getConfig } from "./lib/config.js";
import { IEvent } from "./models/Event.js";
diff --git a/src/index.d.ts b/src/index.d.ts
new file mode 100644
index 0000000..292e5d3
--- /dev/null
+++ b/src/index.d.ts
@@ -0,0 +1,14 @@
+import "express";
+import { GathioConfig } from "./lib/config.js";
+
+interface Locals {
+ config: GathioConfig;
+}
+
+declare module "express" {
+ export interface Response {
+ locals: {
+ config?: GathioConfig;
+ };
+ }
+}
diff --git a/src/lib/config.ts b/src/lib/config.ts
index 1029be9..4bc43bd 100644
--- a/src/lib/config.ts
+++ b/src/lib/config.ts
@@ -1,6 +1,7 @@
import fs from "fs";
import toml from "toml";
import { exitWithError } from "./process.js";
+import { Response } from "express";
interface StaticPage {
title: string;
@@ -8,16 +9,17 @@ interface StaticPage {
filename: string;
}
-interface GathioConfig {
+export interface GathioConfig {
general: {
domain: string;
port: string;
email: string;
site_name: string;
- delete_after_days: number | null;
+ delete_after_days: number;
is_federated: boolean;
email_logo_url: string;
show_kofi: boolean;
+ show_public_event_list: boolean;
mail_service: "nodemailer" | "sendgrid";
creator_email_addresses: string[];
};
@@ -42,6 +44,7 @@ interface FrontendConfig {
isFederated: boolean;
emailLogoUrl: string;
showKofi: boolean;
+ showPublicEventList: boolean;
showInstanceInformation: boolean;
staticPages?: StaticPage[];
version: string;
@@ -56,28 +59,99 @@ const defaultConfig: GathioConfig = {
is_federated: true,
delete_after_days: 7,
email_logo_url: "",
+ show_public_event_list: false,
show_kofi: false,
mail_service: "nodemailer",
+ creator_email_addresses: [],
},
database: {
mongodb_url: "mongodb://localhost:27017/gathio",
},
};
-export const frontendConfig = (): FrontendConfig => {
- const config = getConfig();
+export const frontendConfig = (res: Response): FrontendConfig => {
+ const config = res.locals.config;
+ if (!config) {
+ return {
+ domain: defaultConfig.general.domain,
+ siteName: defaultConfig.general.site_name,
+ isFederated: defaultConfig.general.is_federated,
+ emailLogoUrl: defaultConfig.general.email_logo_url,
+ showPublicEventList: defaultConfig.general.show_public_event_list,
+ showKofi: defaultConfig.general.show_kofi,
+ showInstanceInformation: false,
+ staticPages: [],
+ version: process.env.npm_package_version || "unknown",
+ };
+ }
return {
domain: config.general.domain,
siteName: config.general.site_name,
- isFederated: config.general.is_federated,
+ isFederated: !!config.general.is_federated,
emailLogoUrl: config.general.email_logo_url,
- showKofi: config.general.show_kofi,
+ showPublicEventList: !!config.general.show_public_event_list,
+ showKofi: !!config.general.show_kofi,
showInstanceInformation: !!config.static_pages?.length,
staticPages: config.static_pages,
version: process.env.npm_package_version || "unknown",
};
};
+interface InstanceRule {
+ icon: string;
+ text: string;
+}
+
+export const instanceRules = (): InstanceRule[] => {
+ const config = getConfig();
+ const rules = [];
+ rules.push(
+ config.general.show_public_event_list
+ ? {
+ text: "Public events and groups are displayed on the homepage",
+ icon: "fas fa-eye",
+ }
+ : {
+ text: "Events and groups can only be accessed by direct link",
+ icon: "fas fa-eye-slash",
+ },
+ );
+ rules.push(
+ config.general.creator_email_addresses?.length
+ ? {
+ text: "Only specific people can create events and groups",
+ icon: "fas fa-user-check",
+ }
+ : {
+ text: "Anyone can create events and groups",
+ icon: "fas fa-users",
+ },
+ );
+ rules.push(
+ config.general.delete_after_days > 0
+ ? {
+ text: `Events are automatically deleted ${config.general.delete_after_days} days after they end`,
+ icon: "far fa-calendar-times",
+ }
+ : {
+ text: "Events are permanent, and are never automatically deleted",
+ icon: "far fa-calendar-check",
+ },
+ );
+ rules.push(
+ config.general.is_federated
+ ? {
+ text: "This instance federates with other instances using ActivityPub",
+ icon: "fas fa-globe",
+ }
+ : {
+ text: "This instance does not federate with other instances",
+ icon: "fas fa-globe",
+ },
+ );
+ return rules;
+};
+
// Attempt to load our global config. Will stop the app if the config file
// cannot be read (there's no point trying to continue!)
export const getConfig = (): GathioConfig => {
diff --git a/src/lib/email.ts b/src/lib/email.ts
index 8a215a9..9b8162b 100644
--- a/src/lib/email.ts
+++ b/src/lib/email.ts
@@ -1,6 +1,6 @@
import { Request } from "express";
import sgMail from "@sendgrid/mail";
-import nodemailer, { TransportOptions } from "nodemailer";
+import nodemailer from "nodemailer";
import { getConfig } from "./config.js";
import SMTPTransport from "nodemailer/lib/smtp-transport/index.js";
import { exitWithError } from "./process.js";
diff --git a/src/lib/middleware.ts b/src/lib/middleware.ts
index 0594e90..5073137 100644
--- a/src/lib/middleware.ts
+++ b/src/lib/middleware.ts
@@ -1,14 +1,14 @@
-import { Request, Response } from "express";
+import { NextFunction, Request, Response } from "express";
import MagicLink from "../models/MagicLink.js";
-import getConfig from "../lib/config.js";
-
-const config = getConfig();
+import getConfig, { GathioConfig } from "../lib/config.js";
+import { deepMerge } from "../util/object.js";
export const checkMagicLink = async (
req: Request,
res: Response,
- next: any,
+ next: NextFunction,
) => {
+ const config = getConfig();
if (!config.general.creator_email_addresses?.length) {
// No creator email addresses are configured, so skip the magic link check
return next();
@@ -49,3 +49,22 @@ export const checkMagicLink = async (
}
next();
};
+
+// Route-specific middleware which injects the config into the request object
+// It can also be used to modify the config based on the request, which
+// we use for Cypress testing.
+export const getConfigMiddleware = (
+ req: Request,
+ res: Response,
+ next: NextFunction,
+) => {
+ const config = getConfig();
+ if (process.env.CYPRESS === "true" && req.cookies?.cypressConfigOverride) {
+ console.log("Overriding config with Cypress config");
+ const override = JSON.parse(req.cookies.cypressConfigOverride);
+ res.locals.config = deepMerge<GathioConfig>(config, override);
+ return next();
+ }
+ res.locals.config = config;
+ return next();
+};
diff --git a/src/models/Event.ts b/src/models/Event.ts
index f67d40b..5731680 100644
--- a/src/models/Event.ts
+++ b/src/models/Event.ts
@@ -73,6 +73,7 @@ export interface IEvent extends mongoose.Document {
privateKey?: string;
followers?: IFollower[];
activityPubMessages?: IActivityPubMessage[];
+ showOnPublicList?: boolean;
}
const Attendees = new mongoose.Schema({
@@ -334,6 +335,10 @@ const EventSchema = new mongoose.Schema({
},
followers: [Followers],
activityPubMessages: [ActivityPubMessages],
+ showOnPublicList: {
+ type: Boolean,
+ default: false,
+ },
});
export default mongoose.model<IEvent>("Event", EventSchema);
diff --git a/src/models/EventGroup.ts b/src/models/EventGroup.ts
index 2b5c2aa..de7187d 100755
--- a/src/models/EventGroup.ts
+++ b/src/models/EventGroup.ts
@@ -16,6 +16,7 @@ export interface IEventGroup extends mongoose.Document {
firstLoad?: boolean;
events?: mongoose.Types.ObjectId[];
subscribers?: ISubscriber[];
+ showOnPublicList?: boolean;
}
const Subscriber = new mongoose.Schema({
@@ -70,6 +71,10 @@ const EventGroupSchema = new mongoose.Schema({
},
events: [{ type: mongoose.Schema.Types.ObjectId, ref: "Event" }],
subscribers: [Subscriber],
+ showOnPublicList: {
+ type: Boolean,
+ default: false,
+ },
});
export default mongoose.model<IEventGroup>("EventGroup", EventGroupSchema);
diff --git a/src/routes.js b/src/routes.js
index 8ea7e05..9eedfb5 100755
--- a/src/routes.js
+++ b/src/routes.js
@@ -1511,7 +1511,7 @@ router.post("/activitypub/inbox", (req, res) => {
});
router.use(function (req, res, next) {
- return res.status(404).render("404", frontendConfig());
+ return res.status(404).render("404", frontendConfig(res));
});
addToLog("startup", "success", "Started up successfully");
diff --git a/src/routes/activitypub.ts b/src/routes/activitypub.ts
index 667a44f..fc61dd7 100644
--- a/src/routes/activitypub.ts
+++ b/src/routes/activitypub.ts
@@ -1,21 +1,22 @@
import { Router, Request, Response, NextFunction } from "express";
import { createFeaturedPost, createWebfinger } from "../activitypub.js";
import { acceptsActivityPub } from "../lib/activitypub.js";
-import getConfig, { frontendConfig } from "../lib/config.js";
+import { frontendConfig } from "../lib/config.js";
import Event from "../models/Event.js";
import { addToLog } from "../helpers.js";
-
-const config = getConfig();
+import { getConfigMiddleware } from "../lib/middleware.js";
const router = Router();
+router.use(getConfigMiddleware);
+
const send404IfNotFederated = (
req: Request,
res: Response,
next: NextFunction,
) => {
- if (!config.general.is_federated) {
- return res.status(404).render("404", frontendConfig());
+ if (!res.locals.config?.general.is_federated) {
+ return res.status(404).render("404", frontendConfig(res));
}
next();
};
@@ -27,7 +28,7 @@ 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`,
+ id: `https://${res.locals.config?.general.domain}/${eventID}/featured`,
type: "OrderedCollection",
orderedItems: [createFeaturedPost(eventID)],
};
@@ -41,17 +42,17 @@ router.get("/:eventID/featured", (req: Request, res: Response) => {
// 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}`;
+ const id = `https://${res.locals.config?.general.domain}/${eventID}/m/${hash}`;
try {
const event = await Event.findOne({
id: eventID,
});
if (!event) {
- return res.status(404).render("404", frontendConfig());
+ return res.status(404).render("404", frontendConfig(res));
} else {
if (!event.activityPubMessages) {
- return res.status(404).render("404", frontendConfig());
+ return res.status(404).render("404", frontendConfig(res));
}
const message = event.activityPubMessages.find(
(el) => el.id === id,
@@ -68,7 +69,7 @@ router.get("/:eventID/m/:hash", async (req: Request, res: Response) => {
);
}
} else {
- return res.status(404).render("404", frontendConfig());
+ return res.status(404).render("404", frontendConfig(res));
}
}
} catch (err) {
@@ -80,19 +81,19 @@ router.get("/:eventID/m/:hash", async (req: Request, res: Response) => {
" failed with error: " +
err,
);
- return res.status(404).render("404", frontendConfig());
+ return res.status(404).render("404", frontendConfig(res));
}
});
router.get("/.well-known/nodeinfo", (req, res) => {
- if (!config.general.is_federated) {
- return res.status(404).render("404", frontendConfig());
+ if (!res.locals.config?.general.is_federated) {
+ return res.status(404).render("404", frontendConfig(res));
}
const nodeInfo = {
links: [
{
rel: "http://nodeinfo.diaspora.software/ns/schema/2.2",
- href: `https://${config.general.domain}/.well-known/nodeinfo/2.2`,
+ href: `https://${res.locals.config?.general.domain}/.well-known/nodeinfo/2.2`,
},
],
};
@@ -105,13 +106,13 @@ router.get("/.well-known/nodeinfo", (req, res) => {
router.get("/.well-known/nodeinfo/2.2", async (req, res) => {
const eventCount = await Event.countDocuments();
- if (!config.general.is_federated) {
- return res.status(404).render("404", frontendConfig());
+ if (!res.locals.config?.general.is_federated) {
+ return res.status(404).render("404", frontendConfig(res));
}
const nodeInfo = {
version: "2.2",
instance: {
- name: config.general.site_name,
+ name: res.locals.config?.general.site_name,
description:
"Federated, no-registration, privacy-respecting event hosting.",
},
@@ -157,16 +158,24 @@ router.get("/.well-known/webfinger", async (req, res) => {
const event = await Event.findOne({ id: eventID });
if (!event) {
- return res.status(404).render("404", frontendConfig());
+ return res.status(404).render("404", frontendConfig(res));
} else {
if (acceptsActivityPub(req)) {
res.header(
"Content-Type",
"application/activity+json",
- ).send(createWebfinger(eventID, config.general.domain));
+ ).send(
+ createWebfinger(
+ eventID,
+ res.locals.config?.general.domain,
+ ),
+ );
} else {
res.header("Content-Type", "application/json").send(
- createWebfinger(eventID, config.general.domain),
+ createWebfinger(
+ eventID,
+ res.locals.config?.general.domain,
+ ),
);
}
}
@@ -176,7 +185,7 @@ router.get("/.well-known/webfinger", async (req, res) => {
"error",
`Attempt to render webfinger for ${resource} failed with error: ${err}`,
);
- return res.status(404).render("404", frontendConfig());
+ return res.status(404).render("404", frontendConfig(res));
}
}
});
@@ -192,13 +201,13 @@ router.get("/:eventID/followers", async (req, res) => {
let followersCollection = {
type: "OrderedCollection",
totalItems: followers.length,
- id: `https://${config.general.domain}/${eventID}/followers`,
+ id: `https://${res.locals.config?.general.domain}/${eventID}/followers`,
first: {
type: "OrderedCollectionPage",
totalItems: followers.length,
- partOf: `https://${config.general.domain}/${eventID}/followers`,
+ partOf: `https://${res.locals.config?.general.domain}/${eventID}/followers`,
orderedItems: followers,
- id: `https://${config.general.domain}/${eventID}/followers?page=1`,
+ id: `https://${res.locals.config?.general.domain}/${eventID}/followers?page=1`,
},
"@context": ["https://www.w3.org/ns/activitystreams"],
};
@@ -221,7 +230,7 @@ router.get("/:eventID/followers", async (req, res) => {
"error",
`Attempt to render followers for ${eventID} failed with error: ${err}`,
);
- return res.status(404).render("404", frontendConfig());
+ return res.status(404).render("404", frontendConfig(res));
}
});
diff --git a/src/routes/event.ts b/src/routes/event.ts
index fb9d8c7..ad77052 100644
--- a/src/routes/event.ts
+++ b/src/routes/event.ts
@@ -21,14 +21,11 @@ import {
updateActivityPubActor,
updateActivityPubEvent,
} from "../activitypub.js";
-import getConfig from "../lib/config.js";
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();
+import { checkMagicLink, getConfigMiddleware } from "../lib/middleware.js";
const storage = multer.memoryStorage();
// Accept only JPEG, GIF or PNG images, up to 10MB
@@ -58,6 +55,8 @@ const icsUpload = multer({
const router = Router();
+router.use(getConfigMiddleware);
+
router.post(
"/event",
upload.single("imageUpload"),
@@ -140,6 +139,7 @@ router.post(
viewPassword: "", // Backwards compatibility
editPassword: "", // Backwards compatibility
editToken: editToken,
+ showOnPublicList: eventData?.publicBoolean,
eventGroup: isPartOfEventGroup ? eventGroup?._id : null,
usersCanAttend: eventData.joinBoolean ? true : false,
showUsersList: false, // Backwards compatibility
@@ -148,7 +148,7 @@ router.post(
firstLoad: true,
activityPubActor: createActivityPubActor(
eventID,
- config.general.domain,
+ res.locals.config?.general.domain,
publicKey,
markdownToSanitizedHTML(eventData.eventDescription),
eventData.eventName,
@@ -168,7 +168,7 @@ router.post(
),
activityPubMessages: [
{
- id: `https://${config.general.domain}/${eventID}/m/featuredPost`,
+ id: `https://${res.locals.config?.general.domain}/${eventID}/m/featuredPost`,
content: JSON.stringify(
createFeaturedPost(
eventID,
@@ -197,9 +197,9 @@ router.post(
{
eventID,
editToken,
- siteName: config.general.site_name,
- siteLogo: config.general.email_logo_url,
- domain: config.general.domain,
+ siteName: res.locals.config?.general.site_name,
+ siteLogo: res.locals.config?.general.email_logo_url,
+ domain: res.locals.config?.general.domain,
},
req,
);
@@ -231,9 +231,10 @@ router.post(
`New event in ${eventGroup.name}`,
"eventGroupUpdated",
{
- siteName: config.general.site_name,
- siteLogo: config.general.email_logo_url,
- domain: config.general.domain,
+ siteName: res.locals.config?.general.site_name,
+ siteLogo:
+ res.locals.config?.general.email_logo_url,
+ domain: res.locals.config?.general.domain,
eventGroupName: eventGroup.name,
eventName: event.name,
eventID: event.id,
@@ -371,6 +372,7 @@ router.put(
url: eventData.eventURL,
hostName: eventData.hostName,
image: eventImageFilename,
+ showOnPublicList: eventData.publicBoolean,
usersCanAttend: eventData.joinBoolean,
showUsersList: false, // Backwards compatibility
usersCanComment: eventData.interactionBoolean,
@@ -449,11 +451,11 @@ router.put(
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}`,
+ id: `https://${res.locals.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: <a href="https://${config.general.domain}/${req.params.eventID}">https://${config.general.domain}/${req.params.eventID}</a>`,
+ content: `${diffText} See here: <a href="https://${res.locals.config?.general.domain}/${req.params.eventID}">https://${res.locals.config?.general.domain}/${req.params.eventID}</a>`,
};
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
@@ -470,7 +472,7 @@ router.put(
"@context": "https://www.w3.org/ns/activitystreams",
name: `RSVP to ${event.name}`,
type: "Note",
- content: `<span class=\"h-card\"><a href="${attendee.id}" class="u-url mention">@<span>${attendee.name}</span></a></span> ${diffText} See here: <a href="https://${config.general.domain}/${req.params.eventID}">https://${config.general.domain}/${req.params.eventID}</a>`,
+ content: `<span class=\"h-card\"><a href="${attendee.id}" class="u-url mention">@<span>${attendee.name}</span></a></span> ${diffText} See here: <a href="https://${res.locals.config?.general.domain}/${req.params.eventID}">https://${res.locals.config?.general.domain}/${req.params.eventID}</a>`,
tag: [
{
type: "Mention",
@@ -496,9 +498,9 @@ router.put(
{
diffText,
eventID: req.params.eventID,
- siteName: config.general.site_name,
- siteLogo: config.general.email_logo_url,
- domain: config.general.domain,
+ siteName: res.locals.config?.general.site_name,
+ siteLogo: res.locals.config?.general.email_logo_url,
+ domain: res.locals.config?.general.domain,
},
req,
);
@@ -610,9 +612,9 @@ router.post(
{
eventID,
editToken,
- siteName: config.general.site_name,
- siteLogo: config.general.email_logo_url,
- domain: config.general.domain,
+ siteName: res.locals.config?.general.site_name,
+ siteLogo: res.locals.config?.general.email_logo_url,
+ domain: res.locals.config?.general.domain,
},
req,
);
diff --git a/src/routes/frontend.ts b/src/routes/frontend.ts
index 0d8793a..240aff0 100644
--- a/src/routes/frontend.ts
+++ b/src/routes/frontend.ts
@@ -2,7 +2,7 @@ import { Router, Request, Response } from "express";
import moment from "moment-timezone";
import { marked } from "marked";
import { markdownToSanitizedHTML, renderPlain } from "../util/markdown.js";
-import getConfig, { frontendConfig } from "../lib/config.js";
+import { frontendConfig, instanceRules } from "../lib/config.js";
import { addToLog, exportICal } from "../helpers.js";
import Event from "../models/Event.js";
import EventGroup, { IEventGroup } from "../models/EventGroup.js";
@@ -11,28 +11,44 @@ import {
activityPubContentType,
} from "../lib/activitypub.js";
import MagicLink from "../models/MagicLink.js";
-
-const config = getConfig();
+import { getConfigMiddleware } from "../lib/middleware.js";
const router = Router();
+
+// Add config middleware to all routes
+router.use(getConfigMiddleware);
+
router.get("/", (_: Request, res: Response) => {
- res.render("home", frontendConfig());
+ if (res.locals.config?.general.show_public_event_list) {
+ return res.redirect("/events");
+ }
+ return res.render("home", {
+ ...frontendConfig(res),
+ instanceRules: instanceRules(),
+ });
});
-router.get("/new", (_: Request, res: Response) => {
- if (config.general.creator_email_addresses?.length) {
- return res.render("createEventMagicLink", frontendConfig());
+router.get("/about", (_: Request, res: Response) => {
+ return res.render("home", {
+ ...frontendConfig(res),
+ instanceRules: instanceRules(),
+ });
+});
+
+router.get("/new", (req: Request, res: Response) => {
+ if (res.locals.config?.general.creator_email_addresses?.length) {
+ return res.render("createEventMagicLink", frontendConfig(res));
}
return res.render("newevent", {
title: "New event",
- ...frontendConfig(),
+ ...frontendConfig(res),
});
});
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) {
+ if (!res.locals.config?.general.creator_email_addresses?.length) {
return res.redirect("/new");
}
const magicLink = await MagicLink.findOne({
@@ -42,7 +58,7 @@ router.get("/new/:magicLinkToken", async (req: Request, res: Response) => {
});
if (!magicLink) {
return res.render("createEventMagicLink", {
- ...frontendConfig(),
+ ...frontendConfig(res),
message: {
type: "danger",
text: "This magic link is invalid or has expired. Please request a new one here.",
@@ -51,12 +67,67 @@ router.get("/new/:magicLinkToken", async (req: Request, res: Response) => {
}
res.render("newevent", {
title: "New event",
- ...frontendConfig(),
+ ...frontendConfig(res),
magicLinkToken: req.params.magicLinkToken,
creatorEmail: magicLink.email,
});
});
+router.get("/events", async (_: Request, res: Response) => {
+ if (!res.locals.config?.general.show_public_event_list) {
+ return res.status(404).render("404", frontendConfig(res));
+ }
+ const events = await Event.find({ showOnPublicList: true })
+ .populate("eventGroup")
+ .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,
+ location: event.location,
+ displayDate: isSameDay
+ ? startMoment.format("D MMM YYYY")
+ : `${startMoment.format("D MMM YYYY")} - ${endMoment.format(
+ "D MMM YYYY",
+ )}`,
+ eventHasConcluded: endMoment.isBefore(moment.tz(event.timezone)),
+ eventGroup: event.eventGroup as any as IEventGroup,
+ };
+ });
+ const upcomingEvents = updatedEvents.filter(
+ (event) => event.eventHasConcluded === false,
+ );
+ const pastEvents = updatedEvents.filter(
+ (event) => event.eventHasConcluded === true,
+ );
+ const eventGroups = await EventGroup.find({
+ showOnPublicList: true,
+ }).lean();
+ const updatedEventGroups = eventGroups.map((eventGroup) => {
+ return {
+ name: eventGroup.name,
+ numberOfEvents: updatedEvents.filter(
+ (event) =>
+ event.eventGroup?._id.toString() ===
+ eventGroup._id.toString(),
+ ).length,
+ };
+ });
+
+ res.render("publicEventList", {
+ title: "Public events",
+ upcomingEvents: upcomingEvents,
+ pastEvents: pastEvents,
+ eventGroups: updatedEventGroups,
+ ...frontendConfig(res),
+ });
+});
+
router.get("/:eventID", async (req: Request, res: Response) => {
try {
const event = await Event.findOne({
@@ -65,7 +136,7 @@ router.get("/:eventID", async (req: Request, res: Response) => {
.lean() // Required, see: https://stackoverflow.com/questions/59690923/handlebars-access-has-been-denied-to-resolve-the-property-from-because-it-is
.populate("eventGroup");
if (!event) {
- return res.status(404).render("404", frontendConfig());
+ return res.status(404).render("404", frontendConfig(res));
}
const parsedLocation = event.location.replace(/\s+/g, "+");
let displayDate;
@@ -228,9 +299,12 @@ router.get("/:eventID", async (req: Request, res: Response) => {
.join(" ")
.trim(),
image: eventHasCoverImage
- ? `https://${config.general.domain}/events/` + event.image
+ ? `https://${res.locals.config?.general.domain}/events/` +
+ event.image
: null,
- url: `https://${config.general.domain}/` + req.params.eventID,
+ url:
+ `https://${res.locals.config?.general.domain}/` +
+ req.params.eventID,
};
if (acceptsActivityPub(req)) {
res.header("Content-Type", activityPubContentType).send(
@@ -239,7 +313,7 @@ router.get("/:eventID", async (req: Request, res: Response) => {
} else {
res.set("X-Robots-Tag", "noindex");
res.render("event", {
- ...frontendConfig(),
+ ...frontendConfig(res),
title: event.name,
escapedName: escapedName,
eventData: event,
@@ -266,6 +340,12 @@ router.get("/:eventID", async (req: Request, res: Response) => {
firstLoad: firstLoad,
eventHasConcluded: eventHasConcluded,
eventHasBegun: eventHasBegun,
+ eventWillBeDeleted:
+ (res.locals.config?.general.delete_after_days || 0) > 0,
+ daysUntilDeletion: moment
+ .tz(event.end, event.timezone)
+ .add(res.locals.config?.general.delete_after_days, "days")
+ .fromNow(),
metadata: metadata,
jsonData: {
name: event.name,
@@ -276,6 +356,7 @@ router.get("/:eventID", async (req: Request, res: Response) => {
url: event.url,
hostName: event.hostName,
creatorEmail: event.creatorEmail,
+ showOnPublicList: event.showOnPublicList,
eventGroupID: event.eventGroup
? (event.eventGroup as unknown as IEventGroup).id
: null,
@@ -304,7 +385,7 @@ router.get("/:eventID", async (req: Request, res: Response) => {
err,
);
console.log(err);
- return res.status(404).render("404", frontendConfig());
+ return res.status(404).render("404", frontendConfig(res));
}
});
@@ -315,7 +396,7 @@ router.get("/group/:eventGroupID", async (req: Request, res: Response) => {
}).lean();
if (!eventGroup) {
- return res.status(404).render("404", frontendConfig());
+ return res.status(404).render("404", frontendConfig(res));
}
const parsedDescription = markdownToSanitizedHTML(
eventGroup.description,
@@ -337,6 +418,7 @@ router.get("/group/:eventGroupID", async (req: Request, res: Response) => {
return {
id: event.id,
name: event.name,
+ location: event.location,
displayDate: isSameDay
? startMoment.format("D MMM YYYY")
: `${startMoment.format("D MMM YYYY")} - ${endMoment.format(
@@ -381,14 +463,18 @@ router.get("/group/:eventGroupID", async (req: Request, res: Response) => {
.join(" ")
.trim(),
image: eventGroupHasCoverImage
- ? `https://${config.general.domain}/events/` + eventGroup.image
+ ? `https://${res.locals.config?.general.domain}/events/` +
+ eventGroup.image
: null,
- url: `https://${config.general.domain}/` + req.params.eventID,
+ url:
+ `https://${res.locals.config?.general.domain}/` +
+ req.params.eventID,
};
res.set("X-Robots-Tag", "noindex");
res.render("eventgroup", {
- domain: config.general.domain,
+ ...frontendConfig(res),
+ domain: res.locals.config?.general.domain,
title: eventGroup.name,
eventGroupData: eventGroup,
escapedName: escapedName,
@@ -409,6 +495,7 @@ router.get("/group/:eventGroupID", async (req: Request, res: Response) => {
creatorEmail: eventGroup.creatorEmail,
image: eventGroup.image,
editToken: editingEnabled ? eventGroupEditToken : null,
+ showOnPublicList: eventGroup.showOnPublicList,
},
});
} catch (err) {
@@ -418,7 +505,7 @@ router.get("/group/:eventGroupID", async (req: Request, res: Response) => {
`Attempt to display event group ${req.params.eventGroupID} failed with error: ${err}`,
);
console.log(err);
- return res.status(404).render("404", frontendConfig());
+ return res.status(404).render("404", frontendConfig(res));
}
});
@@ -445,7 +532,7 @@ router.get(
`Attempt to display event group feed for ${req.params.eventGroupID} failed with error: ${err}`,
);
console.log(err);
- return res.status(404).render("404", frontendConfig());
+ return res.status(404).render("404", frontendConfig(res));
}
},
);
@@ -467,7 +554,7 @@ router.get("/export/event/:eventID", async (req: Request, res: Response) => {
`Attempt to export event ${req.params.eventID} failed with error: ${err}`,
);
console.log(err);
- return res.status(404).render("404", frontendConfig());
+ return res.status(404).render("404", frontendConfig(res));
}
});
@@ -493,7 +580,7 @@ router.get(
`Attempt to export event group ${req.params.eventGroupID} failed with error: ${err}`,
);
console.log(err);
- return res.status(404).render("404", frontendConfig());
+ return res.status(404).render("404", frontendConfig(res));
}
},
);
diff --git a/src/routes/group.ts b/src/routes/group.ts
index 34377b0..8afd766 100644
--- a/src/routes/group.ts
+++ b/src/routes/group.ts
@@ -1,5 +1,4 @@
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";
@@ -9,9 +8,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();
+import { checkMagicLink, getConfigMiddleware } from "../lib/middleware.js";
const storage = multer.memoryStorage();
// Accept only JPEG, GIF or PNG images, up to 10MB
@@ -30,6 +27,8 @@ const upload = multer({
const router = Router();
+router.use(getConfigMiddleware);
+
router.post(
"/group",
upload.single("imageUpload"),
@@ -81,6 +80,7 @@ router.post(
hostName: groupData.hostName,
editToken: editToken,
firstLoad: true,
+ showOnPublicList: groupData.publicBoolean,
});
await eventGroup.save();
@@ -100,9 +100,9 @@ router.post(
{
eventGroupID: eventGroup.id,
editToken: eventGroup.editToken,
- siteName: config.general.site_name,
- siteLogo: config.general.email_logo_url,
- domain: config.general.domain,
+ siteName: res.locals.config?.general.site_name,
+ siteLogo: res.locals.config?.general.email_logo_url,
+ domain: res.locals.config?.general.domain,
},
req,
);
@@ -206,6 +206,7 @@ router.put(
url: req.body.eventGroupURL,
hostName: req.body.hostName,
image: eventGroupImageFilename,
+ showOnPublicList: groupData.publicBoolean,
};
await EventGroup.findOneAndUpdate(
diff --git a/src/routes/magicLink.ts b/src/routes/magicLink.ts
index 24f0667..499d0a4 100644
--- a/src/routes/magicLink.ts
+++ b/src/routes/magicLink.ts
@@ -1,17 +1,19 @@
import { Router, Request, Response } from "express";
-import getConfig, { frontendConfig } from "../lib/config.js";
+import { frontendConfig } from "../lib/config.js";
import { sendEmailFromTemplate } from "../lib/email.js";
import { generateMagicLinkToken } from "../util/generator.js";
import MagicLink from "../models/MagicLink.js";
+import { getConfigMiddleware } from "../lib/middleware.js";
const router = Router();
-const config = getConfig();
+
+router.use(getConfigMiddleware);
router.post("/magic-link/event/create", async (req: Request, res: Response) => {
const { email } = req.body;
if (!email) {
res.render("createEventMagicLink", {
- ...frontendConfig(),
+ ...frontendConfig(res),
message: {
type: "danger",
text: "Please provide an email address.",
@@ -19,14 +21,14 @@ router.post("/magic-link/event/create", async (req: Request, res: Response) => {
});
return;
}
- const allowedEmails = config.general.creator_email_addresses;
+ const allowedEmails = res.locals.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(),
+ ...frontendConfig(res),
message: {
type: "success",
text: "Thanks! If this email address can create events, you should receive an email with a magic link.",
@@ -52,14 +54,14 @@ router.post("/magic-link/event/create", async (req: Request, res: Response) => {
"createEventMagicLink",
{
token,
- siteName: config.general.site_name,
- siteLogo: config.general.email_logo_url,
- domain: config.general.domain,
+ siteName: res.locals.config?.general.site_name,
+ siteLogo: res.locals.config?.general.email_logo_url,
+ domain: res.locals.config?.general.domain,
},
req,
);
res.render("createEventMagicLink", {
- ...frontendConfig(),
+ ...frontendConfig(res),
message: {
type: "success",
text: "Thanks! If this email address can create events, you should receive an email with a magic link.",
diff --git a/src/routes/static.ts b/src/routes/static.ts
index 33f0225..6fab98d 100644
--- a/src/routes/static.ts
+++ b/src/routes/static.ts
@@ -21,13 +21,13 @@ if (config.static_pages?.length) {
return res.render("static", {
title: page.title,
content: parsed,
- ...frontendConfig(),
+ ...frontendConfig(res),
});
}
- return res.status(404).render("404", frontendConfig());
+ return res.status(404).render("404", frontendConfig(res));
} catch (err) {
console.error(err);
- return res.status(404).render("404", frontendConfig());
+ return res.status(404).render("404", frontendConfig(res));
}
});
});
diff --git a/src/util/object.ts b/src/util/object.ts
new file mode 100644
index 0000000..1ecc89b
--- /dev/null
+++ b/src/util/object.ts
@@ -0,0 +1,30 @@
+/**
+ * Simple object check.
+ */
+export function isObject(item: any) {
+ return item && typeof item === "object" && !Array.isArray(item);
+}
+
+/**
+ * Deep merge two objects.
+ */
+export function deepMerge<T>(
+ target: Record<any, any>,
+ ...sources: Record<any, any>[]
+): T {
+ if (!sources.length) return target;
+ const source = sources.shift();
+
+ if (isObject(target) && isObject(source)) {
+ for (const key in source) {
+ if (isObject(source[key])) {
+ if (!target[key]) Object.assign(target, { [key]: {} });
+ deepMerge(target[key], source[key]);
+ } else {
+ Object.assign(target, { [key]: source[key] });
+ }
+ }
+ }
+
+ return deepMerge(target, ...sources) as T;
+}
diff --git a/src/util/validation.ts b/src/util/validation.ts
index 732fbf3..b9a0c8a 100644
--- a/src/util/validation.ts
+++ b/src/util/validation.ts
@@ -5,11 +5,16 @@ type Error = {
field?: string;
};
-type ValidationResponse = {
+type EventValidationResponse = {
data?: ValidatedEventData;
errors?: Error[];
};
+type EventGroupValidationResponse = {
+ data?: ValidatedEventGroupData;
+ errors?: Error[];
+};
+
interface EventData {
eventName: string;
eventLocation: string;
@@ -21,6 +26,7 @@ interface EventData {
imagePath: string;
hostName: string;
creatorEmail: string;
+ publicCheckbox: string;
eventGroupCheckbox: string;
eventGroupID: string;
eventGroupEditToken: string;
@@ -33,11 +39,13 @@ interface EventData {
// EventData without the 'checkbox' fields
export type ValidatedEventData = Omit<
EventData,
+ | "publicCheckbox"
| "eventGroupCheckbox"
| "interactionCheckbox"
| "joinCheckbox"
| "maxAttendeesCheckbox"
> & {
+ publicBoolean: boolean;
eventGroupBoolean: boolean;
interactionBoolean: boolean;
joinBoolean: boolean;
@@ -50,8 +58,13 @@ interface EventGroupData {
eventGroupURL: string;
hostName: string;
creatorEmail: string;
+ publicCheckbox: string;
}
+export type ValidatedEventGroupData = Omit<EventGroupData, "publicCheckbox"> & {
+ publicBoolean: boolean;
+};
+
const validateEmail = (email: string) => {
if (!email || email.length === 0 || typeof email !== "string") {
return false;
@@ -89,9 +102,12 @@ export const validateEventTime = (start: Date, end: Date): Error | boolean => {
return true;
};
-export const validateEventData = (eventData: EventData): ValidationResponse => {
+export const validateEventData = (
+ eventData: EventData,
+): EventValidationResponse => {
const validatedData: ValidatedEventData = {
...eventData,
+ publicBoolean: eventData.publicCheckbox === "true",
eventGroupBoolean: eventData.eventGroupCheckbox === "true",
interactionBoolean: eventData.interactionCheckbox === "true",
joinBoolean: eventData.joinCheckbox === "true",
@@ -186,7 +202,9 @@ export const validateEventData = (eventData: EventData): ValidationResponse => {
};
};
-export const validateGroupData = (groupData: EventGroupData) => {
+export const validateGroupData = (
+ groupData: EventGroupData,
+): EventGroupValidationResponse => {
const errors: Error[] = [];
if (!groupData.eventGroupName) {
errors.push({
@@ -209,8 +227,13 @@ export const validateGroupData = (groupData: EventGroupData) => {
}
}
+ const validatedData: ValidatedEventGroupData = {
+ ...groupData,
+ publicBoolean: groupData.publicCheckbox === "true",
+ };
+
return {
- data: groupData,
+ data: validatedData,
errors: errors,
};
};