summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorRaphael Kabo <mail@raphaelkabo.com>2025-05-28 18:32:47 +0100
committerRaphael Kabo <mail@raphaelkabo.com>2025-05-28 18:32:47 +0100
commita6f8ec770d06ce33042ed3f222cba786897e0233 (patch)
treea060e72668de41fcbeae5e891ee86b096b096f26 /src
parent08fa2f616c90e59066d0308097c65c424b5b4a88 (diff)
parentfd637b405c8784a07dabd54b10fda98ad9f4a4ad (diff)
Merge remote-tracking branch 'origin/main' into clearer-editing-mode
Diffstat (limited to 'src')
-rwxr-xr-xsrc/app.ts206
-rw-r--r--src/helpers.ts93
-rw-r--r--src/index.d.ts17
-rw-r--r--src/lib/config.ts88
-rw-r--r--src/lib/email.ts292
-rw-r--r--src/lib/event.ts4
-rw-r--r--src/lib/handlebars.ts23
-rw-r--r--src/lib/middleware.ts4
-rwxr-xr-xsrc/routes.js756
-rw-r--r--src/routes/event.ts212
-rw-r--r--src/routes/frontend.ts106
-rw-r--r--src/routes/group.ts28
-rw-r--r--src/routes/magicLink.ts27
-rw-r--r--src/types/i18next-fs-backend.d.ts5
-rw-r--r--src/util/object.ts30
-rw-r--r--src/util/validation.ts41
16 files changed, 945 insertions, 987 deletions
diff --git a/src/app.ts b/src/app.ts
index 0708081..0f8e1f7 100755
--- a/src/app.ts
+++ b/src/app.ts
@@ -1,6 +1,20 @@
import express from "express";
-import hbs from "express-handlebars";
import cookieParser from "cookie-parser";
+import { create as createHandlebars, ExpressHandlebars } from "express-handlebars";
+import i18next from "i18next";
+import Backend from "i18next-fs-backend";
+import { LanguageDetector, handle } from 'i18next-http-middleware';
+import { createRequire } from 'module';
+import { fileURLToPath } from 'url';
+import { dirname } from 'path';
+import path from 'path';
+
+const require = createRequire(import.meta.url);
+const handlebarsI18next = require('handlebars-i18next');
+
+// Recreate __dirname in ES module
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
import routes from "./routes.js";
import frontend from "./routes/frontend.js";
@@ -9,66 +23,150 @@ 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 { getI18nHelpers } from "./helpers.js";
import {
activityPubContentType,
alternateActivityPubContentType,
} from "./lib/activitypub.js";
+import moment from "moment";
+import { EmailService } from "./lib/email.js";
+import getConfig from "./lib/config.js";
const app = express();
+const config = getConfig();
-app.locals.sendEmails = initEmailService();
-
-// View engine //
-const hbsInstance = hbs.create({
- defaultLayout: "main",
- partialsDir: ["views/partials/"],
- layoutsDir: "views/layouts/",
- helpers: {
- plural: function (number: number, text: string) {
- var singular = number === 1;
- // If no text parameter was given, just return a conditional s.
- if (typeof text !== "string") return singular ? "" : "s";
- // Split with regex into group1/group2 or group1(group3)
- var match = text.match(/^([^()\/]+)(?:\/(.+))?(?:\((\w+)\))?/);
- // If no match, just append a conditional s.
- if (!match) return text + (singular ? "" : "s");
- // We have a good match, so fire away
- return (
- (singular && match[1]) || // Singular case
- match[2] || // Plural case: 'bagel/bagels' --> bagels
- match[1] + (match[3] || "s")
- ); // Plural case: 'bagel(s)' or 'bagel' --> bagels
- },
- json: function (context: any) {
- return JSON.stringify(context);
+// function to construct __dirname with ES module
+const getLocalesPath = () => {
+ const __filename = fileURLToPath(import.meta.url);
+ const __dirname = dirname(__filename);
+ return path.join(__dirname, '..', 'locales');
+};
+
+async function initializeApp() {
+ // Cookies //
+ app.use(cookieParser());
+
+ // i18next configuration
+ await i18next
+ .use(Backend)
+ .use(LanguageDetector)
+ .init({
+ backend: {
+ loadPath: path.join(getLocalesPath(), '{{lng}}.json'),
+ },
+ fallbackLng: 'en',
+ preload: ['en', 'ja'],
+ supportedLngs: ['en', 'ja'],
+ nonExplicitSupportedLngs: true,
+ load: 'languageOnly',
+ debug: false,
+ detection: {
+ order: ['header', 'cookie'],
+ lookupHeader: 'accept-language',
+ lookupCookie: 'i18next',
+ caches: ['cookie']
+ },
+ interpolation: {
+ escapeValue: false
+ }
+ });
+
+ app.use(handle(i18next));
+
+ // to Switch language
+ app.use((req, res, next) => {
+ const currentLanguage = i18next.language;
+ i18next.changeLanguage(req.language);
+ const newLanguage = i18next.language;
+// Uncomment for debugging
+// console.log('Language Change:', {
+// header: req.headers['accept-language'],
+// detected: req.language,
+// currentLanguage: currentLanguage,
+// newLanguage: newLanguage
+// });
+ next();
+ });
+
+// Uncomment for debugging
+// app.use((req, res, next) => {
+// console.log('Language Detection:', {
+// header: req.headers['accept-language'],
+// detected: req.language,
+// i18next: i18next.language
+// });
+// next();
+// });
+
+ // View engine //
+ const hbsInstance = createHandlebars({
+ defaultLayout: "main",
+ partialsDir: ["views/partials/"],
+ layoutsDir: "views/layouts/",
+ helpers: {
+ // add i18next helpers
+ ...getI18nHelpers(),
+ plural: function (key: string, count: number, options: any) { // Register the plural helper
+ const translation = i18next.t(key, { count: count });
+ return translation;
+ },
+ json: function (context: object) {
+ return JSON.stringify(context);
+ }
},
- },
-});
-app.engine("handlebars", hbsInstance.engine);
-app.set("view engine", "handlebars");
-app.set("hbsInstance", hbsInstance);
-
-// Static files //
-app.use(express.static("public"));
-
-// Body parser //
-app.use(express.json({ type: alternateActivityPubContentType }));
-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);
-app.use("/", activitypub);
-app.use("/", event);
-app.use("/", group);
-app.use("/", magicLink);
-app.use("/", routes);
+ });
+
+ const emailService = new EmailService(config, hbsInstance);
+ emailService.verify();
+
+ app.use((req: express.Request, _: express.Response, next: express.NextFunction) => {
+ req.hbsInstance = hbsInstance;
+ req.emailService = emailService;
+ next()
+ return
+ })
+
+ // View engine //
+ app.engine("handlebars", hbsInstance.engine);
+ app.set("view engine", "handlebars");
+ app.set("hbsInstance", hbsInstance);
+
+ // calling i18nextHelper
+ if (typeof handlebarsI18next === 'function') {
+ handlebarsI18next(hbsInstance.handlebars, i18next);
+ } else if (typeof handlebarsI18next.default === 'function') {
+ handlebarsI18next.default(hbsInstance.handlebars, i18next);
+ } else {
+ console.error('handlebars-i18next helper is not properly loaded');
+ }
+
+ i18next.on('languageChanged', function(lng) {
+ moment.locale(lng);
+ });
+
+ app.engine("handlebars", hbsInstance.engine);
+ app.set("view engine", "handlebars");
+ app.set("hbsInstance", hbsInstance);
+
+ // Static files //
+ app.use(express.static("public"));
+
+ // Body parser //
+ app.use(express.json({ type: alternateActivityPubContentType }));
+ app.use(express.json({ type: activityPubContentType }));
+ app.use(express.json({ type: "application/json" }));
+ app.use(express.urlencoded({ extended: true }));
+
+ // Router //
+ app.use("/", staticPages);
+ app.use("/", frontend);
+ app.use("/", activitypub);
+ app.use("/", event);
+ app.use("/", group);
+ app.use("/", magicLink);
+ app.use("/", routes);
+}
+
+initializeApp().catch(console.error);
export default app;
diff --git a/src/helpers.ts b/src/helpers.ts
index 47b380f..5590912 100644
--- a/src/helpers.ts
+++ b/src/helpers.ts
@@ -1,5 +1,8 @@
-import moment from "moment-timezone";
-import icalGenerator from "ical-generator";
+import mongoose from 'mongoose';
+import moment from 'moment-timezone';
+import icalGenerator from 'ical-generator';
+import i18next from 'i18next';
+import handlebars from 'handlebars';
import Log from "./models/Log.js";
import { getConfig } from "./lib/config.js";
import { IEvent } from "./models/Event.js";
@@ -10,41 +13,61 @@ const siteName = config.general.site_name;
// LOGGING
export function addToLog(process: string, status: string, message: string) {
- const logEntry = {
- status,
- process,
- message,
- timestamp: new Date(),
- };
- new Log(logEntry).save().catch(() => {
- console.log("Error saving log entry!");
- });
+ const logEntry = {
+ status,
+ process,
+ message,
+ timestamp: new Date(),
+ };
+ new Log(logEntry).save().catch(() => {
+ console.log("Error saving log entry!");
+ });
}
-export function exportICal(events: IEvent[], calendarName: string) {
- if (!events || events.length < 1) return;
+export function exportIcal(events: IEvent | IEvent[], calendarName?: string) { // Ical -> ICal
+ // Create a new icalGenerator... generator
+ const cal = icalGenerator({
+ name: calendarName || siteName,
+ timezone: 'UTC'
+ });
- // Create a new icalGenerator... generator
- const cal = icalGenerator({
- name: calendarName || siteName,
- });
- events.forEach((event) => {
- // Add the event to the generator
- cal.createEvent({
- start: moment.tz(event.start, event.timezone),
- end: moment.tz(event.end, event.timezone),
- timezone: event.timezone,
- summary: event.name,
- description: event.description,
- organizer: {
- name: event.hostName || "Anonymous",
- email: event.creatorEmail || "anonymous@anonymous.com",
- },
- location: event.location,
- url: "https://" + domain + "/" + event.id,
- });
+ const eventArray = Array.isArray(events) ? events : [events];
+ eventArray.forEach(event => {
+ cal.createEvent({
+ start: moment.tz(event.start, event.timezone),
+ end: moment.tz(event.end, event.timezone),
+ timezone: event.timezone,
+ summary: event.name,
+ description: event.description,
+ organizer: {
+ name: event.hostName || "Anonymous",
+ email: event.creatorEmail || 'anonymous@anonymous.com',
+ },
+ location: event.location,
+ url: 'https://' + domain + '/' + event.id
});
- // Stringify it!
- const string = cal.toString();
- return string;
+ });
+
+ return cal.toString();
+}
+
+interface I18nHelpers {
+ t: (key: string, options?: object) => string;
+ tn: (key: string, options?: object) => string;
+ count?: number;
+}
+
+export function getI18nHelpers(): I18nHelpers {
+ return {
+ t: function(key: string, options?: object) {
+ const translation = i18next.t(key, { ...this, ...options });
+ const template = handlebars.compile(translation);
+ return template(this);
+ },
+ tn: function(key: string, options?: object) {
+ const translation = i18next.t(key, { count: this.count, ...options });
+ const template = handlebars.compile(translation);
+ return template(this);
+ }
+ };
}
diff --git a/src/index.d.ts b/src/index.d.ts
index 292e5d3..4811f7f 100644
--- a/src/index.d.ts
+++ b/src/index.d.ts
@@ -1,14 +1,17 @@
import "express";
-import { GathioConfig } from "./lib/config.js";
+import { GathioConfig } from "./lib/config.ts";
+import { EmailService } from "./lib/email.ts";
+import { ExpressHandlebars } from "express-handlebars";
interface Locals {
config: GathioConfig;
}
-declare module "express" {
- export interface Response {
- locals: {
- config?: GathioConfig;
- };
+declare global {
+ namespace Express {
+ interface Request extends Express.Request {
+ hbsInstance: ExpressHandlebars;
+ emailService: EmailService;
+ }
}
-}
+} \ No newline at end of file
diff --git a/src/lib/config.ts b/src/lib/config.ts
index 003a714..35fc42c 100644
--- a/src/lib/config.ts
+++ b/src/lib/config.ts
@@ -3,6 +3,7 @@ import toml from "toml";
import { exitWithError } from "./process.js";
import { Response } from "express";
import { markdownToSanitizedHTML } from "../util/markdown.js";
+import i18next from "i18next";
interface StaticPage {
title: string;
@@ -21,7 +22,7 @@ export interface GathioConfig {
email_logo_url: string;
show_kofi: boolean;
show_public_event_list: boolean;
- mail_service: "nodemailer" | "sendgrid" | "none";
+ mail_service: "nodemailer" | "sendgrid" | "mailgun" | "none";
creator_email_addresses: string[];
};
database: {
@@ -37,6 +38,11 @@ export interface GathioConfig {
sendgrid?: {
api_key: string;
};
+ mailgun?: {
+ api_key: string;
+ api_url: string;
+ domain: string;
+ };
static_pages?: StaticPage[];
}
@@ -110,59 +116,61 @@ export const instanceRules = (): InstanceRule[] => {
rules.push(
config.general.show_public_event_list
? {
- text: "Public events and groups are displayed on the homepage",
- icon: "fas fa-eye",
- }
+ text: i18next.t("config.instancerule.showpubliceventlist-true"),
+ icon: "fas fa-eye",
+ }
: {
- text: "Events and groups can only be accessed by direct link",
- icon: "fas fa-eye-slash",
- },
+ text: i18next.t("config.instancerule.showpubliceventlist-false"),
+ 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: i18next.t("config.instancerule.creatoremail-true"),
+ icon: "fas fa-user-check",
+ }
: {
- text: "Anyone can create events and groups",
- icon: "fas fa-users",
- },
+ text: i18next.t("config.instancerule.creatoremail-false"),
+ 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: i18next.t("config.instancerule.deleteafterdays-true", { days: config.general.delete_after_days } ),
+ icon: "far fa-calendar-times",
+ }
: {
- text: "Events are permanent, and are never automatically deleted",
- icon: "far fa-calendar-check",
- },
+ text: i18next.t("config.instancerule.deleteafterdays-false"),
+ 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: i18next.t("config.instancerule.isfederated-true"),
+ icon: "fas fa-globe",
+ }
: {
- text: "This instance does not federate with other instances",
- icon: "fas fa-globe",
- },
+ text: i18next.t("config.instancerule.isfederated-false"),
+ icon: "fas fa-globe",
+ },
);
return rules;
};
export const instanceDescription = (): string => {
const config = getConfig();
- const defaultInstanceDescription =
- "**{{ siteName }}** is running on Gathio — a simple, federated, privacy-first event hosting platform.";
+ const defaultInstanceDescription = markdownToSanitizedHTML(
+ i18next.t("config.defaultinstancedesc", "Welcome to this Gathio instance!")
+ );
let instanceDescription = defaultInstanceDescription;
+ let instancedescfile = "./static/instance-description-" + i18next.language + ".md";
try {
- if (fs.existsSync("./static/instance-description.md")) {
+ if (fs.existsSync(instancedescfile)) {
const fileBody = fs.readFileSync(
- "./static/instance-description.md",
+ instancedescfile,
"utf-8",
);
instanceDescription = markdownToSanitizedHTML(fileBody);
@@ -179,17 +187,35 @@ export const instanceDescription = (): string => {
}
};
+let _resolvedConfig: GathioConfig | null = null;
// 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 => {
+ if (_resolvedConfig) {
+ return _resolvedConfig;
+ }
+
try {
const config = toml.parse(
fs.readFileSync("./config/config.toml", "utf-8"),
) as GathioConfig;
- return {
+ const resolvedConfig = {
...defaultConfig,
...config,
- };
+ }
+ if (process.env.CYPRESS || process.env.CI) {
+ config.general.mail_service = "none";
+ console.log(
+ "Running in Cypress or CI, not initializing email service.",
+ );
+ } else if (config.general.mail_service === "none") {
+ console.warn(
+ "You have not configured this Gathio instance to send emails! This means that event creators will not receive emails when their events are created, which means they may end up locked out of editing events. Consider setting up an email service.",
+ );
+ }
+
+ _resolvedConfig = resolvedConfig;
+ return resolvedConfig;
} catch {
exitWithError(
"Configuration file not found! Have you renamed './config/config-example.toml' to './config/config.toml'?",
diff --git a/src/lib/email.ts b/src/lib/email.ts
index 7b7a7a1..fc585a1 100644
--- a/src/lib/email.ts
+++ b/src/lib/email.ts
@@ -1,13 +1,16 @@
-import { Request } from "express";
import sgMail from "@sendgrid/mail";
+import sgHelpers from "@sendgrid/helpers";
+import { ExpressHandlebars } from "express-handlebars";
import nodemailer, { Transporter } from "nodemailer";
-import { getConfig } from "./config.js";
+import { GathioConfig, getConfig } from "./config.js";
import SMTPTransport from "nodemailer/lib/smtp-transport/index.js";
import { exitWithError } from "./process.js";
-import { renderTemplate } from "./handlebars.js";
+import Mailgun from "mailgun.js";
+import { IMailgunClient } from "node_modules/mailgun.js/Types/Interfaces/index.js";
+
const config = getConfig();
-type EmailTemplate =
+type EmailTemplateName =
| "addEventAttendee"
| "addEventComment"
| "createEvent"
@@ -16,58 +19,90 @@ type EmailTemplate =
| "deleteEvent"
| "editEvent"
| "eventGroupUpdated"
+ | "removeEventAttendee"
| "subscribed"
| "unattendEvent";
-export const initEmailService = async (): Promise<boolean> => {
- if (process.env.CYPRESS || process.env.CI) {
- console.log(
- "Running in Cypress or CI, not initializing email service.",
- );
- return false;
- }
- switch (config.general.mail_service) {
- case "sendgrid":
- if (!config.sendgrid?.api_key) {
- return exitWithError(
- "Sendgrid is configured as the email service, but no API key is provided. Please provide an API key in the config file.",
- );
+export class EmailService {
+ nodemailerTransporter: Transporter | undefined = undefined;
+ sgMail: typeof sgMail | undefined = undefined;
+ mailgunClient: IMailgunClient | undefined = undefined;
+ hbs: ExpressHandlebars;
+
+ public constructor(config: GathioConfig, hbs: ExpressHandlebars) {
+ this.hbs = hbs;
+ switch (config.general.mail_service) {
+ case "sendgrid": {
+ if (!config.sendgrid?.api_key) {
+ return exitWithError(
+ "Sendgrid is configured as the email service, but no API key is provided. Please provide an API key in the config file.",
+ );
+ }
+ this.sgMail = sgMail;
+ this.sgMail.setApiKey(config.sendgrid.api_key);
+ console.log("Sendgrid is ready to send emails.");
+ break;
}
- sgMail.setApiKey(config.sendgrid.api_key);
- console.log("Sendgrid is ready to send emails.");
- return true;
- case "nodemailer":
- let nodemailerTransporter:Transporter|undefined = undefined;
- if (config.nodemailer?.smtp_url) {
- nodemailerTransporter = nodemailer.createTransport(config.nodemailer?.smtp_url);
- } else {
+ case "mailgun": {
if (
- !config.nodemailer?.smtp_server ||
- !config.nodemailer?.smtp_port
+ !config.mailgun?.api_key ||
+ !config.mailgun?.api_url ||
+ !config.mailgun?.domain
) {
return exitWithError(
- "Nodemailer is configured as the email service, but not all required fields are provided. Please provide all required fields in the config file.",
+ "Mailgun is configured as the email service, but not all required fields are provided. Please provide all required fields in the config file.",
);
}
- const nodemailerConfig = {
- host: config.nodemailer?.smtp_server,
- port: Number(config.nodemailer?.smtp_port) || 587,
- tls: {
- // do not fail on invalid certs
- rejectUnauthorized: false,
- },
- } as SMTPTransport.Options;
+ const mailgun = new Mailgun(FormData);
+ this.mailgunClient = mailgun.client({
+ username: "api",
+ key: config.mailgun.api_key,
+ url: config.mailgun.api_url,
+ });
+ // TODO: Can we verify the Mailgun connection?
+ console.log("Mailgun is ready to send emails.");
+ break;
+ }
+ case "nodemailer": {
+ if (config.nodemailer?.smtp_url) {
+ this.nodemailerTransporter = nodemailer.createTransport(
+ config.nodemailer?.smtp_url,
+ );
+ } else {
+ if (
+ !config.nodemailer?.smtp_server ||
+ !config.nodemailer?.smtp_port
+ ) {
+ return exitWithError(
+ "Nodemailer is configured as the email service, but not all required fields are provided. Please provide all required fields in the config file.",
+ );
+ }
+ const nodemailerConfig = {
+ host: config.nodemailer?.smtp_server,
+ port: Number(config.nodemailer?.smtp_port) || 587,
+ tls: {
+ // do not fail on invalid certs
+ rejectUnauthorized: false,
+ },
+ } as SMTPTransport.Options;
- if (config.nodemailer?.smtp_username) {
- nodemailerConfig.auth = {
- user: config.nodemailer?.smtp_username,
- pass: config.nodemailer?.smtp_password
- };
+ if (config.nodemailer?.smtp_username) {
+ nodemailerConfig.auth = {
+ user: config.nodemailer?.smtp_username,
+ pass: config.nodemailer?.smtp_password,
+ };
+ }
+ this.nodemailerTransporter =
+ nodemailer.createTransport(nodemailerConfig);
}
- nodemailerTransporter = nodemailer.createTransport(nodemailerConfig);
}
+ }
+ }
- const nodemailerVerified = await nodemailerTransporter.verify();
+ public async verify(): Promise<boolean> {
+ if (this.nodemailerTransporter) {
+ const nodemailerVerified =
+ await this.nodemailerTransporter.verify();
if (nodemailerVerified) {
console.log("Nodemailer is ready to send emails.");
return true;
@@ -76,68 +111,72 @@ export const initEmailService = async (): Promise<boolean> => {
"Error verifying Nodemailer transporter. Please check your Nodemailer configuration.",
);
}
- case "none":
- default:
- console.warn(
- "You have not configured this Gathio instance to send emails! This means that event creators will not receive emails when their events are created, which means they may end up locked out of editing events. Consider setting up an email service.",
- );
- return false;
+ }
+ return true;
}
-};
-export const sendEmail = async (
- to: string,
- bcc: string,
- subject: string,
- text: string,
- html?: string,
-): Promise<boolean> => {
- switch (config.general.mail_service) {
- case "sendgrid":
+ public async sendEmail({
+ to,
+ bcc,
+ subject,
+ text,
+ html,
+ }: {
+ to: string | string[];
+ bcc?: string | string[];
+ subject: string;
+ text: string;
+ html?: string;
+ }): Promise<boolean> {
+ if (this.sgMail) {
try {
- await sgMail.send({
+ await this.sgMail.send({
to,
bcc,
from: config.general.email,
- subject: `${config.general.site_name}: ${subject}`,
+ subject,
text,
html,
});
return true;
- } catch (e: any) {
- if (e.response) {
- console.error(e.response.body);
+ } catch (e: unknown | sgHelpers.classes.ResponseError) {
+ if (e instanceof sgHelpers.classes.ResponseError) {
+ console.error("sendgrid error", e.response.body);
} else {
- console.error(e);
+ console.error("sendgrid error", e);
}
return false;
}
- case "nodemailer":
+ } else if (this.mailgunClient) {
try {
- let nodemailerTransporter:Transporter|undefined = undefined;
- if (config.nodemailer?.smtp_url) {
- nodemailerTransporter = nodemailer.createTransport(config.nodemailer?.smtp_url);
- } else {
- const nodemailerConfig = {
- host: config.nodemailer?.smtp_server,
- port: Number(config.nodemailer?.smtp_port) || 587,
- } as SMTPTransport.Options;
-
- if (config.nodemailer?.smtp_username) {
- nodemailerConfig.auth = {
- user: config.nodemailer?.smtp_username,
- pass: config.nodemailer?.smtp_password
- };
- }
-
- nodemailerTransporter = nodemailer.createTransport(nodemailerConfig);
+ if (!config.mailgun?.domain) {
+ return exitWithError(
+ "Mailgun is configured as the email service, but no domain is provided. Please provide a domain in the config file.",
+ );
}
- await nodemailerTransporter.sendMail({
- envelope: {
+ await this.mailgunClient.messages.create(
+ config.mailgun.domain,
+ {
from: config.general.email,
to,
bcc,
+ subject: `${config.general.site_name}: ${subject}`,
+ text,
+ html,
},
+ );
+ return true;
+ } catch (e: any) {
+ if (e.response) {
+ console.error(e.response.body);
+ } else {
+ console.error(e);
+ }
+ return false;
+ }
+ } else if (this.nodemailerTransporter) {
+ try {
+ await this.nodemailerTransporter.sendMail({
from: config.general.email,
to,
bcc,
@@ -150,31 +189,62 @@ export const sendEmail = async (
console.error(e);
return false;
}
- default:
- return false;
+ } else {
+ // no mailer, so noop
+ return true;
+ }
}
-};
-export const sendEmailFromTemplate = async (
- to: string,
- bcc: string,
- subject: string,
- template: EmailTemplate,
- templateData: Record<string, unknown>,
- req: Request,
-): Promise<boolean> => {
- const html = await renderTemplate(req, `${template}/${template}Html`, {
- siteName: config.general.site_name,
- siteLogo: config.general.email_logo_url,
- domain: config.general.domain,
- cache: true,
- layout: "email.handlebars",
- ...templateData,
- });
- const text = await renderTemplate(
- req,
- `${template}/${template}Text`,
- templateData,
- );
- return await sendEmail(to, bcc, subject, text, html);
-};
+ public async sendEmailFromTemplate({
+ to,
+ bcc = "",
+ subject,
+ templateName,
+ templateData = {},
+ }: {
+ to: string | string[];
+ bcc?: string | string[] | undefined;
+ subject: string;
+ templateName: EmailTemplateName;
+ templateData?: object;
+ }): Promise<boolean> {
+ const [html, text] = await Promise.all([
+ this.hbs.renderView(
+ `./views/emails/${templateName}/${templateName}Html.handlebars`,
+ {
+ domain: config.general.domain,
+ contactEmail: config.general.email,
+ siteName: config.general.site_name,
+ mailService: config.general.mail_service,
+ siteLogo: config.general.email_logo_url,
+ isFederated: config.general.is_federated || true,
+ cache: true,
+ layout: "email.handlebars",
+ ...templateData,
+ },
+ ),
+ this.hbs.renderView(
+ `./views/emails/${templateName}/${templateName}Text.handlebars`,
+ {
+ domain: config.general.domain,
+ contactEmail: config.general.email,
+ siteName: config.general.site_name,
+ mailService: config.general.mail_service,
+ siteLogo: config.general.email_logo_url,
+ isFederated: config.general.is_federated || true,
+ cache: true,
+ layout: "email.handlebars",
+ ...templateData,
+ },
+ ),
+ ]);
+
+ return this.sendEmail({
+ to,
+ bcc,
+ subject: `${config.general.site_name}: ${subject}`,
+ text,
+ html,
+ });
+ }
+}
diff --git a/src/lib/event.ts b/src/lib/event.ts
index 334ddf6..bcb7cd9 100644
--- a/src/lib/event.ts
+++ b/src/lib/event.ts
@@ -1,3 +1,4 @@
+import i18next from "i18next";
import { IEventGroup } from "../models/EventGroup.js";
export interface EventListEvent {
@@ -15,7 +16,8 @@ export const bucketEventsByMonth = (
acc: Record<string, any>[],
event: EventListEvent,
) => {
- const month = event.startMoment.format("MMMM YYYY");
+ event.startMoment.locale(i18next.language);
+ const month = event.startMoment.format(i18next.t("common.year-month-format" ));
const matchingBucket = acc.find((bucket) => bucket.title === month);
if (!matchingBucket) {
acc.push({
diff --git a/src/lib/handlebars.ts b/src/lib/handlebars.ts
deleted file mode 100644
index d5a8b6e..0000000
--- a/src/lib/handlebars.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { Request } from "express";
-
-export const renderTemplate = async (
- req: Request,
- templateName: string,
- data: Record<string, unknown>,
-): Promise<string> => {
- return new Promise<string>((resolve, reject) => {
- req.app
- .get("hbsInstance")
- .renderView(
- `./views/emails/${templateName}.handlebars`,
- data,
- (err: any, html: string) => {
- if (err) {
- console.error(err);
- reject(err);
- }
- resolve(html);
- },
- );
- });
-};
diff --git a/src/lib/middleware.ts b/src/lib/middleware.ts
index 5073137..be05c3f 100644
--- a/src/lib/middleware.ts
+++ b/src/lib/middleware.ts
@@ -1,7 +1,7 @@
import { NextFunction, Request, Response } from "express";
import MagicLink from "../models/MagicLink.js";
import getConfig, { GathioConfig } from "../lib/config.js";
-import { deepMerge } from "../util/object.js";
+import { merge as deepMerge } from "ts-deepmerge";
export const checkMagicLink = async (
req: Request,
@@ -62,7 +62,7 @@ export const getConfigMiddleware = (
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);
+ res.locals.config = deepMerge(config, override);
return next();
}
res.locals.config = config;
diff --git a/src/routes.js b/src/routes.js
index e758e6b..eb7a6a1 100755
--- a/src/routes.js
+++ b/src/routes.js
@@ -9,8 +9,6 @@ import crypto from "crypto";
import request from "request";
import niceware from "niceware";
import ical from "ical";
-import sgMail from "@sendgrid/mail";
-import nodemailer from "nodemailer";
import fileUpload from "express-fileupload";
import Jimp from "jimp";
import schedule from "node-schedule";
@@ -24,14 +22,11 @@ import EventGroup from "./models/EventGroup.js";
import path from "path";
import { activityPubContentType } from "./lib/activitypub.js";
import { hashString } from "./util/generator.js";
-import { initEmailService } from "./lib/email.js";
+import i18next from "i18next";
+import { EmailService } from "./lib/email.js";
const config = getConfig();
const domain = config.general.domain;
-const contactEmail = config.general.email;
-const siteName = config.general.site_name;
-const mailService = config.general.mail_service;
-const siteLogo = config.general.email_logo_url;
const isFederated = config.general.is_federated || true;
// This alphabet (used to generate all event, group, etc. IDs) is missing '-'
@@ -42,12 +37,6 @@ const nanoid = customAlphabet(
);
const router = express.Router();
-
-let sendEmails = false;
-initEmailService().then((emailService) => {
- sendEmails = emailService
-});
-
router.use(fileUpload());
// SCHEDULED DELETION
@@ -58,7 +47,10 @@ schedule.scheduleJob("59 23 * * *", function (fireDate) {
return;
}
- const too_old = moment.tz("Etc/UTC").subtract(deleteAfterDays, "days").toDate();
+ const too_old = moment
+ .tz("Etc/UTC")
+ .subtract(deleteAfterDays, "days")
+ .toDate();
console.log(
"Old event deletion running! Deleting all events concluding before ",
too_old,
@@ -81,9 +73,9 @@ schedule.scheduleJob("59 23 * * *", function (fireDate) {
"deleteOldEvents",
"error",
"Attempt to delete old event " +
- id +
- " failed with error: " +
- err,
+ id +
+ " failed with error: " +
+ err,
);
});
};
@@ -100,9 +92,9 @@ schedule.scheduleJob("59 23 * * *", function (fireDate) {
"deleteOldEvents",
"error",
"Attempt to delete event image for old event " +
- event.id +
- " failed with error: " +
- err,
+ event.id +
+ " failed with error: " +
+ err,
);
}
// Image removed
@@ -149,9 +141,9 @@ schedule.scheduleJob("59 23 * * *", function (fireDate) {
"deleteOldEvents",
"error",
"Attempt to delete old event " +
- event.id +
- " failed with error: " +
- err,
+ event.id +
+ " failed with error: " +
+ err,
);
});
@@ -204,9 +196,9 @@ router.post("/deleteimage/:eventID/:editToken", (req, res) => {
"deleteEventImage",
"error",
"Attempt to delete event image for event " +
- req.params.eventID +
- " failed with error: " +
- err,
+ req.params.eventID +
+ " failed with error: " +
+ err,
);
}
// Image removed
@@ -227,9 +219,9 @@ router.post("/deleteimage/:eventID/:editToken", (req, res) => {
"deleteEventImage",
"error",
"Attempt to delete event image for event " +
- req.params.eventID +
- " failed with error: " +
- err,
+ req.params.eventID +
+ " failed with error: " +
+ err,
);
});
},
@@ -271,9 +263,9 @@ router.post("/deleteevent/:eventID/:editToken", (req, res) => {
"deleteEvent",
"error",
"Attempt to delete event " +
- req.params.eventID +
- " failed with error: " +
- err,
+ req.params.eventID +
+ " failed with error: " +
+ err,
);
}
},
@@ -293,9 +285,9 @@ router.post("/deleteevent/:eventID/:editToken", (req, res) => {
"deleteEvent",
"error",
"Attempt to delete event image for event " +
- req.params.eventID +
- " failed with error: " +
- err,
+ req.params.eventID +
+ " failed with error: " +
+ err,
);
}
// Image removed
@@ -303,8 +295,8 @@ router.post("/deleteevent/:eventID/:editToken", (req, res) => {
"deleteEvent",
"success",
"Event " +
- req.params.eventID +
- " deleted",
+ req.params.eventID +
+ " deleted",
);
},
);
@@ -314,82 +306,44 @@ router.post("/deleteevent/:eventID/:editToken", (req, res) => {
});
res.end();
- // Send emails here otherwise they don't exist lol
- if (sendEmails) {
- const attendeeEmails = event.attendees
- .filter(
- (o) =>
- o.status === "attending" &&
- o.email,
- )
- .map((o) => o.email);
- if (attendeeEmails.length) {
- console.log(
- "Sending emails to: " +
+ const attendeeEmails = event?.attendees?.filter(
+ (o) =>
+ o.status === "attending" &&
+ o.email,
+ )
+ .map((o) => o.email || '') || [];
+ if (attendeeEmails.length) {
+ console.log(
+ "Sending emails to: " +
attendeeEmails,
- );
- req.app.get("hbsInstance").renderView(
- "./views/emails/deleteEvent/deleteEventHtml.handlebars",
- {
- siteName,
- siteLogo,
- domain,
- eventName: event.name,
- cache: true,
- layout: "email.handlebars",
- },
- function (err, html) {
- const msg = {
- to: attendeeEmails,
- from: contactEmail,
- subject: `${siteName}: ${event.name} was deleted`,
- html,
- };
- switch (mailService) {
- case "sendgrid":
- sgMail
- .sendMultiple(msg)
- .catch((e) => {
- console.error(
- e.toString(),
- );
- res.status(
- 500,
- ).end();
- });
- break;
- case "nodemailer":
- nodemailerTransporter
- .sendMail(msg)
- .catch((e) => {
- console.error(
- e.toString(),
- );
- res.status(
- 500,
- ).end();
- });
- break;
- }
- },
- );
- } else {
- console.log("Nothing to send!");
- }
+ );
+ req.emailService.sendEmailFromTemplate({
+ to: attendeeEmails,
+ subject: i18next.t("routes.deleteeventsubject", {eventName: event?.name}),
+ templateName: "deleteEvent",
+ templateData: {
+ eventName: event?.name,
+ },
+ }).catch((e) => {
+ console.error('error sending attendee email', e.toString());
+ res.status(500).end();
+ });
+ } else {
+ console.log("Nothing to send!");
}
})
.catch((err) => {
res.send(
"Sorry! Something went wrong (error deleting): " +
- err,
+ err,
);
addToLog(
"deleteEvent",
"error",
"Attempt to delete event " +
- req.params.eventID +
- " failed with error: " +
- err,
+ req.params.eventID +
+ " failed with error: " +
+ err,
);
});
},
@@ -401,8 +355,8 @@ router.post("/deleteevent/:eventID/:editToken", (req, res) => {
"deleteEvent",
"error",
"Attempt to delete event " +
- req.params.eventID +
- " failed with error: token does not match",
+ req.params.eventID +
+ " failed with error: token does not match",
);
}
})
@@ -412,9 +366,9 @@ router.post("/deleteevent/:eventID/:editToken", (req, res) => {
"deleteEvent",
"error",
"Attempt to delete event " +
- req.params.eventID +
- " failed with error: " +
- err,
+ req.params.eventID +
+ " failed with error: " +
+ err,
);
});
});
@@ -447,9 +401,9 @@ router.post("/deleteeventgroup/:eventGroupID/:editToken", (req, res) => {
"deleteEventGroup",
"error",
"Attempt to delete event group " +
- req.params.eventGroupID +
- " failed with error: " +
- err,
+ req.params.eventGroupID +
+ " failed with error: " +
+ err,
);
}
},
@@ -469,9 +423,9 @@ router.post("/deleteeventgroup/:eventGroupID/:editToken", (req, res) => {
"deleteEventGroup",
"error",
"Attempt to delete event image for event group " +
- req.params.eventGroupID +
- " failed with error: " +
- err,
+ req.params.eventGroupID +
+ " failed with error: " +
+ err,
);
}
},
@@ -487,8 +441,8 @@ router.post("/deleteeventgroup/:eventGroupID/:editToken", (req, res) => {
"deleteEventGroup",
"success",
"Event group " +
- req.params.eventGroupID +
- " deleted",
+ req.params.eventGroupID +
+ " deleted",
);
res.writeHead(302, {
Location: "/",
@@ -498,30 +452,30 @@ router.post("/deleteeventgroup/:eventGroupID/:editToken", (req, res) => {
.catch((err) => {
res.send(
"Sorry! Something went wrong (error deleting): " +
- err,
+ err,
);
addToLog(
"deleteEventGroup",
"error",
"Attempt to delete event group " +
- req.params.eventGroupID +
- " failed with error: " +
- err,
+ req.params.eventGroupID +
+ " failed with error: " +
+ err,
);
});
})
.catch((err) => {
res.send(
"Sorry! Something went wrong (error deleting): " +
- err,
+ err,
);
addToLog(
"deleteEventGroup",
"error",
"Attempt to delete event group " +
- req.params.eventGroupID +
- " failed with error: " +
- err,
+ req.params.eventGroupID +
+ " failed with error: " +
+ err,
);
});
} else {
@@ -531,8 +485,8 @@ router.post("/deleteeventgroup/:eventGroupID/:editToken", (req, res) => {
"deleteEventGroup",
"error",
"Attempt to delete event group " +
- req.params.eventGroupID +
- " failed with error: token does not match",
+ req.params.eventGroupID +
+ " failed with error: token does not match",
);
}
})
@@ -542,9 +496,9 @@ router.post("/deleteeventgroup/:eventGroupID/:editToken", (req, res) => {
"deleteEventGroup",
"error",
"Attempt to delete event group " +
- req.params.eventGroupID +
- " failed with error: " +
- err,
+ req.params.eventGroupID +
+ " failed with error: " +
+ err,
);
});
});
@@ -562,9 +516,9 @@ router.post("/attendee/provision", async (req, res) => {
"provisionEventAttendee",
"error",
"Attempt to provision attendee in event " +
- req.query.eventID +
- " failed with error: " +
- e,
+ req.query.eventID +
+ " failed with error: " +
+ e,
);
return res.sendStatus(500);
});
@@ -580,9 +534,9 @@ router.post("/attendee/provision", async (req, res) => {
"provisionEventAttendee",
"error",
"Attempt to provision attendee in event " +
- req.query.eventID +
- " failed with error: " +
- e,
+ req.query.eventID +
+ " failed with error: " +
+ e,
);
return res.sendStatus(500);
});
@@ -618,9 +572,9 @@ router.post("/attendevent/:eventID", async (req, res) => {
"attendEvent",
"error",
"Attempt to attend event " +
- req.params.eventID +
- " failed with error: " +
- e,
+ req.params.eventID +
+ " failed with error: " +
+ e,
);
return res.sendStatus(500);
});
@@ -659,56 +613,38 @@ router.post("/attendevent/:eventID", async (req, res) => {
"attendees.$.name": req.body.attendeeName,
"attendees.$.email": req.body.attendeeEmail,
"attendees.$.number": req.body.attendeeNumber,
- "attendees.$.visibility": !!req.body.attendeeVisible ? "public" : "private",
+ "attendees.$.visibility": req.body.attendeeVisible
+ ? "public"
+ : "private",
},
},
)
.then((event) => {
+ if (!event) {
+ return res.sendStatus(404);
+ }
+
addToLog(
"addEventAttendee",
"success",
"Attendee added to event " + req.params.eventID,
);
- if (sendEmails) {
- if (req.body.attendeeEmail) {
- req.app.get("hbsInstance").renderView(
- "./views/emails/addEventAttendee/addEventAttendeeHtml.handlebars",
- {
- eventID: req.params.eventID,
- siteName,
- siteLogo,
- domain,
- removalPassword: req.body.removalPassword,
- removalPasswordHash: hashString(req.body.removalPassword),
- cache: true,
- layout: "email.handlebars",
- },
- function (err, html) {
- const msg = {
- to: req.body.attendeeEmail,
- from: contactEmail,
- subject: `${siteName}: You're RSVPed to ${event.name}`,
- html,
- };
- switch (mailService) {
- case "sendgrid":
- sgMail.send(msg).catch((e) => {
- console.error(e.toString());
- res.status(500).end();
- });
- break;
- case "nodemailer":
- nodemailerTransporter
- .sendMail(msg)
- .catch((e) => {
- console.error(e.toString());
- res.status(500).end();
- });
- break;
- }
- },
- );
- }
+ if (req.body.attendeeEmail) {
+ req.emailService.sendEmailFromTemplate({
+ to: req.body.attendeeEmail,
+ subject: i18next.t("routes.addeventattendeesubject", {eventName: event?.name}),
+ templateName: "addEventAttendee",
+ templateData:{
+ eventID: req.params.eventID,
+ removalPassword: req.body.removalPassword,
+ removalPasswordHash: hashString(
+ req.body.removalPassword,
+ ),
+ },
+ }).catch((e) => {
+ console.error('error sending addEventAttendee email', e.toString());
+ res.status(500).end();
+ });
}
res.redirect(`/${req.params.eventID}`);
})
@@ -718,9 +654,9 @@ router.post("/attendevent/:eventID", async (req, res) => {
"addEventAttendee",
"error",
"Attempt to add attendee to event " +
- req.params.eventID +
- " failed with error: " +
- error,
+ req.params.eventID +
+ " failed with error: " +
+ error,
);
});
});
@@ -737,54 +673,32 @@ router.get("/oneclickunattendevent/:eventID/:attendeeID", (req, res) => {
) {
return res.sendStatus(200);
}
- Event.updateOne(
+ Event.findOneAndUpdate(
{ id: req.params.eventID },
{ $pull: { attendees: { _id: req.params.attendeeID } } },
)
- .then((response) => {
+ .then((event) => {
+ if (!event) {
+ return res.sendStatus(404);
+ }
addToLog(
"oneClickUnattend",
"success",
"Attendee removed via one click unattend " + req.params.eventID,
);
- if (sendEmails) {
- // currently this is never called because we don't have the email address
- if (req.body.attendeeEmail) {
- req.app.get("hbsInstance").renderView(
- "./views/emails/removeEventAttendee/removeEventAttendeeHtml.handlebars",
- {
- eventName: req.params.eventName,
- siteName,
- domain,
- cache: true,
- layout: "email.handlebars",
- },
- function (err, html) {
- const msg = {
- to: req.body.attendeeEmail,
- from: contactEmail,
- subject: `${siteName}: You have been removed from an event`,
- html,
- };
- switch (mailService) {
- case "sendgrid":
- sgMail.send(msg).catch((e) => {
- console.error(e.toString());
- res.status(500).end();
- });
- break;
- case "nodemailer":
- nodemailerTransporter
- .sendMail(msg)
- .catch((e) => {
- console.error(e.toString());
- res.status(500).end();
- });
- break;
- }
- },
- );
- }
+ // currently this is never called because we don't have the email address
+ if (req.body.attendeeEmail) {
+ req.emailService.sendEmailFromTemplate({
+ to: req.body.attendeeEmail,
+ subject: i18next.t("routes.removeeventattendeesubject"),
+ templateName: "removeEventAttendee",
+ templateData:{
+ eventName: event.name,
+ },
+ }).catch((e) => {
+ console.error('error sending removeEventAttendeeHtml email', e.toString());
+ res.status(500).end();
+ });
}
res.writeHead(302, {
Location: "/" + req.params.eventID,
@@ -797,63 +711,40 @@ router.get("/oneclickunattendevent/:eventID/:attendeeID", (req, res) => {
"removeEventAttendee",
"error",
"Attempt to remove attendee by admin from event " +
- req.params.eventID +
- " failed with error: " +
- err,
+ req.params.eventID +
+ " failed with error: " +
+ err,
);
});
});
router.post("/removeattendee/:eventID/:attendeeID", (req, res) => {
- Event.updateOne(
+ Event.findOneAndUpdate(
{ id: req.params.eventID },
{ $pull: { attendees: { _id: req.params.attendeeID } } },
)
- .then((response) => {
+ .then((event) => {
+ if (!event) {
+ return res.sendStatus(404);
+ }
addToLog(
"removeEventAttendee",
"success",
"Attendee removed by admin from event " + req.params.eventID,
);
- if (sendEmails) {
- // currently this is never called because we don't have the email address
- if (req.body.attendeeEmail) {
- req.app.get("hbsInstance").renderView(
- "./views/emails/removeEventAttendee/removeEventAttendeeHtml.handlebars",
- {
- eventName: req.params.eventName,
- siteName,
- siteLogo,
- domain,
- cache: true,
- layout: "email.handlebars",
- },
- function (err, html) {
- const msg = {
- to: req.body.attendeeEmail,
- from: contactEmail,
- subject: `${siteName}: You have been removed from an event`,
- html,
- };
- switch (mailService) {
- case "sendgrid":
- sgMail.send(msg).catch((e) => {
- console.error(e.toString());
- res.status(500).end();
- });
- break;
- case "nodemailer":
- nodemailerTransporter
- .sendMail(msg)
- .catch((e) => {
- console.error(e.toString());
- res.status(500).end();
- });
- break;
- }
- },
- );
- }
+ // currently this is never called because we don't have the email address
+ if (req.body.attendeeEmail) {
+ req.emailService.sendEmailFromTemplate({
+ to: req.body.attendeeEmail,
+ subject: i18next.t("routes.removeeventattendeesubject"),
+ templateName: "removeEventAttendee",
+ templateData: {
+ eventName: event.name,
+ },
+ }).catch((e) => {
+ console.error('error sending removeEventAttendeeHtml email', e.toString());
+ res.status(500).end();
+ });
}
res.writeHead(302, {
Location: "/" + req.params.eventID,
@@ -866,9 +757,9 @@ router.post("/removeattendee/:eventID/:attendeeID", (req, res) => {
"removeEventAttendee",
"error",
"Attempt to remove attendee by admin from event " +
- req.params.eventID +
- " failed with error: " +
- err,
+ req.params.eventID +
+ " failed with error: " +
+ err,
);
});
});
@@ -893,45 +784,20 @@ router.post("/subscribe/:eventGroupID", (req, res) => {
}
eventGroup.subscribers.push(subscriber);
eventGroup.save();
- if (sendEmails) {
- req.app.get("hbsInstance").renderView(
- "./views/emails/subscribed/subscribedHtml.handlebars",
- {
- eventGroupName: eventGroup.name,
- eventGroupID: eventGroup.id,
- emailAddress: encodeURIComponent(subscriber.email),
- siteName,
- siteLogo,
- domain,
- cache: true,
- layout: "email.handlebars",
- },
- function (err, html) {
- const msg = {
- to: subscriber.email,
- from: contactEmail,
- subject: `${siteName}: You have subscribed to an event group`,
- html,
- };
- switch (mailService) {
- case "sendgrid":
- sgMail.send(msg).catch((e) => {
- console.error(e.toString());
- res.status(500).end();
- });
- break;
- case "nodemailer":
- nodemailerTransporter
- .sendMail(msg)
- .catch((e) => {
- console.error(e.toString());
- res.status(500).end();
- });
- break;
- }
- },
- );
- }
+ req.emailService.sendEmailFromTemplate({
+ to: subscriber.email,
+ subject: i18next.t("routes.subscribedsubject"),
+ templateName: "subscribed",
+ templateData:{
+ eventGroupName: eventGroup.name,
+ eventGroupID: eventGroup.id,
+ emailAddress: encodeURIComponent(subscriber.email),
+ },
+ }).catch((e) => {
+ console.error('error sending removeEventAttendeeHtml email', e.toString());
+ res.status(500).end();
+ });
+
return res.redirect(`/group/${eventGroup.id}`);
})
.catch((error) => {
@@ -939,11 +805,11 @@ router.post("/subscribe/:eventGroupID", (req, res) => {
"addSubscription",
"error",
"Attempt to subscribe " +
- req.body.emailAddress +
- " to event group " +
- req.params.eventGroupID +
- " failed with error: " +
- error,
+ req.body.emailAddress +
+ " to event group " +
+ req.params.eventGroupID +
+ " failed with error: " +
+ error,
);
return res.sendStatus(500);
});
@@ -970,11 +836,11 @@ router.get("/unsubscribe/:eventGroupID", (req, res) => {
"removeSubscription",
"error",
"Attempt to unsubscribe " +
- req.query.email +
- " from event group " +
- req.params.eventGroupID +
- " failed with error: " +
- error,
+ req.query.email +
+ " from event group " +
+ req.params.eventGroupID +
+ " failed with error: " +
+ error,
);
return res.sendStatus(500);
});
@@ -994,7 +860,9 @@ router.post("/post/comment/:eventID", (req, res) => {
id: req.params.eventID,
},
function (err, event) {
- if (!event) return;
+ if (!event) {
+ return res.sendStatus(404);
+ }
event.comments.push(newComment);
event
.save()
@@ -1020,72 +888,40 @@ router.post("/post/comment/:eventID", (req, res) => {
event.followers,
req.params.eventID,
);
- if (sendEmails) {
- Event.findOne({ id: req.params.eventID }).then(
- (event) => {
- const attendeeEmails = event.attendees
- .filter(
- (o) =>
- o.status === "attending" && o.email,
- )
- .map((o) => o.email);
- if (attendeeEmails.length) {
- console.log(
- "Sending emails to: " + attendeeEmails,
- );
- req.app.get("hbsInstance").renderView(
- "./views/emails/addEventComment/addEventCommentHtml.handlebars",
- {
- siteName,
- siteLogo,
- domain,
- eventID: req.params.eventID,
- commentAuthor:
- req.body.commentAuthor,
- cache: true,
- layout: "email.handlebars",
- },
- function (err, html) {
- const msg = {
- to: attendeeEmails,
- from: contactEmail,
- subject: `${siteName}: New comment in ${event.name}`,
- html,
- };
- switch (mailService) {
- case "sendgrid":
- sgMail
- .sendMultiple(msg)
- .catch((e) => {
- console.error(
- e.toString(),
- );
- res.status(
- 500,
- ).end();
- });
- break;
- case "nodemailer":
- nodemailerTransporter
- .sendMail(msg)
- .catch((e) => {
- console.error(
- e.toString(),
- );
- res.status(
- 500,
- ).end();
- });
- break;
- }
- },
- );
- } else {
- console.log("Nothing to send!");
- }
- },
- );
+ if (!event) {
+ return res.sendStatus(404);
}
+
+ Event.findOne({ id: req.params.eventID }).then(
+ (event) => {
+ const attendeeEmails = event.attendees
+ .filter(
+ (o) =>
+ o.status === "attending" && o.email,
+ )
+ .map((o) => o.email || '') || [];
+ if (attendeeEmails.length) {
+ console.log(
+ "Sending emails to: " + attendeeEmails,
+ );
+ req.emailService.sendEmailFromTemplate({
+ to: event?.creatorEmail || config.general.email,
+ bcc: attendeeEmails,
+ subject: i18next.t("routes.addeventcommentsubject", { eventName: event?.name }),
+ templateName: "addEventComment",
+ templateData:{
+ eventID: req.params.eventID,
+ commentAuthor: req.body.commentAuthor,
+ },
+ }).catch((e) => {
+ console.error('error sending removeEventAttendeeHtml email', e.toString());
+ res.status(500).end();
+ });
+ } else {
+ console.log("Nothing to send!");
+ }
+ },
+ );
res.writeHead(302, {
Location: "/" + req.params.eventID,
});
@@ -1097,9 +933,9 @@ router.post("/post/comment/:eventID", (req, res) => {
"addEventComment",
"error",
"Attempt to add comment to event " +
- req.params.eventID +
- " failed with error: " +
- err,
+ req.params.eventID +
+ " failed with error: " +
+ err,
);
});
},
@@ -1120,7 +956,9 @@ router.post("/post/reply/:eventID/:commentID", (req, res) => {
id: req.params.eventID,
},
function (err, event) {
- if (!event) return;
+ if (!event) {
+ return res.sendStatus(404);
+ }
var parentComment = event.comments.id(commentID);
parentComment.replies.push(newReply);
event
@@ -1130,9 +968,9 @@ router.post("/post/reply/:eventID/:commentID", (req, res) => {
"addEventReply",
"success",
"Reply added to comment " +
- commentID +
- " in event " +
- req.params.eventID,
+ commentID +
+ " in event " +
+ req.params.eventID,
);
// broadcast an identical message to all followers, will show in their home timeline
const guidObject = crypto.randomBytes(16).toString("hex");
@@ -1149,71 +987,39 @@ router.post("/post/reply/:eventID/:commentID", (req, res) => {
event.followers,
req.params.eventID,
);
- if (sendEmails) {
- Event.findOne({ id: req.params.eventID }).then(
- (event) => {
- const attendeeEmails = event.attendees
- .filter(
- (o) =>
- o.status === "attending" && o.email,
- )
- .map((o) => o.email);
- if (attendeeEmails.length) {
- console.log(
- "Sending emails to: " + attendeeEmails,
- );
- req.app.get("hbsInstance").renderView(
- "./views/emails/addEventComment/addEventCommentHtml.handlebars",
- {
- siteName,
- siteLogo,
- domain,
- eventID: req.params.eventID,
- commentAuthor: req.body.replyAuthor,
- cache: true,
- layout: "email.handlebars",
- },
- function (err, html) {
- const msg = {
- to: attendeeEmails,
- from: contactEmail,
- subject: `${siteName}: New comment in ${event.name}`,
- html,
- };
- switch (mailService) {
- case "sendgrid":
- sgMail
- .sendMultiple(msg)
- .catch((e) => {
- console.error(
- e.toString(),
- );
- res.status(
- 500,
- ).end();
- });
- break;
- case "nodemailer":
- nodemailerTransporter
- .sendMail(msg)
- .catch((e) => {
- console.error(
- e.toString(),
- );
- res.status(
- 500,
- ).end();
- });
- break;
- }
- },
- );
- } else {
- console.log("Nothing to send!");
- }
- },
- );
- }
+ Event.findOne({ id: req.params.eventID }).then(
+ (event) => {
+ if (!event) {
+ return res.sendStatus(404);
+ }
+ const attendeeEmails = event.attendees
+ .filter(
+ (o) =>
+ o.status === "attending" && o.email,
+ )
+ .map((o) => o.email || '') || [];
+ if (attendeeEmails.length) {
+ console.log(
+ "Sending emails to: " + attendeeEmails,
+ );
+ req.emailService.sendEmailFromTemplate({
+ to: event?.creatorEmail || config.general.email,
+ bcc: attendeeEmails,
+ subject: i18next.t("routes.addeventcommentsubject", { eventName: event.name }),
+ templateName: "addEventComment",
+ templateData: {
+ eventID: req.params.eventID,
+ commentAuthor: req.body.replyAuthor,
+ },
+ }).catch((e) => {
+ console.error('error sending removeEventAttendeeHtml email', e.toString());
+ res.status(500).end();
+ });
+ } else {
+ console.log("Nothing to send!");
+ }
+ },
+ );
res.writeHead(302, {
Location: "/" + req.params.eventID,
});
@@ -1225,11 +1031,11 @@ router.post("/post/reply/:eventID/:commentID", (req, res) => {
"addEventReply",
"error",
"Attempt to add reply to comment " +
- commentID +
- " in event " +
- req.params.eventID +
- " failed with error: " +
- err,
+ commentID +
+ " in event " +
+ req.params.eventID +
+ " failed with error: " +
+ err,
);
});
},
@@ -1265,17 +1071,17 @@ router.post("/deletecomment/:eventID/:commentID/:editToken", (req, res) => {
.catch((err) => {
res.send(
"Sorry! Something went wrong (error deleting): " +
- err,
+ err,
);
addToLog(
"deleteComment",
"error",
"Attempt to delete comment " +
- req.params.commentID +
- "from event " +
- req.params.eventID +
- " failed with error: " +
- err,
+ req.params.commentID +
+ "from event " +
+ req.params.eventID +
+ " failed with error: " +
+ err,
);
});
} else {
@@ -1285,10 +1091,10 @@ router.post("/deletecomment/:eventID/:commentID/:editToken", (req, res) => {
"deleteComment",
"error",
"Attempt to delete comment " +
- req.params.commentID +
- "from event " +
- req.params.eventID +
- " failed with error: token does not match",
+ req.params.commentID +
+ "from event " +
+ req.params.eventID +
+ " failed with error: token does not match",
);
}
})
@@ -1298,11 +1104,11 @@ router.post("/deletecomment/:eventID/:commentID/:editToken", (req, res) => {
"deleteComment",
"error",
"Attempt to delete comment " +
- req.params.commentID +
- "from event " +
- req.params.eventID +
- " failed with error: " +
- err,
+ req.params.commentID +
+ "from event " +
+ req.params.eventID +
+ " failed with error: " +
+ err,
);
});
});
diff --git a/src/routes/event.ts b/src/routes/event.ts
index de5cb4c..84a7c6b 100644
--- a/src/routes/event.ts
+++ b/src/routes/event.ts
@@ -22,15 +22,15 @@ import {
updateActivityPubActor,
updateActivityPubEvent,
} from "../activitypub.js";
-import { sendEmailFromTemplate } from "../lib/email.js";
import crypto from "crypto";
import ical from "ical";
import { markdownToSanitizedHTML } from "../util/markdown.js";
import { checkMagicLink, getConfigMiddleware } from "../lib/middleware.js";
import { getConfig } from "../lib/config.js";
+import i18next from "i18next";
+moment.locale(i18next.language);
const config = getConfig();
-
const storage = multer.memoryStorage();
// Accept only JPEG, GIF or PNG images, up to 10MB
const upload = multer({
@@ -80,8 +80,8 @@ router.post(
});
}
- let eventID = generateEventID();
- let editToken = generateEditToken();
+ const eventID = generateEventID();
+ const editToken = generateEditToken();
let eventImageFilename;
let isPartOfEventGroup = false;
@@ -125,7 +125,7 @@ router.post(
}
// generate RSA keypair for ActivityPub
- let { publicKey, privateKey } = generateRSAKeypair();
+ const { publicKey, privateKey } = generateRSAKeypair();
const event = new Event({
id: eventID,
@@ -193,25 +193,20 @@ router.post(
const savedEvent = await event.save();
addToLog("createEvent", "success", "Event " + eventID + "created");
// Send email with edit link
- if (eventData.creatorEmail && req.app.locals.sendEmails) {
- sendEmailFromTemplate(
- eventData.creatorEmail,
- "",
- `${eventData.eventName}`,
- "createEvent",
- {
+ if (eventData.creatorEmail) {
+ req.emailService.sendEmailFromTemplate({
+ to: eventData.creatorEmail,
+ subject: eventData.eventName,
+ templateName: "createEvent",
+ templateData: {
eventID,
editToken,
- siteName: res.locals.config?.general.site_name,
- siteLogo: res.locals.config?.general.email_logo_url,
- domain: res.locals.config?.general.domain,
- },
- req,
- );
+ }
+ });
}
// If the event was added to a group, send an email to any group
// subscribers
- if (event.eventGroup && req.app.locals.sendEmails) {
+ if (event.eventGroup) {
try {
const eventGroup = await EventGroup.findOne({
_id: event.eventGroup.toString(),
@@ -231,24 +226,18 @@ router.post(
[] as string[],
);
subscribers?.forEach((emailAddress) => {
- sendEmailFromTemplate(
- emailAddress,
- "",
- `New event in ${eventGroup.name}`,
- "eventGroupUpdated",
- {
- siteName: res.locals.config?.general.site_name,
- siteLogo:
- res.locals.config?.general.email_logo_url,
- domain: res.locals.config?.general.domain,
+ req.emailService.sendEmailFromTemplate({
+ to: emailAddress,
+ subject: `New event in ${eventGroup.name}`,
+ templateName: "eventGroupUpdated",
+ templateData: {
eventGroupName: eventGroup.name,
eventName: event.name,
eventID: event.id,
eventGroupID: eventGroup.id,
emailAddress: encodeURIComponent(emailAddress),
- },
- req,
- );
+ }
+ });
});
} catch (err) {
console.error(err);
@@ -256,7 +245,7 @@ router.post(
"createEvent",
"error",
"Attempt to send event group emails failed with error: " +
- err,
+ err,
);
}
}
@@ -332,7 +321,7 @@ router.put(
}
// Token matches
// If there is a new image, upload that first
- let eventID = req.params.eventID;
+ const eventID = req.params.eventID;
let eventImageFilename = event.image;
if (req.file?.buffer) {
Jimp.read(req.file.buffer)
@@ -388,54 +377,54 @@ router.put(
eventGroup: isPartOfEventGroup ? eventGroup?._id : null,
activityPubActor: event.activityPubActor
? updateActivityPubActor(
- JSON.parse(event.activityPubActor),
- eventData.eventDescription,
- eventData.eventName,
- eventData.eventLocation,
- eventImageFilename,
- startUTC,
- endUTC,
- eventData.timezone,
- )
+ JSON.parse(event.activityPubActor),
+ eventData.eventDescription,
+ eventData.eventName,
+ eventData.eventLocation,
+ eventImageFilename,
+ startUTC,
+ endUTC,
+ eventData.timezone,
+ )
: undefined,
activityPubEvent: event.activityPubEvent
? updateActivityPubEvent(
- JSON.parse(event.activityPubEvent),
- eventData.eventName,
- startUTC,
- endUTC,
- eventData.timezone,
- )
+ JSON.parse(event.activityPubEvent),
+ eventData.eventName,
+ startUTC,
+ endUTC,
+ eventData.timezone,
+ )
: undefined,
};
let diffText =
- "<p>This event was just updated with new information.</p><ul>";
+ "<p>" + i18next.t("routes.event.difftext") + "</p><ul>";
let displayDate;
if (event.name !== updatedEvent.name) {
- diffText += `<li>the event name changed to ${updatedEvent.name}</li>`;
+ diffText += `<li>` + i18next.t("routes.event.namechanged", { eventname: updatedEvent.name} ) + `</li>`;
}
if (event.location !== updatedEvent.location) {
- diffText += `<li>the location changed to ${updatedEvent.location}</li>`;
+ diffText += `<li>` + i18next.t("routes.event.locationchanged", { location: updatedEvent.location} ) + `</li>`;
}
if (
event.start.toISOString() !== updatedEvent.start.toISOString()
) {
displayDate = moment
.tz(updatedEvent.start, updatedEvent.timezone)
- .format("dddd D MMMM YYYY h:mm a");
- diffText += `<li>the start time changed to ${displayDate}</li>`;
+ .format(i18next.t("common.datetimeformat"));
+ diffText += `<li>` + i18next.t("routes.event.starttimechanged", { starttime: displayDate }) + `</li>`;
}
if (event.end.toISOString() !== updatedEvent.end.toISOString()) {
displayDate = moment
.tz(updatedEvent.end, updatedEvent.timezone)
- .format("dddd D MMMM YYYY h:mm a");
- diffText += `<li>the end time changed to ${displayDate}</li>`;
+ .format(i18next.t("common.datetimeformat"));
+ diffText += `<li>` + i18next.t("routes.event.endtimechanged", { endtime: displayDate }) + `</li>`;
}
if (event.timezone !== updatedEvent.timezone) {
- diffText += `<li>the time zone changed to ${updatedEvent.timezone}</li>`;
+ diffText += `<li>` + i18next.t("routes.event.timezonechanged", { timezone: updatedEvent.timezone }) + `</li>`;
}
if (event.description !== updatedEvent.description) {
- diffText += `<li>the event description changed</li>`;
+ diffText += `<li>` + i18next.t("routes.event.descriptionchanged") + `</li>`;
}
diffText += `</ul>`;
const updatedEventObject = await Event.findOneAndUpdate(
@@ -452,7 +441,7 @@ router.put(
"Event " + req.params.eventID + " edited",
);
// send update to ActivityPub subscribers
- let attendees = updatedEventObject.attendees?.filter((el) => el.id);
+ const attendees = updatedEventObject.attendees?.filter((el) => el.id);
// broadcast an identical message to all followers, will show in home timeline
const guidObject = crypto.randomBytes(16).toString("hex");
const jsonObject = {
@@ -492,26 +481,20 @@ router.put(
}
}
// Send update to all attendees
- if (req.app.locals.sendEmails) {
- const attendeeEmails = event.attendees
- ?.filter((o) => o.status === "attending" && o.email)
- .map((o) => o.email);
- if (attendeeEmails?.length) {
- sendEmailFromTemplate(
- config.general.email,
- attendeeEmails.join(","),
- `${event.name} was just edited`,
- "editEvent",
- {
- diffText,
- eventID: req.params.eventID,
- siteName: res.locals.config?.general.site_name,
- siteLogo: res.locals.config?.general.email_logo_url,
- domain: res.locals.config?.general.domain,
- },
- req,
- );
- }
+ const attendeeEmails = event.attendees
+ ?.filter((o) => o.status === "attending" && o.email)
+ .map((o) => o.email!);
+ if (attendeeEmails?.length) {
+ req.emailService.sendEmailFromTemplate({
+ to: config.general.email,
+ bcc: attendeeEmails,
+ subject: i18next.t("routes.event.editedsubject", { eventname: event.name}),
+ templateName: "editEvent",
+ templateData: {
+ diffText,
+ eventID: req.params.eventID,
+ },
+ });
}
res.sendStatus(200);
} catch (err) {
@@ -520,9 +503,9 @@ router.put(
"editEvent",
"error",
"Attempt to edit event " +
- req.params.eventID +
- " failed with error: " +
- err,
+ req.params.eventID +
+ " failed with error: " +
+ err,
);
return res.status(500).json({
errors: [
@@ -550,12 +533,12 @@ router.post(
});
}
- let eventID = generateEventID();
- let editToken = generateEditToken();
+ const eventID = generateEventID();
+ const editToken = generateEditToken();
- let iCalObject = ical.parseICS(req.file.buffer.toString("utf8"));
+ const iCalObject = ical.parseICS(req.file.buffer.toString("utf8"));
- let importedEventData = iCalObject[Object.keys(iCalObject)[0]];
+ const importedEventData = iCalObject[Object.keys(iCalObject)[0]];
let creatorEmail: string | undefined;
if (req.body.creatorEmail) {
@@ -611,21 +594,16 @@ router.post(
await event.save();
addToLog("createEvent", "success", `Event ${eventID} created`);
// Send email with edit link
- if (creatorEmail && req.app.locals.sendEmails) {
- sendEmailFromTemplate(
- creatorEmail,
- "",
- `${importedEventData.summary}`,
- "createEvent",
- {
+ if (creatorEmail) {
+ req.emailService.sendEmailFromTemplate({
+ to: creatorEmail,
+ subject: importedEventData.summary || "",
+ templateName: "createEvent",
+ templateData: {
eventID,
editToken,
- siteName: res.locals.config?.general.site_name,
- siteLogo: res.locals.config?.general.email_logo_url,
- domain: res.locals.config?.general.domain,
},
- req,
- );
+ });
}
return res.json({
eventID: eventID,
@@ -692,20 +670,15 @@ router.delete(
"success",
`Attendee removed self from event ${req.params.eventID}`,
);
- if (attendeeEmail && req.app.locals.sendEmails) {
- await sendEmailFromTemplate(
- attendeeEmail,
- "",
- "You have been removed from an event",
- "unattendEvent",
- {
+ if (attendeeEmail) {
+ await req.emailService.sendEmailFromTemplate({
+ to: attendeeEmail,
+ subject: i18next.t("routes.removeeventattendeesubject"),
+ templateName: "unattendEvent",
+ templateData: {
eventID: req.params.eventID,
- siteName: res.locals.config?.general.site_name,
- siteLogo: res.locals.config?.general.email_logo_url,
- domain: res.locals.config?.general.domain,
},
- req,
- );
+ });
}
res.sendStatus(200);
} catch (e) {
@@ -744,20 +717,15 @@ router.get(
);
await event.save();
// Send email to the attendee
- if (req.app.locals.sendEmails && attendee.email) {
- sendEmailFromTemplate(
- attendee.email,
- "",
- `You have been removed from ${event.name}`,
- "unattendEvent",
- {
+ if (attendee.email) {
+ req.emailService.sendEmailFromTemplate({
+ to: attendee.email,
+ subject: `You have been removed from ${event.name}`,
+ templateName: "unattendEvent",
+ templateData: {
event,
- siteName: res.locals.config?.general.site_name,
- siteLogo: res.locals.config?.general.email_logo_url,
- domain: res.locals.config?.general.domain,
},
- req,
- );
+ });
}
return res.redirect(`/${req.params.eventID}?m=unattend`);
},
diff --git a/src/routes/frontend.ts b/src/routes/frontend.ts
index 14bb779..fca14c6 100644
--- a/src/routes/frontend.ts
+++ b/src/routes/frontend.ts
@@ -1,5 +1,4 @@
import { Router, Request, Response } from "express";
-import fs from "fs";
import moment from "moment-timezone";
import { marked } from "marked";
import { markdownToSanitizedHTML, renderPlain } from "../util/markdown.js";
@@ -8,7 +7,7 @@ import {
instanceDescription,
instanceRules,
} from "../lib/config.js";
-import { addToLog, exportICal } from "../helpers.js";
+import { addToLog, exportIcal } from "../helpers.js";
import Event from "../models/Event.js";
import EventGroup, { IEventGroup } from "../models/EventGroup.js";
import {
@@ -19,13 +18,14 @@ import MagicLink from "../models/MagicLink.js";
import { getConfigMiddleware } from "../lib/middleware.js";
import { getMessage } from "../util/messages.js";
import { EventListEvent, bucketEventsByMonth } from "../lib/event.js";
+import i18next from "i18next";
const router = Router();
// Add config middleware to all routes
router.use(getConfigMiddleware);
-router.get("/", (_: Request, res: Response) => {
+router.get("/", (_, res) => {
if (res.locals.config?.general.show_public_event_list) {
return res.redirect("/events");
}
@@ -44,12 +44,12 @@ router.get("/about", (_: Request, res: Response) => {
});
});
-router.get("/new", (req: Request, res: Response) => {
+router.get("/new", (_: 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",
+ title: i18next.t("frontend.newevent"),
...frontendConfig(res),
});
});
@@ -70,12 +70,12 @@ router.get("/new/:magicLinkToken", async (req: Request, res: Response) => {
...frontendConfig(res),
message: {
type: "danger",
- text: "This magic link is invalid or has expired. Please request a new one here.",
+ text: i18next.t("routes.magiclink-invalid"),
},
});
}
res.render("newevent", {
- title: "New event",
+ title: i18next.t("frontend.newevent"),
...frontendConfig(res),
magicLinkToken: req.params.magicLinkToken,
creatorEmail: magicLink.email,
@@ -100,9 +100,9 @@ router.get("/events", async (_: Request, res: Response) => {
name: event.name,
location: event.location,
displayDate: isSameDay
- ? startMoment.format("D MMM YYYY")
- : `${startMoment.format("D MMM YYYY")} - ${endMoment.format(
- "D MMM YYYY",
+ ? startMoment.format("LL")
+ : `${startMoment.format("LL")} - ${endMoment.format(
+ "LL",
)}`,
eventHasConcluded: endMoment.isBefore(moment.tz(event.timezone)),
eventGroup: event.eventGroup as any as IEventGroup,
@@ -132,7 +132,7 @@ router.get("/events", async (_: Request, res: Response) => {
});
res.render("publicEventList", {
- title: "Public events",
+ title: i18next.t("frontend.publicevents"),
upcomingEvents: upcomingEventsInMonthBuckets,
pastEvents: pastEventsInMonthBuckets,
eventGroups: updatedEventGroups,
@@ -154,31 +154,53 @@ router.get("/:eventID", async (req: Request, res: Response) => {
}
const parsedLocation = event.location.replace(/\s+/g, "+");
let displayDate;
+ const dateformat = i18next.t("frontend.dateformat");
+ const timeformat = i18next.t('frontend.timeformat');
if (moment.tz(event.end, event.timezone).isSame(event.start, "day")) {
// Happening during one day
- displayDate =
- moment
- .tz(event.start, event.timezone)
- .format(
- 'dddd D MMMM YYYY [<span class="text-muted">from</span>] h:mm a',
- ) +
- moment
- .tz(event.end, event.timezone)
- .format(
- ' [<span class="text-muted">to</span>] h:mm a [<span class="text-muted">](z)[</span>]',
- );
+ displayDate = i18next.t("frontend.displaydate-sameday",
+ {
+ startdate:
+ moment
+ .tz(event.start, event.timezone)
+ .format(dateformat),
+ starttime:
+ moment
+ .tz(event.start, event.timezone)
+ .format(timeformat),
+ endtime:
+ moment
+ .tz(event.end, event.timezone)
+ .format(timeformat),
+ timezone:
+ moment
+ .tz(event.end, event.timezone)
+ .format('(z)',)
+ });
} else {
- displayDate =
- moment
- .tz(event.start, event.timezone)
- .format(
- 'dddd D MMMM YYYY [<span class="text-muted">at</span>] h:mm a',
- ) +
- moment
- .tz(event.end, event.timezone)
- .format(
- ' [<span class="text-muted">–</span>] dddd D MMMM YYYY [<span class="text-muted">at</span>] h:mm a [<span class="text-muted">](z)[</span>]',
- );
+ displayDate = i18next.t("frontend.displaydate-days",
+ {
+ startdate:
+ moment
+ .tz(event.start, event.timezone)
+ .format(dateformat),
+ starttime:
+ moment
+ .tz(event.start, event.timezone)
+ .format(timeformat),
+ enddate:
+ moment
+ .tz(event.end, event.timezone)
+ .format(dateformat),
+ endtime:
+ moment
+ .tz(event.end, event.timezone)
+ .format(timeformat),
+ timezone:
+ moment
+ .tz(event.end, event.timezone)
+ .format('(z)',)
+ });
}
let eventStartISO = moment.tz(event.start, "Etc/UTC").toISOString();
let eventEndISO = moment.tz(event.end, "Etc/UTC").toISOString();
@@ -257,7 +279,7 @@ router.get("/:eventID", async (req: Request, res: Response) => {
el.id = el._id;
}
if (el.number && el.number > 1) {
- el.name = `${el.name} (${el.number} people)`;
+ el.name = `${el.name} ${i18next.t("frontend.elnumber", { count: el.number })}`;
}
return {
...el,
@@ -429,8 +451,8 @@ router.get("/group/:eventGroupID", async (req: Request, res: Response) => {
.sort("start");
const updatedEvents: EventListEvent[] = events.map((event) => {
- const startMoment = moment.tz(event.start, event.timezone);
- const endMoment = moment.tz(event.end, event.timezone);
+ const startMoment = moment.tz(event.start, event.timezone).locale(i18next.language);
+ const endMoment = moment.tz(event.end, event.timezone).locale(i18next.language);
const isSameDay = startMoment.isSame(endMoment, "day");
return {
@@ -438,10 +460,8 @@ router.get("/group/:eventGroupID", async (req: Request, res: Response) => {
name: event.name,
location: event.location,
displayDate: isSameDay
- ? startMoment.format("D MMM YYYY")
- : `${startMoment.format("D MMM YYYY")} - ${endMoment.format(
- "D MMM YYYY",
- )}`,
+ ? startMoment.format("LL")
+ : `${startMoment.format("LL")} - ${endMoment.format("LL")}`,
eventHasConcluded: endMoment.isBefore(
moment.tz(event.timezone),
),
@@ -546,7 +566,7 @@ router.get(
const events = await Event.find({
eventGroup: eventGroup._id,
}).sort("start");
- const string = exportICal(events, eventGroup.name);
+ const string = exportIcal(events, eventGroup.name);
res.set("Content-Type", "text/calendar").send(string);
}
} catch (err) {
@@ -568,7 +588,7 @@ router.get("/export/event/:eventID", async (req: Request, res: Response) => {
}).populate("eventGroup");
if (event) {
- const string = exportICal([event], event.name);
+ const string = exportIcal([event], event.name);
res.set("Content-Type", "text/calendar").send(string);
}
} catch (err) {
@@ -594,7 +614,7 @@ router.get(
const events = await Event.find({
eventGroup: eventGroup._id,
}).sort("start");
- const string = exportICal(events, eventGroup.name);
+ const string = exportIcal(events, eventGroup.name);
res.set("Content-Type", "text/calendar").send(string);
}
} catch (err) {
diff --git a/src/routes/group.ts b/src/routes/group.ts
index 9f4105c..cc53976 100644
--- a/src/routes/group.ts
+++ b/src/routes/group.ts
@@ -5,7 +5,6 @@ import { validateGroupData } from "../util/validation.js";
import Jimp from "jimp";
import { addToLog } from "../helpers.js";
import EventGroup from "../models/EventGroup.js";
-import { sendEmailFromTemplate } from "../lib/email.js";
import { marked } from "marked";
import { renderPlain } from "../util/markdown.js";
import { checkMagicLink, getConfigMiddleware } from "../lib/middleware.js";
@@ -92,21 +91,16 @@ router.post(
);
// Send email with edit link
- if (groupData.creatorEmail && req.app.locals.sendEmails) {
- sendEmailFromTemplate(
- groupData.creatorEmail,
- "",
- `${eventGroup.name}`,
- "createEventGroup",
- {
+ if (groupData.creatorEmail) {
+ req.emailService.sendEmailFromTemplate({
+ to: groupData.creatorEmail,
+ subject: eventGroup.name,
+ templateName: "createEventGroup",
+ templateData: {
eventGroupID: eventGroup.id,
editToken: eventGroup.editToken,
- siteName: res.locals.config?.general.site_name,
- siteLogo: res.locals.config?.general.email_logo_url,
- domain: res.locals.config?.general.domain,
},
- req,
- );
+ });
}
res.status(200).json({
@@ -182,7 +176,7 @@ router.put(
}
// Token matches
// If there is a new image, upload that first
- let eventGroupID = req.params.eventGroupID;
+ const eventGroupID = req.params.eventGroupID;
let eventGroupImageFilename = eventGroup.image;
if (req.file?.buffer) {
Jimp.read(req.file.buffer)
@@ -228,9 +222,9 @@ router.put(
"editEventGroup",
"error",
"Attempt to edit event group " +
- req.params.eventGroupID +
- " failed with error: " +
- err,
+ req.params.eventGroupID +
+ " failed with error: " +
+ err,
);
return res.status(500).json({
errors: [
diff --git a/src/routes/magicLink.ts b/src/routes/magicLink.ts
index b4afca6..1e0f87b 100644
--- a/src/routes/magicLink.ts
+++ b/src/routes/magicLink.ts
@@ -1,9 +1,9 @@
import { Router, Request, Response } from "express";
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";
+import i18next from "i18next";
const router = Router();
@@ -16,7 +16,7 @@ router.post("/magic-link/event/create", async (req: Request, res: Response) => {
...frontendConfig(res),
message: {
type: "danger",
- text: "Please provide an email address.",
+ text: i18next.t("routes.magiclink.provideemail"),
},
});
return;
@@ -31,7 +31,7 @@ router.post("/magic-link/event/create", async (req: Request, res: Response) => {
...frontendConfig(res),
message: {
type: "success",
- text: "Thanks! If this email address can create events, you should receive an email with a magic link.",
+ text: i18next.t("routes.magiclink.thanks"),
},
});
return;
@@ -48,24 +48,19 @@ router.post("/magic-link/event/create", async (req: Request, res: Response) => {
// 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: res.locals.config?.general.site_name,
- siteLogo: res.locals.config?.general.email_logo_url,
- domain: res.locals.config?.general.domain,
+ req.emailService.sendEmailFromTemplate({
+ to: email,
+ subject: i18next.t("routes.magiclink.mailsubject"),
+ templateName: "createEventMagicLink",
+ templateData: {
+ token
},
- req,
- );
+ });
res.render("createEventMagicLink", {
...frontendConfig(res),
message: {
type: "success",
- text: "Thanks! If this email address can create events, you should receive an email with a magic link.",
+ text: i18next.t("routes.magiclink.thanks"),
},
});
});
diff --git a/src/types/i18next-fs-backend.d.ts b/src/types/i18next-fs-backend.d.ts
new file mode 100644
index 0000000..33714e7
--- /dev/null
+++ b/src/types/i18next-fs-backend.d.ts
@@ -0,0 +1,5 @@
+declare module 'i18next-fs-backend' {
+ import { BackendModule } from 'i18next';
+ const backend: BackendModule;
+ export default backend;
+} \ No newline at end of file
diff --git a/src/util/object.ts b/src/util/object.ts
deleted file mode 100644
index 1ecc89b..0000000
--- a/src/util/object.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-/**
- * 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 a3bea63..42b524a 100644
--- a/src/util/validation.ts
+++ b/src/util/validation.ts
@@ -1,3 +1,4 @@
+import i18next from "i18next";
import moment from "moment-timezone";
type Error = {
@@ -90,26 +91,26 @@ const validateUrl = (url: string) => {
export const validateEventTime = (start: Date, end: Date): Error | boolean => {
if (moment(start).isAfter(moment(end))) {
return {
- message: "Start time must be before end time.",
+ message: i18next.t('util.validation.eventtime.startisafter'),
field: "eventStart",
};
}
if (moment(start).isBefore(moment())) {
return {
- message: "Start time must be in the future.",
+ message: i18next.t('util.validation.eventtime.startisbefore'),
field: "eventStart",
};
}
if (moment(end).isBefore(moment())) {
return {
- message: "End time must be in the future.",
+ message: i18next.t('util.validation.eventtime.endisbefore'),
field: "eventEnd",
};
}
// Duration cannot be longer than 1 year
if (moment(end).diff(moment(start), "years") > 1) {
return {
- message: "Event duration cannot be longer than 1 year.",
+ message: i18next.t("util.validation.eventtime.endyears"),
field: "eventEnd",
};
}
@@ -130,25 +131,25 @@ export const validateEventData = (
const errors: Error[] = [];
if (!validatedData.eventName) {
errors.push({
- message: "Event name is required.",
+ message: i18next.t('util.validation.eventdata.eventname'),
field: "eventName",
});
}
if (!validatedData.eventLocation) {
errors.push({
- message: "Event location is required.",
+ message: i18next.t("util.validation.eventdata.eventlocation"),
field: "eventLocation",
});
}
if (!validatedData.eventStart) {
errors.push({
- message: "Event start time is required.",
+ message: i18next.t("util.validation.eventdata.eventstart"),
field: "eventStart",
});
}
if (!validatedData.eventEnd) {
errors.push({
- message: "Event end time is required.",
+ message: i18next.t("util.validation.eventdata.eventend"),
field: "eventEnd",
});
}
@@ -163,26 +164,26 @@ export const validateEventData = (
}
if (!validatedData.timezone) {
errors.push({
- message: "Event timezone is required.",
+ message: i18next.t("util.validation.eventdata.timezone"),
field: "timezone",
});
}
if (!validatedData.eventDescription) {
errors.push({
- message: "Event description is required.",
+ message: i18next.t("util.validation.eventdata.eventdescription"),
field: "eventDescription",
});
}
if (validatedData.eventGroupBoolean) {
if (!validatedData.eventGroupID) {
errors.push({
- message: "Event group ID is required.",
+ message: i18next.t("util.validation.eventdata.eventgroupboolean"),
field: "eventGroupID",
});
}
if (!validatedData.eventGroupEditToken) {
errors.push({
- message: "Event group edit token is required.",
+ message: i18next.t("util.validation.eventdata.eventgroupedittoken"),
field: "eventGroupEditToken",
});
}
@@ -190,13 +191,13 @@ export const validateEventData = (
if (validatedData.maxAttendeesBoolean) {
if (!validatedData.maxAttendees) {
errors.push({
- message: "Max number of attendees is required.",
+ message: i18next.t("util.validation.eventdata.maxattendeesboolean"),
field: "maxAttendees",
});
}
if (isNaN(validatedData.maxAttendees)) {
errors.push({
- message: "Max number of attendees must be a number.",
+ message: i18next.t("util.validation.eventdata.maxattendees"),
field: "maxAttendees",
});
}
@@ -204,7 +205,7 @@ export const validateEventData = (
if (validatedData.creatorEmail) {
if (!validateEmail(validatedData.creatorEmail)) {
errors.push({
- message: "Email address is invalid.",
+ message: i18next.t("util.validation.eventdata.creatoremail"),
field: "creatorEmail",
});
}
@@ -212,7 +213,7 @@ export const validateEventData = (
if (validatedData.eventURL) {
if (!validateUrl(validatedData.eventURL)) {
errors.push({
- message: "Event link is invalid.",
+ message: i18next.t("util.validation.eventdata.eventurl"),
field: "eventURL",
});
}
@@ -230,20 +231,20 @@ export const validateGroupData = (
const errors: Error[] = [];
if (!groupData.eventGroupName) {
errors.push({
- message: "Event group name is required.",
+ message: i18next.t("util.validation.groupdata.eventgroupname"),
field: "eventGroupName",
});
}
if (!groupData.eventGroupDescription) {
errors.push({
- message: "Event group description is required.",
+ message: i18next.t("util.validation.groupdata.eventgroupdescription"),
field: "eventGroupDescription",
});
}
if (groupData.creatorEmail) {
if (!validateEmail(groupData.creatorEmail)) {
errors.push({
- message: "Email address is invalid.",
+ message: i18next.t("util.validation.groupdata.creatoremail"),
field: "creatorEmail",
});
}
@@ -251,7 +252,7 @@ export const validateGroupData = (
if (groupData.eventGroupURL) {
if (!validateUrl(groupData.eventGroupURL)) {
errors.push({
- message: "Group link is invalid.",
+ message: i18next.t("util.validation.groupdata.eventgroupurl"),
field: "eventGroupURL",
});
}