From 36b3b51a34123ef269533fb0426532929d5da24a Mon Sep 17 00:00:00 2001 From: Raphael Kabo Date: Fri, 6 Oct 2023 15:21:50 +0100 Subject: [PATCH 01/12] Fix return --- src/lib/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/config.ts b/src/lib/config.ts index d235145..dc8e9c8 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -49,7 +49,7 @@ export const getConfig = (): GathioConfig => { console.error( "\x1b[31mConfiguration file not found! Have you renamed './config/config-example.toml' to './config/config.toml'?" ); - process.exit(1); + return process.exit(1); } }; From db431591ccb4e4bb6902f50a7932c66b88f442f4 Mon Sep 17 00:00:00 2001 From: Raphael Kabo Date: Fri, 6 Oct 2023 15:22:15 +0100 Subject: [PATCH 02/12] Add dependencies --- package.json | 1 + pnpm-lock.yaml | 31 +++++++++++++++++-------------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index 1d7dd37..4f1ac74 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ }, "devDependencies": { "@types/express": "^4.17.18", + "@types/node": "^20.8.2", "cypress": "^13.3.0", "eslint": "^8.50.0", "nodemon": "^2.0.22", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b4f8067..293fffe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,7 +34,7 @@ dependencies: version: 0.6.0 ical-generator: specifier: ^1.15.4 - version: 1.15.4(@types/node@20.7.1) + version: 1.15.4(@types/node@20.8.2) jimp: specifier: ^0.16.13 version: 0.16.13 @@ -79,6 +79,9 @@ devDependencies: '@types/express': specifier: ^4.17.18 version: 4.17.18 + '@types/node': + specifier: ^20.8.2 + version: 20.8.2 cypress: specifier: ^13.3.0 version: 13.3.0 @@ -659,13 +662,13 @@ packages: resolution: {integrity: sha512-oyl4jvAfTGX9Bt6Or4H9ni1Z447/tQuxnZsytsCaExKlmJiU8sFgnIBRzJUpKwB5eWn9HuBYlUlVA74q/yN0eQ==} dependencies: '@types/connect': 3.4.36 - '@types/node': 20.7.1 + '@types/node': 20.8.2 dev: true /@types/bson@4.0.5: resolution: {integrity: sha512-vVLwMUqhYJSQ/WKcE60eFqcyuWse5fGH+NMAXHuKrUAPoryq3ATxk5o4bgYNtg5aOM4APVg7Hnb3ASqUYG0PKg==} dependencies: - '@types/node': 20.7.1 + '@types/node': 20.8.2 dev: false /@types/caseless@0.12.3: @@ -675,13 +678,13 @@ packages: /@types/connect@3.4.36: resolution: {integrity: sha512-P63Zd/JUGq+PdrM1lv0Wv5SBYeA2+CORvbrXbngriYY0jzLUWfQMQQxOhjONEz/wlHOAxOdY7CY65rgQdTjq2w==} dependencies: - '@types/node': 20.7.1 + '@types/node': 20.8.2 dev: true /@types/express-serve-static-core@4.17.37: resolution: {integrity: sha512-ZohaCYTgGFcOP7u6aJOhY9uIZQgZ2vxC2yWoArY+FeDXlqeH66ZVBjgvg+RLVAS/DWNq4Ap9ZXu1+SUQiiWYMg==} dependencies: - '@types/node': 20.7.1 + '@types/node': 20.8.2 '@types/qs': 6.9.8 '@types/range-parser': 1.2.5 '@types/send': 0.17.2 @@ -712,7 +715,7 @@ packages: resolution: {integrity: sha512-WcdpPJCakFzcWWD9juKoZbRtQxKIMYF/JIAM4JrNHrMcnJL6/a2NWjXxW7fo9hxboxxkg+icff8d7+WIEvKgYQ==} dependencies: '@types/bson': 4.0.5 - '@types/node': 20.7.1 + '@types/node': 20.8.2 dev: false /@types/node@16.9.1: @@ -723,8 +726,8 @@ packages: resolution: {integrity: sha512-0OVfGupTl3NBFr8+iXpfZ8NR7jfFO+P1Q+IO/q0wbo02wYkP5gy36phojeYWpLQ6WAMjl+VfmqUk2YbUfp0irA==} dev: true - /@types/node@20.7.1: - resolution: {integrity: sha512-LT+OIXpp2kj4E2S/p91BMe+VgGX2+lfO+XTpfXhh+bCk2LkQtHZSub8ewFBMGP5ClysPjTDFa4sMI8Q3n4T0wg==} + /@types/node@20.8.2: + resolution: {integrity: sha512-Vvycsc9FQdwhxE3y3DzeIxuEJbWGDsnrxvMADzTDF/lcdR9/K+AQIeAghTQsHtotg/q0j3WEOYS/jQgSdWue3w==} /@types/qs@6.9.8: resolution: {integrity: sha512-u95svzDlTysU5xecFNTgfFG5RUWu1A9P0VzgpcIiGZA9iraHOdSzcxMxQ55DyeRaGCSxQi7LxXDI4rzq/MYfdg==} @@ -738,7 +741,7 @@ packages: resolution: {integrity: sha512-4mi2hYsvPAhe8RXjk5DKB09sAUzbK68T2XjORehHdWyxFoX2zUnfi1VQ5wU4Md28H/5+uB4DkxY9BS4B87N/0A==} dependencies: '@types/caseless': 0.12.3 - '@types/node': 20.7.1 + '@types/node': 20.8.2 '@types/tough-cookie': 4.0.3 form-data: 2.5.1 dev: false @@ -747,7 +750,7 @@ packages: resolution: {integrity: sha512-aAG6yRf6r0wQ29bkS+x97BIs64ZLxeE/ARwyS6wrldMm3C1MdKwCcnnEwMC1slI8wuxJOpiUH9MioC0A0i+GJw==} dependencies: '@types/mime': 1.3.3 - '@types/node': 20.7.1 + '@types/node': 20.8.2 dev: true /@types/serve-static@1.15.3: @@ -755,7 +758,7 @@ packages: dependencies: '@types/http-errors': 2.0.2 '@types/mime': 3.0.2 - '@types/node': 20.7.1 + '@types/node': 20.8.2 dev: true /@types/sinonjs__fake-timers@8.1.1: @@ -774,7 +777,7 @@ packages: resolution: {integrity: sha512-CHzgNU3qYBnp/O4S3yv2tXPlvMTq0YWSTVg2/JYLqWZGHwwgJGAwd00poay/11asPq8wLFwHzubyInqHIFmmiw==} requiresBuild: true dependencies: - '@types/node': 20.7.1 + '@types/node': 20.8.2 dev: true optional: true @@ -2074,13 +2077,13 @@ packages: engines: {node: '>=8.12.0'} dev: true - /ical-generator@1.15.4(@types/node@20.7.1): + /ical-generator@1.15.4(@types/node@20.8.2): resolution: {integrity: sha512-drXe4RLkfNlvDvdy/E6BUI9p+01L3ySK1ufNEYI9TxNKG9ZA3G60QWoZvD1dtmH4scwDxYu6/sZBPJvYVNrj8A==} engines: {node: '>=6.0.0'} peerDependencies: '@types/node': '>= 8.0.0' dependencies: - '@types/node': 20.7.1 + '@types/node': 20.8.2 moment-timezone: 0.5.43 dev: false From 1f7a23106bcd107a02e7dafbd006062edabbaece Mon Sep 17 00:00:00 2001 From: Raphael Kabo Date: Fri, 6 Oct 2023 16:15:43 +0100 Subject: [PATCH 03/12] Update marked package --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 4f1ac74..847be4a 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "ical": "^0.6.0", "ical-generator": "^1.15.4", "jimp": "^0.16.13", - "marked": "^4.3.0", + "marked": "^9.1.0", "moment-timezone": "^0.5.43", "mongoose": "^5.13.20", "nanoid": "^3.3.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 293fffe..8d4e799 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,8 +39,8 @@ dependencies: specifier: ^0.16.13 version: 0.16.13 marked: - specifier: ^4.3.0 - version: 4.3.0 + specifier: ^9.1.0 + version: 9.1.0 moment-timezone: specifier: ^0.5.43 version: 0.5.43 @@ -2434,9 +2434,9 @@ packages: dev: false optional: true - /marked@4.3.0: - resolution: {integrity: sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==} - engines: {node: '>= 12'} + /marked@9.1.0: + resolution: {integrity: sha512-VZjm0PM5DMv7WodqOUps3g6Q7dmxs9YGiFUZ7a2majzQTTCgX+6S6NAJHPvOhgFBzYz8s4QZKWWMfZKFmsfOgA==} + engines: {node: '>= 16'} hasBin: true dev: false From c52e51fe5dcc22bed5671d3931c76c47130d3da5 Mon Sep 17 00:00:00 2001 From: Raphael Kabo Date: Fri, 6 Oct 2023 16:16:08 +0100 Subject: [PATCH 04/12] Add new Cypress tests --- cypress/e2e/event.cy.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/cypress/e2e/event.cy.ts b/cypress/e2e/event.cy.ts index ef8b249..10266b7 100644 --- a/cypress/e2e/event.cy.ts +++ b/cypress/e2e/event.cy.ts @@ -22,7 +22,7 @@ describe("Events", () => { beforeEach(() => { cy.clearLocalStorage(); - cy.visit("/new/event/public"); + cy.visit("/new"); cy.get("#showNewEventFormButton").click(); cy.get("#eventName").type(eventData.eventName); @@ -95,4 +95,21 @@ describe("Events", () => { cy.get(".dt-duration").should("contain.text", startTime); cy.get(".dt-duration").should("contain.text", endTime); }); + + it("allows you to attend an event", function () { + cy.get("button#attendEvent").click(); + cy.get("#attendeeName").type("Test Attendee"); + cy.get("#attendeeNumber").clear().type("2"); + cy.get("form#attendEventForm").submit(); + cy.get("#attendees-alert").should("contain.text", "8 spots remaining"); + cy.get(".attendeesList").should("contain.text", "Test Attendee (2 people)"); + }); + + it("allows you to comment on an event", function () { + cy.get("#commentAuthor").type("Test Author"); + cy.get("#commentContent").type("Test Comment"); + cy.get("#postComment").click(); + cy.get(".comment").should("contain.text", "Test Author"); + cy.get(".comment").should("contain.text", "Test Comment"); + }); }); From 5980e63de0d71f2168d4c234312f1e3d6ab23d8c Mon Sep 17 00:00:00 2001 From: Raphael Kabo Date: Fri, 6 Oct 2023 16:16:26 +0100 Subject: [PATCH 05/12] Remove unused Handlebars files --- views/login.handlebars | 32 ---------------------- views/partials/sidebar-dropdown.handlebars | 11 -------- views/register.handlebars | 15 ---------- 3 files changed, 58 deletions(-) delete mode 100755 views/login.handlebars delete mode 100755 views/partials/sidebar-dropdown.handlebars delete mode 100755 views/register.handlebars diff --git a/views/login.handlebars b/views/login.handlebars deleted file mode 100755 index a08f5b4..0000000 --- a/views/login.handlebars +++ /dev/null @@ -1,32 +0,0 @@ -

Log in

-
-
- {{#if isPublic}} -

Register or log in to your account to be able to show this event on your profile, edit it, and delete it. Associating an event with an account is optional, but keep in mind that an unassociated event can not be modified in any way and will be automatically deleted 30 days after it concludes.

- {{else if isPrivate}} -

You must register or log in to your account to create a private event.

- {{else if isOrganisation}} -

You must register or log in to an organisation account to create an organisation event.

- {{/if}} -
-
-
- -
- -
-
-
- -
- -
-
-
-
-
- - -

- Register | Forgot password -

diff --git a/views/partials/sidebar-dropdown.handlebars b/views/partials/sidebar-dropdown.handlebars deleted file mode 100755 index 9f9e564..0000000 --- a/views/partials/sidebar-dropdown.handlebars +++ /dev/null @@ -1,11 +0,0 @@ - \ No newline at end of file diff --git a/views/register.handlebars b/views/register.handlebars deleted file mode 100755 index 1e097c1..0000000 --- a/views/register.handlebars +++ /dev/null @@ -1,15 +0,0 @@ -
- -
- -
-
-
- -
- -
-
-
-
-
\ No newline at end of file From 452d5b0b71ad22d149c61920baddba8377974752 Mon Sep 17 00:00:00 2001 From: Raphael Kabo Date: Fri, 6 Oct 2023 16:16:48 +0100 Subject: [PATCH 06/12] Tweak IAttendee --- src/models/Event.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/models/Event.ts b/src/models/Event.ts index fb8630c..416379a 100644 --- a/src/models/Event.ts +++ b/src/models/Event.ts @@ -1,13 +1,14 @@ import mongoose from "mongoose"; export interface IAttendee { - name?: string; + name: string; status?: string; email?: string; removalPassword?: string; id?: string; number?: number; created?: Date; + _id: string; } export interface IReply { From 44cd1d5b4ee6bfbccffbf6c2448b684a7afd31de Mon Sep 17 00:00:00 2001 From: Raphael Kabo Date: Fri, 6 Oct 2023 16:17:03 +0100 Subject: [PATCH 07/12] Add config util file --- src/util/config.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/util/config.ts diff --git a/src/util/config.ts b/src/util/config.ts new file mode 100644 index 0000000..a012398 --- /dev/null +++ b/src/util/config.ts @@ -0,0 +1,17 @@ +import getConfig from "../lib/config.js"; + +const config = getConfig(); + +interface FrontendConfig { + domain: string; + email: string; + siteName: string; + showKofi: boolean; +} + +export const frontendConfig = (): FrontendConfig => ({ + domain: config.general.domain, + email: config.general.email, + siteName: config.general.site_name, + showKofi: config.general.show_kofi, +}); From c64b6febe5298219858bdc7ad27c3dfa1117a4de Mon Sep 17 00:00:00 2001 From: Raphael Kabo Date: Fri, 6 Oct 2023 16:17:14 +0100 Subject: [PATCH 08/12] Add util files --- src/util/markdown.ts | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 src/util/markdown.ts diff --git a/src/util/markdown.ts b/src/util/markdown.ts new file mode 100644 index 0000000..b1577d9 --- /dev/null +++ b/src/util/markdown.ts @@ -0,0 +1,44 @@ +// Extra marked renderer (used to render plaintext event description for page metadata) +// Adapted from https://dustinpfister.github.io/2017/11/19/nodejs-marked/ + +import { marked } from "marked"; + +// ? to ? helper +function htmlEscapeToText(text: string) { + return text.replace(/\&\#[0-9]*;|&/g, function (escapeCode) { + if (escapeCode.match(/amp/)) { + return "&"; + } + const code = escapeCode.match(/[0-9]+/); + return String.fromCharCode(Number(code)); + }); +} + +export const renderPlain = () => { + var render = new marked.Renderer(); + // render just the text of a link, strong, em + render.link = function (href, title, text) { + return text; + }; + render.strong = function (text) { + return text; + }; + render.em = function (text) { + return text; + }; + // render just the text of a paragraph + render.paragraph = function (text) { + return htmlEscapeToText(text) + "\r\n"; + }; + // render nothing for headings, images, and br + render.heading = function (text, level) { + return ""; + }; + render.image = function (href, title, text) { + return ""; + }; + render.br = function () { + return ""; + }; + return render; +}; From 115210bfd9a5ae7bb8b516ce0d2cf3d9042dead7 Mon Sep 17 00:00:00 2001 From: Raphael Kabo Date: Fri, 6 Oct 2023 16:18:13 +0100 Subject: [PATCH 09/12] refactor: frontend routes into TS file, new event path --- scripts/docker-test.sh | 2 +- src/app.ts | 5 +- src/routes.js | 294 +------------------------ src/routes/frontend.ts | 220 ++++++++++++++++++ views/optionsform.handlebars | 2 +- views/partials/neweventform.handlebars | 12 - views/partials/sidebar.handlebars | 2 +- 7 files changed, 229 insertions(+), 308 deletions(-) create mode 100644 src/routes/frontend.ts diff --git a/scripts/docker-test.sh b/scripts/docker-test.sh index 1cba254..f071740 100755 --- a/scripts/docker-test.sh +++ b/scripts/docker-test.sh @@ -15,6 +15,6 @@ trap cleanup 0 docker-compose up --build & while [[ "$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/)" -ne "200" ]]; do sleep 5; done -curl -v http://localhost:3000/new/event/public +curl -v http://localhost:3000/new cleanup \ No newline at end of file diff --git a/src/app.ts b/src/app.ts index 32e89b6..f49092c 100755 --- a/src/app.ts +++ b/src/app.ts @@ -1,7 +1,9 @@ import express from "express"; -import routes from "./routes.js"; import hbs from "express-handlebars"; +import routes from "./routes.js"; +import frontend from "./routes/frontend.js"; + const app = express(); // View engine // @@ -39,6 +41,7 @@ app.use(express.json({ type: "application/activity+json" })); // support json en app.use(express.urlencoded({ extended: true })); // Router // +app.use("/", frontend); app.use("/", routes); export default app; diff --git a/src/routes.js b/src/routes.js index 55436c9..be0dcde 100755 --- a/src/routes.js +++ b/src/routes.js @@ -51,47 +51,6 @@ const nanoid = customAlphabet( const router = express.Router(); -// Extra marked renderer (used to render plaintext event description for page metadata) -// Adapted from https://dustinpfister.github.io/2017/11/19/nodejs-marked/ -// ? to ? helper -function htmlEscapeToText(text) { - return text.replace(/\&\#[0-9]*;|&/g, function (escapeCode) { - if (escapeCode.match(/amp/)) { - return "&"; - } - return String.fromCharCode(escapeCode.match(/[0-9]+/)); - }); -} - -function render_plain() { - var render = new marked.Renderer(); - // render just the text of a link, strong, em - render.link = function (href, title, text) { - return text; - }; - render.strong = function (text) { - return text; - }; - render.em = function (text) { - return text; - }; - // render just the text of a paragraph - render.paragraph = function (text) { - return htmlEscapeToText(text) + "\r\n"; - }; - // render nothing for headings, images, and br - render.heading = function (text, level) { - return ""; - }; - render.image = function (href, title, text) { - return ""; - }; - render.br = function () { - return ""; - }; - return render; -} - let sendEmails = false; let nodemailerTransporter; if (config.general.mail_service) { @@ -224,46 +183,6 @@ schedule.scheduleJob("59 23 * * *", function (fireDate) { // old (they're not going to become active) }); -// FRONTEND ROUTES - -router.get("/", (req, res) => { - res.render("home", { - domain, - email: contactEmail, - siteName, - showKofi, - }); -}); - -router.get("/new", (req, res) => { - res.render("home"); -}); - -router.get("/new/event", (req, res) => { - res.render("newevent", { - domain: domain, - email: contactEmail, - siteName: siteName, - }); -}); -router.get("/new/event/public", (req, res) => { - let isPrivate = false; - let isPublic = true; - let isOrganisation = false; - let isUnknownType = false; - res.render("newevent", { - title: "New event", - isPrivate: isPrivate, - isPublic: isPublic, - isOrganisation: isOrganisation, - isUnknownType: isUnknownType, - eventType: "public", - domain: domain, - email: contactEmail, - siteName: siteName, - }); -}); - // return the JSON for the featured/pinned post for this event router.get("/:eventID/featured", (req, res) => { if (!isFederated) return res.sendStatus(404); @@ -390,216 +309,6 @@ router.get("/.well-known/webfinger", (req, res) => { } }); -router.get("/:eventID", (req, res) => { - Event.findOne({ - id: req.params.eventID, - }) - .lean() // Required, see: https://stackoverflow.com/questions/59690923/handlebars-access-has-been-denied-to-resolve-the-property-from-because-it-is - .populate("eventGroup") - .then((event) => { - if (event) { - const parsedLocation = event.location.replace(/\s+/g, "+"); - let displayDate; - 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 [from] h:mm a' - ) + - moment - .tz(event.end, event.timezone) - .format( - ' [to] h:mm a [](z)[]' - ); - } else { - displayDate = - moment - .tz(event.start, event.timezone) - .format( - 'dddd D MMMM YYYY [at] h:mm a' - ) + - moment - .tz(event.end, event.timezone) - .format( - ' [] dddd D MMMM YYYY [at] h:mm a [](z)[]' - ); - } - let eventStartISO = moment.tz(event.start, "Etc/UTC").toISOString(); - let eventEndISO = moment.tz(event.end, "Etc/UTC").toISOString(); - let parsedStart = moment - .tz(event.start, event.timezone) - .format("YYYYMMDD[T]HHmmss"); - let parsedEnd = moment - .tz(event.end, event.timezone) - .format("YYYYMMDD[T]HHmmss"); - let eventHasConcluded = false; - if ( - moment - .tz(event.end, event.timezone) - .isBefore(moment.tz(event.timezone)) - ) { - eventHasConcluded = true; - } - let eventHasBegun = false; - if ( - moment - .tz(event.start, event.timezone) - .isBefore(moment.tz(event.timezone)) - ) { - eventHasBegun = true; - } - let fromNow = moment.tz(event.start, event.timezone).fromNow(); - let parsedDescription = marked.parse(event.description); - let eventEditToken = event.editToken; - - let escapedName = event.name.replace(/\s+/g, "+"); - - let eventHasCoverImage = false; - if (event.image) { - eventHasCoverImage = true; - } else { - eventHasCoverImage = false; - } - let eventHasHost = false; - if (event.hostName) { - eventHasHost = true; - } else { - eventHasHost = false; - } - let firstLoad = false; - if (event.firstLoad === true) { - firstLoad = true; - Event.findOneAndUpdate( - { id: req.params.eventID }, - { firstLoad: false }, - function (err, raw) { - if (err) { - res.send(err); - } - } - ); - } - let editingEnabled = false; - if (Object.keys(req.query).length !== 0) { - if (!req.query.e) { - editingEnabled = false; - console.log("No edit token set"); - } else { - if (req.query.e === eventEditToken) { - editingEnabled = true; - } else { - editingEnabled = false; - } - } - } - let eventAttendees = event.attendees - .sort((a, b) => (a.name > b.name ? 1 : b.name > a.name ? -1 : 0)) - .map((el) => { - if (!el.id) { - el.id = el._id; - } - if (el.number > 1) { - el.name = `${el.name} (${el.number} people)`; - } - return el; - }) - .filter((obj, pos, arr) => { - return ( - obj.status === "attending" && - arr.map((mapObj) => mapObj.id).indexOf(obj.id) === pos - ); - }); - - let spotsRemaining, noMoreSpots; - let numberOfAttendees = eventAttendees.reduce((acc, attendee) => { - if (attendee.status === "attending") { - return acc + attendee.number || 1; - } - return acc; - }, 0); - if (event.maxAttendees) { - spotsRemaining = event.maxAttendees - numberOfAttendees; - if (spotsRemaining <= 0) { - noMoreSpots = true; - } - } - let metadata = { - title: event.name, - description: marked - .parse(event.description, { renderer: render_plain() }) - .split(" ") - .splice(0, 40) - .join(" ") - .trim(), - image: eventHasCoverImage - ? `https://${domain}/events/` + event.image - : null, - url: `https://${domain}/` + req.params.eventID, - }; - if ( - req.headers.accept && - (req.headers.accept.includes("application/activity+json") || - req.headers.accept.includes("application/json") || - req.headers.accept.includes("application/json+ld")) - ) { - res - .header("Content-Type", "application/activity+json") - .send(JSON.parse(event.activityPubActor)); - } else { - res.set("X-Robots-Tag", "noindex"); - res.render("event", { - domain: domain, - isFederated: isFederated, - email: contactEmail, - title: event.name, - escapedName: escapedName, - eventData: event, - eventAttendees: eventAttendees, - numberOfAttendees, - spotsRemaining: spotsRemaining, - noMoreSpots: noMoreSpots, - eventStartISO: eventStartISO, - eventEndISO: eventEndISO, - parsedLocation: parsedLocation, - parsedStart: parsedStart, - parsedEnd: parsedEnd, - displayDate: displayDate, - fromNow: fromNow, - timezone: event.timezone, - parsedDescription: parsedDescription, - editingEnabled: editingEnabled, - eventHasCoverImage: eventHasCoverImage, - eventHasHost: eventHasHost, - firstLoad: firstLoad, - eventHasConcluded: eventHasConcluded, - eventHasBegun: eventHasBegun, - metadata: metadata, - siteName: siteName, - }); - } - } else { - res.status(404); - res.render("404", { url: req.url }); - } - }) - .catch((err) => { - addToLog( - "displayEvent", - "error", - "Attempt to display event " + - req.params.eventID + - " failed with error: " + - err - ); - console.log(err); - res.status(404); - res.render("404", { url: req.url }); - return; - }); -}); - router.get("/:eventID/followers", (req, res) => { if (!isFederated) return res.sendStatus(404); const eventID = req.params.eventID; @@ -917,7 +626,7 @@ router.post("/newevent", async (req, res) => { const event = new Event({ id: eventID, - type: req.body.eventType, + type: "public", // This is for backwards compatibility name: req.body.eventName, location: req.body.eventLocation, start: startUTC, @@ -1082,6 +791,7 @@ router.post("/newevent", async (req, res) => { res.end(); }) .catch((err) => { + console.error(err); res.status(500).send("Database error, please try again :( - " + err); addToLog( "createEvent", diff --git a/src/routes/frontend.ts b/src/routes/frontend.ts new file mode 100644 index 0000000..9dea619 --- /dev/null +++ b/src/routes/frontend.ts @@ -0,0 +1,220 @@ +import { Router, Request, Response } from "express"; +import Event from "../models/Event.js"; +import moment from "moment-timezone"; +import { marked } from "marked"; +import { frontendConfig } from "../util/config.js"; +import { renderPlain } from "../util/markdown.js"; +import getConfig from "../lib/config.js"; +import { addToLog } from "../helpers.js"; + +const config = getConfig(); + +const router = Router(); +router.get("/", (_: Request, res: Response) => { + res.render("home", frontendConfig()); +}); + +router.get("/new", (_: Request, res: Response) => { + res.render("newevent", { + title: "New event", + ...frontendConfig(), + }); +}); + +router.get("/:eventID", async (req: Request, res: Response) => { + try { + const event = await Event.findOne({ + id: req.params.eventID, + }) + .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) { + res.status(404); + res.render("404", { url: req.url }); + return; + } + const parsedLocation = event.location.replace(/\s+/g, "+"); + let displayDate; + 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 [from] h:mm a' + ) + + moment + .tz(event.end, event.timezone) + .format( + ' [to] h:mm a [](z)[]' + ); + } else { + displayDate = + moment + .tz(event.start, event.timezone) + .format( + 'dddd D MMMM YYYY [at] h:mm a' + ) + + moment + .tz(event.end, event.timezone) + .format( + ' [] dddd D MMMM YYYY [at] h:mm a [](z)[]' + ); + } + let eventStartISO = moment.tz(event.start, "Etc/UTC").toISOString(); + let eventEndISO = moment.tz(event.end, "Etc/UTC").toISOString(); + let parsedStart = moment + .tz(event.start, event.timezone) + .format("YYYYMMDD[T]HHmmss"); + let parsedEnd = moment + .tz(event.end, event.timezone) + .format("YYYYMMDD[T]HHmmss"); + let eventHasConcluded = false; + if ( + moment.tz(event.end, event.timezone).isBefore(moment.tz(event.timezone)) + ) { + eventHasConcluded = true; + } + let eventHasBegun = false; + if ( + moment.tz(event.start, event.timezone).isBefore(moment.tz(event.timezone)) + ) { + eventHasBegun = true; + } + let fromNow = moment.tz(event.start, event.timezone).fromNow(); + let parsedDescription = marked.parse(event.description); + let eventEditToken = event.editToken; + + let escapedName = event.name.replace(/\s+/g, "+"); + + let eventHasCoverImage = false; + if (event.image) { + eventHasCoverImage = true; + } else { + eventHasCoverImage = false; + } + let eventHasHost = false; + if (event.hostName) { + eventHasHost = true; + } else { + eventHasHost = false; + } + let firstLoad = false; + if (event.firstLoad === true) { + firstLoad = true; + await Event.findOneAndUpdate( + { id: req.params.eventID }, + { firstLoad: false } + ); + } + let editingEnabled = false; + if (Object.keys(req.query).length !== 0) { + if (!req.query.e) { + editingEnabled = false; + console.log("No edit token set"); + } else { + if (req.query.e === eventEditToken) { + editingEnabled = true; + } else { + editingEnabled = false; + } + } + } + let eventAttendees = event.attendees + ?.sort((a, b) => (a.name > b.name ? 1 : b.name > a.name ? -1 : 0)) + .map((el) => { + if (!el.id) { + el.id = el._id; + } + if (el.number && el.number > 1) { + el.name = `${el.name} (${el.number} people)`; + } + return el; + }) + .filter((obj, pos, arr) => { + return ( + obj.status === "attending" && + arr.map((mapObj) => mapObj.id).indexOf(obj.id) === pos + ); + }); + + let spotsRemaining, noMoreSpots; + let numberOfAttendees = + eventAttendees?.reduce((acc, attendee) => { + if (attendee.status === "attending") { + return acc + (attendee.number || 1); + } + return acc; + }, 0) || 0; + if (event.maxAttendees) { + spotsRemaining = event.maxAttendees - numberOfAttendees; + if (spotsRemaining <= 0) { + noMoreSpots = true; + } + } + let metadata = { + title: event.name, + description: marked + .parse(event.description, { renderer: renderPlain() }) + .split(" ") + .splice(0, 40) + .join(" ") + .trim(), + image: eventHasCoverImage + ? `https://${config.general.domain}/events/` + event.image + : null, + url: `https://${config.general.domain}/` + req.params.eventID, + }; + if ( + req.headers.accept && + (req.headers.accept.includes("application/activity+json") || + req.headers.accept.includes("application/json") || + req.headers.accept.includes("application/json+ld")) + ) { + res + .header("Content-Type", "application/activity+json") + .send(JSON.parse(event.activityPubActor || "{}")); + } else { + res.set("X-Robots-Tag", "noindex"); + res.render("event", { + ...frontendConfig(), + title: event.name, + escapedName: escapedName, + eventData: event, + eventAttendees: eventAttendees, + numberOfAttendees, + spotsRemaining: spotsRemaining, + noMoreSpots: noMoreSpots, + eventStartISO: eventStartISO, + eventEndISO: eventEndISO, + parsedLocation: parsedLocation, + parsedStart: parsedStart, + parsedEnd: parsedEnd, + displayDate: displayDate, + fromNow: fromNow, + timezone: event.timezone, + parsedDescription: parsedDescription, + editingEnabled: editingEnabled, + eventHasCoverImage: eventHasCoverImage, + eventHasHost: eventHasHost, + firstLoad: firstLoad, + eventHasConcluded: eventHasConcluded, + eventHasBegun: eventHasBegun, + metadata: metadata, + }); + } + } catch (err) { + addToLog( + "displayEvent", + "error", + "Attempt to display event " + + req.params.eventID + + " failed with error: " + + err + ); + console.log(err); + res.status(404).render("404", { url: req.url }); + } +}); + +export default router; diff --git a/views/optionsform.handlebars b/views/optionsform.handlebars index a844d12..85ebd9f 100755 --- a/views/optionsform.handlebars +++ b/views/optionsform.handlebars @@ -10,7 +10,7 @@
diff --git a/views/partials/neweventform.handlebars b/views/partials/neweventform.handlebars index d456d2e..3c7e060 100755 --- a/views/partials/neweventform.handlebars +++ b/views/partials/neweventform.handlebars @@ -1,6 +1,5 @@

Create an event

-
@@ -54,17 +53,6 @@ Recommended dimensions (w x h): 920px by 300px.
- {{#unless isPublic}} -
- -
- -
-
-
-
-
- {{/unless}}
diff --git a/views/partials/sidebar.handlebars b/views/partials/sidebar.handlebars index 1aa0f74..980e699 100755 --- a/views/partials/sidebar.handlebars +++ b/views/partials/sidebar.handlebars @@ -3,5 +3,5 @@

Nicer events

- New event + New event
From 2996a4d05d0ea2f8446e2a2a0383979ceec748ae Mon Sep 17 00:00:00 2001 From: Raphael Kabo Date: Fri, 6 Oct 2023 16:25:29 +0100 Subject: [PATCH 10/12] Run Prettier on all files --- .eslintrc.json | 30 +- .github/workflows/ci.yaml | 106 +- .prettierrc.json | 4 +- .travis.yml | 4 +- FEDERATION.md | 60 +- cypress.config.ts | 10 +- cypress/e2e/event.cy.ts | 201 +- cypress/fixtures/example.json | 6 +- cypress/support/commands.ts | 2 +- cypress/support/e2e.ts | 4 +- cypress/tsconfig.json | 14 +- docker-compose.yml | 22 +- package.json | 104 +- pnpm-lock.yaml | 10 +- src/activitypub.js | 2402 ++++++++-------- src/app.ts | 38 +- src/helpers.ts | 64 +- src/lib/config.ts | 82 +- src/models/Event.ts | 586 ++-- src/models/EventGroup.ts | 120 +- src/models/Log.ts | 48 +- src/routes.js | 4942 +++++++++++++++++---------------- src/routes/frontend.ts | 382 +-- src/start.ts | 22 +- src/util/config.ts | 16 +- src/util/markdown.ts | 66 +- tsconfig.json | 40 +- 27 files changed, 4855 insertions(+), 4530 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index d3fb99f..e0d7aa4 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,17 +1,17 @@ { - "env": { - "browser": true, - "es6": true, - "node": true - }, - "extends": "eslint:recommended", - "globals": { - "Atomics": "readonly", - "SharedArrayBuffer": "readonly" - }, - "parserOptions": { - "ecmaVersion": 2018, - "sourceType": "module" - }, - "rules": {} + "env": { + "browser": true, + "es6": true, + "node": true + }, + "extends": "eslint:recommended", + "globals": { + "Atomics": "readonly", + "SharedArrayBuffer": "readonly" + }, + "parserOptions": { + "ecmaVersion": 2018, + "sourceType": "module" + }, + "rules": {} } diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1e0be7a..45f0177 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,67 +1,67 @@ name: CI / Build and test on: - pull_request: - workflow_dispatch: - push: - branches: - - main + pull_request: + workflow_dispatch: + push: + branches: + - main jobs: - install: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 + install: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 - - name: Install pnpm - uses: pnpm/action-setup@v2 - with: - version: 8 + - name: Install pnpm + uses: pnpm/action-setup@v2 + with: + version: 8 - - name: Set up config files - run: cp config/config.example.toml config/config.toml + - name: Set up config files + run: cp config/config.example.toml config/config.toml - - name: Cypress install - uses: cypress-io/github-action@v6 - with: - # Disable running of tests within install job - runTests: false - build: pnpm run build - continue-on-error: true + - name: Cypress install + uses: cypress-io/github-action@v6 + with: + # Disable running of tests within install job + runTests: false + build: pnpm run build + continue-on-error: true - - name: Save build folder - uses: actions/upload-artifact@v3 - with: - name: dist - if-no-files-found: error - path: dist + - name: Save build folder + uses: actions/upload-artifact@v3 + with: + name: dist + if-no-files-found: error + path: dist - cypress-run: - runs-on: ubuntu-latest - needs: install - steps: - - name: Checkout - uses: actions/checkout@v4 + cypress-run: + runs-on: ubuntu-latest + needs: install + steps: + - name: Checkout + uses: actions/checkout@v4 - - name: Install pnpm - uses: pnpm/action-setup@v2 - with: - version: 8 + - name: Install pnpm + uses: pnpm/action-setup@v2 + with: + version: 8 - - name: Set up config files - run: cp config/config.example.toml config/config.toml + - name: Set up config files + run: cp config/config.example.toml config/config.toml - - name: Download the build folder - uses: actions/download-artifact@v3 - with: - name: dist - path: dist + - name: Download the build folder + uses: actions/download-artifact@v3 + with: + name: dist + path: dist - - name: Start MongoDB - uses: supercharge/mongodb-github-action@1.10.0 + - name: Start MongoDB + uses: supercharge/mongodb-github-action@1.10.0 - - name: Cypress run - uses: cypress-io/github-action@v6 - with: - start: pnpm start - browser: chrome \ No newline at end of file + - name: Cypress run + uses: cypress-io/github-action@v6 + with: + start: pnpm start + browser: chrome diff --git a/.prettierrc.json b/.prettierrc.json index 0967ef4..0a02bce 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -1 +1,3 @@ -{} +{ + "tabWidth": 4 +} diff --git a/.travis.yml b/.travis.yml index d6fe659..3f00476 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,7 +2,7 @@ language: shell os: linux services: - - docker + - docker script: - - ./test.sh + - ./test.sh diff --git a/FEDERATION.md b/FEDERATION.md index 4c3a10b..c604246 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -6,18 +6,18 @@ This document is meant to be a reference for all the ActivityPub federation-rela To keep things simple, sometimes you will see things formatted like `Create/Note` or `Delete/Event` or `Undo/Follow`. The thing before the slash is the Activity, and the thing after the slash is the Object inside the Activity, in an `object` property. So these are to be read as follows: -- `Create/Note`: a `Create` activity containing a `Note` in the `object` field -- `Delete/Event`: a `Delete` activity containing an `Event` in the `object` field -- `Undo/Follow`: an `Undo` activity containing a `Follow` in the `object` field +- `Create/Note`: a `Create` activity containing a `Note` in the `object` field +- `Delete/Event`: a `Delete` activity containing an `Event` in the `object` field +- `Undo/Follow`: an `Undo` activity containing a `Follow` in the `object` field When the word "broadcast" is used in this document, it means to send an Activity to individual inbox of each of the followers of a given Actor. This document has four main sections: -- **Federation philosophy** lays out the general model of how this is intended to federate -- **General Actor information** contains the basics of what to expect from our `Actor` objects -- **Inbox behavior** lists every incoming ActivityPub activity that the server recognizes, and tells you what it does in response to that activity, including any other ActivityPub activities it sends back out. -- **Activities triggered from the web app** tells you what circumstances on the web application cause the server to emit ActivityPub activities. (For example, when an event is updated via the web application, it lets all the ActivityPub followers know that the event has been updated.) +- **Federation philosophy** lays out the general model of how this is intended to federate +- **General Actor information** contains the basics of what to expect from our `Actor` objects +- **Inbox behavior** lists every incoming ActivityPub activity that the server recognizes, and tells you what it does in response to that activity, including any other ActivityPub activities it sends back out. +- **Activities triggered from the web app** tells you what circumstances on the web application cause the server to emit ActivityPub activities. (For example, when an event is updated via the web application, it lets all the ActivityPub followers know that the event has been updated.) Please note: there is an unfortunate collision between the English language and the ActivityPub spec that can make this document confusing. When this document uses the word 'event' with a lowercase-e and not in monospace, it refers to the thing that is being tracked in gathio: events that are being organized. When this document uses the word `Event` with a capital E and in monospace, it refers to the [`Event` object defined in the ActivityStreams Vocabulary spec](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-event). @@ -37,29 +37,29 @@ Every event has an Actor. The Actor looks like this: ```json { - "@context": [ - "https://www.w3.org/ns/activitystreams", - "https://w3id.org/security/v1" - ], - "id": "https://DOMAIN/EVENTID", - "type": "Person", - "preferredUsername": "EVENTID", - "inbox": "https://DOMAIN/activitypub/inbox", - "outbox": "https://DOMAIN/EVENTID/outbox", - "followers": "https://DOMAIN/EVENTID/followers", - "summary": "

DESCRIPTION

\n

Location: LOCATION.

Starting DATETIME (human readable).

", - "name": "EVENTNAME", - "featured": "https://DOMAIN/EVENTID/featured", - "publicKey": { - "id": "https://DOMAIN/EVENTID#main-key", - "owner": "https://DOMAIN/EVENTID", - "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nOURPUBLICKEY\n-----END PUBLIC KEY-----\n" - }, - "icon": { - "type": "Image", - "mediaType": "image/jpg", - "url": "https://DOMAIN/events/EVENTID.jpg" - } + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1" + ], + "id": "https://DOMAIN/EVENTID", + "type": "Person", + "preferredUsername": "EVENTID", + "inbox": "https://DOMAIN/activitypub/inbox", + "outbox": "https://DOMAIN/EVENTID/outbox", + "followers": "https://DOMAIN/EVENTID/followers", + "summary": "

DESCRIPTION

\n

Location: LOCATION.

Starting DATETIME (human readable).

", + "name": "EVENTNAME", + "featured": "https://DOMAIN/EVENTID/featured", + "publicKey": { + "id": "https://DOMAIN/EVENTID#main-key", + "owner": "https://DOMAIN/EVENTID", + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nOURPUBLICKEY\n-----END PUBLIC KEY-----\n" + }, + "icon": { + "type": "Image", + "mediaType": "image/jpg", + "url": "https://DOMAIN/events/EVENTID.jpg" + } } ``` diff --git a/cypress.config.ts b/cypress.config.ts index 78eed99..ef63579 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -1,10 +1,10 @@ import { defineConfig } from "cypress"; export default defineConfig({ - e2e: { - baseUrl: "http://localhost:3000", - setupNodeEvents(on, config) { - // implement node event listeners here + e2e: { + baseUrl: "http://localhost:3000", + setupNodeEvents(on, config) { + // implement node event listeners here + }, }, - }, }); diff --git a/cypress/e2e/event.cy.ts b/cypress/e2e/event.cy.ts index 10266b7..833a3c9 100644 --- a/cypress/e2e/event.cy.ts +++ b/cypress/e2e/event.cy.ts @@ -1,115 +1,118 @@ const eventData = { - eventName: "Your Event Name", - eventLocation: "Event Location", - timezone: "Europe/London", - eventDescription: "Event Description", - eventURL: "https://example.com", - imagePath: "path/to/your/image.jpg", // If you have an image to upload - hostName: "Your Name", - creatorEmail: "test@example.com", - eventGroupCheckbox: false, - eventGroupID: "YourEventGroupID", - eventGroupEditToken: "YourEventGroupEditToken", - interactionCheckbox: true, - joinCheckbox: true, - maxAttendeesCheckbox: true, - maxAttendees: 10, - eventStart: "", - eventEnd: "", + eventName: "Your Event Name", + eventLocation: "Event Location", + timezone: "Europe/London", + eventDescription: "Event Description", + eventURL: "https://example.com", + imagePath: "path/to/your/image.jpg", // If you have an image to upload + hostName: "Your Name", + creatorEmail: "test@example.com", + eventGroupCheckbox: false, + eventGroupID: "YourEventGroupID", + eventGroupEditToken: "YourEventGroupEditToken", + interactionCheckbox: true, + joinCheckbox: true, + maxAttendeesCheckbox: true, + maxAttendees: 10, + eventStart: "", + eventEnd: "", }; describe("Events", () => { - beforeEach(() => { - cy.clearLocalStorage(); + beforeEach(() => { + cy.clearLocalStorage(); - cy.visit("/new"); - cy.get("#showNewEventFormButton").click(); + cy.visit("/new"); + cy.get("#showNewEventFormButton").click(); - cy.get("#eventName").type(eventData.eventName); - cy.get("#eventLocation").type(eventData.eventLocation); - cy.get("#eventStart").click(); - // This opens a datepicker, so find the first non-disabled day and click it - cy.get(".datepicker--cell-day:not(.-disabled-)").first().click(); - cy.get("#eventStart").invoke("val").as("eventStart"); - // Click away from the datepicker to close it - cy.get("#eventName").click(); - cy.get("#eventEnd").click(); - // This opens a datepicker, so find the last non-disabled day and click it - cy.get(".datepicker--cell-day:not(.-disabled-)").last().click(); - cy.get("#eventEnd").invoke("val").as("eventEnd"); - // Click away from the datepicker to close it - cy.get("#eventName").click(); - // #timezone is a Select2 dropdown, so select the option you want - cy.get("#timezone").select(eventData.timezone, { force: true }); + cy.get("#eventName").type(eventData.eventName); + cy.get("#eventLocation").type(eventData.eventLocation); + cy.get("#eventStart").click(); + // This opens a datepicker, so find the first non-disabled day and click it + cy.get(".datepicker--cell-day:not(.-disabled-)").first().click(); + cy.get("#eventStart").invoke("val").as("eventStart"); + // Click away from the datepicker to close it + cy.get("#eventName").click(); + cy.get("#eventEnd").click(); + // This opens a datepicker, so find the last non-disabled day and click it + cy.get(".datepicker--cell-day:not(.-disabled-)").last().click(); + cy.get("#eventEnd").invoke("val").as("eventEnd"); + // Click away from the datepicker to close it + cy.get("#eventName").click(); + // #timezone is a Select2 dropdown, so select the option you want + cy.get("#timezone").select(eventData.timezone, { force: true }); - cy.get("#eventDescription").type(eventData.eventDescription); - cy.get("#eventURL").type(eventData.eventURL); - // Upload an image - // if (eventData.imagePath) { - // cy.get("#eventImageUpload").attachFile(eventData.imagePath); - // } + cy.get("#eventDescription").type(eventData.eventDescription); + cy.get("#eventURL").type(eventData.eventURL); + // Upload an image + // if (eventData.imagePath) { + // cy.get("#eventImageUpload").attachFile(eventData.imagePath); + // } - cy.get("#hostName").type(eventData.hostName); - cy.get("#creatorEmail").type(eventData.creatorEmail); + cy.get("#hostName").type(eventData.hostName); + cy.get("#creatorEmail").type(eventData.creatorEmail); - // Check checkboxes based on eventData - if (eventData.eventGroupCheckbox) { - cy.get("#eventGroupCheckbox").check(); - cy.get("#eventGroupID").type(eventData.eventGroupID); - cy.get("#eventGroupEditToken").type(eventData.eventGroupEditToken); - } + // Check checkboxes based on eventData + if (eventData.eventGroupCheckbox) { + cy.get("#eventGroupCheckbox").check(); + cy.get("#eventGroupID").type(eventData.eventGroupID); + cy.get("#eventGroupEditToken").type(eventData.eventGroupEditToken); + } - if (eventData.interactionCheckbox) { - cy.get("#interactionCheckbox").check(); - } + if (eventData.interactionCheckbox) { + cy.get("#interactionCheckbox").check(); + } - if (eventData.joinCheckbox) { - cy.get("#joinCheckbox").check(); - } + if (eventData.joinCheckbox) { + cy.get("#joinCheckbox").check(); + } - if (eventData.maxAttendeesCheckbox) { - cy.get("#maxAttendeesCheckbox").check(); - cy.get("#maxAttendees").type(eventData.maxAttendees.toString()); - } + if (eventData.maxAttendeesCheckbox) { + cy.get("#maxAttendeesCheckbox").check(); + cy.get("#maxAttendees").type(eventData.maxAttendees.toString()); + } - // Submit the form - cy.get("#newEventFormSubmit").click(); - }); - it("creates a new event", function () { - // Check that all the data is correct - cy.get(".p-name").should("have.text", eventData.eventName); - cy.get(".p-location").should("have.text", eventData.eventLocation); - cy.get(".p-summary").should("contain.text", eventData.eventDescription); - cy.get("#hosted-by").should( - "contain.text", - `Hosted by ${eventData.hostName}` - ); - cy.get("#attendees-alert").should("contain.text", "10 spots remaining"); - let [startDate, startTime] = this.eventStart.split(", "); - let [endDate, endTime] = this.eventEnd.split(", "); - // Remove leading zeroes from the times - startTime = startTime.replace(/^0+/, ""); - endTime = endTime.replace(/^0+/, ""); - cy.get(".dt-duration").should("contain.text", startDate); - cy.get(".dt-duration").should("contain.text", endDate); - cy.get(".dt-duration").should("contain.text", startTime); - cy.get(".dt-duration").should("contain.text", endTime); - }); + // Submit the form + cy.get("#newEventFormSubmit").click(); + }); + it("creates a new event", function () { + // Check that all the data is correct + cy.get(".p-name").should("have.text", eventData.eventName); + cy.get(".p-location").should("have.text", eventData.eventLocation); + cy.get(".p-summary").should("contain.text", eventData.eventDescription); + cy.get("#hosted-by").should( + "contain.text", + `Hosted by ${eventData.hostName}`, + ); + cy.get("#attendees-alert").should("contain.text", "10 spots remaining"); + let [startDate, startTime] = this.eventStart.split(", "); + let [endDate, endTime] = this.eventEnd.split(", "); + // Remove leading zeroes from the times + startTime = startTime.replace(/^0+/, ""); + endTime = endTime.replace(/^0+/, ""); + cy.get(".dt-duration").should("contain.text", startDate); + cy.get(".dt-duration").should("contain.text", endDate); + cy.get(".dt-duration").should("contain.text", startTime); + cy.get(".dt-duration").should("contain.text", endTime); + }); - it("allows you to attend an event", function () { - cy.get("button#attendEvent").click(); - cy.get("#attendeeName").type("Test Attendee"); - cy.get("#attendeeNumber").clear().type("2"); - cy.get("form#attendEventForm").submit(); - cy.get("#attendees-alert").should("contain.text", "8 spots remaining"); - cy.get(".attendeesList").should("contain.text", "Test Attendee (2 people)"); - }); + it("allows you to attend an event", function () { + cy.get("button#attendEvent").click(); + cy.get("#attendeeName").type("Test Attendee"); + cy.get("#attendeeNumber").clear().type("2"); + cy.get("form#attendEventForm").submit(); + cy.get("#attendees-alert").should("contain.text", "8 spots remaining"); + cy.get(".attendeesList").should( + "contain.text", + "Test Attendee (2 people)", + ); + }); - it("allows you to comment on an event", function () { - cy.get("#commentAuthor").type("Test Author"); - cy.get("#commentContent").type("Test Comment"); - cy.get("#postComment").click(); - cy.get(".comment").should("contain.text", "Test Author"); - cy.get(".comment").should("contain.text", "Test Comment"); - }); + it("allows you to comment on an event", function () { + cy.get("#commentAuthor").type("Test Author"); + cy.get("#commentContent").type("Test Comment"); + cy.get("#postComment").click(); + cy.get(".comment").should("contain.text", "Test Author"); + cy.get(".comment").should("contain.text", "Test Comment"); + }); }); diff --git a/cypress/fixtures/example.json b/cypress/fixtures/example.json index 02e4254..519902d 100644 --- a/cypress/fixtures/example.json +++ b/cypress/fixtures/example.json @@ -1,5 +1,5 @@ { - "name": "Using fixtures to represent data", - "email": "hello@cypress.io", - "body": "Fixtures are a great way to mock data for responses to routes" + "name": "Using fixtures to represent data", + "email": "hello@cypress.io", + "body": "Fixtures are a great way to mock data for responses to routes" } diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 698b01a..95857ae 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -34,4 +34,4 @@ // visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable // } // } -// } \ No newline at end of file +// } diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index f80f74f..6a173d6 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -14,7 +14,7 @@ // *********************************************************** // Import commands.js using ES2015 syntax: -import './commands' +import "./commands"; // Alternatively you can use CommonJS syntax: -// require('./commands') \ No newline at end of file +// require('./commands') diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json index 83fb87e..dc61836 100644 --- a/cypress/tsconfig.json +++ b/cypress/tsconfig.json @@ -1,8 +1,8 @@ { - "compilerOptions": { - "target": "es5", - "lib": ["es5", "dom"], - "types": ["cypress", "node"] - }, - "include": ["**/*.ts"] -} \ No newline at end of file + "compilerOptions": { + "target": "es5", + "lib": ["es5", "dom"], + "types": ["cypress", "node"] + }, + "include": ["**/*.ts"] +} diff --git a/docker-compose.yml b/docker-compose.yml index 334bc3d..4a87aed 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,16 +1,16 @@ version: "3" volumes: - mongodb_data_db: + mongodb_data_db: services: - gathio: - build: . - links: - - mongo - ports: - - 3000:3000 - mongo: - image: mongo:latest - volumes: - - mongodb_data_db:/data/db + gathio: + build: . + links: + - mongo + ports: + - 3000:3000 + mongo: + image: mongo:latest + volumes: + - mongodb_data_db:/data/db diff --git a/package.json b/package.json index 847be4a..bacccca 100644 --- a/package.json +++ b/package.json @@ -1,54 +1,54 @@ { - "name": "gathio", - "version": "1.3.0", - "description": "", - "main": "index.js", - "type": "module", - "scripts": { - "build": "tsc", - "start": "node dist/start.js", - "dev": "nodemon -e ts,js --watch src --exec \"pnpm run build ; pnpm run start\"", - "test:dev": "pnpm run dev & wait-on http://localhost:3000 && cypress open --e2e --browser chrome", - "test": "pnpm run build || true && pnpm run start & wait-on http://localhost:3000 && cypress run --e2e --browser chrome" - }, - "engines": { - "node": ">=16.16.0" - }, - "keywords": [], - "author": "", - "license": "GPL-3.0-or-later", - "dependencies": { - "@sendgrid/mail": "^6.5.5", - "cors": "^2.8.5", - "express": "^4.18.2", - "express-fileupload": "^1.4.1", - "express-handlebars": "^6.0.7", - "express-session": "^1.17.3", - "express-validator": "^6.15.0", - "generate-rsa-keypair": "^0.2.1", - "ical": "^0.6.0", - "ical-generator": "^1.15.4", - "jimp": "^0.16.13", - "marked": "^9.1.0", - "moment-timezone": "^0.5.43", - "mongoose": "^5.13.20", - "nanoid": "^3.3.6", - "niceware": "^3.0.0", - "node-schedule": "^1.3.3", - "nodemailer": "^6.9.5", - "randomstring": "^1.3.0", - "request": "^2.88.2", - "sanitize-html": "^2.11.0", - "toml": "^3.0.0", - "wait-on": "^7.0.1" - }, - "devDependencies": { - "@types/express": "^4.17.18", - "@types/node": "^20.8.2", - "cypress": "^13.3.0", - "eslint": "^8.50.0", - "nodemon": "^2.0.22", - "prettier": "^2.8.8", - "typescript": "5.1.6" - } + "name": "gathio", + "version": "1.3.0", + "description": "", + "main": "index.js", + "type": "module", + "scripts": { + "build": "tsc", + "start": "node dist/start.js", + "dev": "nodemon -e ts,js --watch src --exec \"pnpm run build ; pnpm run start\"", + "test:dev": "pnpm run dev & wait-on http://localhost:3000 && cypress open --e2e --browser chrome", + "test": "pnpm run build || true && pnpm run start & wait-on http://localhost:3000 && cypress run --e2e --browser chrome" + }, + "engines": { + "node": ">=16.16.0" + }, + "keywords": [], + "author": "", + "license": "GPL-3.0-or-later", + "dependencies": { + "@sendgrid/mail": "^6.5.5", + "cors": "^2.8.5", + "express": "^4.18.2", + "express-fileupload": "^1.4.1", + "express-handlebars": "^6.0.7", + "express-session": "^1.17.3", + "express-validator": "^6.15.0", + "generate-rsa-keypair": "^0.2.1", + "ical": "^0.6.0", + "ical-generator": "^1.15.4", + "jimp": "^0.16.13", + "marked": "^9.1.0", + "moment-timezone": "^0.5.43", + "mongoose": "^5.13.20", + "nanoid": "^3.3.6", + "niceware": "^3.0.0", + "node-schedule": "^1.3.3", + "nodemailer": "^6.9.5", + "randomstring": "^1.3.0", + "request": "^2.88.2", + "sanitize-html": "^2.11.0", + "toml": "^3.0.0", + "wait-on": "^7.0.1" + }, + "devDependencies": { + "@types/express": "^4.17.18", + "@types/node": "^20.8.2", + "cypress": "^13.3.0", + "eslint": "^8.50.0", + "nodemon": "^2.0.22", + "prettier": "^3.0.3", + "typescript": "5.1.6" + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d4e799..7bf4c04 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,8 +92,8 @@ devDependencies: specifier: ^2.0.22 version: 2.0.22 prettier: - specifier: ^2.8.8 - version: 2.8.8 + specifier: ^3.0.3 + version: 3.0.3 typescript: specifier: 5.1.6 version: 5.1.6 @@ -2903,9 +2903,9 @@ packages: engines: {node: '>= 0.8.0'} dev: true - /prettier@2.8.8: - resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} - engines: {node: '>=10.13.0'} + /prettier@3.0.3: + resolution: {integrity: sha512-L/4pUDMxcNa8R/EthV08Zt42WBO4h1rarVtK0K+QJG0X187OLo7l699jWw0GKuwzkPQ//jMFA/8Xm6Fh3J/DAg==} + engines: {node: '>=14'} hasBin: true dev: true diff --git a/src/activitypub.js b/src/activitypub.js index 555f44d..d92b399 100644 --- a/src/activitypub.js +++ b/src/activitypub.js @@ -14,1253 +14,1365 @@ import Event from "./models/Event.js"; // This alphabet (used to generate all event, group, etc. IDs) is missing '-' // because ActivityPub doesn't like it in IDs const nanoid = customAlphabet( - "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_", - 21 + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_", + 21, ); export function createActivityPubActor( - eventID, - domain, - pubkey, - description, - name, - location, - imageFilename, - startUTC, - endUTC, - timezone + eventID, + domain, + pubkey, + description, + name, + location, + imageFilename, + startUTC, + endUTC, + timezone, ) { - let actor = { - "@context": [ - "https://www.w3.org/ns/activitystreams", - "https://w3id.org/security/v1", - ], + let actor = { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + ], - id: `https://${domain}/${eventID}`, - type: "Person", - preferredUsername: `${eventID}`, - inbox: `https://${domain}/activitypub/inbox`, - outbox: `https://${domain}/${eventID}/outbox`, - followers: `https://${domain}/${eventID}/followers`, - summary: `

${description}

`, - name: name, - featured: `https://${domain}/${eventID}/featured`, + id: `https://${domain}/${eventID}`, + type: "Person", + preferredUsername: `${eventID}`, + inbox: `https://${domain}/activitypub/inbox`, + outbox: `https://${domain}/${eventID}/outbox`, + followers: `https://${domain}/${eventID}/followers`, + summary: `

${description}

`, + name: name, + featured: `https://${domain}/${eventID}/featured`, - publicKey: { - id: `https://${domain}/${eventID}#main-key`, - owner: `https://${domain}/${eventID}`, - publicKeyPem: pubkey, - }, - }; - if (location) { - actor.summary += `

Location: ${location}.

`; - } - let displayDate; - if (startUTC && timezone) { - displayDate = moment.tz(startUTC, timezone).format("D MMMM YYYY h:mm a"); - actor.summary += `

Starting ${displayDate} ${timezone}.

`; - } - if (imageFilename) { - actor.icon = { - type: "Image", - mediaType: "image/jpg", - url: `https://${domain}/events/${imageFilename}`, + publicKey: { + id: `https://${domain}/${eventID}#main-key`, + owner: `https://${domain}/${eventID}`, + publicKeyPem: pubkey, + }, }; - } - return JSON.stringify(actor); + if (location) { + actor.summary += `

Location: ${location}.

`; + } + let displayDate; + if (startUTC && timezone) { + displayDate = moment + .tz(startUTC, timezone) + .format("D MMMM YYYY h:mm a"); + actor.summary += `

Starting ${displayDate} ${timezone}.

`; + } + if (imageFilename) { + actor.icon = { + type: "Image", + mediaType: "image/jpg", + url: `https://${domain}/events/${imageFilename}`, + }; + } + return JSON.stringify(actor); } export function createActivityPubEvent( - name, - startUTC, - endUTC, - timezone, - description, - location + name, + startUTC, + endUTC, + timezone, + description, + location, ) { - const guid = crypto.randomBytes(16).toString("hex"); - let eventObject = { - "@context": "https://www.w3.org/ns/activitystreams", - id: `https://${domain}/${guid}`, - name: name, - type: "Event", - startTime: moment.tz(startUTC, timezone).format(), - endTime: moment.tz(endUTC, timezone).format(), - content: description, - location: location, - }; - return JSON.stringify(eventObject); + const guid = crypto.randomBytes(16).toString("hex"); + let eventObject = { + "@context": "https://www.w3.org/ns/activitystreams", + id: `https://${domain}/${guid}`, + name: name, + type: "Event", + startTime: moment.tz(startUTC, timezone).format(), + endTime: moment.tz(endUTC, timezone).format(), + content: description, + location: location, + }; + return JSON.stringify(eventObject); } export function createFeaturedPost( - eventID, - name, - startUTC, - endUTC, - timezone, - description, - location + eventID, + name, + startUTC, + endUTC, + timezone, + description, + location, ) { - const featured = { - "@context": "https://www.w3.org/ns/activitystreams", - id: `https://${domain}/${eventID}/m/featuredPost`, - type: "Note", - name: "Test", - cc: "https://www.w3.org/ns/activitystreams#Public", - content: `

This is an event that was posted on ${siteName}. If you follow this account, you'll see updates in your timeline about the event. If your software supports polls, you should get a poll in your DMs asking if you want to RSVP. You can reply and RSVP right from there. If your software has an event calendar built in, you should get an event in your inbox that you can RSVP to like you respond to any event.

For more information on how to interact with this, check out this link.

`, - attributedTo: `https://${domain}/${eventID}`, - }; - return featured; + const featured = { + "@context": "https://www.w3.org/ns/activitystreams", + id: `https://${domain}/${eventID}/m/featuredPost`, + type: "Note", + name: "Test", + cc: "https://www.w3.org/ns/activitystreams#Public", + content: `

This is an event that was posted on ${siteName}. If you follow this account, you'll see updates in your timeline about the event. If your software supports polls, you should get a poll in your DMs asking if you want to RSVP. You can reply and RSVP right from there. If your software has an event calendar built in, you should get an event in your inbox that you can RSVP to like you respond to any event.

For more information on how to interact with this, check out this link.

`, + attributedTo: `https://${domain}/${eventID}`, + }; + return featured; } export function updateActivityPubEvent( - oldEvent, - name, - startUTC, - endUTC, - timezone, - description, - location + oldEvent, + name, + startUTC, + endUTC, + timezone, + description, + location, ) { - // we want to persist the old ID no matter what happens to the Event itself - const id = oldEvent.id; - let eventObject = { - "@context": "https://www.w3.org/ns/activitystreams", - id: id, - name: name, - type: "Event", - startTime: moment.tz(startUTC, timezone).format(), - endTime: moment.tz(endUTC, timezone).format(), - content: description, - location: location, - }; - return JSON.stringify(eventObject); + // we want to persist the old ID no matter what happens to the Event itself + const id = oldEvent.id; + let eventObject = { + "@context": "https://www.w3.org/ns/activitystreams", + id: id, + name: name, + type: "Event", + startTime: moment.tz(startUTC, timezone).format(), + endTime: moment.tz(endUTC, timezone).format(), + content: description, + location: location, + }; + return JSON.stringify(eventObject); } export function updateActivityPubActor( - actor, - description, - name, - location, - imageFilename, - startUTC, - endUTC, - timezone + actor, + description, + name, + location, + imageFilename, + startUTC, + endUTC, + timezone, ) { - if (!actor) return; - actor.summary = `

${description}

`; - actor.name = name; - if (location) { - actor.summary += `

Location: ${location}.

`; - } - let displayDate; - if (startUTC && timezone) { - displayDate = moment.tz(startUTC, timezone).format("D MMMM YYYY h:mm a"); - actor.summary += `

Starting ${displayDate} ${timezone}.

`; - } - if (imageFilename) { - actor.icon = { - type: "Image", - mediaType: "image/jpg", - url: `https://${domain}/events/${imageFilename}`, - }; - } - return JSON.stringify(actor); + if (!actor) return; + actor.summary = `

${description}

`; + actor.name = name; + if (location) { + actor.summary += `

Location: ${location}.

`; + } + let displayDate; + if (startUTC && timezone) { + displayDate = moment + .tz(startUTC, timezone) + .format("D MMMM YYYY h:mm a"); + actor.summary += `

Starting ${displayDate} ${timezone}.

`; + } + if (imageFilename) { + actor.icon = { + type: "Image", + mediaType: "image/jpg", + url: `https://${domain}/events/${imageFilename}`, + }; + } + return JSON.stringify(actor); } export function signAndSend(message, eventID, targetDomain, inbox, callback) { - if (!isFederated) return; - let inboxFragment = inbox.replace("https://" + targetDomain, ""); - // get the private key - Event.findOne({ - id: eventID, - }).then((event) => { - if (event) { - const digest = crypto - .createHash("sha256") - .update(JSON.stringify(message)) - .digest("base64"); - const privateKey = event.privateKey; - const signer = crypto.createSign("sha256"); - let d = new Date(); - let stringToSign = `(request-target): post ${inboxFragment}\nhost: ${targetDomain}\ndate: ${d.toUTCString()}\ndigest: SHA-256=${digest}`; - signer.update(stringToSign); - signer.end(); - const signature = signer.sign(privateKey); - const signature_b64 = signature.toString("base64"); - const algorithm = "rsa-sha256"; - let header = `keyId="https://${domain}/${eventID}",algorithm="${algorithm}",headers="(request-target) host date digest",signature="${signature_b64}"`; - request( - { - url: inbox, - headers: { - Host: targetDomain, - Date: d.toUTCString(), - Signature: header, - Digest: `SHA-256=${digest}`, - "Content-Type": "application/activity+json", - Accept: "application/activity+json", - }, - method: "POST", - json: true, - body: message, - }, - function (error, response) { - if (error) { - callback(error, null, 500); - } else { - // Add the message to the database - const messageID = message.id; - const newMessage = { - id: message.id, - content: JSON.stringify(message), - }; - Event.findOne( - { - id: eventID, - }, - function (err, event) { - if (!event) return; - event.activityPubMessages.push(newMessage); - // also add the message's object if it has one - if (message.object && message.object.id) { - event.activityPubMessages.push({ - id: message.object.id, - content: JSON.stringify(message.object), - }); - } - event - .save() - .then(() => { - addToLog( - "addActivityPubMessage", - "success", - "ActivityPubMessage added to event " + eventID - ); - callback(null, message.id, 200); - }) - .catch((err) => { - addToLog( - "addActivityPubMessage", - "error", - "Attempt to add ActivityPubMessage to event " + - eventID + - " failed with error: " + - err - ); - callback(err, null, 500); - }); - } + if (!isFederated) return; + let inboxFragment = inbox.replace("https://" + targetDomain, ""); + // get the private key + Event.findOne({ + id: eventID, + }).then((event) => { + if (event) { + const digest = crypto + .createHash("sha256") + .update(JSON.stringify(message)) + .digest("base64"); + const privateKey = event.privateKey; + const signer = crypto.createSign("sha256"); + let d = new Date(); + let stringToSign = `(request-target): post ${inboxFragment}\nhost: ${targetDomain}\ndate: ${d.toUTCString()}\ndigest: SHA-256=${digest}`; + signer.update(stringToSign); + signer.end(); + const signature = signer.sign(privateKey); + const signature_b64 = signature.toString("base64"); + const algorithm = "rsa-sha256"; + let header = `keyId="https://${domain}/${eventID}",algorithm="${algorithm}",headers="(request-target) host date digest",signature="${signature_b64}"`; + request( + { + url: inbox, + headers: { + Host: targetDomain, + Date: d.toUTCString(), + Signature: header, + Digest: `SHA-256=${digest}`, + "Content-Type": "application/activity+json", + Accept: "application/activity+json", + }, + method: "POST", + json: true, + body: message, + }, + function (error, response) { + if (error) { + callback(error, null, 500); + } else { + // Add the message to the database + const messageID = message.id; + const newMessage = { + id: message.id, + content: JSON.stringify(message), + }; + Event.findOne( + { + id: eventID, + }, + function (err, event) { + if (!event) return; + event.activityPubMessages.push(newMessage); + // also add the message's object if it has one + if (message.object && message.object.id) { + event.activityPubMessages.push({ + id: message.object.id, + content: JSON.stringify(message.object), + }); + } + event + .save() + .then(() => { + addToLog( + "addActivityPubMessage", + "success", + "ActivityPubMessage added to event " + + eventID, + ); + callback(null, message.id, 200); + }) + .catch((err) => { + addToLog( + "addActivityPubMessage", + "error", + "Attempt to add ActivityPubMessage to event " + + eventID + + " failed with error: " + + err, + ); + callback(err, null, 500); + }); + }, + ); + } + }, ); - } + } else { + callback(`No record found for ${eventID}.`, null, 404); } - ); - } else { - callback(`No record found for ${eventID}.`, null, 404); - } - }); + }); } // this function sends something to the timeline of every follower in the followers array // it's also an unlisted public message, meaning non-followers can see the message if they look at // the profile but it doesn't spam federated timelines export function broadcastCreateMessage(apObject, followers, eventID) { - if (!isFederated) return; - let guidCreate = crypto.randomBytes(16).toString("hex"); - Event.findOne( - { - id: eventID, - }, - function (err, event) { - if (event) { - // iterate over followers - for (const follower of followers) { - let actorId = follower.actorId; - let myURL = new URL(actorId); - let targetDomain = myURL.hostname; - // get the inbox - const followerFound = event.followers.find( - (el) => el.actorId === actorId - ); - if (followerFound) { - const actorJson = JSON.parse(follower.actorJson); - const inbox = actorJson.inbox; - const createMessage = { - "@context": [ - "https://www.w3.org/ns/activitystreams", - "https://w3id.org/security/v1", - ], - id: `https://${domain}/${eventID}/m/${guidCreate}`, - type: "Create", - actor: `https://${domain}/${eventID}`, - to: [actorId], - cc: "https://www.w3.org/ns/activitystreams#Public", - object: apObject, - }; - signAndSend( - createMessage, - eventID, - targetDomain, - inbox, - function (err, resp, status) { - if (err) { - console.log( - `Didn't send to ${actorId}, status ${status} with error ${err}` - ); - } else { - console.log("sent to", actorId); - } - } - ); - } else { - console.log(`No follower found with the id ${actorId}`); - } - } // end followers - } // end if event - else { - console.log(`No event found with the id ${eventID}`); - } - } - ); + if (!isFederated) return; + let guidCreate = crypto.randomBytes(16).toString("hex"); + Event.findOne( + { + id: eventID, + }, + function (err, event) { + if (event) { + // iterate over followers + for (const follower of followers) { + let actorId = follower.actorId; + let myURL = new URL(actorId); + let targetDomain = myURL.hostname; + // get the inbox + const followerFound = event.followers.find( + (el) => el.actorId === actorId, + ); + if (followerFound) { + const actorJson = JSON.parse(follower.actorJson); + const inbox = actorJson.inbox; + const createMessage = { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + ], + id: `https://${domain}/${eventID}/m/${guidCreate}`, + type: "Create", + actor: `https://${domain}/${eventID}`, + to: [actorId], + cc: "https://www.w3.org/ns/activitystreams#Public", + object: apObject, + }; + signAndSend( + createMessage, + eventID, + targetDomain, + inbox, + function (err, resp, status) { + if (err) { + console.log( + `Didn't send to ${actorId}, status ${status} with error ${err}`, + ); + } else { + console.log("sent to", actorId); + } + }, + ); + } else { + console.log(`No follower found with the id ${actorId}`); + } + } // end followers + } // end if event + else { + console.log(`No event found with the id ${eventID}`); + } + }, + ); } // sends an Announce for the apObject export function broadcastAnnounceMessage(apObject, followers, eventID) { - if (!isFederated) return; - let guidUpdate = crypto.randomBytes(16).toString("hex"); - Event.findOne( - { - id: eventID, - }, - function (err, event) { - if (event) { - // iterate over followers - for (const follower of followers) { - let actorId = follower.actorId; - let myURL = new URL(actorId); - let targetDomain = myURL.hostname; - // get the inbox - const followerFound = event.followers.find( - (el) => el.actorId === actorId - ); - if (followerFound) { - const actorJson = JSON.parse(follower.actorJson); - const inbox = actorJson.inbox; - const announceMessage = { - "@context": [ - "https://www.w3.org/ns/activitystreams", - "https://w3id.org/security/v1", - ], - id: `https://${domain}/${eventID}/m/${guidUpdate}`, - cc: "https://www.w3.org/ns/activitystreams#Public", - type: "Announce", - actor: `https://${domain}/${eventID}`, - object: apObject, - to: actorId, - }; - signAndSend( - announceMessage, - eventID, - targetDomain, - inbox, - function (err, resp, status) { - if (err) { - console.log( - `Didn't send to ${actorId}, status ${status} with error ${err}` - ); - } else { - console.log("sent to", actorId); - } - } - ); - } else { - console.log(`No follower found with the id ${actorId}`); - } - } // end followers - } // end if event - else { - console.log(`No event found with the id ${eventID}`); - } - } - ); + if (!isFederated) return; + let guidUpdate = crypto.randomBytes(16).toString("hex"); + Event.findOne( + { + id: eventID, + }, + function (err, event) { + if (event) { + // iterate over followers + for (const follower of followers) { + let actorId = follower.actorId; + let myURL = new URL(actorId); + let targetDomain = myURL.hostname; + // get the inbox + const followerFound = event.followers.find( + (el) => el.actorId === actorId, + ); + if (followerFound) { + const actorJson = JSON.parse(follower.actorJson); + const inbox = actorJson.inbox; + const announceMessage = { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + ], + id: `https://${domain}/${eventID}/m/${guidUpdate}`, + cc: "https://www.w3.org/ns/activitystreams#Public", + type: "Announce", + actor: `https://${domain}/${eventID}`, + object: apObject, + to: actorId, + }; + signAndSend( + announceMessage, + eventID, + targetDomain, + inbox, + function (err, resp, status) { + if (err) { + console.log( + `Didn't send to ${actorId}, status ${status} with error ${err}`, + ); + } else { + console.log("sent to", actorId); + } + }, + ); + } else { + console.log(`No follower found with the id ${actorId}`); + } + } // end followers + } // end if event + else { + console.log(`No event found with the id ${eventID}`); + } + }, + ); } // sends an Update for the apObject export function broadcastUpdateMessage(apObject, followers, eventID) { - if (!isFederated) return; - let guidUpdate = crypto.randomBytes(16).toString("hex"); - // iterate over followers - Event.findOne( - { - id: eventID, - }, - function (err, event) { - if (event) { - for (const follower of followers) { - let actorId = follower.actorId; - let myURL = new URL(actorId); - let targetDomain = myURL.hostname; - // get the inbox - const followerFound = event.followers.find( - (el) => el.actorId === actorId - ); - if (followerFound) { - const actorJson = JSON.parse(follower.actorJson); - const inbox = actorJson.inbox; - const createMessage = { - "@context": "https://www.w3.org/ns/activitystreams", - id: `https://${domain}/${eventID}/m/${guidUpdate}`, - type: "Update", - actor: `https://${domain}/${eventID}`, - object: apObject, - }; - signAndSend( - createMessage, - eventID, - targetDomain, - inbox, - function (err, resp, status) { - if (err) { - console.log( - `Didn't send to ${actorId}, status ${status} with error ${err}` - ); - } else { - console.log("sent to", actorId); - } - } - ); - } else { - console.log(`No follower found with the id ${actorId}`); - } - } // end followers - } else { - console.log(`No event found with the id ${eventID}`); - } - } - ); -} - -export function broadcastDeleteMessage(apObject, followers, eventID, callback) { - callback = callback || function () {}; - if (!isFederated) { - callback([]); - return; - } - // we need to build an array of promises for each message we're sending, run Promise.all(), and then that will resolve when every message has been sent (or failed) - // per spec, each promise will execute *as it is built*, which is fine, we just need the guarantee that they are all done - let promises = []; - - let guidUpdate = crypto.randomBytes(16).toString("hex"); - // iterate over followers - for (const follower of followers) { - promises.push( - new Promise((resolve, reject) => { - let actorId = follower.actorId; - let myURL = new URL(actorId); - let targetDomain = myURL.hostname; - // get the inbox - Event.findOne( - { + if (!isFederated) return; + let guidUpdate = crypto.randomBytes(16).toString("hex"); + // iterate over followers + Event.findOne( + { id: eventID, - }, - function (err, event) { + }, + function (err, event) { if (event) { - const follower = event.followers.find( - (el) => el.actorId === actorId - ); - if (follower) { - const actorJson = JSON.parse(follower.actorJson); - const inbox = actorJson.inbox; - const createMessage = { - "@context": "https://www.w3.org/ns/activitystreams", - id: `https://${domain}/${eventID}/m/${guidUpdate}`, - type: "Delete", - actor: `https://${domain}/${eventID}`, - object: apObject, - }; - signAndSend( - createMessage, - eventID, - targetDomain, - inbox, - function (err, resp, status) { - if (err) { - console.log( - `Didn't send to ${actorId}, status ${status} with error ${err}` - ); - reject( - `Didn't send to ${actorId}, status ${status} with error ${err}` - ); + for (const follower of followers) { + let actorId = follower.actorId; + let myURL = new URL(actorId); + let targetDomain = myURL.hostname; + // get the inbox + const followerFound = event.followers.find( + (el) => el.actorId === actorId, + ); + if (followerFound) { + const actorJson = JSON.parse(follower.actorJson); + const inbox = actorJson.inbox; + const createMessage = { + "@context": "https://www.w3.org/ns/activitystreams", + id: `https://${domain}/${eventID}/m/${guidUpdate}`, + type: "Update", + actor: `https://${domain}/${eventID}`, + object: apObject, + }; + signAndSend( + createMessage, + eventID, + targetDomain, + inbox, + function (err, resp, status) { + if (err) { + console.log( + `Didn't send to ${actorId}, status ${status} with error ${err}`, + ); + } else { + console.log("sent to", actorId); + } + }, + ); } else { - console.log("sent to", actorId); - resolve("sent to", actorId); + console.log(`No follower found with the id ${actorId}`); } - } - ); - } else { - console.log( - `No follower found with the id ${actorId}`, - null, - 404 - ); - reject(`No follower found with the id ${actorId}`, null, 404); - } + } // end followers } else { - console.log(`No event found with the id ${eventID}`, null, 404); - reject(`No event found with the id ${eventID}`, null, 404); + console.log(`No event found with the id ${eventID}`); } - } - ); // end event - }) + }, ); - } // end followers +} + +export function broadcastDeleteMessage(apObject, followers, eventID, callback) { + callback = callback || function () {}; + if (!isFederated) { + callback([]); + return; + } + // we need to build an array of promises for each message we're sending, run Promise.all(), and then that will resolve when every message has been sent (or failed) + // per spec, each promise will execute *as it is built*, which is fine, we just need the guarantee that they are all done + let promises = []; + + let guidUpdate = crypto.randomBytes(16).toString("hex"); + // iterate over followers + for (const follower of followers) { + promises.push( + new Promise((resolve, reject) => { + let actorId = follower.actorId; + let myURL = new URL(actorId); + let targetDomain = myURL.hostname; + // get the inbox + Event.findOne( + { + id: eventID, + }, + function (err, event) { + if (event) { + const follower = event.followers.find( + (el) => el.actorId === actorId, + ); + if (follower) { + const actorJson = JSON.parse( + follower.actorJson, + ); + const inbox = actorJson.inbox; + const createMessage = { + "@context": + "https://www.w3.org/ns/activitystreams", + id: `https://${domain}/${eventID}/m/${guidUpdate}`, + type: "Delete", + actor: `https://${domain}/${eventID}`, + object: apObject, + }; + signAndSend( + createMessage, + eventID, + targetDomain, + inbox, + function (err, resp, status) { + if (err) { + console.log( + `Didn't send to ${actorId}, status ${status} with error ${err}`, + ); + reject( + `Didn't send to ${actorId}, status ${status} with error ${err}`, + ); + } else { + console.log("sent to", actorId); + resolve("sent to", actorId); + } + }, + ); + } else { + console.log( + `No follower found with the id ${actorId}`, + null, + 404, + ); + reject( + `No follower found with the id ${actorId}`, + null, + 404, + ); + } + } else { + console.log( + `No event found with the id ${eventID}`, + null, + 404, + ); + reject( + `No event found with the id ${eventID}`, + null, + 404, + ); + } + }, + ); // end event + }), + ); + } // end followers - Promise.all(promises.map((p) => p.catch((e) => e))).then((statuses) => { - callback(statuses); - }); + Promise.all(promises.map((p) => p.catch((e) => e))).then((statuses) => { + callback(statuses); + }); } // this sends a message "to:" an individual fediverse user export function sendDirectMessage(apObject, actorId, eventID, callback) { - if (!isFederated) return; - callback = callback || function () {}; - const guidCreate = crypto.randomBytes(16).toString("hex"); - const guidObject = crypto.randomBytes(16).toString("hex"); - let d = new Date(); + if (!isFederated) return; + callback = callback || function () {}; + const guidCreate = crypto.randomBytes(16).toString("hex"); + const guidObject = crypto.randomBytes(16).toString("hex"); + let d = new Date(); - apObject.published = d.toISOString(); - apObject.attributedTo = `https://${domain}/${eventID}`; - apObject.to = actorId; - apObject.id = `https://${domain}/${eventID}/m/${guidObject}`; - apObject.content = unescape(apObject.content); + apObject.published = d.toISOString(); + apObject.attributedTo = `https://${domain}/${eventID}`; + apObject.to = actorId; + apObject.id = `https://${domain}/${eventID}/m/${guidObject}`; + apObject.content = unescape(apObject.content); - let createMessage = { - "@context": "https://www.w3.org/ns/activitystreams", - id: `https://${domain}/${eventID}/m/${guidCreate}`, - type: "Create", - actor: `https://${domain}/${eventID}`, - to: [actorId], - object: apObject, - }; + let createMessage = { + "@context": "https://www.w3.org/ns/activitystreams", + id: `https://${domain}/${eventID}/m/${guidCreate}`, + type: "Create", + actor: `https://${domain}/${eventID}`, + to: [actorId], + object: apObject, + }; - let myURL = new URL(actorId); - let targetDomain = myURL.hostname; - // get the inbox - Event.findOne( - { - id: eventID, - }, - function (err, event) { - if (event) { - const follower = event.followers.find((el) => el.actorId === actorId); - if (follower) { - const actorJson = JSON.parse(follower.actorJson); - const inbox = actorJson.inbox; - signAndSend(createMessage, eventID, targetDomain, inbox, callback); - } else { - callback(`No follower found with the id ${actorId}`, null, 404); - } - } else { - callback(`No event found with the id ${eventID}`, null, 404); - } - } - ); + let myURL = new URL(actorId); + let targetDomain = myURL.hostname; + // get the inbox + Event.findOne( + { + id: eventID, + }, + function (err, event) { + if (event) { + const follower = event.followers.find( + (el) => el.actorId === actorId, + ); + if (follower) { + const actorJson = JSON.parse(follower.actorJson); + const inbox = actorJson.inbox; + signAndSend( + createMessage, + eventID, + targetDomain, + inbox, + callback, + ); + } else { + callback( + `No follower found with the id ${actorId}`, + null, + 404, + ); + } + } else { + callback(`No event found with the id ${eventID}`, null, 404); + } + }, + ); } export function sendAcceptMessage(thebody, eventID, targetDomain, callback) { - if (!isFederated) return; - callback = callback || function () {}; - const guid = crypto.randomBytes(16).toString("hex"); - const actorId = thebody.actor; - let message = { - "@context": "https://www.w3.org/ns/activitystreams", - id: `https://${domain}/${guid}`, - type: "Accept", - actor: `https://${domain}/${eventID}`, - object: thebody, - }; - // get the inbox - Event.findOne( - { - id: eventID, - }, - function (err, event) { - if (event) { - const follower = event.followers.find((el) => el.actorId === actorId); - if (follower) { - const actorJson = JSON.parse(follower.actorJson); - const inbox = actorJson.inbox; - signAndSend(message, eventID, targetDomain, inbox, callback); - } - } else { - callback(`Could not find event ${eventID}`, null, 404); - } - } - ); + if (!isFederated) return; + callback = callback || function () {}; + const guid = crypto.randomBytes(16).toString("hex"); + const actorId = thebody.actor; + let message = { + "@context": "https://www.w3.org/ns/activitystreams", + id: `https://${domain}/${guid}`, + type: "Accept", + actor: `https://${domain}/${eventID}`, + object: thebody, + }; + // get the inbox + Event.findOne( + { + id: eventID, + }, + function (err, event) { + if (event) { + const follower = event.followers.find( + (el) => el.actorId === actorId, + ); + if (follower) { + const actorJson = JSON.parse(follower.actorJson); + const inbox = actorJson.inbox; + signAndSend( + message, + eventID, + targetDomain, + inbox, + callback, + ); + } + } else { + callback(`Could not find event ${eventID}`, null, 404); + } + }, + ); } function _handleFollow(req, res) { - const myURL = new URL(req.body.actor); - let targetDomain = myURL.hostname; - let eventID = req.body.object.replace(`https://${domain}/`, ""); - // Add the user to the DB of accounts that follow the account - // get the follower's username - request( - { - url: req.body.actor, - headers: { - Accept: "application/activity+json", - "Content-Type": "application/activity+json", - }, - }, - function (error, response, body) { - body = JSON.parse(body); - const name = body.preferredUsername || body.name || body.attributedTo; - const newFollower = { - actorId: req.body.actor, - followId: req.body.id, - name: name, - actorJson: JSON.stringify(body), - }; - Event.findOne( + const myURL = new URL(req.body.actor); + let targetDomain = myURL.hostname; + let eventID = req.body.object.replace(`https://${domain}/`, ""); + // Add the user to the DB of accounts that follow the account + // get the follower's username + request( { - id: eventID, + url: req.body.actor, + headers: { + Accept: "application/activity+json", + "Content-Type": "application/activity+json", + }, }, - function (err, event) { - // if this account is NOT already in our followers list, add it - if ( - event && - !event.followers.map((el) => el.actorId).includes(req.body.actor) - ) { - event.followers.push(newFollower); - event - .save() - .then(() => { - addToLog( - "addEventFollower", - "success", - "Follower added to event " + eventID - ); - // Accept the follow request - sendAcceptMessage( - req.body, - eventID, - targetDomain, - function (err, resp, status) { - if (err) { - console.log( - `Didn't send Accept to ${req.body.actor}, status ${status} with error ${err}` - ); - } else { - console.log("sent Accept to", req.body.actor); - // ALSO send an ActivityPub Event activity since this person is "interested" in the event, as indicated by the Follow - const jsonEventObject = JSON.parse( - event.activityPubEvent - ); - // send direct message to user - sendDirectMessage( - jsonEventObject, - newFollower.actorId, - event.id - ); + function (error, response, body) { + body = JSON.parse(body); + const name = + body.preferredUsername || body.name || body.attributedTo; + const newFollower = { + actorId: req.body.actor, + followId: req.body.id, + name: name, + actorJson: JSON.stringify(body), + }; + Event.findOne( + { + id: eventID, + }, + function (err, event) { + // if this account is NOT already in our followers list, add it + if ( + event && + !event.followers + .map((el) => el.actorId) + .includes(req.body.actor) + ) { + event.followers.push(newFollower); + event + .save() + .then(() => { + addToLog( + "addEventFollower", + "success", + "Follower added to event " + eventID, + ); + // Accept the follow request + sendAcceptMessage( + req.body, + eventID, + targetDomain, + function (err, resp, status) { + if (err) { + console.log( + `Didn't send Accept to ${req.body.actor}, status ${status} with error ${err}`, + ); + } else { + console.log( + "sent Accept to", + req.body.actor, + ); + // ALSO send an ActivityPub Event activity since this person is "interested" in the event, as indicated by the Follow + const jsonEventObject = JSON.parse( + event.activityPubEvent, + ); + // send direct message to user + sendDirectMessage( + jsonEventObject, + newFollower.actorId, + event.id, + ); - // if users can self-RSVP, send a Question to the new follower - if (event.usersCanAttend) { - const jsonObject = { - "@context": "https://www.w3.org/ns/activitystreams", - name: `RSVP to ${event.name}`, - type: "Question", - content: `@${name} Will you attend ${event.name}? (If you reply "Yes", you'll be listed as an attendee on the event page.)`, - oneOf: [{ type: "Note", name: "Yes" }], - endTime: event.start.toISOString(), - tag: [ - { - type: "Mention", - href: req.body.actor, - name: name, - }, - ], - }; - // send direct message to user - sendDirectMessage( - jsonObject, - req.body.actor, - eventID, - function (error, response, statuscode) { - if (error) { - console.log( - "Error sending direct message:", - error - ); - return res.status(statuscode).json(error); - } else { - return res - .status(statuscode) - .json({ messageid: response }); - } - } - ); - } + // if users can self-RSVP, send a Question to the new follower + if (event.usersCanAttend) { + const jsonObject = { + "@context": + "https://www.w3.org/ns/activitystreams", + name: `RSVP to ${event.name}`, + type: "Question", + content: `@${name} Will you attend ${event.name}? (If you reply "Yes", you'll be listed as an attendee on the event page.)`, + oneOf: [ + { + type: "Note", + name: "Yes", + }, + ], + endTime: + event.start.toISOString(), + tag: [ + { + type: "Mention", + href: req.body + .actor, + name: name, + }, + ], + }; + // send direct message to user + sendDirectMessage( + jsonObject, + req.body.actor, + eventID, + function ( + error, + response, + statuscode, + ) { + if (error) { + console.log( + "Error sending direct message:", + error, + ); + return res + .status( + statuscode, + ) + .json(error); + } else { + return res + .status( + statuscode, + ) + .json({ + messageid: + response, + }); + } + }, + ); + } + } + }, + ); + }) + .catch((err) => { + addToLog( + "addEventFollower", + "error", + "Attempt to add follower to event " + + eventID + + " failed with error: " + + err, + ); + return res + .status(500) + .send( + "Database error, please try again :(", + ); + }); + } else { + // this person is already a follower so just say "ok" + return res.status(200); } - } - ); - }) - .catch((err) => { - addToLog( - "addEventFollower", - "error", - "Attempt to add follower to event " + - eventID + - " failed with error: " + - err - ); - return res - .status(500) - .send("Database error, please try again :("); - }); - } else { - // this person is already a follower so just say "ok" - return res.status(200); - } - } - ); - } - ); //end request + }, + ); + }, + ); //end request } function _handleUndoFollow(req, res) { - // get the record of all followers for this account - const eventID = req.body.object.object.replace(`https://${domain}/`, ""); - Event.findOne( - { - id: eventID, - }, - function (err, event) { - if (!event) return; - // check to see if the Follow object's id matches the id we have on record - // is this even someone who follows us - const indexOfFollower = event.followers.findIndex( - (el) => el.actorId === req.body.object.actor - ); - if (indexOfFollower !== -1) { - // does the id we have match the id we are being given - if (event.followers[indexOfFollower].followId === req.body.object.id) { - // we have a match and can trust the Undo! remove this person from the followers list - event.followers.splice(indexOfFollower, 1); - event - .save() - .then(() => { - addToLog( - "removeEventFollower", - "success", - "Follower removed from event " + eventID - ); - return res.sendStatus(200); - }) - .catch((err) => { - addToLog( - "removeEventFollower", - "error", - "Attempt to remove follower from event " + - eventID + - " failed with error: " + - err - ); - return res.send("Database error, please try again :("); - }); - } - } - } - ); + // get the record of all followers for this account + const eventID = req.body.object.object.replace(`https://${domain}/`, ""); + Event.findOne( + { + id: eventID, + }, + function (err, event) { + if (!event) return; + // check to see if the Follow object's id matches the id we have on record + // is this even someone who follows us + const indexOfFollower = event.followers.findIndex( + (el) => el.actorId === req.body.object.actor, + ); + if (indexOfFollower !== -1) { + // does the id we have match the id we are being given + if ( + event.followers[indexOfFollower].followId === + req.body.object.id + ) { + // we have a match and can trust the Undo! remove this person from the followers list + event.followers.splice(indexOfFollower, 1); + event + .save() + .then(() => { + addToLog( + "removeEventFollower", + "success", + "Follower removed from event " + eventID, + ); + return res.sendStatus(200); + }) + .catch((err) => { + addToLog( + "removeEventFollower", + "error", + "Attempt to remove follower from event " + + eventID + + " failed with error: " + + err, + ); + return res.send( + "Database error, please try again :(", + ); + }); + } + } + }, + ); } function _handleAcceptEvent(req, res) { - let { name, attributedTo, inReplyTo, to, actor } = req.body; - if (Array.isArray(to)) { - to = to[0]; - } - const eventID = to.replace(`https://${domain}/`, ""); - Event.findOne( - { - id: eventID, - }, - function (err, event) { - if (!event) return; - // does the id we got match the id of a thing we sent out - const message = event.activityPubMessages.find( - (el) => el.id === req.body.object - ); - if (message) { - // it's a match - request( - { - url: actor, - headers: { - Accept: "application/activity+json", - "Content-Type": "application/activity+json", - }, - }, - function (error, response, body) { - body = JSON.parse(body); - // if this account is NOT already in our attendees list, add it - if (!event.attendees.map((el) => el.id).includes(actor)) { - const attendeeName = body.preferredUsername || body.name || actor; - const newAttendee = { - name: attendeeName, - status: "attending", - id: actor, - number: 1, - }; - event.attendees.push(newAttendee); - event - .save() - .then((fullEvent) => { - addToLog( - "addEventAttendee", - "success", - "Attendee added to event " + req.params.eventID - ); - // get the new attendee with its hidden id from the full event - let fullAttendee = fullEvent.attendees.find( - (el) => el.id === actor - ); - // send a "click here to remove yourself" link back to the user as a DM - const jsonObject = { - "@context": "https://www.w3.org/ns/activitystreams", - name: `RSVP to ${event.name}`, - type: "Note", - content: `@${newAttendee.name} Thanks for RSVPing! You can remove yourself from the RSVP list by clicking here: https://${domain}/oneclickunattendevent/${event.id}/${fullAttendee._id}`, - tag: [ - { - type: "Mention", - href: newAttendee.id, - name: newAttendee.name, - }, - ], - }; - // send direct message to user - sendDirectMessage(jsonObject, newAttendee.id, event.id); - return res.sendStatus(200); - }) - .catch((err) => { - addToLog( - "addEventAttendee", - "error", - "Attempt to add attendee to event " + - req.params.eventID + - " failed with error: " + - err - ); - return res - .status(500) - .send("Database error, please try again :("); - }); - } else { - // it's a duplicate and this person is already rsvped so just say OK - return res.status(200).send("Attendee is already registered."); - } - } - ); - } + let { name, attributedTo, inReplyTo, to, actor } = req.body; + if (Array.isArray(to)) { + to = to[0]; } - ); + const eventID = to.replace(`https://${domain}/`, ""); + Event.findOne( + { + id: eventID, + }, + function (err, event) { + if (!event) return; + // does the id we got match the id of a thing we sent out + const message = event.activityPubMessages.find( + (el) => el.id === req.body.object, + ); + if (message) { + // it's a match + request( + { + url: actor, + headers: { + Accept: "application/activity+json", + "Content-Type": "application/activity+json", + }, + }, + function (error, response, body) { + body = JSON.parse(body); + // if this account is NOT already in our attendees list, add it + if ( + !event.attendees.map((el) => el.id).includes(actor) + ) { + const attendeeName = + body.preferredUsername || body.name || actor; + const newAttendee = { + name: attendeeName, + status: "attending", + id: actor, + number: 1, + }; + event.attendees.push(newAttendee); + event + .save() + .then((fullEvent) => { + addToLog( + "addEventAttendee", + "success", + "Attendee added to event " + + req.params.eventID, + ); + // get the new attendee with its hidden id from the full event + let fullAttendee = fullEvent.attendees.find( + (el) => el.id === actor, + ); + // send a "click here to remove yourself" link back to the user as a DM + const jsonObject = { + "@context": + "https://www.w3.org/ns/activitystreams", + name: `RSVP to ${event.name}`, + type: "Note", + content: `@${newAttendee.name} Thanks for RSVPing! You can remove yourself from the RSVP list by clicking here: https://${domain}/oneclickunattendevent/${event.id}/${fullAttendee._id}`, + tag: [ + { + type: "Mention", + href: newAttendee.id, + name: newAttendee.name, + }, + ], + }; + // send direct message to user + sendDirectMessage( + jsonObject, + newAttendee.id, + event.id, + ); + return res.sendStatus(200); + }) + .catch((err) => { + addToLog( + "addEventAttendee", + "error", + "Attempt to add attendee to event " + + req.params.eventID + + " failed with error: " + + err, + ); + return res + .status(500) + .send( + "Database error, please try again :(", + ); + }); + } else { + // it's a duplicate and this person is already rsvped so just say OK + return res + .status(200) + .send("Attendee is already registered."); + } + }, + ); + } + }, + ); } function _handleUndoAcceptEvent(req, res) { - let { name, attributedTo, inReplyTo, to, actor } = req.body; - if (Array.isArray(to)) { - to = to[0]; - } - const eventID = to.replace(`https://${domain}/`, ""); - Event.findOne( - { - id: eventID, - }, - function (err, event) { - if (!event) return; - // does the id we got match the id of a thing we sent out - const message = event.activityPubMessages.find( - (el) => el.id === req.body.object.object - ); - if (message) { - // it's a match - Event.update( - { id: eventID }, - { $pull: { attendees: { id: actor } } } - ).then((response) => { - addToLog( - "oneClickUnattend", - "success", - "Attendee removed via one click unattend " + req.params.eventID - ); - }); - } + let { name, attributedTo, inReplyTo, to, actor } = req.body; + if (Array.isArray(to)) { + to = to[0]; } - ); + const eventID = to.replace(`https://${domain}/`, ""); + Event.findOne( + { + id: eventID, + }, + function (err, event) { + if (!event) return; + // does the id we got match the id of a thing we sent out + const message = event.activityPubMessages.find( + (el) => el.id === req.body.object.object, + ); + if (message) { + // it's a match + Event.update( + { id: eventID }, + { $pull: { attendees: { id: actor } } }, + ).then((response) => { + addToLog( + "oneClickUnattend", + "success", + "Attendee removed via one click unattend " + + req.params.eventID, + ); + }); + } + }, + ); } function _handleCreateNote(req, res) { - // figure out what this is in reply to -- it should be addressed specifically to us - let { name, attributedTo, inReplyTo, to } = req.body.object; - // if it's an array just grab the first element, since a poll should only broadcast back to the pollster - if (Array.isArray(to)) { - to = to[0]; - } - const eventID = to.replace(`https://${domain}/`, ""); - // make sure this person is actually a follower - Event.findOne( - { - id: eventID, - }, - function (err, event) { - if (!event) return; - // is this even someone who follows us - const indexOfFollower = event.followers.findIndex( - (el) => el.actorId === req.body.object.attributedTo - ); - if (indexOfFollower !== -1) { - // compare the inReplyTo to its stored message, if it exists and it's going to the right follower then this is a valid reply - const message = event.activityPubMessages.find((el) => { - const content = JSON.parse(el.content); - return inReplyTo === (content.object && content.object.id); - }); - if (message) { - const content = JSON.parse(message.content); - // check if the message we sent out was sent to the actor this incoming message is attributedTo - if (content.to[0] === attributedTo) { - // it's a match, this is a valid poll response, add RSVP to database - // fetch the profile information of the user - request( - { - url: attributedTo, - headers: { - Accept: "application/activity+json", - "Content-Type": "application/activity+json", - }, - }, - function (error, response, body) { - body = JSON.parse(body); - // if this account is NOT already in our attendees list, add it - if ( - !event.attendees.map((el) => el.id).includes(attributedTo) - ) { - const attendeeName = - body.preferredUsername || body.name || attributedTo; - const newAttendee = { - name: attendeeName, - status: "attending", - id: attributedTo, - number: 1, - }; - event.attendees.push(newAttendee); - event - .save() - .then((fullEvent) => { - addToLog( - "addEventAttendee", - "success", - "Attendee added to event " + req.params.eventID - ); - // get the new attendee with its hidden id from the full event - let fullAttendee = fullEvent.attendees.find( - (el) => el.id === attributedTo - ); - // send a "click here to remove yourself" link back to the user as a DM - const jsonObject = { - "@context": "https://www.w3.org/ns/activitystreams", - name: `RSVP to ${event.name}`, - type: "Note", - content: `@${newAttendee.name} Thanks for RSVPing! You can remove yourself from the RSVP list by clicking here: https://${domain}/oneclickunattendevent/${event.id}/${fullAttendee._id}`, - tag: [ - { - type: "Mention", - href: newAttendee.id, - name: newAttendee.name, - }, - ], - }; - // send direct message to user - sendDirectMessage(jsonObject, newAttendee.id, event.id); - return res.sendStatus(200); - }) - .catch((err) => { - addToLog( - "addEventAttendee", - "error", - "Attempt to add attendee to event " + - req.params.eventID + - " failed with error: " + - err - ); - return res - .status(500) - .send("Database error, please try again :("); - }); - } else { - // it's a duplicate and this person is already rsvped so just say OK - return res - .status(200) - .send("Attendee is already registered."); - } - } - ); - } - } - } + // figure out what this is in reply to -- it should be addressed specifically to us + let { name, attributedTo, inReplyTo, to } = req.body.object; + // if it's an array just grab the first element, since a poll should only broadcast back to the pollster + if (Array.isArray(to)) { + to = to[0]; } - ); + const eventID = to.replace(`https://${domain}/`, ""); + // make sure this person is actually a follower + Event.findOne( + { + id: eventID, + }, + function (err, event) { + if (!event) return; + // is this even someone who follows us + const indexOfFollower = event.followers.findIndex( + (el) => el.actorId === req.body.object.attributedTo, + ); + if (indexOfFollower !== -1) { + // compare the inReplyTo to its stored message, if it exists and it's going to the right follower then this is a valid reply + const message = event.activityPubMessages.find((el) => { + const content = JSON.parse(el.content); + return inReplyTo === (content.object && content.object.id); + }); + if (message) { + const content = JSON.parse(message.content); + // check if the message we sent out was sent to the actor this incoming message is attributedTo + if (content.to[0] === attributedTo) { + // it's a match, this is a valid poll response, add RSVP to database + // fetch the profile information of the user + request( + { + url: attributedTo, + headers: { + Accept: "application/activity+json", + "Content-Type": "application/activity+json", + }, + }, + function (error, response, body) { + body = JSON.parse(body); + // if this account is NOT already in our attendees list, add it + if ( + !event.attendees + .map((el) => el.id) + .includes(attributedTo) + ) { + const attendeeName = + body.preferredUsername || + body.name || + attributedTo; + const newAttendee = { + name: attendeeName, + status: "attending", + id: attributedTo, + number: 1, + }; + event.attendees.push(newAttendee); + event + .save() + .then((fullEvent) => { + addToLog( + "addEventAttendee", + "success", + "Attendee added to event " + + req.params.eventID, + ); + // get the new attendee with its hidden id from the full event + let fullAttendee = + fullEvent.attendees.find( + (el) => + el.id === attributedTo, + ); + // send a "click here to remove yourself" link back to the user as a DM + const jsonObject = { + "@context": + "https://www.w3.org/ns/activitystreams", + name: `RSVP to ${event.name}`, + type: "Note", + content: `@${newAttendee.name} Thanks for RSVPing! You can remove yourself from the RSVP list by clicking here: https://${domain}/oneclickunattendevent/${event.id}/${fullAttendee._id}`, + tag: [ + { + type: "Mention", + href: newAttendee.id, + name: newAttendee.name, + }, + ], + }; + // send direct message to user + sendDirectMessage( + jsonObject, + newAttendee.id, + event.id, + ); + return res.sendStatus(200); + }) + .catch((err) => { + addToLog( + "addEventAttendee", + "error", + "Attempt to add attendee to event " + + req.params.eventID + + " failed with error: " + + err, + ); + return res + .status(500) + .send( + "Database error, please try again :(", + ); + }); + } else { + // it's a duplicate and this person is already rsvped so just say OK + return res + .status(200) + .send( + "Attendee is already registered.", + ); + } + }, + ); + } + } + } + }, + ); } function _handleDelete(req, res) { - const deleteObjectId = req.body.object.id; - // find all events with comments from the author - Event.find( - { - "comments.actorId": req.body.actor, - }, - function (err, events) { - if (!events) { - return res.sendStatus(404); - } - - // find the event with THIS comment from the author - let eventWithComment = events.find((event) => { - let comments = event.comments; - return comments.find((comment) => { - if (!comment.activityJson) { - return false; - } - return ( - JSON.parse(comment.activityJson).object.id === req.body.object.id - ); - }); - }); - - if (!eventWithComment) { - return res.sendStatus(404); - } - - // delete the comment - // find the index of the comment, it should have an activityJson field because from an AP server you can only delete an AP-originated comment (and of course it needs to be yours) - let indexOfComment = eventWithComment.comments.findIndex((comment) => { - return ( - comment.activityJson && - JSON.parse(comment.activityJson).object.id === req.body.object.id - ); - }); - eventWithComment.comments.splice(indexOfComment, 1); - eventWithComment - .save() - .then(() => { - addToLog( - "deleteComment", - "success", - "Comment deleted from event " + eventWithComment.id - ); - return res.sendStatus(200); - }) - .catch((err) => { - addToLog( - "deleteComment", - "error", - "Attempt to delete comment " + - req.body.object.id + - "from event " + - eventWithComment.id + - " failed with error: " + - err - ); - return res.sendStatus(500); - }); - } - ); -} - -function _handleCreateNoteComment(req, res) { - // figure out what this is in reply to -- it should be addressed specifically to us - let { attributedTo, inReplyTo, to, cc } = req.body.object; - // normalize cc into an array - if (typeof cc === "string") { - cc = [cc]; - } - // normalize to into an array - if (typeof to === "string") { - to = [to]; - } - - // if this is a public message (in the to or cc fields) - if ( - to.includes("https://www.w3.org/ns/activitystreams#Public") || - (Array.isArray(cc) && - cc.includes("https://www.w3.org/ns/activitystreams#Public")) - ) { - // figure out which event(s) of ours it was addressing - let ourEvents = cc - .filter((el) => el.includes(`https://${domain}/`)) - .map((el) => el.replace(`https://${domain}/`, "")); - // comments should only be on one event. if more than one, ignore (spam, probably) - if (ourEvents.length === 1) { - let eventID = ourEvents[0]; - // add comment - let commentID = nanoid(); - // get the actor for the commenter - request( + const deleteObjectId = req.body.object.id; + // find all events with comments from the author + Event.find( { - url: req.body.actor, - headers: { - Accept: "application/activity+json", - "Content-Type": "application/activity+json", - }, + "comments.actorId": req.body.actor, }, - function (error, response, actor) { - if (!error) { - const parsedActor = JSON.parse(actor); - const name = - parsedActor.preferredUsername || - parsedActor.name || - req.body.actor; - const newComment = { - id: commentID, - actorId: req.body.actor, - activityId: req.body.object.id, - author: name, - content: sanitizeHtml(req.body.object.content, { - allowedTags: [], - allowedAttributes: {}, - }).replace("@" + eventID, ""), - timestamp: moment(), - activityJson: JSON.stringify(req.body), - actorJson: actor, - }; + function (err, events) { + if (!events) { + return res.sendStatus(404); + } - Event.findOne( - { - id: eventID, - }, - function (err, event) { - if (!event) { - return res.sendStatus(404); - } - if (!event.usersCanComment) { - return res.sendStatus(200); - } - event.comments.push(newComment); - event - .save() - .then(() => { - addToLog( - "addEventComment", - "success", - "Comment added to event " + eventID + // find the event with THIS comment from the author + let eventWithComment = events.find((event) => { + let comments = event.comments; + return comments.find((comment) => { + if (!comment.activityJson) { + return false; + } + return ( + JSON.parse(comment.activityJson).object.id === + req.body.object.id ); - const guidObject = crypto.randomBytes(16).toString("hex"); - const jsonObject = req.body.object; - jsonObject.attributedTo = newComment.actorId; - broadcastAnnounceMessage( - jsonObject, - event.followers, - eventID + }); + }); + + if (!eventWithComment) { + return res.sendStatus(404); + } + + // delete the comment + // find the index of the comment, it should have an activityJson field because from an AP server you can only delete an AP-originated comment (and of course it needs to be yours) + let indexOfComment = eventWithComment.comments.findIndex( + (comment) => { + return ( + comment.activityJson && + JSON.parse(comment.activityJson).object.id === + req.body.object.id + ); + }, + ); + eventWithComment.comments.splice(indexOfComment, 1); + eventWithComment + .save() + .then(() => { + addToLog( + "deleteComment", + "success", + "Comment deleted from event " + eventWithComment.id, ); return res.sendStatus(200); - }) - .catch((err) => { + }) + .catch((err) => { addToLog( - "addEventComment", - "error", - "Attempt to add comment to event " + - eventID + - " failed with error: " + - err + "deleteComment", + "error", + "Attempt to delete comment " + + req.body.object.id + + "from event " + + eventWithComment.id + + " failed with error: " + + err, ); - res - .status(500) - .send("Database error, please try again :(" + err); - }); - } - ); - } - } - ); - } // end ourevent - } // end public message + return res.sendStatus(500); + }); + }, + ); } -export function processInbox(req, res) { - if (!isFederated) return res.sendStatus(404); - try { - // if a Follow activity hits the inbox - if (typeof req.body.object === "string" && req.body.type === "Follow") { - _handleFollow(req, res); - } - // if an Undo activity with a Follow object hits the inbox - if ( - req.body && - req.body.type === "Undo" && - req.body.object && - req.body.object.type === "Follow" - ) { - _handleUndoFollow(req, res); +function _handleCreateNoteComment(req, res) { + // figure out what this is in reply to -- it should be addressed specifically to us + let { attributedTo, inReplyTo, to, cc } = req.body.object; + // normalize cc into an array + if (typeof cc === "string") { + cc = [cc]; } - // if an Accept activity with the id of the Event we sent out hits the inbox, it is an affirmative RSVP - if ( - req.body && - req.body.type === "Accept" && - req.body.object && - typeof req.body.object === "string" - ) { - _handleAcceptEvent(req, res); + // normalize to into an array + if (typeof to === "string") { + to = [to]; } - // if an Undo activity containing an Accept containing the id of the Event we sent out hits the inbox, it is an undo RSVP - if ( - req.body && - req.body.type === "Undo" && - req.body.object && - req.body.object.object && - typeof req.body.object.object === "string" && - req.body.object.type === "Accept" - ) { - _handleUndoAcceptEvent(req, res); - } - // if a Create activity with a Note object hits the inbox, and it's a reply, it might be a vote in a poll + + // if this is a public message (in the to or cc fields) if ( - req.body && - req.body.type === "Create" && - req.body.object && - req.body.object.type === "Note" && - req.body.object.inReplyTo && - req.body.object.to + to.includes("https://www.w3.org/ns/activitystreams#Public") || + (Array.isArray(cc) && + cc.includes("https://www.w3.org/ns/activitystreams#Public")) ) { - _handleCreateNote(req, res); - } - // if a Delete activity hits the inbox, it might a deletion of a comment - if (req.body && req.body.type === "Delete") { - _handleDelete(req, res); + // figure out which event(s) of ours it was addressing + let ourEvents = cc + .filter((el) => el.includes(`https://${domain}/`)) + .map((el) => el.replace(`https://${domain}/`, "")); + // comments should only be on one event. if more than one, ignore (spam, probably) + if (ourEvents.length === 1) { + let eventID = ourEvents[0]; + // add comment + let commentID = nanoid(); + // get the actor for the commenter + request( + { + url: req.body.actor, + headers: { + Accept: "application/activity+json", + "Content-Type": "application/activity+json", + }, + }, + function (error, response, actor) { + if (!error) { + const parsedActor = JSON.parse(actor); + const name = + parsedActor.preferredUsername || + parsedActor.name || + req.body.actor; + const newComment = { + id: commentID, + actorId: req.body.actor, + activityId: req.body.object.id, + author: name, + content: sanitizeHtml(req.body.object.content, { + allowedTags: [], + allowedAttributes: {}, + }).replace("@" + eventID, ""), + timestamp: moment(), + activityJson: JSON.stringify(req.body), + actorJson: actor, + }; + + Event.findOne( + { + id: eventID, + }, + function (err, event) { + if (!event) { + return res.sendStatus(404); + } + if (!event.usersCanComment) { + return res.sendStatus(200); + } + event.comments.push(newComment); + event + .save() + .then(() => { + addToLog( + "addEventComment", + "success", + "Comment added to event " + eventID, + ); + const guidObject = crypto + .randomBytes(16) + .toString("hex"); + const jsonObject = req.body.object; + jsonObject.attributedTo = + newComment.actorId; + broadcastAnnounceMessage( + jsonObject, + event.followers, + eventID, + ); + return res.sendStatus(200); + }) + .catch((err) => { + addToLog( + "addEventComment", + "error", + "Attempt to add comment to event " + + eventID + + " failed with error: " + + err, + ); + res.status(500).send( + "Database error, please try again :(" + + err, + ); + }); + }, + ); + } + }, + ); + } // end ourevent + } // end public message +} + +export function processInbox(req, res) { + if (!isFederated) return res.sendStatus(404); + try { + // if a Follow activity hits the inbox + if (typeof req.body.object === "string" && req.body.type === "Follow") { + _handleFollow(req, res); + } + // if an Undo activity with a Follow object hits the inbox + if ( + req.body && + req.body.type === "Undo" && + req.body.object && + req.body.object.type === "Follow" + ) { + _handleUndoFollow(req, res); + } + // if an Accept activity with the id of the Event we sent out hits the inbox, it is an affirmative RSVP + if ( + req.body && + req.body.type === "Accept" && + req.body.object && + typeof req.body.object === "string" + ) { + _handleAcceptEvent(req, res); + } + // if an Undo activity containing an Accept containing the id of the Event we sent out hits the inbox, it is an undo RSVP + if ( + req.body && + req.body.type === "Undo" && + req.body.object && + req.body.object.object && + typeof req.body.object.object === "string" && + req.body.object.type === "Accept" + ) { + _handleUndoAcceptEvent(req, res); + } + // if a Create activity with a Note object hits the inbox, and it's a reply, it might be a vote in a poll + if ( + req.body && + req.body.type === "Create" && + req.body.object && + req.body.object.type === "Note" && + req.body.object.inReplyTo && + req.body.object.to + ) { + _handleCreateNote(req, res); + } + // if a Delete activity hits the inbox, it might a deletion of a comment + if (req.body && req.body.type === "Delete") { + _handleDelete(req, res); + } + // if we are CC'ed on a public or unlisted Create/Note, then this is a comment to us we should boost (Announce) to our followers + if ( + req.body && + req.body.type === "Create" && + req.body.object && + req.body.object.type === "Note" && + req.body.object.to + ) { + _handleCreateNoteComment(req, res); + } // CC'ed + } catch (e) { + console.log("Error in processing inbox:", e); } - // if we are CC'ed on a public or unlisted Create/Note, then this is a comment to us we should boost (Announce) to our followers - if ( - req.body && - req.body.type === "Create" && - req.body.object && - req.body.object.type === "Note" && - req.body.object.to - ) { - _handleCreateNoteComment(req, res); - } // CC'ed - } catch (e) { - console.log("Error in processing inbox:", e); - } } export function createWebfinger(eventID, domain) { - return { - subject: `acct:${eventID}@${domain}`, + return { + subject: `acct:${eventID}@${domain}`, - links: [ - { - rel: "self", - type: "application/activity+json", - href: `https://${domain}/${eventID}`, - }, - ], - }; + links: [ + { + rel: "self", + type: "application/activity+json", + href: `https://${domain}/${eventID}`, + }, + ], + }; } diff --git a/src/app.ts b/src/app.ts index f49092c..5b01b3c 100755 --- a/src/app.ts +++ b/src/app.ts @@ -8,26 +8,26 @@ const app = express(); // 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 + 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 + }, }, - }, }); app.engine("handlebars", hbsInstance.engine); app.set("view engine", "handlebars"); diff --git a/src/helpers.ts b/src/helpers.ts index 4528042..72bbd17 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -10,41 +10,41 @@ 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; + if (!events || events.length < 1) return; - // 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, + // 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, + }); }); - }); - // Stringify it! - const string = cal.toString(); - return string; + // Stringify it! + const string = cal.toString(); + return string; } diff --git a/src/lib/config.ts b/src/lib/config.ts index dc8e9c8..9577fd6 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -2,55 +2,55 @@ import fs from "fs"; import toml from "toml"; interface GathioConfig { - general: { - domain: string; - port: string; - email: string; - site_name: string; - is_federated: boolean; - email_logo_url: string; - show_kofi: boolean; - mail_service: "nodemailer" | "sendgrid"; - }; - database: { - mongodb_url: string; - }; - nodemailer?: { - smtp_server: string; - smtp_port: string; - smtp_username: string; - smtp_password: string; - }; - sendgrid?: { - api_key: string; - }; + general: { + domain: string; + port: string; + email: string; + site_name: string; + is_federated: boolean; + email_logo_url: string; + show_kofi: boolean; + mail_service: "nodemailer" | "sendgrid"; + }; + database: { + mongodb_url: string; + }; + nodemailer?: { + smtp_server: string; + smtp_port: string; + smtp_username: string; + smtp_password: string; + }; + sendgrid?: { + api_key: string; + }; } export const publicConfig = () => { - const config = getConfig(); - return { - domain: config.general.domain, - siteName: config.general.site_name, - isFederated: config.general.is_federated, - emailLogoUrl: config.general.email_logo_url, - showKofi: config.general.show_kofi, - }; + const config = getConfig(); + return { + domain: config.general.domain, + siteName: config.general.site_name, + isFederated: config.general.is_federated, + emailLogoUrl: config.general.email_logo_url, + showKofi: config.general.show_kofi, + }; }; // 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 => { - try { - const config = toml.parse( - fs.readFileSync("./config/config.toml", "utf-8") - ) as GathioConfig; - return config; - } catch { - console.error( - "\x1b[31mConfiguration file not found! Have you renamed './config/config-example.toml' to './config/config.toml'?" - ); - return process.exit(1); - } + try { + const config = toml.parse( + fs.readFileSync("./config/config.toml", "utf-8"), + ) as GathioConfig; + return config; + } catch { + console.error( + "\x1b[31mConfiguration file not found! Have you renamed './config/config-example.toml' to './config/config.toml'?", + ); + return process.exit(1); + } }; export default getConfig; diff --git a/src/models/Event.ts b/src/models/Event.ts index 416379a..94be087 100644 --- a/src/models/Event.ts +++ b/src/models/Event.ts @@ -1,333 +1,333 @@ import mongoose from "mongoose"; export interface IAttendee { - name: string; - status?: string; - email?: string; - removalPassword?: string; - id?: string; - number?: number; - created?: Date; - _id: string; + name: string; + status?: string; + email?: string; + removalPassword?: string; + id?: string; + number?: number; + created?: Date; + _id: string; } export interface IReply { - id: string; - author: string; - content: string; - timestamp: Date; + id: string; + author: string; + content: string; + timestamp: Date; } export interface IComment { - id: string; - author: string; - content: string; - timestamp: Date; - activityJson?: string; - actorJson?: string; - activityId?: string; - actorId?: string; - replies?: IReply[]; + id: string; + author: string; + content: string; + timestamp: Date; + activityJson?: string; + actorJson?: string; + activityId?: string; + actorId?: string; + replies?: IReply[]; } export interface IFollower { - followId?: string; - actorId?: string; - actorJson?: string; - name?: string; + followId?: string; + actorId?: string; + actorJson?: string; + name?: string; } export interface IActivityPubMessage { - id?: string; - content?: string; + id?: string; + content?: string; } export interface IEvent extends mongoose.Document { - id: string; - type: string; - name: string; - location: string; - start: Date; - end: Date; - timezone: string; - description: string; - image?: string; - url?: string; - creatorEmail?: string; - hostName?: string; - viewPassword?: string; - editPassword?: string; - editToken?: string; - eventGroup?: mongoose.Types.ObjectId; - usersCanAttend?: boolean; - showUsersList?: boolean; - usersCanComment?: boolean; - firstLoad?: boolean; - attendees?: IAttendee[]; - maxAttendees?: number; - comments?: IComment[]; - activityPubActor?: string; - activityPubEvent?: string; - publicKey?: string; - privateKey?: string; - followers?: IFollower[]; - activityPubMessages?: IActivityPubMessage[]; + id: string; + type: string; + name: string; + location: string; + start: Date; + end: Date; + timezone: string; + description: string; + image?: string; + url?: string; + creatorEmail?: string; + hostName?: string; + viewPassword?: string; + editPassword?: string; + editToken?: string; + eventGroup?: mongoose.Types.ObjectId; + usersCanAttend?: boolean; + showUsersList?: boolean; + usersCanComment?: boolean; + firstLoad?: boolean; + attendees?: IAttendee[]; + maxAttendees?: number; + comments?: IComment[]; + activityPubActor?: string; + activityPubEvent?: string; + publicKey?: string; + privateKey?: string; + followers?: IFollower[]; + activityPubMessages?: IActivityPubMessage[]; } const Attendees = new mongoose.Schema({ - name: { - type: String, - trim: true, - }, - status: { - type: String, - trim: true, - }, - email: { - type: String, - trim: true, - }, - removalPassword: { - type: String, - trim: true, - unique: true, - sparse: true, - }, - id: { - type: String, - trim: true, - unique: true, - sparse: true, - }, - // The number of people that are attending under one 'attendee' object - number: { - type: Number, - trim: true, - default: 1, - }, - created: Date, -}); - -const Followers = new mongoose.Schema( - { - // this is the id of the original follow *request*, which we use to validate Undo events - followId: { - type: String, - trim: true, + name: { + type: String, + trim: true, }, - // this is the actual remote user profile id - actorId: { - type: String, - trim: true, + status: { + type: String, + trim: true, }, - // this is the stringified JSON of the entire user profile - actorJson: { - type: String, - trim: true, + email: { + type: String, + trim: true, }, - name: { - type: String, - trim: true, + removalPassword: { + type: String, + trim: true, + unique: true, + sparse: true, }, - }, - { _id: false } + id: { + type: String, + trim: true, + unique: true, + sparse: true, + }, + // The number of people that are attending under one 'attendee' object + number: { + type: Number, + trim: true, + default: 1, + }, + created: Date, +}); + +const Followers = new mongoose.Schema( + { + // this is the id of the original follow *request*, which we use to validate Undo events + followId: { + type: String, + trim: true, + }, + // this is the actual remote user profile id + actorId: { + type: String, + trim: true, + }, + // this is the stringified JSON of the entire user profile + actorJson: { + type: String, + trim: true, + }, + name: { + type: String, + trim: true, + }, + }, + { _id: false }, ); const ReplySchema = new mongoose.Schema({ - id: { - type: String, - required: true, - unique: true, - sparse: true, - }, - author: { - type: String, - trim: true, - required: true, - }, - content: { - type: String, - trim: true, - required: true, - }, - timestamp: { - type: Date, - trim: true, - required: true, - }, + id: { + type: String, + required: true, + unique: true, + sparse: true, + }, + author: { + type: String, + trim: true, + required: true, + }, + content: { + type: String, + trim: true, + required: true, + }, + timestamp: { + type: Date, + trim: true, + required: true, + }, }); const ActivityPubMessages = new mongoose.Schema({ - id: { - type: String, - required: true, - unique: true, - sparse: true, - }, - content: { - type: String, - trim: true, - required: true, - }, + id: { + type: String, + required: true, + unique: true, + sparse: true, + }, + content: { + type: String, + trim: true, + required: true, + }, }); const CommentSchema = new mongoose.Schema({ - id: { - type: String, - required: true, - unique: true, - sparse: true, - }, - author: { - type: String, - trim: true, - required: true, - }, - content: { - type: String, - trim: true, - required: true, - }, - timestamp: { - type: Date, - trim: true, - required: true, - }, - activityJson: { - type: String, - trim: true, - }, - actorJson: { - type: String, - trim: true, - }, - activityId: { - type: String, - trim: true, - }, - actorId: { - type: String, - trim: true, - }, - replies: [ReplySchema], + id: { + type: String, + required: true, + unique: true, + sparse: true, + }, + author: { + type: String, + trim: true, + required: true, + }, + content: { + type: String, + trim: true, + required: true, + }, + timestamp: { + type: Date, + trim: true, + required: true, + }, + activityJson: { + type: String, + trim: true, + }, + actorJson: { + type: String, + trim: true, + }, + activityId: { + type: String, + trim: true, + }, + actorId: { + type: String, + trim: true, + }, + replies: [ReplySchema], }); const EventSchema = new mongoose.Schema({ - id: { - type: String, - required: true, - unique: true, - }, - type: { - type: String, - trim: true, - required: true, - }, - name: { - type: String, - trim: true, - required: true, - }, - location: { - type: String, - trim: true, - required: true, - }, - start: { - // Stored as a UTC timestamp - type: Date, - trim: true, - required: true, - }, - end: { - // Stored as a UTC timestamp - type: Date, - trim: true, - required: true, - }, - timezone: { - type: String, - default: "Etc/UTC", - }, - description: { - type: String, - trim: true, - required: true, - }, - image: { - type: String, - trim: true, - }, - url: { - type: String, - trim: true, - }, - creatorEmail: { - type: String, - trim: true, - }, - hostName: { - type: String, - trim: true, - }, - viewPassword: { - type: String, - trim: true, - }, - editPassword: { - type: String, - trim: true, - }, - editToken: { - type: String, - trim: true, - minlength: 32, - maxlength: 32, - }, - eventGroup: { type: mongoose.Schema.Types.ObjectId, ref: "EventGroup" }, - usersCanAttend: { - type: Boolean, - trim: true, - default: false, - }, - showUsersList: { - type: Boolean, - trim: true, - default: false, - }, - usersCanComment: { - type: Boolean, - trim: true, - default: false, - }, - firstLoad: { - type: Boolean, - trim: true, - default: true, - }, - attendees: [Attendees], - maxAttendees: { - type: Number, - }, - comments: [CommentSchema], - activityPubActor: { - type: String, - trim: true, - }, - activityPubEvent: { - type: String, - trim: true, - }, - publicKey: { - type: String, - trim: true, - }, - privateKey: { - type: String, - trim: true, - }, - followers: [Followers], - activityPubMessages: [ActivityPubMessages], + id: { + type: String, + required: true, + unique: true, + }, + type: { + type: String, + trim: true, + required: true, + }, + name: { + type: String, + trim: true, + required: true, + }, + location: { + type: String, + trim: true, + required: true, + }, + start: { + // Stored as a UTC timestamp + type: Date, + trim: true, + required: true, + }, + end: { + // Stored as a UTC timestamp + type: Date, + trim: true, + required: true, + }, + timezone: { + type: String, + default: "Etc/UTC", + }, + description: { + type: String, + trim: true, + required: true, + }, + image: { + type: String, + trim: true, + }, + url: { + type: String, + trim: true, + }, + creatorEmail: { + type: String, + trim: true, + }, + hostName: { + type: String, + trim: true, + }, + viewPassword: { + type: String, + trim: true, + }, + editPassword: { + type: String, + trim: true, + }, + editToken: { + type: String, + trim: true, + minlength: 32, + maxlength: 32, + }, + eventGroup: { type: mongoose.Schema.Types.ObjectId, ref: "EventGroup" }, + usersCanAttend: { + type: Boolean, + trim: true, + default: false, + }, + showUsersList: { + type: Boolean, + trim: true, + default: false, + }, + usersCanComment: { + type: Boolean, + trim: true, + default: false, + }, + firstLoad: { + type: Boolean, + trim: true, + default: true, + }, + attendees: [Attendees], + maxAttendees: { + type: Number, + }, + comments: [CommentSchema], + activityPubActor: { + type: String, + trim: true, + }, + activityPubEvent: { + type: String, + trim: true, + }, + publicKey: { + type: String, + trim: true, + }, + privateKey: { + type: String, + trim: true, + }, + followers: [Followers], + activityPubMessages: [ActivityPubMessages], }); export default mongoose.model("Event", EventSchema); diff --git a/src/models/EventGroup.ts b/src/models/EventGroup.ts index f097843..2b5c2aa 100755 --- a/src/models/EventGroup.ts +++ b/src/models/EventGroup.ts @@ -1,75 +1,75 @@ import mongoose from "mongoose"; export interface ISubscriber { - email?: string; + email?: string; } export interface IEventGroup extends mongoose.Document { - id: string; - name: string; - description: string; - image?: string; - url?: string; - creatorEmail?: string; - hostName?: string; - editToken?: string; - firstLoad?: boolean; - events?: mongoose.Types.ObjectId[]; - subscribers?: ISubscriber[]; + id: string; + name: string; + description: string; + image?: string; + url?: string; + creatorEmail?: string; + hostName?: string; + editToken?: string; + firstLoad?: boolean; + events?: mongoose.Types.ObjectId[]; + subscribers?: ISubscriber[]; } const Subscriber = new mongoose.Schema({ - email: { - type: String, - trim: true, - }, + email: { + type: String, + trim: true, + }, }); const EventGroupSchema = new mongoose.Schema({ - id: { - type: String, - required: true, - unique: true, - }, - name: { - type: String, - trim: true, - required: true, - }, - description: { - type: String, - trim: true, - required: true, - }, - image: { - type: String, - trim: true, - }, - url: { - type: String, - trim: true, - }, - creatorEmail: { - type: String, - trim: true, - }, - hostName: { - type: String, - trim: true, - }, - editToken: { - type: String, - trim: true, - minlength: 32, - maxlength: 32, - }, - firstLoad: { - type: Boolean, - trim: true, - default: true, - }, - events: [{ type: mongoose.Schema.Types.ObjectId, ref: "Event" }], - subscribers: [Subscriber], + id: { + type: String, + required: true, + unique: true, + }, + name: { + type: String, + trim: true, + required: true, + }, + description: { + type: String, + trim: true, + required: true, + }, + image: { + type: String, + trim: true, + }, + url: { + type: String, + trim: true, + }, + creatorEmail: { + type: String, + trim: true, + }, + hostName: { + type: String, + trim: true, + }, + editToken: { + type: String, + trim: true, + minlength: 32, + maxlength: 32, + }, + firstLoad: { + type: Boolean, + trim: true, + default: true, + }, + events: [{ type: mongoose.Schema.Types.ObjectId, ref: "Event" }], + subscribers: [Subscriber], }); export default mongoose.model("EventGroup", EventGroupSchema); diff --git a/src/models/Log.ts b/src/models/Log.ts index 8f905fd..24991d4 100755 --- a/src/models/Log.ts +++ b/src/models/Log.ts @@ -1,33 +1,33 @@ import mongoose from "mongoose"; export interface ILog extends mongoose.Document { - status: string; - process: string; - message: string; - timestamp: Date; + status: string; + process: string; + message: string; + timestamp: Date; } const LogSchema = new mongoose.Schema({ - status: { - type: String, - trim: true, - required: true, - }, - process: { - type: String, - trim: true, - required: true, - }, - message: { - type: String, - trim: true, - required: true, - }, - timestamp: { - type: Date, - trim: true, - required: true, - }, + status: { + type: String, + trim: true, + required: true, + }, + process: { + type: String, + trim: true, + required: true, + }, + message: { + type: String, + trim: true, + required: true, + }, + timestamp: { + type: Date, + trim: true, + required: true, + }, }); export default mongoose.model("Log", LogSchema); diff --git a/src/routes.js b/src/routes.js index be0dcde..7257bdb 100755 --- a/src/routes.js +++ b/src/routes.js @@ -17,17 +17,17 @@ import fileUpload from "express-fileupload"; import Jimp from "jimp"; import schedule from "node-schedule"; import { - createActivityPubActor, - createActivityPubEvent, - createFeaturedPost, - createWebfinger, - updateActivityPubActor, - updateActivityPubEvent, - broadcastCreateMessage, - broadcastUpdateMessage, - broadcastDeleteMessage, - sendDirectMessage, - processInbox, + createActivityPubActor, + createActivityPubEvent, + createFeaturedPost, + createWebfinger, + updateActivityPubActor, + updateActivityPubEvent, + broadcastCreateMessage, + broadcastUpdateMessage, + broadcastDeleteMessage, + sendDirectMessage, + processInbox, } from "./activitypub.js"; import Event from "./models/Event.js"; import EventGroup from "./models/EventGroup.js"; @@ -45,8 +45,8 @@ const showKofi = config.general.show_kofi; // This alphabet (used to generate all event, group, etc. IDs) is missing '-' // because ActivityPub doesn't like it in IDs const nanoid = customAlphabet( - "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_", - 21 + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_", + 21, ); const router = express.Router(); @@ -54,2556 +54,2760 @@ const router = express.Router(); let sendEmails = false; let nodemailerTransporter; if (config.general.mail_service) { - switch (config.general.mail_service) { - case "sendgrid": - sgMail.setApiKey(config.sendgrid?.api_key); - console.log("Sendgrid is ready to send emails."); - sendEmails = true; - break; - case "nodemailer": - nodemailerTransporter = nodemailer.createTransport({ - host: config.nodemailer?.smtp_server, - port: config.nodemailer?.smtp_port, - secure: false, // true for 465, false for other ports - auth: { - user: config.nodemailer?.smtp_username, - pass: config.nodemailer?.smtp_password, - }, - }); - nodemailerTransporter.verify((error, success) => { - if (error) { - console.log(error); - } else { - console.log("Nodemailer SMTP server is ready to send emails."); - sendEmails = true; - } - }); - break; - default: - console.error( - "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." - ); - } + switch (config.general.mail_service) { + case "sendgrid": + sgMail.setApiKey(config.sendgrid?.api_key); + console.log("Sendgrid is ready to send emails."); + sendEmails = true; + break; + case "nodemailer": + nodemailerTransporter = nodemailer.createTransport({ + host: config.nodemailer?.smtp_server, + port: config.nodemailer?.smtp_port, + secure: false, // true for 465, false for other ports + auth: { + user: config.nodemailer?.smtp_username, + pass: config.nodemailer?.smtp_password, + }, + }); + nodemailerTransporter.verify((error, success) => { + if (error) { + console.log(error); + } else { + console.log( + "Nodemailer SMTP server is ready to send emails.", + ); + sendEmails = true; + } + }); + break; + default: + console.error( + "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.", + ); + } } router.use(fileUpload()); // SCHEDULED DELETION schedule.scheduleJob("59 23 * * *", function (fireDate) { - const too_old = moment.tz("Etc/UTC").subtract(7, "days").toDate(); - console.log( - "Old event deletion running! Deleting all events concluding before ", - too_old - ); - - Event.find({ end: { $lte: too_old } }) - .then((oldEvents) => { - oldEvents.forEach((event) => { - const deleteEventFromDB = (id) => { - Event.remove({ _id: id }) - .then((response) => { - addToLog( - "deleteOldEvents", - "success", - "Old event " + id + " deleted" - ); - }) - .catch((err) => { - addToLog( + const too_old = moment.tz("Etc/UTC").subtract(7, "days").toDate(); + console.log( + "Old event deletion running! Deleting all events concluding before ", + too_old, + ); + + Event.find({ end: { $lte: too_old } }) + .then((oldEvents) => { + oldEvents.forEach((event) => { + const deleteEventFromDB = (id) => { + Event.remove({ _id: id }) + .then((response) => { + addToLog( + "deleteOldEvents", + "success", + "Old event " + id + " deleted", + ); + }) + .catch((err) => { + addToLog( + "deleteOldEvents", + "error", + "Attempt to delete old event " + + id + + " failed with error: " + + err, + ); + }); + }; + + if (event.image) { + fs.unlink( + path.join( + process.cwd(), + "/public/events/" + event.image, + ), + (err) => { + if (err) { + addToLog( + "deleteOldEvents", + "error", + "Attempt to delete event image for old event " + + event.id + + " failed with error: " + + err, + ); + } + // Image removed + addToLog( + "deleteOldEvents", + "error", + "Image deleted for old event " + event.id, + ); + }, + ); + } + // Check if event has ActivityPub fields + if (event.activityPubActor && event.activityPubEvent) { + // Broadcast a Delete profile message to all followers so that at least Mastodon servers will delete their local profile information + const guidUpdateObject = crypto + .randomBytes(16) + .toString("hex"); + const jsonUpdateObject = JSON.parse(event.activityPubActor); + const jsonEventObject = JSON.parse(event.activityPubEvent); + // first broadcast AP messages, THEN delete from DB + broadcastDeleteMessage( + jsonUpdateObject, + event.followers, + event.id, + function (statuses) { + broadcastDeleteMessage( + jsonEventObject, + event.followers, + event.id, + function (statuses) { + deleteEventFromDB(event._id); + }, + ); + }, + ); + } else { + // No ActivityPub data - simply delete the event + deleteEventFromDB(event._id); + } + }); + }) + .catch((err) => { + addToLog( "deleteOldEvents", "error", "Attempt to delete old event " + - id + - " failed with error: " + - err - ); - }); - }; - - if (event.image) { - fs.unlink( - path.join(process.cwd(), "/public/events/" + event.image), - (err) => { - if (err) { - addToLog( - "deleteOldEvents", - "error", - "Attempt to delete event image for old event " + event.id + " failed with error: " + - err - ); - } - // Image removed - addToLog( - "deleteOldEvents", - "error", - "Image deleted for old event " + event.id - ); - } - ); - } - // Check if event has ActivityPub fields - if (event.activityPubActor && event.activityPubEvent) { - // Broadcast a Delete profile message to all followers so that at least Mastodon servers will delete their local profile information - const guidUpdateObject = crypto.randomBytes(16).toString("hex"); - const jsonUpdateObject = JSON.parse(event.activityPubActor); - const jsonEventObject = JSON.parse(event.activityPubEvent); - // first broadcast AP messages, THEN delete from DB - broadcastDeleteMessage( - jsonUpdateObject, - event.followers, - event.id, - function (statuses) { - broadcastDeleteMessage( - jsonEventObject, - event.followers, - event.id, - function (statuses) { - deleteEventFromDB(event._id); - } - ); - } - ); - } else { - // No ActivityPub data - simply delete the event - deleteEventFromDB(event._id); - } - }); - }) - .catch((err) => { - addToLog( - "deleteOldEvents", - "error", - "Attempt to delete old event " + event.id + " failed with error: " + err - ); - }); + err, + ); + }); - // TODO: While we're here, also remove all provisioned event attendees over a day - // old (they're not going to become active) + // TODO: While we're here, also remove all provisioned event attendees over a day + // old (they're not going to become active) }); // return the JSON for the featured/pinned post for this event router.get("/:eventID/featured", (req, res) => { - if (!isFederated) return res.sendStatus(404); - const { eventID } = req.params; - const guidObject = crypto.randomBytes(16).toString("hex"); - const featured = { - "@context": "https://www.w3.org/ns/activitystreams", - id: `https://${domain}/${eventID}/featured`, - type: "OrderedCollection", - orderedItems: [createFeaturedPost(eventID)], - }; - if ( - req.headers.accept && - (req.headers.accept.includes("application/activity+json") || - req.headers.accept.includes("application/ld+json")) - ) { - res.header("Content-Type", "application/activity+json").send(featured); - } else { - res.header("Content-Type", "application/json").send(featured); - } + if (!isFederated) return res.sendStatus(404); + const { eventID } = req.params; + const guidObject = crypto.randomBytes(16).toString("hex"); + const featured = { + "@context": "https://www.w3.org/ns/activitystreams", + id: `https://${domain}/${eventID}/featured`, + type: "OrderedCollection", + orderedItems: [createFeaturedPost(eventID)], + }; + if ( + req.headers.accept && + (req.headers.accept.includes("application/activity+json") || + req.headers.accept.includes("application/ld+json")) + ) { + res.header("Content-Type", "application/activity+json").send(featured); + } else { + res.header("Content-Type", "application/json").send(featured); + } }); // return the JSON for a given activitypub message router.get("/:eventID/m/:hash", (req, res) => { - if (!isFederated) return res.sendStatus(404); - const { hash, eventID } = req.params; - const id = `https://${domain}/${eventID}/m/${hash}`; - - Event.findOne({ - id: eventID, - }) - .then((event) => { - if (!event) { - res.status(404); - res.render("404", { url: req.url }); - } else { - const message = event.activityPubMessages.find((el) => el.id === id); - if (message) { - if ( - req.headers.accept && - (req.headers.accept.includes("application/activity+json") || - req.headers.accept.includes("application/ld+json")) - ) { - res - .header("Content-Type", "application/activity+json") - .send(JSON.parse(message.content)); - } else { - res - .header("Content-Type", "application/json") - .send(JSON.parse(message.content)); - } - } else { - res.status(404); - return res.render("404", { url: req.url }); - } - } - }) - .catch((err) => { - addToLog( - "getActivityPubMessage", - "error", - "Attempt to get Activity Pub Message for " + - id + - " failed with error: " + - err - ); - res.status(404); - res.render("404", { url: req.url }); - return; - }); -}); + if (!isFederated) return res.sendStatus(404); + const { hash, eventID } = req.params; + const id = `https://${domain}/${eventID}/m/${hash}`; -// return the webfinger record required for the initial activitypub handshake -router.get("/.well-known/webfinger", (req, res) => { - if (!isFederated) return res.sendStatus(404); - let resource = req.query.resource; - if (!resource || !resource.includes("acct:")) { - return res - .status(400) - .send( - 'Bad request. Please make sure "acct:USER@DOMAIN" is what you are sending as the "resource" query parameter.' - ); - } else { - // "foo@domain" - let activityPubAccount = resource.replace("acct:", ""); - // "foo" - let eventID = activityPubAccount.replace(/@.*/, ""); Event.findOne({ - id: eventID, + id: eventID, }) - .then((event) => { - if (!event) { - res.status(404); - res.render("404", { url: req.url }); - } else { - if ( - req.headers.accept && - (req.headers.accept.includes("application/activity+json") || - req.headers.accept.includes("application/ld+json")) - ) { - res - .header("Content-Type", "application/activity+json") - .send(createWebfinger(eventID, domain)); - } else { - res - .header("Content-Type", "application/json") - .send(createWebfinger(eventID, domain)); - } - } - }) - .catch((err) => { - addToLog( - "renderWebfinger", - "error", - "Attempt to render webfinger for " + - req.params.eventID + - " failed with error: " + - err - ); - res.status(404); - res.render("404", { url: req.url }); - return; - }); - } + .then((event) => { + if (!event) { + res.status(404); + res.render("404", { url: req.url }); + } else { + const message = event.activityPubMessages.find( + (el) => el.id === id, + ); + if (message) { + if ( + req.headers.accept && + (req.headers.accept.includes( + "application/activity+json", + ) || + req.headers.accept.includes("application/ld+json")) + ) { + res.header( + "Content-Type", + "application/activity+json", + ).send(JSON.parse(message.content)); + } else { + res.header("Content-Type", "application/json").send( + JSON.parse(message.content), + ); + } + } else { + res.status(404); + return res.render("404", { url: req.url }); + } + } + }) + .catch((err) => { + addToLog( + "getActivityPubMessage", + "error", + "Attempt to get Activity Pub Message for " + + id + + " failed with error: " + + err, + ); + res.status(404); + res.render("404", { url: req.url }); + return; + }); }); -router.get("/:eventID/followers", (req, res) => { - if (!isFederated) return res.sendStatus(404); - const eventID = req.params.eventID; - Event.findOne({ - id: eventID, - }).then((event) => { - if (event) { - const followers = event.followers.map((el) => el.actorId); - let followersCollection = { - type: "OrderedCollection", - totalItems: followers.length, - id: `https://${domain}/${eventID}/followers`, - first: { - type: "OrderedCollectionPage", - totalItems: followers.length, - partOf: `https://${domain}/${eventID}/followers`, - orderedItems: followers, - id: `https://${domain}/${eventID}/followers?page=1`, - }, - "@context": ["https://www.w3.org/ns/activitystreams"], - }; - if ( - req.headers.accept && - (req.headers.accept.includes("application/activity+json") || - req.headers.accept.includes("application/ld+json")) - ) { - return res - .header("Content-Type", "application/activity+json") - .send(followersCollection); - } else { +// return the webfinger record required for the initial activitypub handshake +router.get("/.well-known/webfinger", (req, res) => { + if (!isFederated) return res.sendStatus(404); + let resource = req.query.resource; + if (!resource || !resource.includes("acct:")) { return res - .header("Content-Type", "application/json") - .send(followersCollection); - } + .status(400) + .send( + 'Bad request. Please make sure "acct:USER@DOMAIN" is what you are sending as the "resource" query parameter.', + ); } else { - return res.status(400).send("Bad request."); + // "foo@domain" + let activityPubAccount = resource.replace("acct:", ""); + // "foo" + let eventID = activityPubAccount.replace(/@.*/, ""); + Event.findOne({ + id: eventID, + }) + .then((event) => { + if (!event) { + res.status(404); + res.render("404", { url: req.url }); + } else { + if ( + req.headers.accept && + (req.headers.accept.includes( + "application/activity+json", + ) || + req.headers.accept.includes("application/ld+json")) + ) { + res.header( + "Content-Type", + "application/activity+json", + ).send(createWebfinger(eventID, domain)); + } else { + res.header("Content-Type", "application/json").send( + createWebfinger(eventID, domain), + ); + } + } + }) + .catch((err) => { + addToLog( + "renderWebfinger", + "error", + "Attempt to render webfinger for " + + req.params.eventID + + " failed with error: " + + err, + ); + res.status(404); + res.render("404", { url: req.url }); + return; + }); } - }); }); -router.get("/group/:eventGroupID", (req, res) => { - EventGroup.findOne({ - id: req.params.eventGroupID, - }) - .lean() // Required, see: https://stackoverflow.com/questions/59690923/handlebars-access-has-been-denied-to-resolve-the-property-from-because-it-is - .then(async (eventGroup) => { - if (eventGroup) { - let parsedDescription = marked.parse(eventGroup.description); - let eventGroupEditToken = eventGroup.editToken; - - let escapedName = eventGroup.name.replace(/\s+/g, "+"); - - let eventGroupHasCoverImage = false; - if (eventGroup.image) { - eventGroupHasCoverImage = true; - } else { - eventGroupHasCoverImage = false; - } - let eventGroupHasHost = false; - if (eventGroup.hostName) { - eventGroupHasHost = true; +router.get("/:eventID/followers", (req, res) => { + if (!isFederated) return res.sendStatus(404); + const eventID = req.params.eventID; + Event.findOne({ + id: eventID, + }).then((event) => { + if (event) { + const followers = event.followers.map((el) => el.actorId); + let followersCollection = { + type: "OrderedCollection", + totalItems: followers.length, + id: `https://${domain}/${eventID}/followers`, + first: { + type: "OrderedCollectionPage", + totalItems: followers.length, + partOf: `https://${domain}/${eventID}/followers`, + orderedItems: followers, + id: `https://${domain}/${eventID}/followers?page=1`, + }, + "@context": ["https://www.w3.org/ns/activitystreams"], + }; + if ( + req.headers.accept && + (req.headers.accept.includes("application/activity+json") || + req.headers.accept.includes("application/ld+json")) + ) { + return res + .header("Content-Type", "application/activity+json") + .send(followersCollection); + } else { + return res + .header("Content-Type", "application/json") + .send(followersCollection); + } } else { - eventGroupHasHost = false; + return res.status(400).send("Bad request."); } + }); +}); - let events = await Event.find({ eventGroup: eventGroup._id }) - .lean() - .sort("start"); - - events.map((event) => { - if (moment.tz(event.end, event.timezone).isSame(event.start, "day")) { - // Happening during one day - event.displayDate = moment - .tz(event.start, event.timezone) - .format("D MMM YYYY"); - } else { - event.displayDate = - moment.tz(event.start, event.timezone).format("D MMM YYYY") + - moment.tz(event.end, event.timezone).format(" - D MMM YYYY"); - } - if ( - moment - .tz(event.end, event.timezone) - .isBefore(moment.tz(event.timezone)) - ) { - event.eventHasConcluded = true; - } else { - event.eventHasConcluded = false; - } - return (({ id, name, displayDate, eventHasConcluded }) => ({ - id, - name, - displayDate, - eventHasConcluded, - }))(event); - }); +router.get("/group/:eventGroupID", (req, res) => { + EventGroup.findOne({ + id: req.params.eventGroupID, + }) + .lean() // Required, see: https://stackoverflow.com/questions/59690923/handlebars-access-has-been-denied-to-resolve-the-property-from-because-it-is + .then(async (eventGroup) => { + if (eventGroup) { + let parsedDescription = marked.parse(eventGroup.description); + let eventGroupEditToken = eventGroup.editToken; - let upcomingEventsExist = false; - if (events.some((e) => e.eventHasConcluded === false)) { - upcomingEventsExist = true; - } + let escapedName = eventGroup.name.replace(/\s+/g, "+"); - let firstLoad = false; - if (eventGroup.firstLoad === true) { - firstLoad = true; - EventGroup.findOneAndUpdate( - { id: req.params.eventGroupID }, - { firstLoad: false }, - function (err, raw) { - if (err) { - res.send(err); - } - } - ); - } - let editingEnabled = false; - if (Object.keys(req.query).length !== 0) { - if (!req.query.e) { - editingEnabled = false; - console.log("No edit token set"); - } else { - if (req.query.e === eventGroupEditToken) { - editingEnabled = true; + let eventGroupHasCoverImage = false; + if (eventGroup.image) { + eventGroupHasCoverImage = true; + } else { + eventGroupHasCoverImage = false; + } + let eventGroupHasHost = false; + if (eventGroup.hostName) { + eventGroupHasHost = true; + } else { + eventGroupHasHost = false; + } + + let events = await Event.find({ eventGroup: eventGroup._id }) + .lean() + .sort("start"); + + events.map((event) => { + if ( + moment + .tz(event.end, event.timezone) + .isSame(event.start, "day") + ) { + // Happening during one day + event.displayDate = moment + .tz(event.start, event.timezone) + .format("D MMM YYYY"); + } else { + event.displayDate = + moment + .tz(event.start, event.timezone) + .format("D MMM YYYY") + + moment + .tz(event.end, event.timezone) + .format(" - D MMM YYYY"); + } + if ( + moment + .tz(event.end, event.timezone) + .isBefore(moment.tz(event.timezone)) + ) { + event.eventHasConcluded = true; + } else { + event.eventHasConcluded = false; + } + return (({ id, name, displayDate, eventHasConcluded }) => ({ + id, + name, + displayDate, + eventHasConcluded, + }))(event); + }); + + let upcomingEventsExist = false; + if (events.some((e) => e.eventHasConcluded === false)) { + upcomingEventsExist = true; + } + + let firstLoad = false; + if (eventGroup.firstLoad === true) { + firstLoad = true; + EventGroup.findOneAndUpdate( + { id: req.params.eventGroupID }, + { firstLoad: false }, + function (err, raw) { + if (err) { + res.send(err); + } + }, + ); + } + let editingEnabled = false; + if (Object.keys(req.query).length !== 0) { + if (!req.query.e) { + editingEnabled = false; + console.log("No edit token set"); + } else { + if (req.query.e === eventGroupEditToken) { + editingEnabled = true; + } else { + editingEnabled = false; + } + } + } + let metadata = { + title: eventGroup.name, + description: marked + .parse(eventGroup.description, { + renderer: render_plain(), + }) + .split(" ") + .splice(0, 40) + .join(" ") + .trim(), + image: eventGroupHasCoverImage + ? `https://${domain}/events/` + eventGroup.image + : null, + url: `https://${domain}/` + req.params.eventID, + }; + res.set("X-Robots-Tag", "noindex"); + res.render("eventgroup", { + domain: domain, + title: eventGroup.name, + eventGroupData: eventGroup, + escapedName: escapedName, + events: events, + upcomingEventsExist: upcomingEventsExist, + parsedDescription: parsedDescription, + editingEnabled: editingEnabled, + eventGroupHasCoverImage: eventGroupHasCoverImage, + eventGroupHasHost: eventGroupHasHost, + firstLoad: firstLoad, + metadata: metadata, + }); } else { - editingEnabled = false; + res.status(404); + res.render("404", { url: req.url }); } - } - } - let metadata = { - title: eventGroup.name, - description: marked - .parse(eventGroup.description, { renderer: render_plain() }) - .split(" ") - .splice(0, 40) - .join(" ") - .trim(), - image: eventGroupHasCoverImage - ? `https://${domain}/events/` + eventGroup.image - : null, - url: `https://${domain}/` + req.params.eventID, - }; - res.set("X-Robots-Tag", "noindex"); - res.render("eventgroup", { - domain: domain, - title: eventGroup.name, - eventGroupData: eventGroup, - escapedName: escapedName, - events: events, - upcomingEventsExist: upcomingEventsExist, - parsedDescription: parsedDescription, - editingEnabled: editingEnabled, - eventGroupHasCoverImage: eventGroupHasCoverImage, - eventGroupHasHost: eventGroupHasHost, - firstLoad: firstLoad, - metadata: metadata, + }) + .catch((err) => { + addToLog( + "displayEventGroup", + "error", + "Attempt to display event group " + + req.params.eventGroupID + + " failed with error: " + + err, + ); + console.log(err); + res.status(404); + res.render("404", { url: req.url }); + return; }); - } else { - res.status(404); - res.render("404", { url: req.url }); - } - }) - .catch((err) => { - addToLog( - "displayEventGroup", - "error", - "Attempt to display event group " + - req.params.eventGroupID + - " failed with error: " + - err - ); - console.log(err); - res.status(404); - res.render("404", { url: req.url }); - return; - }); }); router.get("/group/:eventGroupID/feed.ics", (req, res) => { - EventGroup.findOne({ - id: req.params.eventGroupID, - }) - .lean() // Required, see: https://stackoverflow.com/questions/59690923/handlebars-access-has-been-denied-to-resolve-the-property-from-because-it-is - .then(async (eventGroup) => { - if (eventGroup) { - let events = await Event.find({ eventGroup: eventGroup._id }) - .lean() - .sort("start"); - const string = exportIcal(events, eventGroup.name); - res.set("Content-Type", "text/calendar"); - return res.send(string); - } + EventGroup.findOne({ + id: req.params.eventGroupID, }) - .catch((err) => { - addToLog( - "eventGroupFeed", - "error", - "Attempt to display event group feed for " + - req.params.eventGroupID + - " failed with error: " + - err - ); - console.log(err); - res.status(404); - res.render("404", { url: req.url }); - return; - }); + .lean() // Required, see: https://stackoverflow.com/questions/59690923/handlebars-access-has-been-denied-to-resolve-the-property-from-because-it-is + .then(async (eventGroup) => { + if (eventGroup) { + let events = await Event.find({ eventGroup: eventGroup._id }) + .lean() + .sort("start"); + const string = exportIcal(events, eventGroup.name); + res.set("Content-Type", "text/calendar"); + return res.send(string); + } + }) + .catch((err) => { + addToLog( + "eventGroupFeed", + "error", + "Attempt to display event group feed for " + + req.params.eventGroupID + + " failed with error: " + + err, + ); + console.log(err); + res.status(404); + res.render("404", { url: req.url }); + return; + }); }); router.get("/exportevent/:eventID", (req, res) => { - Event.findOne({ - id: req.params.eventID, - }) - .populate("eventGroup") - .then((event) => { - if (event) { - const string = exportIcal([event]); - res.send(string); - } + Event.findOne({ + id: req.params.eventID, }) - .catch((err) => { - addToLog( - "exportEvent", - "error", - "Attempt to export event " + - req.params.eventID + - " failed with error: " + - err - ); - console.log(err); - res.status(404); - res.render("404", { url: req.url }); - return; - }); + .populate("eventGroup") + .then((event) => { + if (event) { + const string = exportIcal([event]); + res.send(string); + } + }) + .catch((err) => { + addToLog( + "exportEvent", + "error", + "Attempt to export event " + + req.params.eventID + + " failed with error: " + + err, + ); + console.log(err); + res.status(404); + res.render("404", { url: req.url }); + return; + }); }); router.get("/exportgroup/:eventGroupID", (req, res) => { - EventGroup.findOne({ - id: req.params.eventGroupID, - }) - .lean() // Required, see: https://stackoverflow.com/questions/59690923/handlebars-access-has-been-denied-to-resolve-the-property-from-because-it-is - .then(async (eventGroup) => { - if (eventGroup) { - let events = await Event.find({ eventGroup: eventGroup._id }) - .lean() - .sort("start"); - const string = exportIcal(events); - res.send(string); - } + EventGroup.findOne({ + id: req.params.eventGroupID, }) - .catch((err) => { - addToLog( - "exportEvent", - "error", - "Attempt to export event group " + - req.params.eventGroupID + - " failed with error: " + - err - ); - console.log(err); - res.status(404); - res.render("404", { url: req.url }); - return; - }); + .lean() // Required, see: https://stackoverflow.com/questions/59690923/handlebars-access-has-been-denied-to-resolve-the-property-from-because-it-is + .then(async (eventGroup) => { + if (eventGroup) { + let events = await Event.find({ eventGroup: eventGroup._id }) + .lean() + .sort("start"); + const string = exportIcal(events); + res.send(string); + } + }) + .catch((err) => { + addToLog( + "exportEvent", + "error", + "Attempt to export event group " + + req.params.eventGroupID + + " failed with error: " + + err, + ); + console.log(err); + res.status(404); + res.render("404", { url: req.url }); + return; + }); }); // BACKEND ROUTES router.post("/newevent", async (req, res) => { - let eventID = nanoid(); - let editToken = randomstring.generate(); - let eventImageFilename = ""; - let isPartOfEventGroup = false; - if (req.files && Object.keys(req.files).length !== 0) { - let eventImageBuffer = req.files.imageUpload.data; - eventImageFilename = await Jimp.read(eventImageBuffer) - .then((img) => { - img - .resize(920, Jimp.AUTO) // resize - .quality(80) // set JPEG quality - .write("./public/events/" + eventID + ".jpg"); // save - const filename = eventID + ".jpg"; - return filename; - }) - .catch((err) => { - addToLog( - "Jimp", - "error", - "Attempt to edit image failed with error: " + err - ); - }); - } - let startUTC = moment.tz( - req.body.eventStart, - "D MMMM YYYY, hh:mm a", - req.body.timezone - ); - let endUTC = moment.tz( - req.body.eventEnd, - "D MMMM YYYY, hh:mm a", - req.body.timezone - ); - let eventGroup; - if (req.body.eventGroupCheckbox) { - eventGroup = await EventGroup.findOne({ - id: req.body.eventGroupID, - editToken: req.body.eventGroupEditToken, - }); - if (eventGroup) { - isPartOfEventGroup = true; + let eventID = nanoid(); + let editToken = randomstring.generate(); + let eventImageFilename = ""; + let isPartOfEventGroup = false; + if (req.files && Object.keys(req.files).length !== 0) { + let eventImageBuffer = req.files.imageUpload.data; + eventImageFilename = await Jimp.read(eventImageBuffer) + .then((img) => { + img.resize(920, Jimp.AUTO) // resize + .quality(80) // set JPEG quality + .write("./public/events/" + eventID + ".jpg"); // save + const filename = eventID + ".jpg"; + return filename; + }) + .catch((err) => { + addToLog( + "Jimp", + "error", + "Attempt to edit image failed with error: " + err, + ); + }); + } + let startUTC = moment.tz( + req.body.eventStart, + "D MMMM YYYY, hh:mm a", + req.body.timezone, + ); + let endUTC = moment.tz( + req.body.eventEnd, + "D MMMM YYYY, hh:mm a", + req.body.timezone, + ); + let eventGroup; + if (req.body.eventGroupCheckbox) { + eventGroup = await EventGroup.findOne({ + id: req.body.eventGroupID, + editToken: req.body.eventGroupEditToken, + }); + if (eventGroup) { + isPartOfEventGroup = true; + } } - } - - // generate RSA keypair for ActivityPub - let pair = generateRSAKeypair(); - - const event = new Event({ - id: eventID, - type: "public", // This is for backwards compatibility - name: req.body.eventName, - location: req.body.eventLocation, - start: startUTC, - end: endUTC, - timezone: req.body.timezone, - description: req.body.eventDescription, - image: eventImageFilename, - creatorEmail: req.body.creatorEmail, - url: req.body.eventURL, - hostName: req.body.hostName, - viewPassword: req.body.viewPassword, - editPassword: req.body.editPassword, - editToken: editToken, - eventGroup: isPartOfEventGroup ? eventGroup._id : null, - usersCanAttend: req.body.joinCheckbox ? true : false, - showUsersList: req.body.guestlistCheckbox ? true : false, - usersCanComment: req.body.interactionCheckbox ? true : false, - maxAttendees: req.body.maxAttendees, - firstLoad: true, - activityPubActor: createActivityPubActor( - eventID, - domain, - pair.public, - marked.parse(req.body.eventDescription), - req.body.eventName, - req.body.eventLocation, - eventImageFilename, - startUTC, - endUTC, - req.body.timezone - ), - activityPubEvent: createActivityPubEvent( - req.body.eventName, - startUTC, - endUTC, - req.body.timezone, - req.body.eventDescription, - req.body.eventLocation - ), - activityPubMessages: [ - { - id: `https://${domain}/${eventID}/m/featuredPost`, - content: JSON.stringify( - createFeaturedPost( + + // generate RSA keypair for ActivityPub + let pair = generateRSAKeypair(); + + const event = new Event({ + id: eventID, + type: "public", // This is for backwards compatibility + name: req.body.eventName, + location: req.body.eventLocation, + start: startUTC, + end: endUTC, + timezone: req.body.timezone, + description: req.body.eventDescription, + image: eventImageFilename, + creatorEmail: req.body.creatorEmail, + url: req.body.eventURL, + hostName: req.body.hostName, + viewPassword: req.body.viewPassword, + editPassword: req.body.editPassword, + editToken: editToken, + eventGroup: isPartOfEventGroup ? eventGroup._id : null, + usersCanAttend: req.body.joinCheckbox ? true : false, + showUsersList: req.body.guestlistCheckbox ? true : false, + usersCanComment: req.body.interactionCheckbox ? true : false, + maxAttendees: req.body.maxAttendees, + firstLoad: true, + activityPubActor: createActivityPubActor( eventID, + domain, + pair.public, + marked.parse(req.body.eventDescription), + req.body.eventName, + req.body.eventLocation, + eventImageFilename, + startUTC, + endUTC, + req.body.timezone, + ), + activityPubEvent: createActivityPubEvent( req.body.eventName, startUTC, endUTC, req.body.timezone, req.body.eventDescription, - req.body.eventLocation - ) + req.body.eventLocation, ), - }, - ], - publicKey: pair.public, - privateKey: pair.private, - }); - event - .save() - .then((event) => { - addToLog("createEvent", "success", "Event " + eventID + "created"); - // Send email with edit link - if (req.body.creatorEmail && sendEmails) { - req.app.get("hbsInstance").renderView( - "./views/emails/createevent.handlebars", - { - eventID, - editToken, - siteName, - siteLogo, - domain, - cache: true, - layout: "email.handlebars", - }, - function (err, html) { - const msg = { - to: req.body.creatorEmail, - from: { - name: siteName, - email: contactEmail, - address: contactEmail, - }, - subject: `${siteName}: ${req.body.eventName}`, - 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; + activityPubMessages: [ + { + id: `https://${domain}/${eventID}/m/featuredPost`, + content: JSON.stringify( + createFeaturedPost( + eventID, + req.body.eventName, + startUTC, + endUTC, + req.body.timezone, + req.body.eventDescription, + req.body.eventLocation, + ), + ), + }, + ], + publicKey: pair.public, + privateKey: pair.private, + }); + event + .save() + .then((event) => { + addToLog("createEvent", "success", "Event " + eventID + "created"); + // Send email with edit link + if (req.body.creatorEmail && sendEmails) { + req.app.get("hbsInstance").renderView( + "./views/emails/createevent.handlebars", + { + eventID, + editToken, + siteName, + siteLogo, + domain, + cache: true, + layout: "email.handlebars", + }, + function (err, html) { + const msg = { + to: req.body.creatorEmail, + from: { + name: siteName, + email: contactEmail, + address: contactEmail, + }, + subject: `${siteName}: ${req.body.eventName}`, + 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 the event was added to a group, send an email to any group - // subscribers - if (event.eventGroup && sendEmails) { - EventGroup.findOne({ _id: event.eventGroup._id }).then((eventGroup) => { - const subscribers = eventGroup.subscribers.reduce((acc, current) => { - if (acc.includes(current.email)) { - return acc; + // If the event was added to a group, send an email to any group + // subscribers + if (event.eventGroup && sendEmails) { + EventGroup.findOne({ _id: event.eventGroup._id }).then( + (eventGroup) => { + const subscribers = eventGroup.subscribers.reduce( + (acc, current) => { + if (acc.includes(current.email)) { + return acc; + } + return [current.email, ...acc]; + }, + [], + ); + subscribers.forEach((emailAddress) => { + req.app.get("hbsInstance").renderView( + "./views/emails/eventgroupupdated.handlebars", + { + siteName, + siteLogo, + domain, + eventID: req.params.eventID, + eventGroupName: eventGroup.name, + eventName: event.name, + eventID: event.id, + eventGroupID: eventGroup.id, + emailAddress: + encodeURIComponent(emailAddress), + cache: true, + layout: "email.handlebars", + }, + function (err, html) { + const msg = { + to: emailAddress, + from: { + name: siteName, + email: contactEmail, + }, + subject: `${siteName}: New event in ${eventGroup.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; + } + }, + ); + }); + }, + ); } - return [current.email, ...acc]; - }, []); - subscribers.forEach((emailAddress) => { - req.app.get("hbsInstance").renderView( - "./views/emails/eventgroupupdated.handlebars", - { - siteName, - siteLogo, - domain, - eventID: req.params.eventID, - eventGroupName: eventGroup.name, - eventName: event.name, - eventID: event.id, - eventGroupID: eventGroup.id, - emailAddress: encodeURIComponent(emailAddress), - cache: true, - layout: "email.handlebars", - }, - function (err, html) { - const msg = { - to: emailAddress, - from: { - name: siteName, - email: contactEmail, - }, - subject: `${siteName}: New event in ${eventGroup.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; - } - } + res.writeHead(302, { + Location: "/" + eventID + "?e=" + editToken, + }); + res.end(); + }) + .catch((err) => { + console.error(err); + res.status(500).send( + "Database error, please try again :( - " + err, + ); + addToLog( + "createEvent", + "error", + "Attempt to create event failed with error: " + err, ); - }); }); - } - res.writeHead(302, { - Location: "/" + eventID + "?e=" + editToken, - }); - res.end(); - }) - .catch((err) => { - console.error(err); - res.status(500).send("Database error, please try again :( - " + err); - addToLog( - "createEvent", - "error", - "Attempt to create event failed with error: " + err - ); - }); }); router.post("/importevent", (req, res) => { - let eventID = nanoid(); - let editToken = randomstring.generate(); - if (req.files && Object.keys(req.files).length !== 0) { - let iCalObject = ical.parseICS( - req.files.icsImportControl.data.toString("utf8") - ); - let importedEventData = iCalObject[Object.keys(iCalObject)]; - - let creatorEmail; - if (req.body.creatorEmail) { - creatorEmail = req.body.creatorEmail; - } else if (importedEventData.organizer) { - creatorEmail = importedEventData.organizer.val.replace("MAILTO:", ""); - } + let eventID = nanoid(); + let editToken = randomstring.generate(); + if (req.files && Object.keys(req.files).length !== 0) { + let iCalObject = ical.parseICS( + req.files.icsImportControl.data.toString("utf8"), + ); + let importedEventData = iCalObject[Object.keys(iCalObject)]; + + let creatorEmail; + if (req.body.creatorEmail) { + creatorEmail = req.body.creatorEmail; + } else if (importedEventData.organizer) { + creatorEmail = importedEventData.organizer.val.replace( + "MAILTO:", + "", + ); + } - const event = new Event({ - id: eventID, - type: "public", - name: importedEventData.summary, - location: importedEventData.location, - start: importedEventData.start, - end: importedEventData.end, - timezone: - typeof importedEventData.start.tz !== "undefined" - ? importedEventData.start.tz - : "Etc/UTC", - description: importedEventData.description, - image: "", - creatorEmail: creatorEmail, - url: "", - hostName: importedEventData.organizer - ? importedEventData.organizer.params.CN.replace(/["]+/g, "") - : "", - viewPassword: "", - editPassword: "", - editToken: editToken, - usersCanAttend: false, - showUsersList: false, - usersCanComment: false, - firstLoad: true, - }); - event - .save() - .then(() => { - addToLog("createEvent", "success", "Event " + eventID + " created"); - // Send email with edit link - if (creatorEmail && sendEmails) { - req.app.get("hbsInstance").renderView( - "./views/emails/createevent.handlebars", - { - eventID, - editToken, - siteName, - siteLogo, - domain, - cache: true, - layout: "email.handlebars", - }, - function (err, html) { - const msg = { - to: req.body.creatorEmail, - from: { - name: siteName, - email: contactEmail, - address: contactEmail, - }, - subject: `${siteName}: ${importedEventData.summary}`, - 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; - } - } - ); - } - res.writeHead(302, { - Location: "/" + eventID + "?e=" + editToken, + const event = new Event({ + id: eventID, + type: "public", + name: importedEventData.summary, + location: importedEventData.location, + start: importedEventData.start, + end: importedEventData.end, + timezone: + typeof importedEventData.start.tz !== "undefined" + ? importedEventData.start.tz + : "Etc/UTC", + description: importedEventData.description, + image: "", + creatorEmail: creatorEmail, + url: "", + hostName: importedEventData.organizer + ? importedEventData.organizer.params.CN.replace(/["]+/g, "") + : "", + viewPassword: "", + editPassword: "", + editToken: editToken, + usersCanAttend: false, + showUsersList: false, + usersCanComment: false, + firstLoad: true, }); - res.end(); - }) - .catch((err) => { - res.send("Database error, please try again :("); - addToLog( - "createEvent", - "error", - "Attempt to create event failed with error: " + err - ); - }); - } else { - console.log("Files array is empty!"); - res.status(500).end(); - } + event + .save() + .then(() => { + addToLog( + "createEvent", + "success", + "Event " + eventID + " created", + ); + // Send email with edit link + if (creatorEmail && sendEmails) { + req.app.get("hbsInstance").renderView( + "./views/emails/createevent.handlebars", + { + eventID, + editToken, + siteName, + siteLogo, + domain, + cache: true, + layout: "email.handlebars", + }, + function (err, html) { + const msg = { + to: req.body.creatorEmail, + from: { + name: siteName, + email: contactEmail, + address: contactEmail, + }, + subject: `${siteName}: ${importedEventData.summary}`, + 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; + } + }, + ); + } + res.writeHead(302, { + Location: "/" + eventID + "?e=" + editToken, + }); + res.end(); + }) + .catch((err) => { + res.send("Database error, please try again :("); + addToLog( + "createEvent", + "error", + "Attempt to create event failed with error: " + err, + ); + }); + } else { + console.log("Files array is empty!"); + res.status(500).end(); + } }); router.post("/neweventgroup", (req, res) => { - let eventGroupID = nanoid(); - let editToken = randomstring.generate(); - let eventGroupImageFilename = ""; - if (req.files && Object.keys(req.files).length !== 0) { - let eventImageBuffer = req.files.imageUpload.data; - Jimp.read(eventImageBuffer, (err, img) => { - if (err) - addToLog( - "Jimp", - "error", - "Attempt to edit image failed with error: " + err - ); - img - .resize(920, Jimp.AUTO) // resize - .quality(80) // set JPEG quality - .write("./public/events/" + eventGroupID + ".jpg"); // save + let eventGroupID = nanoid(); + let editToken = randomstring.generate(); + let eventGroupImageFilename = ""; + if (req.files && Object.keys(req.files).length !== 0) { + let eventImageBuffer = req.files.imageUpload.data; + Jimp.read(eventImageBuffer, (err, img) => { + if (err) + addToLog( + "Jimp", + "error", + "Attempt to edit image failed with error: " + err, + ); + img.resize(920, Jimp.AUTO) // resize + .quality(80) // set JPEG quality + .write("./public/events/" + eventGroupID + ".jpg"); // save + }); + eventGroupImageFilename = eventGroupID + ".jpg"; + } + const eventGroup = new EventGroup({ + id: eventGroupID, + name: req.body.eventGroupName, + description: req.body.eventGroupDescription, + image: eventGroupImageFilename, + creatorEmail: req.body.creatorEmail, + url: req.body.eventGroupURL, + hostName: req.body.hostName, + editToken: editToken, + firstLoad: true, }); - eventGroupImageFilename = eventGroupID + ".jpg"; - } - const eventGroup = new EventGroup({ - id: eventGroupID, - name: req.body.eventGroupName, - description: req.body.eventGroupDescription, - image: eventGroupImageFilename, - creatorEmail: req.body.creatorEmail, - url: req.body.eventGroupURL, - hostName: req.body.hostName, - editToken: editToken, - firstLoad: true, - }); - eventGroup - .save() - .then(() => { - addToLog( - "createEventGroup", - "success", - "Event group " + eventGroupID + " created" - ); - // Send email with edit link - if (req.body.creatorEmail && sendEmails) { - req.app.get("hbsInstance").renderView( - "./views/emails/createeventgroup.handlebars", - { - eventGroupID, - editToken, - siteName, - siteLogo, - domain, - cache: true, - layout: "email.handlebars", - }, - function (err, html) { - const msg = { - to: req.body.creatorEmail, - from: { - name: siteName, - email: contactEmail, - address: contactEmail, - }, - subject: `${siteName}: ${req.body.eventGroupName}`, - 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; + eventGroup + .save() + .then(() => { + addToLog( + "createEventGroup", + "success", + "Event group " + eventGroupID + " created", + ); + // Send email with edit link + if (req.body.creatorEmail && sendEmails) { + req.app.get("hbsInstance").renderView( + "./views/emails/createeventgroup.handlebars", + { + eventGroupID, + editToken, + siteName, + siteLogo, + domain, + cache: true, + layout: "email.handlebars", + }, + function (err, html) { + const msg = { + to: req.body.creatorEmail, + from: { + name: siteName, + email: contactEmail, + address: contactEmail, + }, + subject: `${siteName}: ${req.body.eventGroupName}`, + 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; + } + }, + ); } - } - ); - } - res.writeHead(302, { - Location: "/group/" + eventGroupID + "?e=" + editToken, - }); - res.end(); - }) - .catch((err) => { - res.send("Database error, please try again :( - " + err); - addToLog( - "createEvent", - "error", - "Attempt to create event failed with error: " + err - ); - }); + res.writeHead(302, { + Location: "/group/" + eventGroupID + "?e=" + editToken, + }); + res.end(); + }) + .catch((err) => { + res.send("Database error, please try again :( - " + err); + addToLog( + "createEvent", + "error", + "Attempt to create event failed with error: " + err, + ); + }); }); router.post("/verifytoken/event/:eventID", (req, res) => { - Event.findOne({ - id: req.params.eventID, - editToken: req.body.editToken, - }).then((event) => { - if (event) return res.sendStatus(200); - return res.sendStatus(404); - }); + Event.findOne({ + id: req.params.eventID, + editToken: req.body.editToken, + }).then((event) => { + if (event) return res.sendStatus(200); + return res.sendStatus(404); + }); }); router.post("/verifytoken/group/:eventGroupID", (req, res) => { - EventGroup.findOne({ - id: req.params.eventGroupID, - editToken: req.body.editToken, - }).then((group) => { - if (group) return res.sendStatus(200); - return res.sendStatus(404); - }); + EventGroup.findOne({ + id: req.params.eventGroupID, + editToken: req.body.editToken, + }).then((group) => { + if (group) return res.sendStatus(200); + return res.sendStatus(404); + }); }); router.post("/editevent/:eventID/:editToken", (req, res) => { - let submittedEditToken = req.params.editToken; - Event.findOne({ - id: req.params.eventID, - }) - .then(async (event) => { - if (event.editToken === submittedEditToken) { - // Token matches - - // If there is a new image, upload that first - let eventID = req.params.eventID; - let eventImageFilename = event.image; - if (req.files && Object.keys(req.files).length !== 0) { - let eventImageBuffer = req.files.imageUpload.data; - Jimp.read(eventImageBuffer, (err, img) => { - if (err) throw err; - img - .resize(920, Jimp.AUTO) // resize - .quality(80) // set JPEG - .write("./public/events/" + eventID + ".jpg"); // save - }); - eventImageFilename = eventID + ".jpg"; - } - let startUTC = moment.tz( - req.body.eventStart, - "D MMMM YYYY, hh:mm a", - req.body.timezone - ); - let endUTC = moment.tz( - req.body.eventEnd, - "D MMMM YYYY, hh:mm a", - req.body.timezone - ); - - let isPartOfEventGroup = false; - let eventGroup; - if (req.body.eventGroupCheckbox) { - eventGroup = await EventGroup.findOne({ - id: req.body.eventGroupID, - editToken: req.body.eventGroupEditToken, - }); - if (eventGroup) { - isPartOfEventGroup = true; - } - } - const updatedEvent = { - name: req.body.eventName, - location: req.body.eventLocation, - start: startUTC, - end: endUTC, - timezone: req.body.timezone, - description: req.body.eventDescription, - url: req.body.eventURL, - hostName: req.body.hostName, - image: eventImageFilename, - usersCanAttend: req.body.joinCheckbox ? true : false, - showUsersList: req.body.guestlistCheckbox ? true : false, - usersCanComment: req.body.interactionCheckbox ? true : false, - maxAttendees: req.body.maxAttendeesCheckbox - ? req.body.maxAttendees - : null, - eventGroup: isPartOfEventGroup ? eventGroup._id : null, - activityPubActor: event.activityPubActor - ? updateActivityPubActor( - JSON.parse(event.activityPubActor), - req.body.eventDescription, - req.body.eventName, - req.body.eventLocation, - eventImageFilename, - startUTC, - endUTC, - req.body.timezone - ) - : null, - activityPubEvent: event.activityPubEvent - ? updateActivityPubEvent( - JSON.parse(event.activityPubEvent), - req.body.eventName, - req.body.startUTC, - req.body.endUTC, - req.body.timezone - ) - : null, - }; - let diffText = - "

This event was just updated with new information.

    "; - let displayDate; - if (event.name !== updatedEvent.name) { - diffText += `
  • the event name changed to ${updatedEvent.name}
  • `; - } - if (event.location !== updatedEvent.location) { - diffText += `
  • the location changed to ${updatedEvent.location}
  • `; - } - if (event.start.toISOString() !== updatedEvent.start.toISOString()) { - displayDate = moment - .tz(updatedEvent.start, updatedEvent.timezone) - .format("dddd D MMMM YYYY h:mm a"); - diffText += `
  • the start time changed to ${displayDate}
  • `; - } - if (event.end.toISOString() !== updatedEvent.end.toISOString()) { - displayDate = moment - .tz(updatedEvent.end, updatedEvent.timezone) - .format("dddd D MMMM YYYY h:mm a"); - diffText += `
  • the end time changed to ${displayDate}
  • `; - } - if (event.timezone !== updatedEvent.timezone) { - diffText += `
  • the time zone changed to ${updatedEvent.timezone}
  • `; - } - if (event.description !== updatedEvent.description) { - diffText += `
  • the event description changed
  • `; - } - diffText += `
`; - Event.findOneAndUpdate( - { id: req.params.eventID }, - updatedEvent, - function (err, raw) { - if (err) { - addToLog( - "editEvent", - "error", - "Attempt to edit event " + - req.params.eventID + - " failed with error: " + - err - ); - res.send(err); - } - } - ) - .then(() => { - addToLog( - "editEvent", - "success", - "Event " + req.params.eventID + " edited" - ); - // send update to ActivityPub subscribers - Event.findOne({ id: req.params.eventID }, function (err, event) { - if (!event) return; - let attendees = event.attendees.filter((el) => el.id); - if (!err) { - // broadcast an identical message to all followers, will show in home timeline - const guidObject = crypto.randomBytes(16).toString("hex"); - const jsonObject = { - "@context": "https://www.w3.org/ns/activitystreams", - id: `https://${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: https://${domain}/${req.params.eventID}`, - }; - 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 - const jsonUpdateObject = JSON.parse(event.activityPubActor); - broadcastUpdateMessage( - jsonUpdateObject, - event.followers, - eventID + let submittedEditToken = req.params.editToken; + Event.findOne({ + id: req.params.eventID, + }) + .then(async (event) => { + if (event.editToken === submittedEditToken) { + // Token matches + + // If there is a new image, upload that first + let eventID = req.params.eventID; + let eventImageFilename = event.image; + if (req.files && Object.keys(req.files).length !== 0) { + let eventImageBuffer = req.files.imageUpload.data; + Jimp.read(eventImageBuffer, (err, img) => { + if (err) throw err; + img.resize(920, Jimp.AUTO) // resize + .quality(80) // set JPEG + .write("./public/events/" + eventID + ".jpg"); // save + }); + eventImageFilename = eventID + ".jpg"; + } + let startUTC = moment.tz( + req.body.eventStart, + "D MMMM YYYY, hh:mm a", + req.body.timezone, ); - // also broadcast an Update/Event for any calendar apps that are consuming our Events - const jsonEventObject = JSON.parse(event.activityPubEvent); - broadcastUpdateMessage( - jsonEventObject, - event.followers, - eventID + let endUTC = moment.tz( + req.body.eventEnd, + "D MMMM YYYY, hh:mm a", + req.body.timezone, ); - // DM to attendees - for (const attendee of attendees) { - const jsonObject = { - "@context": "https://www.w3.org/ns/activitystreams", - name: `RSVP to ${event.name}`, - type: "Note", - content: `@${attendee.name} ${diffText} See here: https://${domain}/${req.params.eventID}`, - tag: [ - { - type: "Mention", - href: attendee.id, - name: attendee.name, - }, - ], - }; - // send direct message to user - sendDirectMessage(jsonObject, attendee.id, eventID); - } - } - }); - // Send update to all attendees - 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/editevent.handlebars", - { - diffText, - eventID: req.params.eventID, - siteName, - siteLogo, - domain, - cache: true, - layout: "email.handlebars", - }, - function (err, html) { - const msg = { - to: attendeeEmails, - from: { - name: siteName, - email: contactEmail, - address: contactEmail, - }, - subject: `${siteName}: ${event.name} was just edited`, - 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; - } + let isPartOfEventGroup = false; + let eventGroup; + if (req.body.eventGroupCheckbox) { + eventGroup = await EventGroup.findOne({ + id: req.body.eventGroupID, + editToken: req.body.eventGroupEditToken, + }); + if (eventGroup) { + isPartOfEventGroup = true; } - ); - } else { - console.log("Nothing to send!"); } - }); + const updatedEvent = { + name: req.body.eventName, + location: req.body.eventLocation, + start: startUTC, + end: endUTC, + timezone: req.body.timezone, + description: req.body.eventDescription, + url: req.body.eventURL, + hostName: req.body.hostName, + image: eventImageFilename, + usersCanAttend: req.body.joinCheckbox ? true : false, + showUsersList: req.body.guestlistCheckbox ? true : false, + usersCanComment: req.body.interactionCheckbox + ? true + : false, + maxAttendees: req.body.maxAttendeesCheckbox + ? req.body.maxAttendees + : null, + eventGroup: isPartOfEventGroup ? eventGroup._id : null, + activityPubActor: event.activityPubActor + ? updateActivityPubActor( + JSON.parse(event.activityPubActor), + req.body.eventDescription, + req.body.eventName, + req.body.eventLocation, + eventImageFilename, + startUTC, + endUTC, + req.body.timezone, + ) + : null, + activityPubEvent: event.activityPubEvent + ? updateActivityPubEvent( + JSON.parse(event.activityPubEvent), + req.body.eventName, + req.body.startUTC, + req.body.endUTC, + req.body.timezone, + ) + : null, + }; + let diffText = + "

This event was just updated with new information.

    "; + let displayDate; + if (event.name !== updatedEvent.name) { + diffText += `
  • the event name changed to ${updatedEvent.name}
  • `; + } + if (event.location !== updatedEvent.location) { + diffText += `
  • the location changed to ${updatedEvent.location}
  • `; + } + if ( + event.start.toISOString() !== + updatedEvent.start.toISOString() + ) { + displayDate = moment + .tz(updatedEvent.start, updatedEvent.timezone) + .format("dddd D MMMM YYYY h:mm a"); + diffText += `
  • the start time changed to ${displayDate}
  • `; + } + if ( + event.end.toISOString() !== updatedEvent.end.toISOString() + ) { + displayDate = moment + .tz(updatedEvent.end, updatedEvent.timezone) + .format("dddd D MMMM YYYY h:mm a"); + diffText += `
  • the end time changed to ${displayDate}
  • `; + } + if (event.timezone !== updatedEvent.timezone) { + diffText += `
  • the time zone changed to ${updatedEvent.timezone}
  • `; + } + if (event.description !== updatedEvent.description) { + diffText += `
  • the event description changed
  • `; + } + diffText += `
`; + Event.findOneAndUpdate( + { id: req.params.eventID }, + updatedEvent, + function (err, raw) { + if (err) { + addToLog( + "editEvent", + "error", + "Attempt to edit event " + + req.params.eventID + + " failed with error: " + + err, + ); + res.send(err); + } + }, + ) + .then(() => { + addToLog( + "editEvent", + "success", + "Event " + req.params.eventID + " edited", + ); + // send update to ActivityPub subscribers + Event.findOne( + { id: req.params.eventID }, + function (err, event) { + if (!event) return; + let attendees = event.attendees.filter( + (el) => el.id, + ); + if (!err) { + // broadcast an identical message to all followers, will show in home timeline + const guidObject = crypto + .randomBytes(16) + .toString("hex"); + const jsonObject = { + "@context": + "https://www.w3.org/ns/activitystreams", + id: `https://${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: https://${domain}/${req.params.eventID}`, + }; + 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 + const jsonUpdateObject = JSON.parse( + event.activityPubActor, + ); + broadcastUpdateMessage( + jsonUpdateObject, + event.followers, + eventID, + ); + // also broadcast an Update/Event for any calendar apps that are consuming our Events + const jsonEventObject = JSON.parse( + event.activityPubEvent, + ); + broadcastUpdateMessage( + jsonEventObject, + event.followers, + eventID, + ); + + // DM to attendees + for (const attendee of attendees) { + const jsonObject = { + "@context": + "https://www.w3.org/ns/activitystreams", + name: `RSVP to ${event.name}`, + type: "Note", + content: `@${attendee.name} ${diffText} See here: https://${domain}/${req.params.eventID}`, + tag: [ + { + type: "Mention", + href: attendee.id, + name: attendee.name, + }, + ], + }; + // send direct message to user + sendDirectMessage( + jsonObject, + attendee.id, + eventID, + ); + } + } + }, + ); + // Send update to all attendees + 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/editevent.handlebars", + { + diffText, + eventID: req.params.eventID, + siteName, + siteLogo, + domain, + cache: true, + layout: "email.handlebars", + }, + function (err, html) { + const msg = { + to: attendeeEmails, + from: { + name: siteName, + email: contactEmail, + address: contactEmail, + }, + subject: `${siteName}: ${event.name} was just edited`, + 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!"); + } + }, + ); + } + res.writeHead(302, { + Location: + "/" + + req.params.eventID + + "?e=" + + req.params.editToken, + }); + res.end(); + }) + .catch((err) => { + console.error(err); + res.send("Sorry! Something went wrong!"); + addToLog( + "editEvent", + "error", + "Attempt to edit event " + + req.params.eventID + + " failed with error: " + + err, + ); + }); + } else { + // Token doesn't match + res.send("Sorry! Something went wrong"); + addToLog( + "editEvent", + "error", + "Attempt to edit event " + + req.params.eventID + + " failed with error: token does not match", + ); } - res.writeHead(302, { - Location: "/" + req.params.eventID + "?e=" + req.params.editToken, - }); - res.end(); - }) - .catch((err) => { + }) + .catch((err) => { console.error(err); res.send("Sorry! Something went wrong!"); addToLog( - "editEvent", - "error", - "Attempt to edit event " + - req.params.eventID + - " failed with error: " + - err + "editEvent", + "error", + "Attempt to edit event " + + req.params.eventID + + " failed with error: " + + err, ); - }); - } else { - // Token doesn't match - res.send("Sorry! Something went wrong"); - addToLog( - "editEvent", - "error", - "Attempt to edit event " + - req.params.eventID + - " failed with error: token does not match" - ); - } - }) - .catch((err) => { - console.error(err); - res.send("Sorry! Something went wrong!"); - addToLog( - "editEvent", - "error", - "Attempt to edit event " + - req.params.eventID + - " failed with error: " + - err - ); - }); + }); }); router.post("/editeventgroup/:eventGroupID/:editToken", (req, res) => { - let submittedEditToken = req.params.editToken; - EventGroup.findOne({ - id: req.params.eventGroupID, - }) - .then((eventGroup) => { - if (eventGroup.editToken === submittedEditToken) { - // Token matches - - // If there is a new image, upload that first - let eventGroupID = req.params.eventGroupID; - let eventGroupImageFilename = eventGroup.image; - if (req.files && Object.keys(req.files).length !== 0) { - let eventImageBuffer = req.files.eventGroupImageUpload.data; - Jimp.read(eventImageBuffer, (err, img) => { - if (err) throw err; - img - .resize(920, Jimp.AUTO) // resize - .quality(80) // set JPEG - .write("./public/events/" + eventGroupID + ".jpg"); // save - }); - eventGroupImageFilename = eventGroupID + ".jpg"; - } - const updatedEventGroup = { - name: req.body.eventGroupName, - description: req.body.eventGroupDescription, - url: req.body.eventGroupURL, - hostName: req.body.hostName, - image: eventGroupImageFilename, - }; - EventGroup.findOneAndUpdate( - { id: req.params.eventGroupID }, - updatedEventGroup, - function (err, raw) { - if (err) { - addToLog( - "editEventGroup", - "error", - "Attempt to edit event group " + - req.params.eventGroupID + - " failed with error: " + - err - ); - res.send(err); + let submittedEditToken = req.params.editToken; + EventGroup.findOne({ + id: req.params.eventGroupID, + }) + .then((eventGroup) => { + if (eventGroup.editToken === submittedEditToken) { + // Token matches + + // If there is a new image, upload that first + let eventGroupID = req.params.eventGroupID; + let eventGroupImageFilename = eventGroup.image; + if (req.files && Object.keys(req.files).length !== 0) { + let eventImageBuffer = req.files.eventGroupImageUpload.data; + Jimp.read(eventImageBuffer, (err, img) => { + if (err) throw err; + img.resize(920, Jimp.AUTO) // resize + .quality(80) // set JPEG + .write("./public/events/" + eventGroupID + ".jpg"); // save + }); + eventGroupImageFilename = eventGroupID + ".jpg"; + } + const updatedEventGroup = { + name: req.body.eventGroupName, + description: req.body.eventGroupDescription, + url: req.body.eventGroupURL, + hostName: req.body.hostName, + image: eventGroupImageFilename, + }; + EventGroup.findOneAndUpdate( + { id: req.params.eventGroupID }, + updatedEventGroup, + function (err, raw) { + if (err) { + addToLog( + "editEventGroup", + "error", + "Attempt to edit event group " + + req.params.eventGroupID + + " failed with error: " + + err, + ); + res.send(err); + } + }, + ) + .then(() => { + addToLog( + "editEventGroup", + "success", + "Event group " + + req.params.eventGroupID + + " edited", + ); + res.writeHead(302, { + Location: + "/group/" + + req.params.eventGroupID + + "?e=" + + req.params.editToken, + }); + res.end(); + }) + .catch((err) => { + console.error(err); + res.send("Sorry! Something went wrong!"); + addToLog( + "editEventGroup", + "error", + "Attempt to edit event group " + + req.params.eventGroupID + + " failed with error: " + + err, + ); + }); + } else { + // Token doesn't match + res.send("Sorry! Something went wrong"); + addToLog( + "editEventGroup", + "error", + "Attempt to edit event group " + + req.params.eventGroupID + + " failed with error: token does not match", + ); } - } - ) - .then(() => { - addToLog( - "editEventGroup", - "success", - "Event group " + req.params.eventGroupID + " edited" - ); - res.writeHead(302, { - Location: - "/group/" + - req.params.eventGroupID + - "?e=" + - req.params.editToken, - }); - res.end(); - }) - .catch((err) => { + }) + .catch((err) => { console.error(err); res.send("Sorry! Something went wrong!"); addToLog( - "editEventGroup", - "error", - "Attempt to edit event group " + - req.params.eventGroupID + - " failed with error: " + - err + "editEventGroup", + "error", + "Attempt to edit event group " + + req.params.eventGroupID + + " failed with error: " + + err, ); - }); - } else { - // Token doesn't match - res.send("Sorry! Something went wrong"); - addToLog( - "editEventGroup", - "error", - "Attempt to edit event group " + - req.params.eventGroupID + - " failed with error: token does not match" - ); - } - }) - .catch((err) => { - console.error(err); - res.send("Sorry! Something went wrong!"); - addToLog( - "editEventGroup", - "error", - "Attempt to edit event group " + - req.params.eventGroupID + - " failed with error: " + - err - ); - }); + }); }); router.post("/deleteimage/:eventID/:editToken", (req, res) => { - let submittedEditToken = req.params.editToken; - Event.findOne({ - id: req.params.eventID, - }).then((event) => { - if (event.editToken === submittedEditToken) { - // Token matches - if (event.image) { - eventImage = event.image; - } else { - res - .status(500) - .send( - "This event doesn't have a linked image. What are you even doing" - ); - } - fs.unlink( - path.join(process.cwd(), "/public/events/" + eventImage), - (err) => { - if (err) { - res.status(500).send(err); - addToLog( - "deleteEventImage", - "error", - "Attempt to delete event image for event " + - req.params.eventID + - " failed with error: " + - err + let submittedEditToken = req.params.editToken; + Event.findOne({ + id: req.params.eventID, + }).then((event) => { + if (event.editToken === submittedEditToken) { + // Token matches + if (event.image) { + eventImage = event.image; + } else { + res.status(500).send( + "This event doesn't have a linked image. What are you even doing", + ); + } + fs.unlink( + path.join(process.cwd(), "/public/events/" + eventImage), + (err) => { + if (err) { + res.status(500).send(err); + addToLog( + "deleteEventImage", + "error", + "Attempt to delete event image for event " + + req.params.eventID + + " failed with error: " + + err, + ); + } + // Image removed + addToLog( + "deleteEventImage", + "success", + "Image for event " + req.params.eventID + " deleted", + ); + event.image = ""; + event + .save() + .then((response) => { + res.status(200).send("Success"); + }) + .catch((err) => { + res.status(500).send(err); + addToLog( + "deleteEventImage", + "error", + "Attempt to delete event image for event " + + req.params.eventID + + " failed with error: " + + err, + ); + }); + }, ); - } - // Image removed - addToLog( - "deleteEventImage", - "success", - "Image for event " + req.params.eventID + " deleted" - ); - event.image = ""; - event - .save() - .then((response) => { - res.status(200).send("Success"); - }) - .catch((err) => { - res.status(500).send(err); - addToLog( - "deleteEventImage", - "error", - "Attempt to delete event image for event " + - req.params.eventID + - " failed with error: " + - err - ); - }); } - ); - } - }); + }); }); router.post("/deleteevent/:eventID/:editToken", (req, res) => { - let submittedEditToken = req.params.editToken; - let eventImage; - Event.findOne({ - id: req.params.eventID, - }) - .then((event) => { - if (event.editToken === submittedEditToken) { - // Token matches - - let eventImage; - if (event.image) { - eventImage = event.image; - } + let submittedEditToken = req.params.editToken; + let eventImage; + Event.findOne({ + id: req.params.eventID, + }) + .then((event) => { + if (event.editToken === submittedEditToken) { + // Token matches - // broadcast a Delete profile message to all followers so that at least Mastodon servers will delete their local profile information - const guidUpdateObject = crypto.randomBytes(16).toString("hex"); - const jsonUpdateObject = JSON.parse(event.activityPubActor); - // first broadcast AP messages, THEN delete from DB - broadcastDeleteMessage( - jsonUpdateObject, - event.followers, - req.params.eventID, - function (statuses) { - Event.deleteOne({ id: req.params.eventID }, function (err, raw) { - if (err) { - res.send(err); - addToLog( - "deleteEvent", - "error", - "Attempt to delete event " + - req.params.eventID + - " failed with error: " + - err - ); - } - }) - .then(() => { - // Delete image - if (eventImage) { - fs.unlink( - path.join(process.cwd(), "/public/events/" + eventImage), - (err) => { - if (err) { - res.send(err); - addToLog( - "deleteEvent", - "error", - "Attempt to delete event image for event " + - req.params.eventID + - " failed with error: " + - err - ); - } - // Image removed - addToLog( - "deleteEvent", - "success", - "Event " + req.params.eventID + " deleted" - ); - } - ); + let eventImage; + if (event.image) { + eventImage = event.image; } - res.writeHead(302, { - Location: "/", - }); - 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: " + attendeeEmails); - req.app.get("hbsInstance").renderView( - "./views/emails/deleteevent.handlebars", - { - siteName, - siteLogo, - domain, - eventName: event.name, - cache: true, - layout: "email.handlebars", - }, - function (err, html) { - const msg = { - to: attendeeEmails, - from: { - name: siteName, - email: contactEmail, - address: 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(); + // broadcast a Delete profile message to all followers so that at least Mastodon servers will delete their local profile information + const guidUpdateObject = crypto.randomBytes(16).toString("hex"); + const jsonUpdateObject = JSON.parse(event.activityPubActor); + // first broadcast AP messages, THEN delete from DB + broadcastDeleteMessage( + jsonUpdateObject, + event.followers, + req.params.eventID, + function (statuses) { + Event.deleteOne( + { id: req.params.eventID }, + function (err, raw) { + if (err) { + res.send(err); + addToLog( + "deleteEvent", + "error", + "Attempt to delete event " + + req.params.eventID + + " failed with error: " + + err, + ); + } + }, + ) + .then(() => { + // Delete image + if (eventImage) { + fs.unlink( + path.join( + process.cwd(), + "/public/events/" + eventImage, + ), + (err) => { + if (err) { + res.send(err); + addToLog( + "deleteEvent", + "error", + "Attempt to delete event image for event " + + req.params.eventID + + " failed with error: " + + err, + ); + } + // Image removed + addToLog( + "deleteEvent", + "success", + "Event " + + req.params.eventID + + " deleted", + ); + }, + ); + } + res.writeHead(302, { + Location: "/", + }); + 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: " + + attendeeEmails, + ); + req.app.get("hbsInstance").renderView( + "./views/emails/deleteevent.handlebars", + { + siteName, + siteLogo, + domain, + eventName: event.name, + cache: true, + layout: "email.handlebars", + }, + function (err, html) { + const msg = { + to: attendeeEmails, + from: { + name: siteName, + email: contactEmail, + address: 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!"); + } + } + }) + .catch((err) => { + res.send( + "Sorry! Something went wrong (error deleting): " + + err, + ); + addToLog( + "deleteEvent", + "error", + "Attempt to delete event " + + req.params.eventID + + " failed with error: " + + err, + ); }); - break; - } - } - ); - } else { - console.log("Nothing to send!"); - } - } - }) - .catch((err) => { - res.send( - "Sorry! Something went wrong (error deleting): " + err + }, ); + } else { + // Token doesn't match + res.send("Sorry! Something went wrong"); addToLog( - "deleteEvent", - "error", - "Attempt to delete event " + + "deleteEvent", + "error", + "Attempt to delete event " + + req.params.eventID + + " failed with error: token does not match", + ); + } + }) + .catch((err) => { + res.send("Sorry! Something went wrong: " + err); + addToLog( + "deleteEvent", + "error", + "Attempt to delete event " + req.params.eventID + " failed with error: " + - err - ); - }); - } - ); - } else { - // Token doesn't match - res.send("Sorry! Something went wrong"); - addToLog( - "deleteEvent", - "error", - "Attempt to delete event " + - req.params.eventID + - " failed with error: token does not match" - ); - } - }) - .catch((err) => { - res.send("Sorry! Something went wrong: " + err); - addToLog( - "deleteEvent", - "error", - "Attempt to delete event " + - req.params.eventID + - " failed with error: " + - err - ); - }); + err, + ); + }); }); router.post("/deleteeventgroup/:eventGroupID/:editToken", (req, res) => { - let submittedEditToken = req.params.editToken; - EventGroup.findOne({ - id: req.params.eventGroupID, - }) - .then(async (eventGroup) => { - if (eventGroup.editToken === submittedEditToken) { - // Token matches - - let linkedEvents = await Event.find({ eventGroup: eventGroup._id }); - - let linkedEventIDs = linkedEvents.map((event) => event._id); - let eventGroupImage = false; - if (eventGroup.image) { - eventGroupImage = eventGroup.image; - } + let submittedEditToken = req.params.editToken; + EventGroup.findOne({ + id: req.params.eventGroupID, + }) + .then(async (eventGroup) => { + if (eventGroup.editToken === submittedEditToken) { + // Token matches - EventGroup.deleteOne( - { id: req.params.eventGroupID }, - function (err, raw) { - if (err) { - res.send(err); - addToLog( - "deleteEventGroup", - "error", - "Attempt to delete event group " + - req.params.eventGroupID + - " failed with error: " + - err - ); - } - } - ) - .then(() => { - // Delete image - if (eventGroupImage) { - fs.unlink( - path.join(process.cwd(), "/public/events/" + eventGroupImage), - (err) => { - if (err) { - res.send(err); - addToLog( - "deleteEventGroup", - "error", - "Attempt to delete event image for event group " + - req.params.eventGroupID + - " failed with error: " + - err - ); - } + let linkedEvents = await Event.find({ + eventGroup: eventGroup._id, + }); + + let linkedEventIDs = linkedEvents.map((event) => event._id); + let eventGroupImage = false; + if (eventGroup.image) { + eventGroupImage = eventGroup.image; } - ); - } - Event.update( - { _id: { $in: linkedEventIDs } }, - { $set: { eventGroup: null } }, - { multi: true } - ) - .then((response) => { - console.log(response); + + EventGroup.deleteOne( + { id: req.params.eventGroupID }, + function (err, raw) { + if (err) { + res.send(err); + addToLog( + "deleteEventGroup", + "error", + "Attempt to delete event group " + + req.params.eventGroupID + + " failed with error: " + + err, + ); + } + }, + ) + .then(() => { + // Delete image + if (eventGroupImage) { + fs.unlink( + path.join( + process.cwd(), + "/public/events/" + eventGroupImage, + ), + (err) => { + if (err) { + res.send(err); + addToLog( + "deleteEventGroup", + "error", + "Attempt to delete event image for event group " + + req.params.eventGroupID + + " failed with error: " + + err, + ); + } + }, + ); + } + Event.update( + { _id: { $in: linkedEventIDs } }, + { $set: { eventGroup: null } }, + { multi: true }, + ) + .then((response) => { + console.log(response); + addToLog( + "deleteEventGroup", + "success", + "Event group " + + req.params.eventGroupID + + " deleted", + ); + res.writeHead(302, { + Location: "/", + }); + res.end(); + }) + .catch((err) => { + res.send( + "Sorry! Something went wrong (error deleting): " + + err, + ); + addToLog( + "deleteEventGroup", + "error", + "Attempt to delete event group " + + req.params.eventGroupID + + " failed with error: " + + err, + ); + }); + }) + .catch((err) => { + res.send( + "Sorry! Something went wrong (error deleting): " + + err, + ); + addToLog( + "deleteEventGroup", + "error", + "Attempt to delete event group " + + req.params.eventGroupID + + " failed with error: " + + err, + ); + }); + } else { + // Token doesn't match + res.send("Sorry! Something went wrong"); addToLog( - "deleteEventGroup", - "success", - "Event group " + req.params.eventGroupID + " deleted" - ); - res.writeHead(302, { - Location: "/", - }); - res.end(); - }) - .catch((err) => { - res.send( - "Sorry! Something went wrong (error deleting): " + err + "deleteEventGroup", + "error", + "Attempt to delete event group " + + req.params.eventGroupID + + " failed with error: token does not match", ); - addToLog( - "deleteEventGroup", - "error", - "Attempt to delete event group " + + } + }) + .catch((err) => { + res.send("Sorry! Something went wrong: " + err); + addToLog( + "deleteEventGroup", + "error", + "Attempt to delete event group " + req.params.eventGroupID + " failed with error: " + - err - ); - }); - }) - .catch((err) => { - res.send("Sorry! Something went wrong (error deleting): " + err); - addToLog( - "deleteEventGroup", - "error", - "Attempt to delete event group " + - req.params.eventGroupID + - " failed with error: " + - err + err, ); - }); - } else { - // Token doesn't match - res.send("Sorry! Something went wrong"); - addToLog( - "deleteEventGroup", - "error", - "Attempt to delete event group " + - req.params.eventGroupID + - " failed with error: token does not match" - ); - } - }) - .catch((err) => { - res.send("Sorry! Something went wrong: " + err); - addToLog( - "deleteEventGroup", - "error", - "Attempt to delete event group " + - req.params.eventGroupID + - " failed with error: " + - err - ); - }); + }); }); router.post("/attendee/provision", async (req, res) => { - const removalPassword = niceware.generatePassphrase(6).join("-"); - const newAttendee = { - status: "provisioned", - removalPassword, - created: Date.now(), - }; - - const event = await Event.findOne({ id: req.query.eventID }).catch((e) => { - addToLog( - "provisionEventAttendee", - "error", - "Attempt to provision attendee in event " + - req.query.eventID + - " failed with error: " + - e - ); - return res.sendStatus(500); - }); + const removalPassword = niceware.generatePassphrase(6).join("-"); + const newAttendee = { + status: "provisioned", + removalPassword, + created: Date.now(), + }; + + const event = await Event.findOne({ id: req.query.eventID }).catch((e) => { + addToLog( + "provisionEventAttendee", + "error", + "Attempt to provision attendee in event " + + req.query.eventID + + " failed with error: " + + e, + ); + return res.sendStatus(500); + }); - if (!event) { - return res.sendStatus(404); - } + if (!event) { + return res.sendStatus(404); + } - event.attendees.push(newAttendee); - await event.save().catch((e) => { - console.log(e); + event.attendees.push(newAttendee); + await event.save().catch((e) => { + console.log(e); + addToLog( + "provisionEventAttendee", + "error", + "Attempt to provision attendee in event " + + req.query.eventID + + " failed with error: " + + e, + ); + return res.sendStatus(500); + }); addToLog( - "provisionEventAttendee", - "error", - "Attempt to provision attendee in event " + - req.query.eventID + - " failed with error: " + - e + "provisionEventAttendee", + "success", + "Attendee provisioned in event " + req.query.eventID, ); - return res.sendStatus(500); - }); - addToLog( - "provisionEventAttendee", - "success", - "Attendee provisioned in event " + req.query.eventID - ); - - // Return the removal password and the number of free spots remaining - let freeSpots; - if (event.maxAttendees !== null && event.maxAttendees !== undefined) { - freeSpots = - event.maxAttendees - - event.attendees.reduce( - (acc, a) => acc + (a.status === "attending" ? a.number || 1 : 0), - 0 - ); - } else { - freeSpots = undefined; - } - return res.json({ removalPassword, freeSpots }); + + // Return the removal password and the number of free spots remaining + let freeSpots; + if (event.maxAttendees !== null && event.maxAttendees !== undefined) { + freeSpots = + event.maxAttendees - + event.attendees.reduce( + (acc, a) => + acc + (a.status === "attending" ? a.number || 1 : 0), + 0, + ); + } else { + freeSpots = undefined; + } + return res.json({ removalPassword, freeSpots }); }); router.post("/attendevent/:eventID", async (req, res) => { - // Do not allow empty removal passwords - if (!req.body.removalPassword) { - return res.sendStatus(500); - } - const event = await Event.findOne({ id: req.params.eventID }).catch((e) => { - addToLog( - "attendEvent", - "error", - "Attempt to attend event " + - req.params.eventID + - " failed with error: " + - e + // Do not allow empty removal passwords + if (!req.body.removalPassword) { + return res.sendStatus(500); + } + const event = await Event.findOne({ id: req.params.eventID }).catch((e) => { + addToLog( + "attendEvent", + "error", + "Attempt to attend event " + + req.params.eventID + + " failed with error: " + + e, + ); + return res.sendStatus(500); + }); + if (!event) { + return res.sendStatus(404); + } + const attendee = event.attendees.find( + (a) => a.removalPassword === req.body.removalPassword, ); - return res.sendStatus(500); - }); - if (!event) { - return res.sendStatus(404); - } - const attendee = event.attendees.find( - (a) => a.removalPassword === req.body.removalPassword - ); - if (!attendee) { - return res.sendStatus(404); - } - // Do we have enough free spots in this event to accomodate this attendee? - // First, check if the event has a max number of attendees - if (event.maxAttendees !== null && event.maxAttendees !== undefined) { - const freeSpots = - event.maxAttendees - - event.attendees.reduce( - (acc, a) => acc + (a.status === "attending" ? a.number || 1 : 0), - 0 - ); - if (req.body.attendeeNumber > freeSpots) { - return res.sendStatus(403); + if (!attendee) { + return res.sendStatus(404); } - } - - Event.findOneAndUpdate( - { - id: req.params.eventID, - "attendees.removalPassword": req.body.removalPassword, - }, - { - $set: { - "attendees.$.status": "attending", - "attendees.$.name": req.body.attendeeName, - "attendees.$.email": req.body.attendeeEmail, - "attendees.$.number": req.body.attendeeNumber, - }, + // Do we have enough free spots in this event to accomodate this attendee? + // First, check if the event has a max number of attendees + if (event.maxAttendees !== null && event.maxAttendees !== undefined) { + const freeSpots = + event.maxAttendees - + event.attendees.reduce( + (acc, a) => + acc + (a.status === "attending" ? a.number || 1 : 0), + 0, + ); + if (req.body.attendeeNumber > freeSpots) { + return res.sendStatus(403); + } } - ) - .then((event) => { - addToLog( - "addEventAttendee", - "success", - "Attendee added to event " + req.params.eventID - ); - if (sendEmails) { - if (req.body.attendeeEmail) { - req.app.get("hbsInstance").renderView( - "./views/emails/addeventattendee.handlebars", - { - eventID: req.params.eventID, - siteName, - siteLogo, - domain, - removalPassword: req.body.removalPassword, - cache: true, - layout: "email.handlebars", + + Event.findOneAndUpdate( + { + id: req.params.eventID, + "attendees.removalPassword": req.body.removalPassword, + }, + { + $set: { + "attendees.$.status": "attending", + "attendees.$.name": req.body.attendeeName, + "attendees.$.email": req.body.attendeeEmail, + "attendees.$.number": req.body.attendeeNumber, }, - function (err, html) { - const msg = { - to: req.body.attendeeEmail, - from: { - name: siteName, - email: 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; - } + }, + ) + .then((event) => { + addToLog( + "addEventAttendee", + "success", + "Attendee added to event " + req.params.eventID, + ); + if (sendEmails) { + if (req.body.attendeeEmail) { + req.app.get("hbsInstance").renderView( + "./views/emails/addeventattendee.handlebars", + { + eventID: req.params.eventID, + siteName, + siteLogo, + domain, + removalPassword: req.body.removalPassword, + cache: true, + layout: "email.handlebars", + }, + function (err, html) { + const msg = { + to: req.body.attendeeEmail, + from: { + name: siteName, + email: 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; + } + }, + ); + } } - ); - } - } - res.redirect(`/${req.params.eventID}`); - }) - .catch((error) => { - res.send("Database error, please try again :("); - addToLog( - "addEventAttendee", - "error", - "Attempt to add attendee to event " + - req.params.eventID + - " failed with error: " + - error - ); - }); + res.redirect(`/${req.params.eventID}`); + }) + .catch((error) => { + res.send("Database error, please try again :("); + addToLog( + "addEventAttendee", + "error", + "Attempt to add attendee to event " + + req.params.eventID + + " failed with error: " + + error, + ); + }); }); router.post("/unattendevent/:eventID", (req, res) => { - const removalPassword = req.body.removalPassword; - // Don't allow blank removal passwords! - if (!removalPassword) { - return res.sendStatus(500); - } - - Event.update( - { id: req.params.eventID }, - { $pull: { attendees: { removalPassword } } } - ) - .then((response) => { - console.log(response); - addToLog( - "unattendEvent", - "success", - "Attendee removed self from event " + req.params.eventID - ); - if (sendEmails) { - if (req.body.attendeeEmail) { - req.app.get("hbsInstance").renderView( - "./views/emails/unattendevent.handlebars", - { - eventID: req.params.eventID, - siteName, - siteLogo, - domain, - cache: true, - layout: "email.handlebars", - }, - function (err, html) { - const msg = { - to: req.body.attendeeEmail, - from: { - name: siteName, - email: 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; - } + const removalPassword = req.body.removalPassword; + // Don't allow blank removal passwords! + if (!removalPassword) { + return res.sendStatus(500); + } + + Event.update( + { id: req.params.eventID }, + { $pull: { attendees: { removalPassword } } }, + ) + .then((response) => { + console.log(response); + addToLog( + "unattendEvent", + "success", + "Attendee removed self from event " + req.params.eventID, + ); + if (sendEmails) { + if (req.body.attendeeEmail) { + req.app.get("hbsInstance").renderView( + "./views/emails/unattendevent.handlebars", + { + eventID: req.params.eventID, + siteName, + siteLogo, + domain, + cache: true, + layout: "email.handlebars", + }, + function (err, html) { + const msg = { + to: req.body.attendeeEmail, + from: { + name: siteName, + email: 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; + } + }, + ); + } } - ); - } - } - res.writeHead(302, { - Location: "/" + req.params.eventID, - }); - res.end(); - }) - .catch((err) => { - res.send("Database error, please try again :("); - addToLog( - "removeEventAttendee", - "error", - "Attempt to remove attendee from event " + - req.params.eventID + - " failed with error: " + - err - ); - }); + res.writeHead(302, { + Location: "/" + req.params.eventID, + }); + res.end(); + }) + .catch((err) => { + res.send("Database error, please try again :("); + addToLog( + "removeEventAttendee", + "error", + "Attempt to remove attendee from event " + + req.params.eventID + + " failed with error: " + + err, + ); + }); }); // this is a one-click unattend that requires a secret URL that only the person who RSVPed over // activitypub knows router.get("/oneclickunattendevent/:eventID/:attendeeID", (req, res) => { - // Mastodon will "click" links that sent to its users, presumably as a prefetch? - // Anyway, this ignores the automated clicks that are done without the user's knowledge - if ( - req.headers["user-agent"] && - req.headers["user-agent"].includes("Mastodon") - ) { - return res.sendStatus(200); - } - Event.update( - { id: req.params.eventID }, - { $pull: { attendees: { _id: req.params.attendeeID } } } - ) - .then((response) => { - 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.handlebars", - { - eventName: req.params.eventName, - siteName, - domain, - cache: true, - layout: "email.handlebars", - }, - function (err, html) { - const msg = { - to: req.body.attendeeEmail, - from: { - name: siteName, - email: 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; - } + // Mastodon will "click" links that sent to its users, presumably as a prefetch? + // Anyway, this ignores the automated clicks that are done without the user's knowledge + if ( + req.headers["user-agent"] && + req.headers["user-agent"].includes("Mastodon") + ) { + return res.sendStatus(200); + } + Event.update( + { id: req.params.eventID }, + { $pull: { attendees: { _id: req.params.attendeeID } } }, + ) + .then((response) => { + 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.handlebars", + { + eventName: req.params.eventName, + siteName, + domain, + cache: true, + layout: "email.handlebars", + }, + function (err, html) { + const msg = { + to: req.body.attendeeEmail, + from: { + name: siteName, + email: 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; + } + }, + ); + } } - ); - } - } - res.writeHead(302, { - Location: "/" + req.params.eventID, - }); - res.end(); - }) - .catch((err) => { - res.send("Database error, please try again :("); - addToLog( - "removeEventAttendee", - "error", - "Attempt to remove attendee by admin from event " + - req.params.eventID + - " failed with error: " + - err - ); - }); + res.writeHead(302, { + Location: "/" + req.params.eventID, + }); + res.end(); + }) + .catch((err) => { + res.send("Database error, please try again :("); + addToLog( + "removeEventAttendee", + "error", + "Attempt to remove attendee by admin from event " + + req.params.eventID + + " failed with error: " + + err, + ); + }); }); router.post("/removeattendee/:eventID/:attendeeID", (req, res) => { - Event.update( - { id: req.params.eventID }, - { $pull: { attendees: { _id: req.params.attendeeID } } } - ) - .then((response) => { - console.log(response); - 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.handlebars", - { - eventName: req.params.eventName, - siteName, - siteLogo, - domain, - cache: true, - layout: "email.handlebars", - }, - function (err, html) { - const msg = { - to: req.body.attendeeEmail, - from: { - name: siteName, - email: 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; - } + Event.update( + { id: req.params.eventID }, + { $pull: { attendees: { _id: req.params.attendeeID } } }, + ) + .then((response) => { + console.log(response); + 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.handlebars", + { + eventName: req.params.eventName, + siteName, + siteLogo, + domain, + cache: true, + layout: "email.handlebars", + }, + function (err, html) { + const msg = { + to: req.body.attendeeEmail, + from: { + name: siteName, + email: 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; + } + }, + ); + } } - ); - } - } - res.writeHead(302, { - Location: "/" + req.params.eventID, - }); - res.end(); - }) - .catch((err) => { - res.send("Database error, please try again :("); - addToLog( - "removeEventAttendee", - "error", - "Attempt to remove attendee by admin from event " + - req.params.eventID + - " failed with error: " + - err - ); - }); + res.writeHead(302, { + Location: "/" + req.params.eventID, + }); + res.end(); + }) + .catch((err) => { + res.send("Database error, please try again :("); + addToLog( + "removeEventAttendee", + "error", + "Attempt to remove attendee by admin from event " + + req.params.eventID + + " failed with error: " + + err, + ); + }); }); /* * Create an email subscription on an event group. */ router.post("/subscribe/:eventGroupID", (req, res) => { - const subscriber = { - email: req.body.emailAddress, - }; - if (!subscriber.email) { - return res.sendStatus(500); - } - - EventGroup.findOne({ - id: req.params.eventGroupID, - }) - .then((eventGroup) => { - if (!eventGroup) { - return res.sendStatus(404); - } - eventGroup.subscribers.push(subscriber); - eventGroup.save(); - if (sendEmails) { - req.app.get("hbsInstance").renderView( - "./views/emails/subscribed.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: { - name: siteName, - email: 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; - } - } - ); - } - return res.redirect(`/group/${eventGroup.id}`); + const subscriber = { + email: req.body.emailAddress, + }; + if (!subscriber.email) { + return res.sendStatus(500); + } + + EventGroup.findOne({ + id: req.params.eventGroupID, }) - .catch((error) => { - addToLog( - "addSubscription", - "error", - "Attempt to subscribe " + - req.body.emailAddress + - " to event group " + - req.params.eventGroupID + - " failed with error: " + - error - ); - return res.sendStatus(500); - }); + .then((eventGroup) => { + if (!eventGroup) { + return res.sendStatus(404); + } + eventGroup.subscribers.push(subscriber); + eventGroup.save(); + if (sendEmails) { + req.app.get("hbsInstance").renderView( + "./views/emails/subscribed.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: { + name: siteName, + email: 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; + } + }, + ); + } + return res.redirect(`/group/${eventGroup.id}`); + }) + .catch((error) => { + addToLog( + "addSubscription", + "error", + "Attempt to subscribe " + + req.body.emailAddress + + " to event group " + + req.params.eventGroupID + + " failed with error: " + + error, + ); + return res.sendStatus(500); + }); }); /* * Delete an existing email subscription on an event group. */ router.get("/unsubscribe/:eventGroupID", (req, res) => { - const email = req.query.email; - console.log(email); - if (!email) { - return res.sendStatus(500); - } - - EventGroup.update( - { id: req.params.eventGroupID }, - { $pull: { subscribers: { email } } } - ) - .then((response) => { - return res.redirect("/"); - }) - .catch((error) => { - addToLog( - "removeSubscription", - "error", - "Attempt to unsubscribe " + - req.query.email + - " from event group " + - req.params.eventGroupID + - " failed with error: " + - error - ); - return res.sendStatus(500); - }); + const email = req.query.email; + console.log(email); + if (!email) { + return res.sendStatus(500); + } + + EventGroup.update( + { id: req.params.eventGroupID }, + { $pull: { subscribers: { email } } }, + ) + .then((response) => { + return res.redirect("/"); + }) + .catch((error) => { + addToLog( + "removeSubscription", + "error", + "Attempt to unsubscribe " + + req.query.email + + " from event group " + + req.params.eventGroupID + + " failed with error: " + + error, + ); + return res.sendStatus(500); + }); }); router.post("/post/comment/:eventID", (req, res) => { - let commentID = nanoid(); - const newComment = { - id: commentID, - author: req.body.commentAuthor, - content: req.body.commentContent, - timestamp: moment(), - }; - - Event.findOne( - { - id: req.params.eventID, - }, - function (err, event) { - if (!event) return; - event.comments.push(newComment); - event - .save() - .then(() => { - addToLog( - "addEventComment", - "success", - "Comment added to event " + req.params.eventID - ); - // broadcast an identical message to all followers, will show in their home timeline - // and in the home timeline of the event - const guidObject = crypto.randomBytes(16).toString("hex"); - const jsonObject = { - "@context": "https://www.w3.org/ns/activitystreams", - id: `https://${domain}/${req.params.eventID}/m/${guidObject}`, - name: `Comment on ${event.name}`, - type: "Note", - cc: "https://www.w3.org/ns/activitystreams#Public", - content: `

${req.body.commentAuthor} commented: ${req.body.commentContent}.

See the full conversation here.

`, - }; - broadcastCreateMessage( - jsonObject, - 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.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: { - name: siteName, - email: contactEmail, - }, - subject: `${siteName}: New comment in ${event.name}`, - html, + let commentID = nanoid(); + const newComment = { + id: commentID, + author: req.body.commentAuthor, + content: req.body.commentContent, + timestamp: moment(), + }; + + Event.findOne( + { + id: req.params.eventID, + }, + function (err, event) { + if (!event) return; + event.comments.push(newComment); + event + .save() + .then(() => { + addToLog( + "addEventComment", + "success", + "Comment added to event " + req.params.eventID, + ); + // broadcast an identical message to all followers, will show in their home timeline + // and in the home timeline of the event + const guidObject = crypto.randomBytes(16).toString("hex"); + const jsonObject = { + "@context": "https://www.w3.org/ns/activitystreams", + id: `https://${domain}/${req.params.eventID}/m/${guidObject}`, + name: `Comment on ${event.name}`, + type: "Note", + cc: "https://www.w3.org/ns/activitystreams#Public", + content: `

${req.body.commentAuthor} commented: ${req.body.commentContent}.

See the full conversation here.

`, }; - 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; + broadcastCreateMessage( + jsonObject, + 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.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: { + name: siteName, + email: 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!"); + } + }, + ); } - } - ); - } else { - console.log("Nothing to send!"); - } - }); - } - res.writeHead(302, { - Location: "/" + req.params.eventID, - }); - res.end(); - }) - .catch((err) => { - res.send("Database error, please try again :(" + err); - addToLog( - "addEventComment", - "error", - "Attempt to add comment to event " + - req.params.eventID + - " failed with error: " + - err - ); - }); - } - ); + res.writeHead(302, { + Location: "/" + req.params.eventID, + }); + res.end(); + }) + .catch((err) => { + res.send("Database error, please try again :(" + err); + addToLog( + "addEventComment", + "error", + "Attempt to add comment to event " + + req.params.eventID + + " failed with error: " + + err, + ); + }); + }, + ); }); router.post("/post/reply/:eventID/:commentID", (req, res) => { - let replyID = nanoid(); - let commentID = req.params.commentID; - const newReply = { - id: replyID, - author: req.body.replyAuthor, - content: req.body.replyContent, - timestamp: moment(), - }; - Event.findOne( - { - id: req.params.eventID, - }, - function (err, event) { - if (!event) return; - var parentComment = event.comments.id(commentID); - parentComment.replies.push(newReply); - event - .save() - .then(() => { - addToLog( - "addEventReply", - "success", - "Reply added to comment " + - 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"); - const jsonObject = { - "@context": "https://www.w3.org/ns/activitystreams", - id: `https://${domain}/${req.params.eventID}/m/${guidObject}`, - name: `Comment on ${event.name}`, - type: "Note", - cc: "https://www.w3.org/ns/activitystreams#Public", - content: `

${req.body.replyAuthor} commented: ${req.body.replyContent}

See the full conversation here.

`, - }; - broadcastCreateMessage( - jsonObject, - 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.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: { - name: siteName, - email: contactEmail, - }, - subject: `${siteName}: New comment in ${event.name}`, - html, + let replyID = nanoid(); + let commentID = req.params.commentID; + const newReply = { + id: replyID, + author: req.body.replyAuthor, + content: req.body.replyContent, + timestamp: moment(), + }; + Event.findOne( + { + id: req.params.eventID, + }, + function (err, event) { + if (!event) return; + var parentComment = event.comments.id(commentID); + parentComment.replies.push(newReply); + event + .save() + .then(() => { + addToLog( + "addEventReply", + "success", + "Reply added to comment " + + 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"); + const jsonObject = { + "@context": "https://www.w3.org/ns/activitystreams", + id: `https://${domain}/${req.params.eventID}/m/${guidObject}`, + name: `Comment on ${event.name}`, + type: "Note", + cc: "https://www.w3.org/ns/activitystreams#Public", + content: `

${req.body.replyAuthor} commented: ${req.body.replyContent}

See the full conversation here.

`, }; - 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; + broadcastCreateMessage( + jsonObject, + 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.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: { + name: siteName, + email: 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!"); + } + }, + ); } - } - ); - } else { - console.log("Nothing to send!"); - } - }); - } - res.writeHead(302, { - Location: "/" + req.params.eventID, - }); - res.end(); - }) - .catch((err) => { - res.send("Database error, please try again :("); - addToLog( - "addEventReply", - "error", - "Attempt to add reply to comment " + - commentID + - " in event " + - req.params.eventID + - " failed with error: " + - err - ); - }); - } - ); + res.writeHead(302, { + Location: "/" + req.params.eventID, + }); + res.end(); + }) + .catch((err) => { + res.send("Database error, please try again :("); + addToLog( + "addEventReply", + "error", + "Attempt to add reply to comment " + + commentID + + " in event " + + req.params.eventID + + " failed with error: " + + err, + ); + }); + }, + ); }); router.post("/deletecomment/:eventID/:commentID/:editToken", (req, res) => { - let submittedEditToken = req.params.editToken; - Event.findOne({ - id: req.params.eventID, - }) - .then((event) => { - if (event.editToken === submittedEditToken) { - // Token matches - event.comments.id(req.params.commentID).remove(); - event - .save() - .then(() => { - addToLog( - "deleteComment", - "success", - "Comment deleted from event " + req.params.eventID - ); - res.writeHead(302, { - Location: "/" + req.params.eventID + "?e=" + req.params.editToken, - }); - res.end(); - }) - .catch((err) => { - res.send("Sorry! Something went wrong (error deleting): " + err); + let submittedEditToken = req.params.editToken; + Event.findOne({ + id: req.params.eventID, + }) + .then((event) => { + if (event.editToken === submittedEditToken) { + // Token matches + event.comments.id(req.params.commentID).remove(); + event + .save() + .then(() => { + addToLog( + "deleteComment", + "success", + "Comment deleted from event " + req.params.eventID, + ); + res.writeHead(302, { + Location: + "/" + + req.params.eventID + + "?e=" + + req.params.editToken, + }); + res.end(); + }) + .catch((err) => { + res.send( + "Sorry! Something went wrong (error deleting): " + + err, + ); + addToLog( + "deleteComment", + "error", + "Attempt to delete comment " + + req.params.commentID + + "from event " + + req.params.eventID + + " failed with error: " + + err, + ); + }); + } else { + // Token doesn't match + res.send("Sorry! Something went wrong"); + addToLog( + "deleteComment", + "error", + "Attempt to delete comment " + + req.params.commentID + + "from event " + + req.params.eventID + + " failed with error: token does not match", + ); + } + }) + .catch((err) => { + res.send("Sorry! Something went wrong: " + err); addToLog( - "deleteComment", - "error", - "Attempt to delete comment " + - req.params.commentID + - "from event " + - req.params.eventID + - " failed with error: " + - err + "deleteComment", + "error", + "Attempt to delete comment " + + req.params.commentID + + "from event " + + req.params.eventID + + " failed with error: " + + err, ); - }); - } else { - // Token doesn't match - res.send("Sorry! Something went wrong"); - addToLog( - "deleteComment", - "error", - "Attempt to delete comment " + - req.params.commentID + - "from event " + - req.params.eventID + - " failed with error: token does not match" - ); - } - }) - .catch((err) => { - res.send("Sorry! Something went wrong: " + err); - addToLog( - "deleteComment", - "error", - "Attempt to delete comment " + - req.params.commentID + - "from event " + - req.params.eventID + - " failed with error: " + - err - ); - }); + }); }); router.post("/activitypub/inbox", (req, res) => { - if (!isFederated) return res.sendStatus(404); - // validate the incoming message - const signature = req.get("Signature"); - let signature_header = signature - .split(",") - .map((pair) => { - return pair.split("=").map((value) => { - return value.replace(/^"/g, "").replace(/"$/g, ""); - }); - }) - .reduce((acc, el) => { - acc[el[0]] = el[1]; - return acc; - }, {}); - - // get the actor - // TODO if this is a Delete for an Actor this won't work - request( - { - url: signature_header.keyId, - headers: { - Accept: "application/activity+json", - "Content-Type": "application/activity+json", - }, - }, - function (error, response, actor) { - let publicKey = ""; - - try { - if (JSON.parse(actor).publicKey) { - publicKey = JSON.parse(actor).publicKey.publicKeyPem; - } - } catch (err) { - return res.status(500).send("Actor could not be parsed" + err); - } - - let comparison_string = signature_header.headers - .split(" ") - .map((header) => { - if (header === "(request-target)") { - return "(request-target): post /activitypub/inbox"; - } else { - return `${header}: ${req.get(header)}`; - } + if (!isFederated) return res.sendStatus(404); + // validate the incoming message + const signature = req.get("Signature"); + let signature_header = signature + .split(",") + .map((pair) => { + return pair.split("=").map((value) => { + return value.replace(/^"/g, "").replace(/"$/g, ""); + }); }) - .join("\n"); - - const verifier = crypto.createVerify("RSA-SHA256"); - verifier.update(comparison_string, "ascii"); - const publicKeyBuf = new Buffer(publicKey, "ascii"); - const signatureBuf = new Buffer(signature_header.signature, "base64"); - try { - const result = verifier.verify(publicKeyBuf, signatureBuf); - if (result) { - // actually process the ActivityPub message now that it's been verified - processInbox(req, res); - } else { - return res.status(401).send("Signature could not be verified."); - } - } catch (err) { - return res.status(401).send("Signature could not be verified: " + err); - } - } - ); + .reduce((acc, el) => { + acc[el[0]] = el[1]; + return acc; + }, {}); + + // get the actor + // TODO if this is a Delete for an Actor this won't work + request( + { + url: signature_header.keyId, + headers: { + Accept: "application/activity+json", + "Content-Type": "application/activity+json", + }, + }, + function (error, response, actor) { + let publicKey = ""; + + try { + if (JSON.parse(actor).publicKey) { + publicKey = JSON.parse(actor).publicKey.publicKeyPem; + } + } catch (err) { + return res.status(500).send("Actor could not be parsed" + err); + } + + let comparison_string = signature_header.headers + .split(" ") + .map((header) => { + if (header === "(request-target)") { + return "(request-target): post /activitypub/inbox"; + } else { + return `${header}: ${req.get(header)}`; + } + }) + .join("\n"); + + const verifier = crypto.createVerify("RSA-SHA256"); + verifier.update(comparison_string, "ascii"); + const publicKeyBuf = new Buffer(publicKey, "ascii"); + const signatureBuf = new Buffer( + signature_header.signature, + "base64", + ); + try { + const result = verifier.verify(publicKeyBuf, signatureBuf); + if (result) { + // actually process the ActivityPub message now that it's been verified + processInbox(req, res); + } else { + return res + .status(401) + .send("Signature could not be verified."); + } + } catch (err) { + return res + .status(401) + .send("Signature could not be verified: " + err); + } + }, + ); }); router.use(function (req, res, next) { - res.status(404); - res.render("404", { url: req.url }); - return; + res.status(404); + res.render("404", { url: req.url }); + return; }); addToLog("startup", "success", "Started up successfully"); diff --git a/src/routes/frontend.ts b/src/routes/frontend.ts index 9dea619..71984ec 100644 --- a/src/routes/frontend.ts +++ b/src/routes/frontend.ts @@ -11,210 +11,214 @@ const config = getConfig(); const router = Router(); router.get("/", (_: Request, res: Response) => { - res.render("home", frontendConfig()); + res.render("home", frontendConfig()); }); router.get("/new", (_: Request, res: Response) => { - res.render("newevent", { - title: "New event", - ...frontendConfig(), - }); + res.render("newevent", { + title: "New event", + ...frontendConfig(), + }); }); router.get("/:eventID", async (req: Request, res: Response) => { - try { - const event = await Event.findOne({ - id: req.params.eventID, - }) - .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) { - res.status(404); - res.render("404", { url: req.url }); - return; - } - const parsedLocation = event.location.replace(/\s+/g, "+"); - let displayDate; - 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 [from] h:mm a' - ) + - moment - .tz(event.end, event.timezone) - .format( - ' [to] h:mm a [](z)[]' - ); - } else { - displayDate = - moment - .tz(event.start, event.timezone) - .format( - 'dddd D MMMM YYYY [at] h:mm a' - ) + - moment - .tz(event.end, event.timezone) - .format( - ' [] dddd D MMMM YYYY [at] h:mm a [](z)[]' - ); - } - let eventStartISO = moment.tz(event.start, "Etc/UTC").toISOString(); - let eventEndISO = moment.tz(event.end, "Etc/UTC").toISOString(); - let parsedStart = moment - .tz(event.start, event.timezone) - .format("YYYYMMDD[T]HHmmss"); - let parsedEnd = moment - .tz(event.end, event.timezone) - .format("YYYYMMDD[T]HHmmss"); - let eventHasConcluded = false; - if ( - moment.tz(event.end, event.timezone).isBefore(moment.tz(event.timezone)) - ) { - eventHasConcluded = true; - } - let eventHasBegun = false; - if ( - moment.tz(event.start, event.timezone).isBefore(moment.tz(event.timezone)) - ) { - eventHasBegun = true; - } - let fromNow = moment.tz(event.start, event.timezone).fromNow(); - let parsedDescription = marked.parse(event.description); - let eventEditToken = event.editToken; + try { + const event = await Event.findOne({ + id: req.params.eventID, + }) + .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) { + res.status(404); + res.render("404", { url: req.url }); + return; + } + const parsedLocation = event.location.replace(/\s+/g, "+"); + let displayDate; + 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 [from] h:mm a', + ) + + moment + .tz(event.end, event.timezone) + .format( + ' [to] h:mm a [](z)[]', + ); + } else { + displayDate = + moment + .tz(event.start, event.timezone) + .format( + 'dddd D MMMM YYYY [at] h:mm a', + ) + + moment + .tz(event.end, event.timezone) + .format( + ' [] dddd D MMMM YYYY [at] h:mm a [](z)[]', + ); + } + let eventStartISO = moment.tz(event.start, "Etc/UTC").toISOString(); + let eventEndISO = moment.tz(event.end, "Etc/UTC").toISOString(); + let parsedStart = moment + .tz(event.start, event.timezone) + .format("YYYYMMDD[T]HHmmss"); + let parsedEnd = moment + .tz(event.end, event.timezone) + .format("YYYYMMDD[T]HHmmss"); + let eventHasConcluded = false; + if ( + moment + .tz(event.end, event.timezone) + .isBefore(moment.tz(event.timezone)) + ) { + eventHasConcluded = true; + } + let eventHasBegun = false; + if ( + moment + .tz(event.start, event.timezone) + .isBefore(moment.tz(event.timezone)) + ) { + eventHasBegun = true; + } + let fromNow = moment.tz(event.start, event.timezone).fromNow(); + let parsedDescription = marked.parse(event.description); + let eventEditToken = event.editToken; - let escapedName = event.name.replace(/\s+/g, "+"); + let escapedName = event.name.replace(/\s+/g, "+"); - let eventHasCoverImage = false; - if (event.image) { - eventHasCoverImage = true; - } else { - eventHasCoverImage = false; - } - let eventHasHost = false; - if (event.hostName) { - eventHasHost = true; - } else { - eventHasHost = false; - } - let firstLoad = false; - if (event.firstLoad === true) { - firstLoad = true; - await Event.findOneAndUpdate( - { id: req.params.eventID }, - { firstLoad: false } - ); - } - let editingEnabled = false; - if (Object.keys(req.query).length !== 0) { - if (!req.query.e) { - editingEnabled = false; - console.log("No edit token set"); - } else { - if (req.query.e === eventEditToken) { - editingEnabled = true; + let eventHasCoverImage = false; + if (event.image) { + eventHasCoverImage = true; } else { - editingEnabled = false; + eventHasCoverImage = false; } - } - } - let eventAttendees = event.attendees - ?.sort((a, b) => (a.name > b.name ? 1 : b.name > a.name ? -1 : 0)) - .map((el) => { - if (!el.id) { - el.id = el._id; + let eventHasHost = false; + if (event.hostName) { + eventHasHost = true; + } else { + eventHasHost = false; } - if (el.number && el.number > 1) { - el.name = `${el.name} (${el.number} people)`; + let firstLoad = false; + if (event.firstLoad === true) { + firstLoad = true; + await Event.findOneAndUpdate( + { id: req.params.eventID }, + { firstLoad: false }, + ); } - return el; - }) - .filter((obj, pos, arr) => { - return ( - obj.status === "attending" && - arr.map((mapObj) => mapObj.id).indexOf(obj.id) === pos - ); - }); + let editingEnabled = false; + if (Object.keys(req.query).length !== 0) { + if (!req.query.e) { + editingEnabled = false; + console.log("No edit token set"); + } else { + if (req.query.e === eventEditToken) { + editingEnabled = true; + } else { + editingEnabled = false; + } + } + } + let eventAttendees = event.attendees + ?.sort((a, b) => (a.name > b.name ? 1 : b.name > a.name ? -1 : 0)) + .map((el) => { + if (!el.id) { + el.id = el._id; + } + if (el.number && el.number > 1) { + el.name = `${el.name} (${el.number} people)`; + } + return el; + }) + .filter((obj, pos, arr) => { + return ( + obj.status === "attending" && + arr.map((mapObj) => mapObj.id).indexOf(obj.id) === pos + ); + }); - let spotsRemaining, noMoreSpots; - let numberOfAttendees = - eventAttendees?.reduce((acc, attendee) => { - if (attendee.status === "attending") { - return acc + (attendee.number || 1); + let spotsRemaining, noMoreSpots; + let numberOfAttendees = + eventAttendees?.reduce((acc, attendee) => { + if (attendee.status === "attending") { + return acc + (attendee.number || 1); + } + return acc; + }, 0) || 0; + if (event.maxAttendees) { + spotsRemaining = event.maxAttendees - numberOfAttendees; + if (spotsRemaining <= 0) { + noMoreSpots = true; + } } - return acc; - }, 0) || 0; - if (event.maxAttendees) { - spotsRemaining = event.maxAttendees - numberOfAttendees; - if (spotsRemaining <= 0) { - noMoreSpots = true; - } - } - let metadata = { - title: event.name, - description: marked - .parse(event.description, { renderer: renderPlain() }) - .split(" ") - .splice(0, 40) - .join(" ") - .trim(), - image: eventHasCoverImage - ? `https://${config.general.domain}/events/` + event.image - : null, - url: `https://${config.general.domain}/` + req.params.eventID, - }; - if ( - req.headers.accept && - (req.headers.accept.includes("application/activity+json") || - req.headers.accept.includes("application/json") || - req.headers.accept.includes("application/json+ld")) - ) { - res - .header("Content-Type", "application/activity+json") - .send(JSON.parse(event.activityPubActor || "{}")); - } else { - res.set("X-Robots-Tag", "noindex"); - res.render("event", { - ...frontendConfig(), - title: event.name, - escapedName: escapedName, - eventData: event, - eventAttendees: eventAttendees, - numberOfAttendees, - spotsRemaining: spotsRemaining, - noMoreSpots: noMoreSpots, - eventStartISO: eventStartISO, - eventEndISO: eventEndISO, - parsedLocation: parsedLocation, - parsedStart: parsedStart, - parsedEnd: parsedEnd, - displayDate: displayDate, - fromNow: fromNow, - timezone: event.timezone, - parsedDescription: parsedDescription, - editingEnabled: editingEnabled, - eventHasCoverImage: eventHasCoverImage, - eventHasHost: eventHasHost, - firstLoad: firstLoad, - eventHasConcluded: eventHasConcluded, - eventHasBegun: eventHasBegun, - metadata: metadata, - }); + let metadata = { + title: event.name, + description: marked + .parse(event.description, { renderer: renderPlain() }) + .split(" ") + .splice(0, 40) + .join(" ") + .trim(), + image: eventHasCoverImage + ? `https://${config.general.domain}/events/` + event.image + : null, + url: `https://${config.general.domain}/` + req.params.eventID, + }; + if ( + req.headers.accept && + (req.headers.accept.includes("application/activity+json") || + req.headers.accept.includes("application/json") || + req.headers.accept.includes("application/json+ld")) + ) { + res.header("Content-Type", "application/activity+json").send( + JSON.parse(event.activityPubActor || "{}"), + ); + } else { + res.set("X-Robots-Tag", "noindex"); + res.render("event", { + ...frontendConfig(), + title: event.name, + escapedName: escapedName, + eventData: event, + eventAttendees: eventAttendees, + numberOfAttendees, + spotsRemaining: spotsRemaining, + noMoreSpots: noMoreSpots, + eventStartISO: eventStartISO, + eventEndISO: eventEndISO, + parsedLocation: parsedLocation, + parsedStart: parsedStart, + parsedEnd: parsedEnd, + displayDate: displayDate, + fromNow: fromNow, + timezone: event.timezone, + parsedDescription: parsedDescription, + editingEnabled: editingEnabled, + eventHasCoverImage: eventHasCoverImage, + eventHasHost: eventHasHost, + firstLoad: firstLoad, + eventHasConcluded: eventHasConcluded, + eventHasBegun: eventHasBegun, + metadata: metadata, + }); + } + } catch (err) { + addToLog( + "displayEvent", + "error", + "Attempt to display event " + + req.params.eventID + + " failed with error: " + + err, + ); + console.log(err); + res.status(404).render("404", { url: req.url }); } - } catch (err) { - addToLog( - "displayEvent", - "error", - "Attempt to display event " + - req.params.eventID + - " failed with error: " + - err - ); - console.log(err); - res.status(404).render("404", { url: req.url }); - } }); export default router; diff --git a/src/start.ts b/src/start.ts index fcdfaea..a6399ac 100755 --- a/src/start.ts +++ b/src/start.ts @@ -5,21 +5,21 @@ import app from "./app.js"; const config = getConfig(); mongoose.connect(config.database.mongodb_url, { - useNewUrlParser: true, - useUnifiedTopology: true, + useNewUrlParser: true, + useUnifiedTopology: true, }); mongoose.set("useCreateIndex", true); mongoose.Promise = global.Promise; mongoose.connection - .on("connected", () => { - console.log("Mongoose connection open!"); - }) - .on("error", (err: any) => { - console.log(`Connection error: ${err.message}`); - }); + .on("connected", () => { + console.log("Mongoose connection open!"); + }) + .on("error", (err: any) => { + console.log(`Connection error: ${err.message}`); + }); const server = app.listen(config.general.port, () => { - console.log( - `Welcome to gathio! The app is now running on http://localhost:${config.general.port}` - ); + console.log( + `Welcome to gathio! The app is now running on http://localhost:${config.general.port}`, + ); }); diff --git a/src/util/config.ts b/src/util/config.ts index a012398..c65fdb0 100644 --- a/src/util/config.ts +++ b/src/util/config.ts @@ -3,15 +3,15 @@ import getConfig from "../lib/config.js"; const config = getConfig(); interface FrontendConfig { - domain: string; - email: string; - siteName: string; - showKofi: boolean; + domain: string; + email: string; + siteName: string; + showKofi: boolean; } export const frontendConfig = (): FrontendConfig => ({ - domain: config.general.domain, - email: config.general.email, - siteName: config.general.site_name, - showKofi: config.general.show_kofi, + domain: config.general.domain, + email: config.general.email, + siteName: config.general.site_name, + showKofi: config.general.show_kofi, }); diff --git a/src/util/markdown.ts b/src/util/markdown.ts index b1577d9..9f5d384 100644 --- a/src/util/markdown.ts +++ b/src/util/markdown.ts @@ -5,40 +5,40 @@ import { marked } from "marked"; // ? to ? helper function htmlEscapeToText(text: string) { - return text.replace(/\&\#[0-9]*;|&/g, function (escapeCode) { - if (escapeCode.match(/amp/)) { - return "&"; - } - const code = escapeCode.match(/[0-9]+/); - return String.fromCharCode(Number(code)); - }); + return text.replace(/\&\#[0-9]*;|&/g, function (escapeCode) { + if (escapeCode.match(/amp/)) { + return "&"; + } + const code = escapeCode.match(/[0-9]+/); + return String.fromCharCode(Number(code)); + }); } export const renderPlain = () => { - var render = new marked.Renderer(); - // render just the text of a link, strong, em - render.link = function (href, title, text) { - return text; - }; - render.strong = function (text) { - return text; - }; - render.em = function (text) { - return text; - }; - // render just the text of a paragraph - render.paragraph = function (text) { - return htmlEscapeToText(text) + "\r\n"; - }; - // render nothing for headings, images, and br - render.heading = function (text, level) { - return ""; - }; - render.image = function (href, title, text) { - return ""; - }; - render.br = function () { - return ""; - }; - return render; + var render = new marked.Renderer(); + // render just the text of a link, strong, em + render.link = function (href, title, text) { + return text; + }; + render.strong = function (text) { + return text; + }; + render.em = function (text) { + return text; + }; + // render just the text of a paragraph + render.paragraph = function (text) { + return htmlEscapeToText(text) + "\r\n"; + }; + // render nothing for headings, images, and br + render.heading = function (text, level) { + return ""; + }; + render.image = function (href, title, text) { + return ""; + }; + render.br = function () { + return ""; + }; + return render; }; diff --git a/tsconfig.json b/tsconfig.json index 89f1604..4e6ba76 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,22 +1,22 @@ { - "compilerOptions": { - "target": "ES2020", - "module": "ES2020", - "allowJs": true, - "checkJs": true, - "removeComments": true, - "resolveJsonModule": true, - "typeRoots": ["./node_modules/@types"], - "sourceMap": true, - "outDir": "dist", - "strict": true, - "baseUrl": ".", - "forceConsistentCasingInFileNames": true, - "esModuleInterop": true, - "experimentalDecorators": true, - "emitDecoratorMetadata": true, - "moduleResolution": "nodenext", - "skipLibCheck": true, - }, - "include": ["src/**/*"] + "compilerOptions": { + "target": "ES2020", + "module": "ES2020", + "allowJs": true, + "checkJs": true, + "removeComments": true, + "resolveJsonModule": true, + "typeRoots": ["./node_modules/@types"], + "sourceMap": true, + "outDir": "dist", + "strict": true, + "baseUrl": ".", + "forceConsistentCasingInFileNames": true, + "esModuleInterop": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "moduleResolution": "nodenext", + "skipLibCheck": true + }, + "include": ["src/**/*"] } From 0549a34053f43f6be73c5187a930e357ef493ba3 Mon Sep 17 00:00:00 2001 From: Raphael Kabo Date: Fri, 6 Oct 2023 16:44:54 +0100 Subject: [PATCH 11/12] Fix Cypress tests --- cypress/e2e/event.cy.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cypress/e2e/event.cy.ts b/cypress/e2e/event.cy.ts index 833a3c9..3536806 100644 --- a/cypress/e2e/event.cy.ts +++ b/cypress/e2e/event.cy.ts @@ -82,7 +82,7 @@ describe("Events", () => { cy.get(".p-summary").should("contain.text", eventData.eventDescription); cy.get("#hosted-by").should( "contain.text", - `Hosted by ${eventData.hostName}`, + `Hosted by ${eventData.hostName}` ); cy.get("#attendees-alert").should("contain.text", "10 spots remaining"); let [startDate, startTime] = this.eventStart.split(", "); @@ -99,12 +99,13 @@ describe("Events", () => { it("allows you to attend an event", function () { cy.get("button#attendEvent").click(); cy.get("#attendeeName").type("Test Attendee"); - cy.get("#attendeeNumber").clear().type("2"); + cy.get("#attendeeNumber").clear(); + cy.get("#attendeeNumber").type("2"); cy.get("form#attendEventForm").submit(); cy.get("#attendees-alert").should("contain.text", "8 spots remaining"); cy.get(".attendeesList").should( "contain.text", - "Test Attendee (2 people)", + "Test Attendee (2 people)" ); }); From 722d54e5ae8957436818b14e7aea613b19b12d28 Mon Sep 17 00:00:00 2001 From: Raphael Kabo Date: Fri, 6 Oct 2023 16:50:51 +0100 Subject: [PATCH 12/12] ci: upload screenshots on failure --- .github/workflows/ci.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 45f0177..087d25e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -65,3 +65,10 @@ jobs: with: start: pnpm start browser: chrome + + - name: Upload screenshots + uses: actions/upload-artifact@v3 + if: failure() + with: + name: cypress-screenshots + path: cypress/screenshots