diff options
Diffstat (limited to 'src')
-rwxr-xr-x | src/app.ts | 4 | ||||
-rw-r--r-- | src/helpers.ts | 2 | ||||
-rw-r--r-- | src/index.d.ts | 14 | ||||
-rw-r--r-- | src/lib/config.ts | 86 | ||||
-rw-r--r-- | src/lib/email.ts | 2 | ||||
-rw-r--r-- | src/lib/middleware.ts | 29 | ||||
-rw-r--r-- | src/models/Event.ts | 5 | ||||
-rwxr-xr-x | src/models/EventGroup.ts | 5 | ||||
-rwxr-xr-x | src/routes.js | 2 | ||||
-rw-r--r-- | src/routes/activitypub.ts | 59 | ||||
-rw-r--r-- | src/routes/event.ts | 44 | ||||
-rw-r--r-- | src/routes/frontend.ts | 135 | ||||
-rw-r--r-- | src/routes/group.ts | 15 | ||||
-rw-r--r-- | src/routes/magicLink.ts | 20 | ||||
-rw-r--r-- | src/routes/static.ts | 6 | ||||
-rw-r--r-- | src/util/object.ts | 30 | ||||
-rw-r--r-- | src/util/validation.ts | 31 |
17 files changed, 382 insertions, 107 deletions
@@ -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, }; }; |