diff options
author | Raphael <mail@raphaelkabo.com> | 2024-02-26 15:06:01 +0000 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-02-26 15:06:01 +0000 |
commit | de688444d167fdb80c6e88b8ba837405ba7651a6 (patch) | |
tree | 051fa37a2fe30052254bf79a60bd870667fcc0b7 | |
parent | afd9fc4477fff90e5db917f350d99c3d01fba2bd (diff) | |
parent | 1275280a9e3a31f6080079d564a8fb9e1847db8b (diff) |
Merge pull request #135 from lowercasename/rk/public-events
Events and groups optionally visible on front page
44 files changed, 769 insertions, 184 deletions
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ee429c0..e4f0e14 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -65,6 +65,8 @@ jobs: with: start: pnpm start browser: chrome + env: + CYPRESS: true - name: Upload screenshots uses: actions/upload-artifact@v3 diff --git a/config/config.example.toml b/config/config.example.toml index e9995de..4e00171 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -16,6 +16,9 @@ email_logo_url = "" # Show a Ko-Fi box to donate money to Raphael (Gathio's creator) on the front # page. show_kofi = false +# Show a list of events and groups on the front page which have been marked as +# 'Display this event/group on the public event/group list'. +show_public_event_list = false # Which mail service to use to send emails to hosts and attendees. Options are # 'nodemailer' or 'sendgrid'. Configure settings for this mail # service below. diff --git a/cypress/e2e/event.cy.ts b/cypress/e2e/event.cy.ts index 8870164..c49c518 100644 --- a/cypress/e2e/event.cy.ts +++ b/cypress/e2e/event.cy.ts @@ -1,19 +1,4 @@ -const eventData = { - eventName: "Your Event Name", - eventLocation: "Event Location", - timezone: "America/New York", - eventDescription: "Event Description", - eventURL: "https://example.com", - hostName: "Your Name", - creatorEmail: "test@example.com", - eventGroupCheckbox: false, - interactionCheckbox: true, - joinCheckbox: true, - maxAttendeesCheckbox: true, - maxAttendees: 10, - eventStart: "2030-01-01T00:00", - eventEnd: "2030-01-01T01:00", -}; +import eventData from "../fixtures/eventData.json"; describe("Events", () => { beforeEach(() => { @@ -37,13 +22,6 @@ describe("Events", () => { 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); - } - if (eventData.interactionCheckbox) { cy.get("#interactionCheckbox").check(); } diff --git a/cypress/e2e/group.cy.ts b/cypress/e2e/group.cy.ts index 279cb6c..69c722a 100644 --- a/cypress/e2e/group.cy.ts +++ b/cypress/e2e/group.cy.ts @@ -1,14 +1,8 @@ -const groupData = { - eventGroupName: "Test Group", - eventGroupDescription: "Test Group Description", - eventGroupURL: "https://example.com", - hostName: "Test Host", - creatorEmail: "test@example.com", -}; +import groupData from "../fixtures/groupData.json"; describe("Groups", () => { beforeEach(() => { - cy.createGroup(groupData); + cy.createGroup(groupData, false); }); it("creates a new group", function () { cy.get("#eventGroupName").should("have.text", groupData.eventGroupName); diff --git a/cypress/e2e/magicLink.cy.ts b/cypress/e2e/magicLink.cy.ts new file mode 100644 index 0000000..5540415 --- /dev/null +++ b/cypress/e2e/magicLink.cy.ts @@ -0,0 +1,14 @@ +describe("Restricted Event Creation", () => { + it("should redirect to the magic link form", () => { + cy.setCookie( + "cypressConfigOverride", + JSON.stringify({ + general: { + creator_email_addresses: ["test@test.com"], + }, + }), + ); + cy.visit("/new"); + cy.get("h2").should("contain", "Request a link to create a new event"); + }); +}); diff --git a/cypress/e2e/publicEvent.cy.ts b/cypress/e2e/publicEvent.cy.ts new file mode 100644 index 0000000..f110c02 --- /dev/null +++ b/cypress/e2e/publicEvent.cy.ts @@ -0,0 +1,75 @@ +import eventData from "../fixtures/eventData.json"; + +describe("Events", () => { + beforeEach(() => { + cy.setCookie( + "cypressConfigOverride", + JSON.stringify({ + general: { + show_public_event_list: true, + }, + }), + ); + cy.visit("/new"); + cy.get("#showNewEventFormButton").click(); + + cy.get("#eventName").type(eventData.eventName); + cy.get("#eventLocation").type(eventData.eventLocation); + // These are datetime-local inputs + cy.get("#eventStart").type(eventData.eventStart); + cy.get("#eventEnd").type(eventData.eventEnd); + + cy.get("select#timezone + span.select2").click(); + cy.get(".select2-results__option") + .contains(eventData.timezone) + .click({ force: true }); + + cy.get("#eventDescription").type(eventData.eventDescription); + cy.get("#eventURL").type(eventData.eventURL); + + cy.get("#hostName").type(eventData.hostName); + cy.get("#creatorEmail").type(eventData.creatorEmail); + + // Check checkboxes based on eventData + if (eventData.interactionCheckbox) { + cy.get("#interactionCheckbox").check(); + } + + if (eventData.joinCheckbox) { + cy.get("#joinCheckbox").check(); + } + + if (eventData.maxAttendeesCheckbox) { + cy.get("#maxAttendeesCheckbox").check(); + cy.get("#maxAttendees").type(eventData.maxAttendees.toString()); + } + + cy.get("#publicEventCheckbox").check(); + + // Submit the form + cy.get("#newEventFormSubmit").click(); + + // Wait for the new page to load + cy.url({ timeout: 10000 }).should("not.include", "/new"); + + // Get the new event ID from the URL + cy.url().then((url) => { + const [eventID, editToken] = url.split("/").pop().split("?"); + cy.wrap(eventID).as("eventID"); + cy.wrap(editToken).as("editToken"); + }); + }); + + it("should be visible in the public event list", function () { + cy.setCookie( + "cypressConfigOverride", + JSON.stringify({ + general: { + show_public_event_list: true, + }, + }), + ); + cy.visit("/"); + cy.get("#upcomingEvents").should("contain", eventData.eventName); + }); +}); diff --git a/cypress/e2e/publicGroup.cy.ts b/cypress/e2e/publicGroup.cy.ts new file mode 100644 index 0000000..4536195 --- /dev/null +++ b/cypress/e2e/publicGroup.cy.ts @@ -0,0 +1,28 @@ +import groupData from "../fixtures/groupData.json"; + +describe("Groups", () => { + beforeEach(() => { + cy.setCookie( + "cypressConfigOverride", + JSON.stringify({ + general: { + show_public_event_list: true, + }, + }), + ); + cy.createGroup(groupData, true); + }); + it("should be visible in the public group list", function () { + cy.setCookie( + "cypressConfigOverride", + JSON.stringify({ + general: { + show_public_event_list: true, + }, + }), + ); + cy.visit("/"); + cy.get("#groupsTab").click(); + cy.get("#eventGroups").should("contain", groupData.eventGroupName); + }); +}); diff --git a/cypress/fixtures/eventData.json b/cypress/fixtures/eventData.json new file mode 100644 index 0000000..a38ccf2 --- /dev/null +++ b/cypress/fixtures/eventData.json @@ -0,0 +1,16 @@ +{ + "eventName": "Your Event Name", + "eventLocation": "Event Location", + "timezone": "America/New York", + "eventDescription": "Event Description", + "eventURL": "https://example.com", + "hostName": "Your Name", + "creatorEmail": "test@example.com", + "eventGroupCheckbox": false, + "interactionCheckbox": true, + "joinCheckbox": true, + "maxAttendeesCheckbox": true, + "maxAttendees": 10, + "eventStart": "2030-01-01T00:00", + "eventEnd": "2030-01-01T01:00" +} diff --git a/cypress/fixtures/example.json b/cypress/fixtures/example.json deleted file mode 100644 index 519902d..0000000 --- a/cypress/fixtures/example.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "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/fixtures/groupData.json b/cypress/fixtures/groupData.json new file mode 100644 index 0000000..907c3b2 --- /dev/null +++ b/cypress/fixtures/groupData.json @@ -0,0 +1,7 @@ +{ + "eventGroupName": "Test Group", + "eventGroupDescription": "Test Group Description", + "eventGroupURL": "https://example.com", + "hostName": "Test Host", + "creatorEmail": "test@example.com" +} diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 7535103..eadcd20 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -38,17 +38,20 @@ declare namespace Cypress { interface Chainable<Subject> { - createGroup(groupData: { - eventGroupName: string; - eventGroupDescription: string; - eventGroupURL: string; - hostName: string; - creatorEmail: string; - }): Chainable<Subject>; + createGroup( + groupData: { + eventGroupName: string; + eventGroupDescription: string; + eventGroupURL: string; + hostName: string; + creatorEmail: string; + }, + isPublic: boolean, + ): Chainable<Subject>; } } -Cypress.Commands.add("createGroup", (groupData) => { +Cypress.Commands.add("createGroup", (groupData, isPublic) => { cy.visit("/new"); cy.get("#showNewEventGroupFormButton").click(); @@ -59,6 +62,10 @@ Cypress.Commands.add("createGroup", (groupData) => { cy.get("#eventGroupHostName").type(groupData.hostName); cy.get("#eventGroupCreatorEmail").type(groupData.creatorEmail); + if (isPublic) { + cy.get("#publicGroupCheckbox").check(); + } + // Submit the form cy.get("#newEventGroupForm").submit(); diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json index dc61836..9a8b8a0 100644 --- a/cypress/tsconfig.json +++ b/cypress/tsconfig.json @@ -2,7 +2,9 @@ "compilerOptions": { "target": "es5", "lib": ["es5", "dom"], - "types": ["cypress", "node"] + "types": ["cypress", "node"], + "resolveJsonModule": true, + "esModuleInterop": true }, "include": ["**/*.ts"] } diff --git a/package.json b/package.json index 92c6f34..f96ef07 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "gathio", - "version": "1.3.1", - "description": "", + "version": "1.4.0", + "description": "A simple, federated, privacy-first event hosting platform", "main": "index.js", "type": "module", "scripts": { @@ -20,7 +20,9 @@ "license": "GPL-3.0-or-later", "dependencies": { "@sendgrid/mail": "^6.5.5", + "@types/cookie-parser": "^1.4.6", "activitypub-types": "^1.0.3", + "cookie-parser": "^1.4.6", "cors": "^2.8.5", "dompurify": "^3.0.6", "express": "^4.18.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 51126fe..831f278 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,15 @@ dependencies: '@sendgrid/mail': specifier: ^6.5.5 version: 6.5.5 + '@types/cookie-parser': + specifier: ^1.4.6 + version: 1.4.6 activitypub-types: specifier: ^1.0.3 version: 1.0.3 + cookie-parser: + specifier: ^1.4.6 + version: 1.4.6 cors: specifier: ^2.8.5 version: 2.8.5 @@ -695,7 +701,6 @@ packages: dependencies: '@types/connect': 3.4.36 '@types/node': 20.8.2 - dev: true /@types/bson@4.0.5: resolution: {integrity: sha512-vVLwMUqhYJSQ/WKcE60eFqcyuWse5fGH+NMAXHuKrUAPoryq3ATxk5o4bgYNtg5aOM4APVg7Hnb3ASqUYG0PKg==} @@ -711,7 +716,12 @@ packages: resolution: {integrity: sha512-P63Zd/JUGq+PdrM1lv0Wv5SBYeA2+CORvbrXbngriYY0jzLUWfQMQQxOhjONEz/wlHOAxOdY7CY65rgQdTjq2w==} dependencies: '@types/node': 20.8.2 - dev: true + + /@types/cookie-parser@1.4.6: + resolution: {integrity: sha512-KoooCrD56qlLskXPLGUiJxOMnv5l/8m7cQD2OxJ73NPMhuSz9PmvwRD6EpjDyKBVrdJDdQ4bQK7JFNHnNmax0w==} + dependencies: + '@types/express': 4.17.18 + dev: false /@types/dompurify@3.0.3: resolution: {integrity: sha512-odiGr/9/qMqjcBOe5UhcNLOFHSYmKFOyr+bJ/Xu3Qp4k1pNPAlNLUVNNLcLfjQI7+W7ObX58EdD3H+3p3voOvA==} @@ -726,7 +736,6 @@ packages: '@types/qs': 6.9.8 '@types/range-parser': 1.2.5 '@types/send': 0.17.2 - dev: true /@types/express@4.17.18: resolution: {integrity: sha512-Sxv8BSLLgsBYmcnGdGjjEjqET2U+AKAdCRODmMiq02FgjwuV75Ut85DRpvFjyw/Mk0vgUOliGRU0UUmuuZHByQ==} @@ -735,11 +744,9 @@ packages: '@types/express-serve-static-core': 4.17.37 '@types/qs': 6.9.8 '@types/serve-static': 1.15.3 - dev: true /@types/http-errors@2.0.2: resolution: {integrity: sha512-lPG6KlZs88gef6aD85z3HNkztpj7w2R7HmR3gygjfXCQmsLloWNARFkMuzKiiY8FGdh1XDpgBdrSf4aKDiA7Kg==} - dev: true /@types/ical@0.8.1: resolution: {integrity: sha512-JQyqcdMGEa0aUaZPablO5okXvrAspGMzQYriYUV0C5RjDOk/7dqFklvl9yA1uidc0qtrZu4VBFgF0LXhPGPAJw==} @@ -757,11 +764,9 @@ packages: /@types/mime@1.3.3: resolution: {integrity: sha512-Ys+/St+2VF4+xuY6+kDIXGxbNRO0mesVg0bbxEfB97Od1Vjpjx9KD1qxs64Gcb3CWPirk9Xe+PT4YiiHQ9T+eg==} - dev: true /@types/mime@3.0.2: resolution: {integrity: sha512-Wj+fqpTLtTbG7c0tH47dkahefpLKEbB+xAZuLq7b4/IDHPl/n6VoXcyUQ2bypFlbSwvCr0y+bD4euTTqTJsPxQ==} - dev: true /@types/mongodb@3.6.20: resolution: {integrity: sha512-WcdpPJCakFzcWWD9juKoZbRtQxKIMYF/JIAM4JrNHrMcnJL6/a2NWjXxW7fo9hxboxxkg+icff8d7+WIEvKgYQ==} @@ -795,11 +800,9 @@ packages: /@types/qs@6.9.8: resolution: {integrity: sha512-u95svzDlTysU5xecFNTgfFG5RUWu1A9P0VzgpcIiGZA9iraHOdSzcxMxQ55DyeRaGCSxQi7LxXDI4rzq/MYfdg==} - dev: true /@types/range-parser@1.2.5: resolution: {integrity: sha512-xrO9OoVPqFuYyR/loIHjnbvvyRZREYKLjxV4+dY6v3FQR3stQ9ZxIGkaclF7YhI9hfjpuTbu14hZEy94qKLtOA==} - dev: true /@types/request@2.48.9: resolution: {integrity: sha512-4mi2hYsvPAhe8RXjk5DKB09sAUzbK68T2XjORehHdWyxFoX2zUnfi1VQ5wU4Md28H/5+uB4DkxY9BS4B87N/0A==} @@ -815,7 +818,6 @@ packages: dependencies: '@types/mime': 1.3.3 '@types/node': 20.8.2 - dev: true /@types/serve-static@1.15.3: resolution: {integrity: sha512-yVRvFsEMrv7s0lGhzrggJjNOSmZCdgCjw9xWrPr/kNNLp6FaDfMC1KaYl3TSJ0c58bECwNBMoQrZJ8hA8E1eFg==} @@ -823,7 +825,6 @@ packages: '@types/http-errors': 2.0.2 '@types/mime': 3.0.2 '@types/node': 20.8.2 - dev: true /@types/sinonjs__fake-timers@8.1.1: resolution: {integrity: sha512-0kSuKjAS0TrGLJ0M/+8MaFkGsQhZpB6pxOmvS3K8FYI72K//YmdfoW9X2qPsAKh1mkwxGD5zib9s1FIFed6E8g==} @@ -1283,10 +1284,23 @@ packages: engines: {node: '>= 0.6'} dev: false + /cookie-parser@1.4.6: + resolution: {integrity: sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==} + engines: {node: '>= 0.8.0'} + dependencies: + cookie: 0.4.1 + cookie-signature: 1.0.6 + dev: false + /cookie-signature@1.0.6: resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} dev: false + /cookie@0.4.1: + resolution: {integrity: sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==} + engines: {node: '>= 0.6'} + dev: false + /cookie@0.4.2: resolution: {integrity: sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==} engines: {node: '>= 0.6'} diff --git a/public/css/style.css b/public/css/style.css index dd59d6b..24d10b8 100755 --- a/public/css/style.css +++ b/public/css/style.css @@ -381,7 +381,7 @@ li.hidden-attendee .attendee-name { } } -@media (min-width: 577px) { +@media (min-width: 576px) { #sidebar { border-right: 2px solid #e0e0e0; min-height: 100vh; @@ -422,7 +422,7 @@ li.hidden-attendee .attendee-name { } .list-group-item-action:hover { - background-color: #d4edda; + background-color: #f2f8ff; } .code { @@ -548,3 +548,38 @@ img.group-preview__image { opacity: 1; pointer-events: auto; } + +ul#sidebar__nav { + padding: 0; + margin: 0; + list-style: none; + display: flex; + flex-direction: row; + gap: 0.5rem; + align-items: center; + justify-content: center; + margin-bottom: 1rem; +} + +ul#sidebar__nav li { + padding: 0 1rem 0.5rem 1rem; + text-align: center; +} + +ul#sidebar__nav a { + display: block; + width: 100%; +} + +@media (min-width: 576px) { + ul#sidebar__nav { + flex-direction: column; + } + ul#sidebar__nav li { + width: 100%; + padding: 0 0 0.5rem 0; + } + ul#sidebar__nav li:has(a:not(.btn)):not(:last-child) { + border-bottom: 1px solid #e0e0e0; + } +} diff --git a/public/js/modules/event-edit.js b/public/js/modules/event-edit.js index 740c861..736547f 100644 --- a/public/js/modules/event-edit.js +++ b/public/js/modules/event-edit.js @@ -32,6 +32,7 @@ function editEventForm() { creatorEmail: window.eventData.creatorEmail, eventGroupID: window.eventData.eventGroupID, eventGroupEditToken: window.eventData.eventGroupEditToken, + publicCheckbox: window.eventData.showOnPublicList, interactionCheckbox: window.eventData.usersCanComment, joinCheckbox: window.eventData.usersCanAttend, maxAttendeesCheckbox: window.eventData.maxAttendees !== null, @@ -53,6 +54,7 @@ function editEventForm() { this.data.joinCheckbox = window.eventData.usersCanAttend; this.data.maxAttendeesCheckbox = window.eventData.maxAttendees !== null; + this.data.publicCheckbox = window.eventData.showOnPublicList; }, async submitForm() { this.submitting = true; diff --git a/public/js/modules/group-edit.js b/public/js/modules/group-edit.js index 1a2c1db..2d55346 100644 --- a/public/js/modules/group-edit.js +++ b/public/js/modules/group-edit.js @@ -27,6 +27,11 @@ function editEventGroupForm() { eventGroupURL: window.groupData.url, hostName: window.groupData.hostName, creatorEmail: window.groupData.creatorEmail, + publicCheckbox: window.groupData.showOnPublicList, + }, + init() { + // Set checkboxes + this.data.publicCheckbox = window.groupData.showOnPublicList; }, errors: [], submitting: false, diff --git a/public/js/modules/new.js b/public/js/modules/new.js index a018087..f7c3e34 100644 --- a/public/js/modules/new.js +++ b/public/js/modules/new.js @@ -87,6 +87,7 @@ function newEventForm() { creatorEmail: "", eventGroupID: "", eventGroupEditToken: "", + publicCheckbox: false, interactionCheckbox: false, joinCheckbox: false, maxAttendeesCheckbox: false, @@ -107,6 +108,7 @@ function newEventForm() { this.data.interactionCheckbox = false; this.data.joinCheckbox = false; this.data.maxAttendeesCheckbox = false; + this.data.publicCheckbox = false; }, async submitForm() { this.submitting = true; @@ -160,6 +162,11 @@ function newEventGroupForm() { eventGroupURL: "", hostName: "", creatorEmail: "", + publicCheckbox: false, + }, + init() { + // Reset checkboxes + this.data.publicCheckbox = false; }, errors: [], submitting: false, @@ -1,5 +1,6 @@ import express from "express"; import hbs from "express-handlebars"; +import cookieParser from "cookie-parser"; import routes from "./routes.js"; import frontend from "./routes/frontend.js"; @@ -58,6 +59,9 @@ app.use(express.json({ type: activityPubContentType })); app.use(express.json({ type: "application/json" })); app.use(express.urlencoded({ extended: true })); +// Cookies // +app.use(cookieParser()); + // Router // app.use("/", staticPages); app.use("/", frontend); diff --git a/src/helpers.ts b/src/helpers.ts index 6eda3d0..47b380f 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,6 +1,6 @@ import moment from "moment-timezone"; import icalGenerator from "ical-generator"; -import Log, { ILog } from "./models/Log.js"; +import Log from "./models/Log.js"; import { getConfig } from "./lib/config.js"; import { IEvent } from "./models/Event.js"; diff --git a/src/index.d.ts b/src/index.d.ts new file mode 100644 index 0000000..292e5d3 --- /dev/null +++ b/src/index.d.ts @@ -0,0 +1,14 @@ +import "express"; +import { GathioConfig } from "./lib/config.js"; + +interface Locals { + config: GathioConfig; +} + +declare module "express" { + export interface Response { + locals: { + config?: GathioConfig; + }; + } +} diff --git a/src/lib/config.ts b/src/lib/config.ts index 1029be9..4bc43bd 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -1,6 +1,7 @@ import fs from "fs"; import toml from "toml"; import { exitWithError } from "./process.js"; +import { Response } from "express"; interface StaticPage { title: string; @@ -8,16 +9,17 @@ interface StaticPage { filename: string; } -interface GathioConfig { +export interface GathioConfig { general: { domain: string; port: string; email: string; site_name: string; - delete_after_days: number | null; + delete_after_days: number; is_federated: boolean; email_logo_url: string; show_kofi: boolean; + show_public_event_list: boolean; mail_service: "nodemailer" | "sendgrid"; creator_email_addresses: string[]; }; @@ -42,6 +44,7 @@ interface FrontendConfig { isFederated: boolean; emailLogoUrl: string; showKofi: boolean; + showPublicEventList: boolean; showInstanceInformation: boolean; staticPages?: StaticPage[]; version: string; @@ -56,28 +59,99 @@ const defaultConfig: GathioConfig = { is_federated: true, delete_after_days: 7, email_logo_url: "", + show_public_event_list: false, show_kofi: false, mail_service: "nodemailer", + creator_email_addresses: [], }, database: { mongodb_url: "mongodb://localhost:27017/gathio", }, }; -export const frontendConfig = (): FrontendConfig => { - const config = getConfig(); +export const frontendConfig = (res: Response): FrontendConfig => { + const config = res.locals.config; + if (!config) { + return { + domain: defaultConfig.general.domain, + siteName: defaultConfig.general.site_name, + isFederated: defaultConfig.general.is_federated, + emailLogoUrl: defaultConfig.general.email_logo_url, + showPublicEventList: defaultConfig.general.show_public_event_list, + showKofi: defaultConfig.general.show_kofi, + showInstanceInformation: false, + staticPages: [], + version: process.env.npm_package_version || "unknown", + }; + } return { domain: config.general.domain, siteName: config.general.site_name, - isFederated: config.general.is_federated, + isFederated: !!config.general.is_federated, emailLogoUrl: config.general.email_logo_url, - showKofi: config.general.show_kofi, + showPublicEventList: !!config.general.show_public_event_list, + showKofi: !!config.general.show_kofi, showInstanceInformation: !!config.static_pages?.length, staticPages: config.static_pages, version: process.env.npm_package_version || "unknown", }; }; +interface InstanceRule { + icon: string; + text: string; +} + +export const instanceRules = (): InstanceRule[] => { + const config = getConfig(); + const rules = []; + rules.push( + config.general.show_public_event_list + ? { + text: "Public events and groups are displayed on the homepage", + icon: "fas fa-eye", + } + : { + text: "Events and groups can only be accessed by direct link", + icon: "fas fa-eye-slash", + }, + ); + rules.push( + config.general.creator_email_addresses?.length + ? { + text: "Only specific people can create events and groups", + icon: "fas fa-user-check", + } + : { + text: "Anyone can create events and groups", + icon: "fas fa-users", + }, + ); + rules.push( + config.general.delete_after_days > 0 + ? { + text: `Events are automatically deleted ${config.general.delete_after_days} days after they end`, + icon: "far fa-calendar-times", + } + : { + text: "Events are permanent, and are never automatically deleted", + icon: "far fa-calendar-check", + }, + ); + rules.push( + config.general.is_federated + ? { + text: "This instance federates with other instances using ActivityPub", + icon: "fas fa-globe", + } + : { + text: "This instance does not federate with other instances", + icon: "fas fa-globe", + }, + ); + return rules; +}; + // Attempt to load our global config. Will stop the app if the config file // cannot be read (there's no point trying to continue!) export const getConfig = (): GathioConfig => { diff --git a/src/lib/email.ts b/src/lib/email.ts index 8a215a9..9b8162b 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -1,6 +1,6 @@ import { Request } from "express"; import sgMail from "@sendgrid/mail"; -import nodemailer, { TransportOptions } from "nodemailer"; +import nodemailer from "nodemailer"; import { getConfig } from "./config.js"; import SMTPTransport from "nodemailer/lib/smtp-transport/index.js"; import { exitWithError } from "./process.js"; diff --git a/src/lib/middleware.ts b/src/lib/middleware.ts index 0594e90..5073137 100644 --- a/src/lib/middleware.ts +++ b/src/lib/middleware.ts @@ -1,14 +1,14 @@ -import { Request, Response } from "express"; +import { NextFunction, Request, Response } from "express"; import MagicLink from "../models/MagicLink.js"; -import getConfig from "../lib/config.js"; - -const config = getConfig(); +import getConfig, { GathioConfig } from "../lib/config.js"; +import { deepMerge } from "../util/object.js"; export const checkMagicLink = async ( req: Request, res: Response, - next: any, + next: NextFunction, ) => { + const config = getConfig(); if (!config.general.creator_email_addresses?.length) { // No creator email addresses are configured, so skip the magic link check return next(); @@ -49,3 +49,22 @@ export const checkMagicLink = async ( } next(); }; + +// Route-specific middleware which injects the config into the request object +// It can also be used to modify the config based on the request, which +// we use for Cypress testing. +export const getConfigMiddleware = ( + req: Request, + res: Response, + next: NextFunction, +) => { + const config = getConfig(); + if (process.env.CYPRESS === "true" && req.cookies?.cypressConfigOverride) { + console.log("Overriding config with Cypress config"); + const override = JSON.parse(req.cookies.cypressConfigOverride); + res.locals.config = deepMerge<GathioConfig>(config, override); + return next(); + } + res.locals.config = config; + return next(); +}; diff --git a/src/models/Event.ts b/src/models/Event.ts index f67d40b..5731680 100644 --- a/src/models/Event.ts +++ b/src/models/Event.ts @@ -73,6 +73,7 @@ export interface IEvent extends mongoose.Document { privateKey?: string; followers?: IFollower[]; activityPubMessages?: IActivityPubMessage[]; + showOnPublicList?: boolean; } const Attendees = new mongoose.Schema({ @@ -334,6 +335,10 @@ const EventSchema = new mongoose.Schema({ }, followers: [Followers], activityPubMessages: [ActivityPubMessages], + showOnPublicList: { + type: Boolean, + default: false, + }, }); export default mongoose.model<IEvent>("Event", EventSchema); diff --git a/src/models/EventGroup.ts b/src/models/EventGroup.ts index 2b5c2aa..de7187d 100755 --- a/src/models/EventGroup.ts +++ b/src/models/EventGroup.ts @@ -16,6 +16,7 @@ export interface IEventGroup extends mongoose.Document { firstLoad?: boolean; events?: mongoose.Types.ObjectId[]; subscribers?: ISubscriber[]; + showOnPublicList?: boolean; } const Subscriber = new mongoose.Schema({ @@ -70,6 +71,10 @@ const EventGroupSchema = new mongoose.Schema({ }, events: [{ type: mongoose.Schema.Types.ObjectId, ref: "Event" }], subscribers: [Subscriber], + showOnPublicList: { + type: Boolean, + default: false, + }, }); export default mongoose.model<IEventGroup>("EventGroup", EventGroupSchema); diff --git a/src/routes.js b/src/routes.js index 8ea7e05..9eedfb5 100755 --- a/src/routes.js +++ b/src/routes.js @@ -1511,7 +1511,7 @@ router.post("/activitypub/inbox", (req, res) => { }); router.use(function (req, res, next) { - return res.status(404).render("404", frontendConfig()); + return res.status(404).render("404", frontendConfig(res)); }); addToLog("startup", "success", "Started up successfully"); diff --git a/src/routes/activitypub.ts b/src/routes/activitypub.ts index 667a44f..fc61dd7 100644 --- a/src/routes/activitypub.ts +++ b/src/routes/activitypub.ts @@ -1,21 +1,22 @@ import { Router, Request, Response, NextFunction } from "express"; import { createFeaturedPost, createWebfinger } from "../activitypub.js"; import { acceptsActivityPub } from "../lib/activitypub.js"; -import getConfig, { frontendConfig } from "../lib/config.js"; +import { frontendConfig } from "../lib/config.js"; import Event from "../models/Event.js"; import { addToLog } from "../helpers.js"; - -const config = getConfig(); +import { getConfigMiddleware } from "../lib/middleware.js"; const router = Router(); +router.use(getConfigMiddleware); + const send404IfNotFederated = ( req: Request, res: Response, next: NextFunction, ) => { - if (!config.general.is_federated) { - return res.status(404).render("404", frontendConfig()); + if (!res.locals.config?.general.is_federated) { + return res.status(404).render("404", frontendConfig(res)); } next(); }; @@ -27,7 +28,7 @@ router.get("/:eventID/featured", (req: Request, res: Response) => { const { eventID } = req.params; const featured = { "@context": "https://www.w3.org/ns/activitystreams", - id: `https://${config.general.domain}/${eventID}/featured`, + id: `https://${res.locals.config?.general.domain}/${eventID}/featured`, type: "OrderedCollection", orderedItems: [createFeaturedPost(eventID)], }; @@ -41,17 +42,17 @@ router.get("/:eventID/featured", (req: Request, res: Response) => { // return the JSON for a given activitypub message router.get("/:eventID/m/:hash", async (req: Request, res: Response) => { const { hash, eventID } = req.params; - const id = `https://${config.general.domain}/${eventID}/m/${hash}`; + const id = `https://${res.locals.config?.general.domain}/${eventID}/m/${hash}`; try { const event = await Event.findOne({ id: eventID, }); if (!event) { - return res.status(404).render("404", frontendConfig()); + return res.status(404).render("404", frontendConfig(res)); } else { if (!event.activityPubMessages) { - return res.status(404).render("404", frontendConfig()); + return res.status(404).render("404", frontendConfig(res)); } const message = event.activityPubMessages.find( (el) => el.id === id, @@ -68,7 +69,7 @@ router.get("/:eventID/m/:hash", async (req: Request, res: Response) => { ); } } else { - return res.status(404).render("404", frontendConfig()); + return res.status(404).render("404", frontendConfig(res)); } } } catch (err) { @@ -80,19 +81,19 @@ router.get("/:eventID/m/:hash", async (req: Request, res: Response) => { " failed with error: " + err, ); - return res.status(404).render("404", frontendConfig()); + return res.status(404).render("404", frontendConfig(res)); } }); router.get("/.well-known/nodeinfo", (req, res) => { - if (!config.general.is_federated) { - return res.status(404).render("404", frontendConfig()); + if (!res.locals.config?.general.is_federated) { + return res.status(404).render("404", frontendConfig(res)); } const nodeInfo = { links: [ { rel: "http://nodeinfo.diaspora.software/ns/schema/2.2", - href: `https://${config.general.domain}/.well-known/nodeinfo/2.2`, + href: `https://${res.locals.config?.general.domain}/.well-known/nodeinfo/2.2`, }, ], }; @@ -105,13 +106,13 @@ router.get("/.well-known/nodeinfo", (req, res) => { router.get("/.well-known/nodeinfo/2.2", async (req, res) => { const eventCount = await Event.countDocuments(); - if (!config.general.is_federated) { - return res.status(404).render("404", frontendConfig()); + if (!res.locals.config?.general.is_federated) { + return res.status(404).render("404", frontendConfig(res)); } const nodeInfo = { version: "2.2", instance: { - name: config.general.site_name, + name: res.locals.config?.general.site_name, description: "Federated, no-registration, privacy-respecting event hosting.", }, @@ -157,16 +158,24 @@ router.get("/.well-known/webfinger", async (req, res) => { const event = await Event.findOne({ id: eventID }); if (!event) { - return res.status(404).render("404", frontendConfig()); + return res.status(404).render("404", frontendConfig(res)); } else { if (acceptsActivityPub(req)) { res.header( "Content-Type", "application/activity+json", - ).send(createWebfinger(eventID, config.general.domain)); + ).send( + createWebfinger( + eventID, + res.locals.config?.general.domain, + ), + ); } else { res.header("Content-Type", "application/json").send( - createWebfinger(eventID, config.general.domain), + createWebfinger( + eventID, + res.locals.config?.general.domain, + ), ); } } @@ -176,7 +185,7 @@ router.get("/.well-known/webfinger", async (req, res) => { "error", `Attempt to render webfinger for ${resource} failed with error: ${err}`, ); - return res.status(404).render("404", frontendConfig()); + return res.status(404).render("404", frontendConfig(res)); } } }); @@ -192,13 +201,13 @@ router.get("/:eventID/followers", async (req, res) => { let followersCollection = { type: "OrderedCollection", totalItems: followers.length, - id: `https://${config.general.domain}/${eventID}/followers`, + id: `https://${res.locals.config?.general.domain}/${eventID}/followers`, first: { type: "OrderedCollectionPage", totalItems: followers.length, - partOf: `https://${config.general.domain}/${eventID}/followers`, + partOf: `https://${res.locals.config?.general.domain}/${eventID}/followers`, orderedItems: followers, - id: `https://${config.general.domain}/${eventID}/followers?page=1`, + id: `https://${res.locals.config?.general.domain}/${eventID}/followers?page=1`, }, "@context": ["https://www.w3.org/ns/activitystreams"], }; @@ -221,7 +230,7 @@ router.get("/:eventID/followers", async (req, res) => { "error", `Attempt to render followers for ${eventID} failed with error: ${err}`, ); - return res.status(404).render("404", frontendConfig()); + return res.status(404).render("404", frontendConfig(res)); } }); diff --git a/src/routes/event.ts b/src/routes/event.ts index fb9d8c7..ad77052 100644 --- a/src/routes/event.ts +++ b/src/routes/event.ts @@ -21,14 +21,11 @@ import { updateActivityPubActor, updateActivityPubEvent, } from "../activitypub.js"; -import getConfig from "../lib/config.js"; import { sendEmailFromTemplate } from "../lib/email.js"; import crypto from "crypto"; import ical from "ical"; import { markdownToSanitizedHTML } from "../util/markdown.js"; -import { checkMagicLink } from "../lib/middleware.js"; - -const config = getConfig(); +import { checkMagicLink, getConfigMiddleware } from "../lib/middleware.js"; const storage = multer.memoryStorage(); // Accept only JPEG, GIF or PNG images, up to 10MB @@ -58,6 +55,8 @@ const icsUpload = multer({ const router = Router(); +router.use(getConfigMiddleware); + router.post( "/event", upload.single("imageUpload"), @@ -140,6 +139,7 @@ router.post( viewPassword: "", // Backwards compatibility editPassword: "", // Backwards compatibility editToken: editToken, + showOnPublicList: eventData?.publicBoolean, eventGroup: isPartOfEventGroup ? eventGroup?._id : null, usersCanAttend: eventData.joinBoolean ? true : false, showUsersList: false, // Backwards compatibility @@ -148,7 +148,7 @@ router.post( firstLoad: true, activityPubActor: createActivityPubActor( eventID, - config.general.domain, + res.locals.config?.general.domain, publicKey, markdownToSanitizedHTML(eventData.eventDescription), eventData.eventName, @@ -168,7 +168,7 @@ router.post( ), activityPubMessages: [ { - id: `https://${config.general.domain}/${eventID}/m/featuredPost`, + id: `https://${res.locals.config?.general.domain}/${eventID}/m/featuredPost`, content: JSON.stringify( createFeaturedPost( eventID, @@ -197,9 +197,9 @@ router.post( { eventID, editToken, - siteName: config.general.site_name, - siteLogo: config.general.email_logo_url, - domain: config.general.domain, + siteName: res.locals.config?.general.site_name, + siteLogo: res.locals.config?.general.email_logo_url, + domain: res.locals.config?.general.domain, }, req, ); @@ -231,9 +231,10 @@ router.post( `New event in ${eventGroup.name}`, "eventGroupUpdated", { - siteName: config.general.site_name, - siteLogo: config.general.email_logo_url, - domain: config.general.domain, + siteName: res.locals.config?.general.site_name, + siteLogo: + res.locals.config?.general.email_logo_url, + domain: res.locals.config?.general.domain, eventGroupName: eventGroup.name, eventName: event.name, eventID: event.id, @@ -371,6 +372,7 @@ router.put( url: eventData.eventURL, hostName: eventData.hostName, image: eventImageFilename, + showOnPublicList: eventData.publicBoolean, usersCanAttend: eventData.joinBoolean, showUsersList: false, // Backwards compatibility usersCanComment: eventData.interactionBoolean, @@ -449,11 +451,11 @@ router.put( const guidObject = crypto.randomBytes(16).toString("hex"); const jsonObject = { "@context": "https://www.w3.org/ns/activitystreams", - id: `https://${config.general.domain}/${req.params.eventID}/m/${guidObject}`, + id: `https://${res.locals.config?.general.domain}/${req.params.eventID}/m/${guidObject}`, name: `RSVP to ${event.name}`, type: "Note", cc: "https://www.w3.org/ns/activitystreams#Public", - content: `${diffText} See here: <a href="https://${config.general.domain}/${req.params.eventID}">https://${config.general.domain}/${req.params.eventID}</a>`, + content: `${diffText} See here: <a href="https://${res.locals.config?.general.domain}/${req.params.eventID}">https://${res.locals.config?.general.domain}/${req.params.eventID}</a>`, }; broadcastCreateMessage(jsonObject, event.followers, eventID); // also broadcast an Update profile message to all followers so that at least Mastodon servers will update the local profile information @@ -470,7 +472,7 @@ router.put( "@context": "https://www.w3.org/ns/activitystreams", name: `RSVP to ${event.name}`, type: "Note", - content: `<span class=\"h-card\"><a href="${attendee.id}" class="u-url mention">@<span>${attendee.name}</span></a></span> ${diffText} See here: <a href="https://${config.general.domain}/${req.params.eventID}">https://${config.general.domain}/${req.params.eventID}</a>`, + content: `<span class=\"h-card\"><a href="${attendee.id}" class="u-url mention">@<span>${attendee.name}</span></a></span> ${diffText} See here: <a href="https://${res.locals.config?.general.domain}/${req.params.eventID}">https://${res.locals.config?.general.domain}/${req.params.eventID}</a>`, tag: [ { type: "Mention", @@ -496,9 +498,9 @@ router.put( { diffText, eventID: req.params.eventID, - siteName: config.general.site_name, - siteLogo: config.general.email_logo_url, - domain: config.general.domain, + siteName: res.locals.config?.general.site_name, + siteLogo: res.locals.config?.general.email_logo_url, + domain: res.locals.config?.general.domain, }, req, ); @@ -610,9 +612,9 @@ router.post( { eventID, editToken, - siteName: config.general.site_name, - siteLogo: config.general.email_logo_url, - domain: config.general.domain, + siteName: res.locals.config?.general.site_name, + siteLogo: res.locals.config?.general.email_logo_url, + domain: res.locals.config?.general.domain, }, req, ); diff --git a/src/routes/frontend.ts b/src/routes/frontend.ts index 0d8793a..240aff0 100644 --- a/src/routes/frontend.ts +++ b/src/routes/frontend.ts @@ -2,7 +2,7 @@ import { Router, Request, Response } from "express"; import moment from "moment-timezone"; import { marked } from "marked"; import { markdownToSanitizedHTML, renderPlain } from "../util/markdown.js"; -import getConfig, { frontendConfig } from "../lib/config.js"; +import { frontendConfig, instanceRules } from "../lib/config.js"; import { addToLog, exportICal } from "../helpers.js"; import Event from "../models/Event.js"; import EventGroup, { IEventGroup } from "../models/EventGroup.js"; @@ -11,28 +11,44 @@ import { activityPubContentType, } from "../lib/activitypub.js"; import MagicLink from "../models/MagicLink.js"; - -const config = getConfig(); +import { getConfigMiddleware } from "../lib/middleware.js"; const router = Router(); + +// Add config middleware to all routes +router.use(getConfigMiddleware); + router.get("/", (_: Request, res: Response) => { - res.render("home", frontendConfig()); + if (res.locals.config?.general.show_public_event_list) { + return res.redirect("/events"); + } + return res.render("home", { + ...frontendConfig(res), + instanceRules: instanceRules(), + }); }); -router.get("/new", (_: Request, res: Response) => { - if (config.general.creator_email_addresses?.length) { - return res.render("createEventMagicLink", frontendConfig()); +router.get("/about", (_: Request, res: Response) => { + return res.render("home", { + ...frontendConfig(res), + instanceRules: instanceRules(), + }); +}); + +router.get("/new", (req: Request, res: Response) => { + if (res.locals.config?.general.creator_email_addresses?.length) { + return res.render("createEventMagicLink", frontendConfig(res)); } return res.render("newevent", { title: "New event", - ...frontendConfig(), + ...frontendConfig(res), }); }); router.get("/new/:magicLinkToken", async (req: Request, res: Response) => { // If we don't have any creator email addresses, we don't need to check the magic link // so we can just redirect to the new event page - if (!config.general.creator_email_addresses?.length) { + if (!res.locals.config?.general.creator_email_addresses?.length) { return res.redirect("/new"); } const magicLink = await MagicLink.findOne({ @@ -42,7 +58,7 @@ router.get("/new/:magicLinkToken", async (req: Request, res: Response) => { }); if (!magicLink) { return res.render("createEventMagicLink", { - ...frontendConfig(), + ...frontendConfig(res), message: { type: "danger", text: "This magic link is invalid or has expired. Please request a new one here.", @@ -51,12 +67,67 @@ router.get("/new/:magicLinkToken", async (req: Request, res: Response) => { } res.render("newevent", { title: "New event", - ...frontendConfig(), + ...frontendConfig(res), magicLinkToken: req.params.magicLinkToken, creatorEmail: magicLink.email, }); }); +router.get("/events", async (_: Request, res: Response) => { + if (!res.locals.config?.general.show_public_event_list) { + return res.status(404).render("404", frontendConfig(res)); + } + const events = await Event.find({ showOnPublicList: true }) + .populate("eventGroup") + .lean() + .sort("start"); + const updatedEvents = events.map((event) => { + const startMoment = moment.tz(event.start, event.timezone); + const endMoment = moment.tz(event.end, event.timezone); + const isSameDay = startMoment.isSame(endMoment, "day"); + + return { + id: event.id, + name: event.name, + location: event.location, + displayDate: isSameDay + ? startMoment.format("D MMM YYYY") + : `${startMoment.format("D MMM YYYY")} - ${endMoment.format( + "D MMM YYYY", + )}`, + eventHasConcluded: endMoment.isBefore(moment.tz(event.timezone)), + eventGroup: event.eventGroup as any as IEventGroup, + }; + }); + const upcomingEvents = updatedEvents.filter( + (event) => event.eventHasConcluded === false, + ); + const pastEvents = updatedEvents.filter( + (event) => event.eventHasConcluded === true, + ); + const eventGroups = await EventGroup.find({ + showOnPublicList: true, + }).lean(); + const updatedEventGroups = eventGroups.map((eventGroup) => { + return { + name: eventGroup.name, + numberOfEvents: updatedEvents.filter( + (event) => + event.eventGroup?._id.toString() === + eventGroup._id.toString(), + ).length, + }; + }); + + res.render("publicEventList", { + title: "Public events", + upcomingEvents: upcomingEvents, + pastEvents: pastEvents, + eventGroups: updatedEventGroups, + ...frontendConfig(res), + }); +}); + router.get("/:eventID", async (req: Request, res: Response) => { try { const event = await Event.findOne({ @@ -65,7 +136,7 @@ router.get("/:eventID", async (req: Request, res: Response) => { .lean() // Required, see: https://stackoverflow.com/questions/59690923/handlebars-access-has-been-denied-to-resolve-the-property-from-because-it-is .populate("eventGroup"); if (!event) { - return res.status(404).render("404", frontendConfig()); + return res.status(404).render("404", frontendConfig(res)); } const parsedLocation = event.location.replace(/\s+/g, "+"); let displayDate; @@ -228,9 +299,12 @@ router.get("/:eventID", async (req: Request, res: Response) => { .join(" ") .trim(), image: eventHasCoverImage - ? `https://${config.general.domain}/events/` + event.image + ? `https://${res.locals.config?.general.domain}/events/` + + event.image : null, - url: `https://${config.general.domain}/` + req.params.eventID, + url: + `https://${res.locals.config?.general.domain}/` + + req.params.eventID, }; if (acceptsActivityPub(req)) { res.header("Content-Type", activityPubContentType).send( @@ -239,7 +313,7 @@ router.get("/:eventID", async (req: Request, res: Response) => { } else { res.set("X-Robots-Tag", "noindex"); res.render("event", { - ...frontendConfig(), + ...frontendConfig(res), title: event.name, escapedName: escapedName, eventData: event, @@ -266,6 +340,12 @@ router.get("/:eventID", async (req: Request, res: Response) => { firstLoad: firstLoad, eventHasConcluded: eventHasConcluded, eventHasBegun: eventHasBegun, + eventWillBeDeleted: + (res.locals.config?.general.delete_after_days || 0) > 0, + daysUntilDeletion: moment + .tz(event.end, event.timezone) + .add(res.locals.config?.general.delete_after_days, "days") + .fromNow(), metadata: metadata, jsonData: { name: event.name, @@ -276,6 +356,7 @@ router.get("/:eventID", async (req: Request, res: Response) => { url: event.url, hostName: event.hostName, creatorEmail: event.creatorEmail, + showOnPublicList: event.showOnPublicList, eventGroupID: event.eventGroup ? (event.eventGroup as unknown as IEventGroup).id : null, @@ -304,7 +385,7 @@ router.get("/:eventID", async (req: Request, res: Response) => { err, ); console.log(err); - return res.status(404).render("404", frontendConfig()); + return res.status(404).render("404", frontendConfig(res)); } }); @@ -315,7 +396,7 @@ router.get("/group/:eventGroupID", async (req: Request, res: Response) => { }).lean(); if (!eventGroup) { - return res.status(404).render("404", frontendConfig()); + return res.status(404).render("404", frontendConfig(res)); } const parsedDescription = markdownToSanitizedHTML( eventGroup.description, @@ -337,6 +418,7 @@ router.get("/group/:eventGroupID", async (req: Request, res: Response) => { return { id: event.id, name: event.name, + location: event.location, displayDate: isSameDay ? startMoment.format("D MMM YYYY") : `${startMoment.format("D MMM YYYY")} - ${endMoment.format( @@ -381,14 +463,18 @@ router.get("/group/:eventGroupID", async (req: Request, res: Response) => { .join(" ") .trim(), image: eventGroupHasCoverImage - ? `https://${config.general.domain}/events/` + eventGroup.image + ? `https://${res.locals.config?.general.domain}/events/` + + eventGroup.image : null, - url: `https://${config.general.domain}/` + req.params.eventID, + url: + `https://${res.locals.config?.general.domain}/` + + req.params.eventID, }; res.set("X-Robots-Tag", "noindex"); res.render("eventgroup", { - domain: config.general.domain, + ...frontendConfig(res), + domain: res.locals.config?.general.domain, title: eventGroup.name, eventGroupData: eventGroup, escapedName: escapedName, @@ -409,6 +495,7 @@ router.get("/group/:eventGroupID", async (req: Request, res: Response) => { creatorEmail: eventGroup.creatorEmail, image: eventGroup.image, editToken: editingEnabled ? eventGroupEditToken : null, + showOnPublicList: eventGroup.showOnPublicList, }, }); } catch (err) { @@ -418,7 +505,7 @@ router.get("/group/:eventGroupID", async (req: Request, res: Response) => { `Attempt to display event group ${req.params.eventGroupID} failed with error: ${err}`, ); console.log(err); - return res.status(404).render("404", frontendConfig()); + return res.status(404).render("404", frontendConfig(res)); } }); @@ -445,7 +532,7 @@ router.get( `Attempt to display event group feed for ${req.params.eventGroupID} failed with error: ${err}`, ); console.log(err); - return res.status(404).render("404", frontendConfig()); + return res.status(404).render("404", frontendConfig(res)); } }, ); @@ -467,7 +554,7 @@ router.get("/export/event/:eventID", async (req: Request, res: Response) => { `Attempt to export event ${req.params.eventID} failed with error: ${err}`, ); console.log(err); - return res.status(404).render("404", frontendConfig()); + return res.status(404).render("404", frontendConfig(res)); } }); @@ -493,7 +580,7 @@ router.get( `Attempt to export event group ${req.params.eventGroupID} failed with error: ${err}`, ); console.log(err); - return res.status(404).render("404", frontendConfig()); + return res.status(404).render("404", frontendConfig(res)); } }, ); diff --git a/src/routes/group.ts b/src/routes/group.ts index 34377b0..8afd766 100644 --- a/src/routes/group.ts +++ b/src/routes/group.ts @@ -1,5 +1,4 @@ import { Router, Response, Request } from "express"; -import getConfig from "../lib/config.js"; import multer from "multer"; import { generateEditToken, generateEventID } from "../util/generator.js"; import { validateGroupData } from "../util/validation.js"; @@ -9,9 +8,7 @@ import EventGroup from "../models/EventGroup.js"; import { sendEmailFromTemplate } from "../lib/email.js"; import { marked } from "marked"; import { renderPlain } from "../util/markdown.js"; -import { checkMagicLink } from "../lib/middleware.js"; - -const config = getConfig(); +import { checkMagicLink, getConfigMiddleware } from "../lib/middleware.js"; const storage = multer.memoryStorage(); // Accept only JPEG, GIF or PNG images, up to 10MB @@ -30,6 +27,8 @@ const upload = multer({ const router = Router(); +router.use(getConfigMiddleware); + router.post( "/group", upload.single("imageUpload"), @@ -81,6 +80,7 @@ router.post( hostName: groupData.hostName, editToken: editToken, firstLoad: true, + showOnPublicList: groupData.publicBoolean, }); await eventGroup.save(); @@ -100,9 +100,9 @@ router.post( { eventGroupID: eventGroup.id, editToken: eventGroup.editToken, - siteName: config.general.site_name, - siteLogo: config.general.email_logo_url, - domain: config.general.domain, + siteName: res.locals.config?.general.site_name, + siteLogo: res.locals.config?.general.email_logo_url, + domain: res.locals.config?.general.domain, }, req, ); @@ -206,6 +206,7 @@ router.put( url: req.body.eventGroupURL, hostName: req.body.hostName, image: eventGroupImageFilename, + showOnPublicList: groupData.publicBoolean, }; await EventGroup.findOneAndUpdate( diff --git a/src/routes/magicLink.ts b/src/routes/magicLink.ts index 24f0667..499d0a4 100644 --- a/src/routes/magicLink.ts +++ b/src/routes/magicLink.ts @@ -1,17 +1,19 @@ import { Router, Request, Response } from "express"; -import getConfig, { frontendConfig } from "../lib/config.js"; +import { frontendConfig } from "../lib/config.js"; import { sendEmailFromTemplate } from "../lib/email.js"; import { generateMagicLinkToken } from "../util/generator.js"; import MagicLink from "../models/MagicLink.js"; +import { getConfigMiddleware } from "../lib/middleware.js"; const router = Router(); -const config = getConfig(); + +router.use(getConfigMiddleware); router.post("/magic-link/event/create", async (req: Request, res: Response) => { const { email } = req.body; if (!email) { res.render("createEventMagicLink", { - ...frontendConfig(), + ...frontendConfig(res), message: { type: "danger", text: "Please provide an email address.", @@ -19,14 +21,14 @@ router.post("/magic-link/event/create", async (req: Request, res: Response) => { }); return; } - const allowedEmails = config.general.creator_email_addresses; + const allowedEmails = res.locals.config?.general.creator_email_addresses; if (!allowedEmails?.length) { // No creator email addresses are configured, so skip the magic link check return res.redirect("/new"); } if (!allowedEmails.includes(email)) { res.render("createEventMagicLink", { - ...frontendConfig(), + ...frontendConfig(res), message: { type: "success", text: "Thanks! If this email address can create events, you should receive an email with a magic link.", @@ -52,14 +54,14 @@ router.post("/magic-link/event/create", async (req: Request, res: Response) => { "createEventMagicLink", { token, - siteName: config.general.site_name, - siteLogo: config.general.email_logo_url, - domain: config.general.domain, + siteName: res.locals.config?.general.site_name, + siteLogo: res.locals.config?.general.email_logo_url, + domain: res.locals.config?.general.domain, }, req, ); res.render("createEventMagicLink", { - ...frontendConfig(), + ...frontendConfig(res), message: { type: "success", text: "Thanks! If this email address can create events, you should receive an email with a magic link.", diff --git a/src/routes/static.ts b/src/routes/static.ts index 33f0225..6fab98d 100644 --- a/src/routes/static.ts +++ b/src/routes/static.ts @@ -21,13 +21,13 @@ if (config.static_pages?.length) { return res.render("static", { title: page.title, content: parsed, - ...frontendConfig(), + ...frontendConfig(res), }); } - return res.status(404).render("404", frontendConfig()); + return res.status(404).render("404", frontendConfig(res)); } catch (err) { console.error(err); - return res.status(404).render("404", frontendConfig()); + return res.status(404).render("404", frontendConfig(res)); } }); }); diff --git a/src/util/object.ts b/src/util/object.ts new file mode 100644 index 0000000..1ecc89b --- /dev/null +++ b/src/util/object.ts @@ -0,0 +1,30 @@ +/** + * Simple object check. + */ +export function isObject(item: any) { + return item && typeof item === "object" && !Array.isArray(item); +} + +/** + * Deep merge two objects. + */ +export function deepMerge<T>( + target: Record<any, any>, + ...sources: Record<any, any>[] +): T { + if (!sources.length) return target; + const source = sources.shift(); + + if (isObject(target) && isObject(source)) { + for (const key in source) { + if (isObject(source[key])) { + if (!target[key]) Object.assign(target, { [key]: {} }); + deepMerge(target[key], source[key]); + } else { + Object.assign(target, { [key]: source[key] }); + } + } + } + + return deepMerge(target, ...sources) as T; +} diff --git a/src/util/validation.ts b/src/util/validation.ts index 732fbf3..b9a0c8a 100644 --- a/src/util/validation.ts +++ b/src/util/validation.ts @@ -5,11 +5,16 @@ type Error = { field?: string; }; -type ValidationResponse = { +type EventValidationResponse = { data?: ValidatedEventData; errors?: Error[]; }; +type EventGroupValidationResponse = { + data?: ValidatedEventGroupData; + errors?: Error[]; +}; + interface EventData { eventName: string; eventLocation: string; @@ -21,6 +26,7 @@ interface EventData { imagePath: string; hostName: string; creatorEmail: string; + publicCheckbox: string; eventGroupCheckbox: string; eventGroupID: string; eventGroupEditToken: string; @@ -33,11 +39,13 @@ interface EventData { // EventData without the 'checkbox' fields export type ValidatedEventData = Omit< EventData, + | "publicCheckbox" | "eventGroupCheckbox" | "interactionCheckbox" | "joinCheckbox" | "maxAttendeesCheckbox" > & { + publicBoolean: boolean; eventGroupBoolean: boolean; interactionBoolean: boolean; joinBoolean: boolean; @@ -50,8 +58,13 @@ interface EventGroupData { eventGroupURL: string; hostName: string; creatorEmail: string; + publicCheckbox: string; } +export type ValidatedEventGroupData = Omit<EventGroupData, "publicCheckbox"> & { + publicBoolean: boolean; +}; + const validateEmail = (email: string) => { if (!email || email.length === 0 || typeof email !== "string") { return false; @@ -89,9 +102,12 @@ export const validateEventTime = (start: Date, end: Date): Error | boolean => { return true; }; -export const validateEventData = (eventData: EventData): ValidationResponse => { +export const validateEventData = ( + eventData: EventData, +): EventValidationResponse => { const validatedData: ValidatedEventData = { ...eventData, + publicBoolean: eventData.publicCheckbox === "true", eventGroupBoolean: eventData.eventGroupCheckbox === "true", interactionBoolean: eventData.interactionCheckbox === "true", joinBoolean: eventData.joinCheckbox === "true", @@ -186,7 +202,9 @@ export const validateEventData = (eventData: EventData): ValidationResponse => { }; }; -export const validateGroupData = (groupData: EventGroupData) => { +export const validateGroupData = ( + groupData: EventGroupData, +): EventGroupValidationResponse => { const errors: Error[] = []; if (!groupData.eventGroupName) { errors.push({ @@ -209,8 +227,13 @@ export const validateGroupData = (groupData: EventGroupData) => { } } + const validatedData: ValidatedEventGroupData = { + ...groupData, + publicBoolean: groupData.publicCheckbox === "true", + }; + return { - data: groupData, + data: validatedData, errors: errors, }; }; diff --git a/views/createEventMagicLink.handlebars b/views/createEventMagicLink.handlebars index 563af82..ab00dc5 100644 --- a/views/createEventMagicLink.handlebars +++ b/views/createEventMagicLink.handlebars @@ -1,6 +1,6 @@ <article> - <h3 class="mb-4">Request a link to create a new event</h3> + <h2 class="mb-4">Request a link to create a new event</h2> <form action="/magic-link/event/create" @@ -9,7 +9,7 @@ hx-target="article" > <p> - The administrator of this instance has limited event creation to a set of specific email addresses. If your email address is allowed to create events, you will be sent a magic link to create an event. If not, you won't receive anything. + The administrator of this instance has limited event creation to a set of specific email addresses. If your email address is allowed to create events, you will be sent a magic link. If not, you won't receive anything. </p> <p> If you run into any issues, please contact the instance administrator. diff --git a/views/event.handlebars b/views/event.handlebars index 4402578..44c2f4b 100755 --- a/views/event.handlebars +++ b/views/event.handlebars @@ -109,7 +109,7 @@ {{#if eventHasConcluded}} <div class="alert alert-warning mb-4" role="alert"> - This event has concluded. It can no longer be edited, and will be automatically deleted <span class="daysToDeletion"></span>. + This event has concluded. It can no longer be edited{{#if eventWillBeDeleted}}, and will be automatically deleted {{daysUntilDeletion}}{{/if}}. </div> {{/if}} {{#if firstLoad}} @@ -523,7 +523,6 @@ window.eventData = {{{ json jsonData }}}; $(this).html('<i class="fas fa-copy"></i> Copied!'); setTimeout(function(){ $("#copyAPLink").html('<i class="fas fa-copy"></i> Copy');}, 5000); }) - $(".daysToDeletion").html(moment("{{eventEndISO}}").add(7, 'days').fromNow()); if ($("#joinCheckbox").is(':checked')){ $("#maxAttendeesCheckboxContainer").css("display","flex"); } diff --git a/views/eventgroup.handlebars b/views/eventgroup.handlebars index 434f691..7ad3570 100755 --- a/views/eventgroup.handlebars +++ b/views/eventgroup.handlebars @@ -122,6 +122,7 @@ <a href="/{{this.id}}" class="list-group-item list-group-item-action" target="_blank"> <i class="fas fa-fw fa-calendar-day"></i> <strong>{{this.name}}</strong> + {{#if this.location}}<span class="ml-2 text-muted"><i class="fas fa-map-marker-alt"></i> {{this.location}}</span>{{/if}} <span class="ml-2 text-muted">{{this.displayDate}}</span> </a> {{/unless}} diff --git a/views/home.handlebars b/views/home.handlebars index add7eac..bf92724 100755 --- a/views/home.handlebars +++ b/views/home.handlebars @@ -1,25 +1,53 @@ -<p class="lead">A quick and easy way to make and share events which respects your privacy.</p> +<p class="lead">Gathio is a simple, federated, privacy-first event hosting platform.</p> -<hr> +<div class="card mb-3 border-primary"> +<div class="card-header"> + This instance, <strong>{{siteName}}</strong>, has the following features: +</div> -<p>You don't need to sign up for an account. When you create an event, we generate a password which allows you to edit the event. Send all your guests the public link, and all your co-hosts the secret editing link containing the password. A week after the event finishes, it's deleted from our servers for ever, and your email goes with it.</p> +<div class="card-body m-0 p-0"> + <ul class="list-group list-group-flush"> + {{#each instanceRules}} + <li class="list-group-item"><i class="{{this.icon}} fa-fw mr-2"></i> {{this.text}}</li> + {{/each}} + </ul> +</div> +</div> <div id="example-event" class="text-center w-100 mt-4 mb-5"> <img alt ="An example event page for a picnic. The page shows the event's location, host, date and time, and description, as well as buttons to save the event to Google Calendar, export it, and open the location in OpenStreetMap and Google Maps." src="images/example-event-2023.png" class="img-fluid w-75 mx-auto shadow-lg rounded"> </div> -<p>Also, we don't show you ads, don't sell your data, and never send you unnecessary emails.</p> +<h3>Privacy-first</h3> + +<p>There are no accounts on Gathio. When you create an event, we generate a password which allows you to edit the event. Send all your guests the public link, and all your co-hosts the secret editing link containing the password.</p> + +<p>If you supply your email, we'll send you the editing password so you don't lose it - but supplying your email is optional!</p> + +<p>If this instance automatically deletes its events, sometime after the event finishes, it's deleted from the database for ever, and your data goes with it.</p> + +<p>Also, Gathio doesn't show you ads, doesn't sell your data, and never sends you unnecessary emails.</p> + +<p>But remember: all events are visible to anyone who knows the link, so probably don't use Gathio to plot your surprise birthday party or revolution. Or whatever, you do you.</p> + +<h3>Configurable</h3> + +<p>The <a href="https://gath.io">flagship Gathio instance at gath.io</a> is designed for anyone to create ephemeral, hidden events. Anyone can create an event; events are never displayed anywhere public; and they're deleted 7 days after they end.</p> + +<p>But if your community sets up their own instance, you can limit event creation to a specific list of people, display events on a handy list on the homepage, and disable event deletion entirely!</p> + +<h3>Federation and self-hosting</h3> -<p>But remember: all events are visible to anyone who knows the link, so probably don't use <strong>gathio</strong> to plot your surprise birthday party or revolution. Or whatever, you do you.</p> +<p>Gathio can easily be self-hosted, and supports ActivityPub services like Mastodon, Pleroma, and Friendica, allowing you to access events from anywhere on the Fediverse. We encourage you to spin up your own instance for your community. Detailed instructions on <a href="https://github.com/lowercasename/gathio/wiki/Fediverse-Instructions">ActivityPub access</a> and <a href="https://github.com/lowercasename/gathio/wiki/Installation-instructions">self-hosted installation</a> live on our GitHub wiki. -<h4 class="pt-2">Federation and self-hosting</h4> +<h3>Open source</h3> -<p><strong>gathio</strong> can easily be self-hosted, and supports ActivityPub services like Mastodon, Pleroma, and Friendica, allowing you to access events from anywhere on the Fediverse. We encourage you to spin up your own instance for your community. Detailed instructions on <a href="https://github.com/lowercasename/gathio/wiki/Fediverse-Instructions">ActivityPub access</a> and <a href="https://github.com/lowercasename/gathio/wiki/Installation-instructions">self-hosted installation</a> live on our GitHub wiki. Leave a question in our <a href="https://github.com/lowercasename/gathio/issues">tracker</a> if you encounter any issues.</p> +<p>Gathio is delighted to be open source, and is built by a lovely group of people. Leave a question in our <a href="https://github.com/lowercasename/gathio/issues">tracker</a> if you encounter any issues.</p> {{#if showKofi}} <div class="card border-secondary mt-5 mb-3 mx-auto" style="min-width:300px;max-width:50%;"> <div class="card-body text-secondary"> - <p>If you find yourself using and enjoying <strong>gath<span class="text-muted">io</span></strong>, consider buying me a coffee. It'll help keep the site running! <i class="far fa-heart"></i></p> + <p>If you find yourself using and enjoying Gathio, consider buying Raphael a coffee. It'll help keep the project and main site running! <i class="far fa-heart"></i></p> <script type='text/javascript' src='https://ko-fi.com/widgets/widget_2.js'></script><script type='text/javascript'>kofiwidget2.init('Support Me on Ko-fi', '#46b798', 'Q5Q2O7T5');kofiwidget2.draw();</script> </div> </div> diff --git a/views/layouts/main.handlebars b/views/layouts/main.handlebars index d45d596..996d35f 100755 --- a/views/layouts/main.handlebars +++ b/views/layouts/main.handlebars @@ -70,7 +70,7 @@ <div id="footerContainer"> {{#if showInstanceInformation}} <p class="small text-muted"> - <strong>{{domain}}</strong> + <strong>{{siteName}}</strong> {{#each staticPages}} {{#if @first}} · diff --git a/views/partials/eventForm.handlebars b/views/partials/eventForm.handlebars index c2eebc3..9227300 100755 --- a/views/partials/eventForm.handlebars +++ b/views/partials/eventForm.handlebars @@ -73,6 +73,14 @@ <div class="form-group"> <label>Options</label> <div > + {{#if showPublicEventList}} + <div class="form-check"> + <input class="form-check-input" type="checkbox" id="publicEventCheckbox" name="publicCheckbox" x-model="data.publicCheckbox"> + <label class="form-check-label" for="publicEventCheckbox"> + Display this event on the public event list + </label> + </div> + {{/if}} <div class="form-check"> <input class="form-check-input" type="checkbox" id="eventGroupCheckbox" name="eventGroupCheckbox" x-model="data.eventGroupCheckbox"> <label class="form-check-label" for="eventGroupCheckbox"> diff --git a/views/partials/eventGroupForm.handlebars b/views/partials/eventGroupForm.handlebars index 258c321..284343f 100644 --- a/views/partials/eventGroupForm.handlebars +++ b/views/partials/eventGroupForm.handlebars @@ -31,6 +31,17 @@ </div> <small class="form-text">Recommended dimensions (w x h): 920px by 300px.</small> </div> +{{#if showPublicEventList}} + <div class="form-group"> + <label>Options</label> + <div class="form-check"> + <input class="form-check-input" type="checkbox" id="publicGroupCheckbox" name="publicCheckbox" x-model="data.publicCheckbox"> + <label class="form-check-label" for="publicGroupCheckbox"> + Display this group on the public group list + </label> + </div> + </div> +{{/if}} <div class="form-group"> <div class="col-12"> <div diff --git a/views/partials/sidebar.handlebars b/views/partials/sidebar.handlebars index 5d8e847..1882aef 100755 --- a/views/partials/sidebar.handlebars +++ b/views/partials/sidebar.handlebars @@ -1,7 +1,11 @@ <div id="fixedContainer" class="pt-3"> - <h1><a href="/">gath<span class="text-muted">io</span></a></h1> + <h1 class="mb-4"><a href="/">gathio</a></h1> - <p class="lead text-center mb-4">Nicer events</p> - - <a class="btn btn-success mb-2 btn-block" href="/new"><i class="far fa-calendar-plus"></i> New</a> + <ul id="sidebar__nav"> + <li><a class="btn btn-success" href="/new"><i class="far fa-calendar-plus"></i> New</a></li> + {{#if showPublicEventList}} + <li><a href="/events">View events</a></li> + <li><a href="/about">About</a></li> + {{/if}} + </ul> </div> diff --git a/views/publicEventList.handlebars b/views/publicEventList.handlebars new file mode 100644 index 0000000..bceae58 --- /dev/null +++ b/views/publicEventList.handlebars @@ -0,0 +1,73 @@ +<article x-data="{currentTab: 'events'}"> +<h2 class="mb-4">{{siteName}}</h2> +<p><strong>{{siteName}}</strong> runs on <a href="/about">Gathio</a> — a simple, federated, privacy-first event hosting platform.</p> +<ul class="nav nav-pills"> + <li class="nav-item"> + <a id="eventsTab" class="nav-link" x-bind:class="currentTab === 'events' && 'active'" aria-current="page" href="#" x-on:click.prevent="currentTab = 'events'">Events</a> + </li> + <li class="nav-item"> + <a id="groupsTab" class="nav-link" x-bind:class="currentTab === 'groups' && 'active'" href="#" x-on:click.prevent="currentTab = 'groups'">Groups</a> + </li> +</ul> + +<div x-show="currentTab === 'events'"> + <div class="card mt-4 mb-4" id="upcomingEvents"> + <h5 class="card-header">Upcoming events</h5> + <div class="list-group list-group-flush"> + {{#if upcomingEvents}} + {{#each upcomingEvents}} + <a href="/{{this.id}}" class="list-group-item list-group-item-action"> + <i class="fas fa-fw fa-calendar-day"></i> + <strong>{{this.name}}</strong> + {{#if this.location}}<span class="ml-2 text-muted"><i class="fas fa-map-marker-alt"></i> {{this.location}}</span>{{/if}} + <span class="ml-2 text-muted">{{this.displayDate}}</span> + {{#if this.eventGroup}} + <span class="badge badge-secondary ml-2">{{this.eventGroup.name}}</span> + {{/if}} + </a> + {{/each}} + {{else}} + <div class="list-group-item">No events!</div> + {{/if}} + </div> + </div> + + <div class="card mt-4 mb-4" id="pastEvents"> + <h5 class="card-header">Past events</h5> + <div class="list-group list-group-flush"> + {{#if pastEvents}} + {{#each pastEvents}} + <a href="/{{this.id}}" class="list-group-item list-group-item-action"> + <i class="fas fa-fw fa-calendar-day"></i> + <strong>{{this.name}}</strong> + <span class="ml-2 text-muted">{{this.displayDate}}</span> + {{#if this.eventGroup}} + <span class="badge badge-secondary ml-2">{{this.eventGroup.name}}</span> + {{/if}} + </a> + {{/each}} + {{else}} + <div class="list-group-item">No events!</div> + {{/if}} + </div> + </div> +</div> + +<div x-show="currentTab === 'groups'"> + <div class="card mt-4 mb-4" id="eventGroups"> + <h5 class="card-header">Event groups</h5> + <div class="list-group list-group-flush"> + {{#if eventGroups}} + {{#each eventGroups}} + <a href="/group/{{this.id}}" class="list-group-item list-group-item-action"> + <i class="fas fa-fw fa-calendar-alt"></i> + <strong>{{this.name}}</strong> + <span class="badge badge-secondary ml-2">{{this.numberOfEvents}} {{plural this.numberOfEvents "event(s)"}}</span> + </a> + {{/each}} + {{else}} + <div class="list-group-item">No groups!</div> + {{/if}} +</div> + +</article>
\ No newline at end of file |