diff options
60 files changed, 3099 insertions, 2194 deletions
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 087d25e..ee429c0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -65,7 +65,7 @@ jobs: with: start: pnpm start browser: chrome - + - name: Upload screenshots uses: actions/upload-artifact@v3 if: failure() diff --git a/.prettierignore b/.prettierignore index 96fa736..f02c836 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,3 @@ dist -public views pnpm-lock.yaml
\ No newline at end of file diff --git a/cypress/e2e/event.cy.ts b/cypress/e2e/event.cy.ts index 3536806..1160586 100644 --- a/cypress/e2e/event.cy.ts +++ b/cypress/e2e/event.cy.ts @@ -1,10 +1,9 @@ const eventData = { eventName: "Your Event Name", eventLocation: "Event Location", - timezone: "Europe/London", + timezone: "America/New York", 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, @@ -14,40 +13,28 @@ const eventData = { joinCheckbox: true, maxAttendeesCheckbox: true, maxAttendees: 10, - eventStart: "", - eventEnd: "", + eventStart: "2030-01-01T00:00", + eventEnd: "2030-01-01T01:00", }; describe("Events", () => { beforeEach(() => { - cy.clearLocalStorage(); - 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 }); + // These are datetime-local inputs + cy.get("#eventStart").type(eventData.eventStart); + cy.get("#eventEnd").type(eventData.eventEnd); + + cy.get(".select2-container").click(); + cy.get(".select2-results__option") + .contains(eventData.timezone) + .click({ 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("#hostName").type(eventData.hostName); cy.get("#creatorEmail").type(eventData.creatorEmail); @@ -74,6 +61,16 @@ describe("Events", () => { // Submit the form cy.get("#newEventFormSubmit").click(); + + // Wait for the new page to load + cy.url().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("creates a new event", function () { // Check that all the data is correct @@ -82,30 +79,25 @@ 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(", "); - 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); + cy.get(".dt-duration").should( + "contain.text", + "Tuesday 1 January 2030 from 12:00 am to 1:00 am (EST)", + ); }); it("allows you to attend an event", function () { cy.get("button#attendEvent").click(); cy.get("#attendeeName").type("Test Attendee"); - cy.get("#attendeeNumber").clear(); + cy.get("#attendeeNumber").focus().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)", ); }); @@ -116,4 +108,114 @@ describe("Events", () => { cy.get(".comment").should("contain.text", "Test Author"); cy.get(".comment").should("contain.text", "Test Comment"); }); + + it("displays the ActivityPub featured post", function () { + cy.log(this.eventID); + + cy.request({ + url: `/${this.eventID}/featured`, + headers: { + Accept: "application/activity+json", + }, + }).then((response) => { + expect(response.body).to.have.property("@context"); + expect(response.body).to.have.property("id"); + expect(response.body).to.have.property("type"); + expect(response.body).to.have.property("orderedItems"); + expect(response.body.orderedItems) + .to.be.an("array") + .and.to.have.lengthOf(1); + const featuredPost = response.body.orderedItems[0]; + expect(featuredPost).to.have.property("@context"); + expect(featuredPost).to.have.property("id"); + expect(featuredPost).to.have.property("type"); + expect(featuredPost).to.have.property("name"); + expect(featuredPost).to.have.property("content"); + expect(featuredPost).to.have.property("attributedTo"); + }); + }); + + it("responds correctly to ActivityPub webfinger requests", function () { + cy.request({ + url: `/.well-known/webfinger?resource=acct:${ + this.eventID + }@${Cypress.env("CYPRESS_DOMAIN")}`, + headers: { + Accept: "application/activity+json", + }, + }).then((response) => { + expect(response.body).to.have.property("subject"); + expect(response.body).to.have.property("links"); + expect(response.body.links) + .to.be.an("array") + .and.to.have.lengthOf(1); + const link = response.body.links[0]; + expect(link).to.have.property("rel"); + expect(link).to.have.property("type"); + expect(link).to.have.property("href"); + }); + }); + + it("edits an event", function () { + cy.get("#editEvent").click(); + + // The edit form is the same as the new form, so we can just re-use the same selectors + // but we need to clear the fields first + cy.get("#editEventForm #eventName").focus().clear(); + cy.get("#editEventForm #eventLocation").focus().clear(); + cy.get("#editEventForm #eventStart").focus().clear(); + cy.get("#editEventForm #eventEnd").focus().clear(); + cy.get("#editEventForm #eventDescription").focus().clear(); + cy.get("#editEventForm #eventURL").focus().clear(); + cy.get("#editEventForm #hostName").focus().clear(); + cy.get("#editEventForm #creatorEmail").focus().clear(); + cy.get("#editEventForm #maxAttendees").focus().clear(); + + cy.get("#editEventForm #eventName").type("Edited Event Name"); + cy.get("#editEventForm #eventLocation").type("Edited Event Location"); + // These are datetime-local inputs + cy.get("#editEventForm #eventStart").type("2030-12-01T00:00"); + cy.get("#editEventForm #eventEnd").type("2030-12-01T01:00"); + + cy.get("#editEventForm .select2-container").click(); + cy.get(".select2-results__option") + .contains("Australia/Sydney") + .click({ force: true }); + + cy.get("#editEventForm #eventDescription").type( + "Edited Event Description", + ); + cy.get("#editEventForm #eventURL").type("https://edited.example.com"); + cy.get("#editEventForm #hostName").type("Edited Name"); + cy.get("#editEventForm #creatorEmail").type("edited@example.com"); + + cy.get("#editEventForm #maxAttendeesCheckbox").uncheck(); + + cy.get("#editEventForm #interactionCheckbox").uncheck(); + + cy.get("#editEventForm #joinCheckbox").uncheck(); + + // Submit the form + cy.get("#editEventForm").submit(); + + // Wait for the modal to not be visible + cy.get("#editModal").should("not.be.visible"); + + // Check that all the data is correct + cy.get(".p-name").should("have.text", "Edited Event Name"); + cy.get(".p-location").should("have.text", "Edited Event Location"); + cy.get(".p-summary").should("contain.text", "Edited Event Description"); + cy.get("#hosted-by").should("contain.text", "Hosted by Edited Name"); + cy.get(".dt-duration").should( + "contain.text", + "Sunday 1 December 2030 from 12:00 am to 1:00 am", + ); + cy.get(".dt-duration") + .invoke("text") + .should("match", /AE(D|S)T/); + // Check that the comment form is not visible + cy.get("#postComment").should("not.exist"); + // Check that the attendee form is not visible + cy.get("#attendEvent").should("not.exist"); + }); }); diff --git a/cypress/e2e/group.cy.ts b/cypress/e2e/group.cy.ts new file mode 100644 index 0000000..8250179 --- /dev/null +++ b/cypress/e2e/group.cy.ts @@ -0,0 +1,69 @@ +const groupData = { + eventGroupName: "Test Group", + eventGroupDescription: "Test Group Description", + eventGroupURL: "https://example.com", + hostName: "Test Host", + creatorEmail: "test@example.com", +}; + +describe("Groups", () => { + beforeEach(() => { + cy.visit("/new"); + cy.get("#showNewEventGroupFormButton").click(); + + // Fill in the form + cy.get("#eventGroupName").type(groupData.eventGroupName); + cy.get("#eventGroupDescription").type(groupData.eventGroupDescription); + cy.get("#eventGroupURL").type(groupData.eventGroupURL); + cy.get("#eventGroupHostName").type(groupData.hostName); + cy.get("#eventGroupCreatorEmail").type(groupData.creatorEmail); + + // Submit the form + cy.get("#newEventGroupForm").submit(); + + // Wait for the new page to load + cy.url().should("not.include", "/new"); + + // Get the new group ID from the URL + cy.url().then((url) => { + const [groupID, editToken] = url.split("/").pop().split("?"); + cy.wrap(groupID).as("groupID"); + cy.wrap(editToken.slice(2)).as("editToken"); + }); + }); + it("creates a new group", function () { + cy.get("#eventGroupName").should("have.text", groupData.eventGroupName); + cy.get("#eventDescription").should( + "contain.text", + groupData.eventGroupDescription, + ); + cy.get("#eventGroupURL").should( + "contain.text", + groupData.eventGroupURL, + ); + cy.get("#hostName").should("contain.text", groupData.hostName); + cy.get("#eventGroupID").should("contain.text", this.groupID); + cy.get("#eventGroupEditToken").should("contain.text", this.editToken); + }); + + it("edits a group", function () { + // // Wait for the modal to not be visible + // cy.get("#editModal").should("not.be.visible"); + // // Check that all the data is correct + // cy.get(".p-name").should("have.text", "Edited Event Name"); + // cy.get(".p-location").should("have.text", "Edited Event Location"); + // cy.get(".p-summary").should("contain.text", "Edited Event Description"); + // cy.get("#hosted-by").should("contain.text", "Hosted by Edited Name"); + // cy.get(".dt-duration").should( + // "contain.text", + // "Sunday 1 December 2030 from 12:00 am to 1:00 am", + // ); + // cy.get(".dt-duration") + // .invoke("text") + // .should("match", /AE(D|S)T/); + // // Check that the comment form is not visible + // cy.get("#postComment").should("not.exist"); + // // Check that the attendee form is not visible + // cy.get("#attendEvent").should("not.exist"); + }); +}); diff --git a/package.json b/package.json index 59f461f..f10d5e9 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,8 @@ "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", + "test:dev": "CYPRESS=true pnpm run dev & wait-on http://localhost:3000 && cypress open --e2e --browser chrome", + "test": "pnpm run build || true && CYPRESS=true pnpm run start & wait-on http://localhost:3000 && cypress run --e2e --browser chrome" "deploy": "pm2 deploy ecosystem.config.cjs production" }, "engines": { @@ -33,6 +33,7 @@ "marked": "^9.1.0", "moment-timezone": "^0.5.43", "mongoose": "^5.13.20", + "multer": "1.4.5-lts.1", "nanoid": "^3.3.6", "niceware": "^3.0.0", "node-schedule": "^1.3.3", @@ -45,7 +46,10 @@ }, "devDependencies": { "@types/express": "^4.17.18", + "@types/ical": "^0.8.1", + "@types/multer": "^1.4.8", "@types/node": "^20.8.2", + "@types/nodemailer": "^6.4.11", "cypress": "^13.3.0", "eslint": "^8.50.0", "nodemon": "^2.0.22", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7bf4c04..9c1c8e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ dependencies: mongoose: specifier: ^5.13.20 version: 5.13.20 + multer: + specifier: 1.4.5-lts.1 + version: 1.4.5-lts.1 nanoid: specifier: ^3.3.6 version: 3.3.6 @@ -79,9 +82,18 @@ devDependencies: '@types/express': specifier: ^4.17.18 version: 4.17.18 + '@types/ical': + specifier: ^0.8.1 + version: 0.8.1 + '@types/multer': + specifier: ^1.4.8 + version: 1.4.8 '@types/node': specifier: ^20.8.2 version: 20.8.2 + '@types/nodemailer': + specifier: ^6.4.11 + version: 6.4.11 cypress: specifier: ^13.3.0 version: 13.3.0 @@ -703,6 +715,12 @@ packages: resolution: {integrity: sha512-lPG6KlZs88gef6aD85z3HNkztpj7w2R7HmR3gygjfXCQmsLloWNARFkMuzKiiY8FGdh1XDpgBdrSf4aKDiA7Kg==} dev: true + /@types/ical@0.8.1: + resolution: {integrity: sha512-JQyqcdMGEa0aUaZPablO5okXvrAspGMzQYriYUV0C5RjDOk/7dqFklvl9yA1uidc0qtrZu4VBFgF0LXhPGPAJw==} + dependencies: + rrule: 2.6.4 + dev: true + /@types/mime@1.3.3: resolution: {integrity: sha512-Ys+/St+2VF4+xuY6+kDIXGxbNRO0mesVg0bbxEfB97Od1Vjpjx9KD1qxs64Gcb3CWPirk9Xe+PT4YiiHQ9T+eg==} dev: true @@ -718,6 +736,12 @@ packages: '@types/node': 20.8.2 dev: false + /@types/multer@1.4.8: + resolution: {integrity: sha512-VMZOW6mnmMMhA5m3fsCdXBwFwC+a+27/8gctNMuQC4f7UtWcF79KAFGoIfKZ4iqrElgWIa3j5vhMJDp0iikQ1g==} + dependencies: + '@types/express': 4.17.18 + dev: true + /@types/node@16.9.1: resolution: {integrity: sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==} dev: false @@ -729,6 +753,12 @@ packages: /@types/node@20.8.2: resolution: {integrity: sha512-Vvycsc9FQdwhxE3y3DzeIxuEJbWGDsnrxvMADzTDF/lcdR9/K+AQIeAghTQsHtotg/q0j3WEOYS/jQgSdWue3w==} + /@types/nodemailer@6.4.11: + resolution: {integrity: sha512-Ld2c0frwpGT4VseuoeboCXQ7UJIkK3X7Lx/4YsZEiUHtHsthWAOCYtf6PAiLhMtfwV0cWJRabLBS3+LD8x6Nrw==} + dependencies: + '@types/node': 20.8.2 + dev: true + /@types/qs@6.9.8: resolution: {integrity: sha512-u95svzDlTysU5xecFNTgfFG5RUWu1A9P0VzgpcIiGZA9iraHOdSzcxMxQ55DyeRaGCSxQi7LxXDI4rzq/MYfdg==} dev: true @@ -866,6 +896,10 @@ packages: picomatch: 2.3.1 dev: true + /append-field@1.0.0: + resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + dev: false + /arch@2.2.0: resolution: {integrity: sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==} dev: true @@ -1016,6 +1050,10 @@ packages: engines: {node: '>=0.4.0'} dev: false + /buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + dev: false + /buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} dependencies: @@ -1169,6 +1207,16 @@ packages: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} dev: true + /concat-stream@1.6.2: + resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==} + engines: {'0': node >= 0.8} + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 2.3.8 + typedarray: 0.0.6 + dev: false + /content-disposition@0.5.4: resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} engines: {node: '>= 0.6'} @@ -2431,7 +2479,6 @@ packages: /luxon@1.28.1: resolution: {integrity: sha512-gYHAa180mKrNIUJCbwpmD0aTu9kV0dREDrwNnuyFAsO1Wt0EVYSZelPnJlbj9HplzXX/YWXHFTL45kvZ53M0pw==} requiresBuild: true - dev: false optional: true /marked@9.1.0: @@ -2620,6 +2667,19 @@ packages: /ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + /multer@1.4.5-lts.1: + resolution: {integrity: sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ==} + engines: {node: '>= 6.0.0'} + dependencies: + append-field: 1.0.0 + busboy: 1.6.0 + concat-stream: 1.6.2 + mkdirp: 0.5.6 + object-assign: 4.1.1 + type-is: 1.6.18 + xtend: 4.0.2 + dev: false + /nanoid@3.3.6: resolution: {integrity: sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -3140,6 +3200,14 @@ packages: luxon: 1.28.1 dev: false + /rrule@2.6.4: + resolution: {integrity: sha512-sLdnh4lmjUqq8liFiOUXD5kWp/FcnbDLPwq5YAc/RrN6120XOPb86Ae5zxF7ttBVq8O3LxjjORMEit1baluahA==} + dependencies: + tslib: 1.14.1 + optionalDependencies: + luxon: 1.28.1 + dev: true + /run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} dependencies: @@ -3484,6 +3552,10 @@ packages: url-parse: 1.5.10 dev: true + /tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + dev: true + /tslib@2.6.2: resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} @@ -3520,6 +3592,10 @@ packages: mime-types: 2.1.35 dev: false + /typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + dev: false + /typescript@5.1.6: resolution: {integrity: sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==} engines: {node: '>=14.17'} diff --git a/public/css/style.css b/public/css/style.css index 93789b7..0f149e8 100755 --- a/public/css/style.css +++ b/public/css/style.css @@ -7,133 +7,152 @@ body, html { /* FONTS */ @font-face { - font-family: 'Fredoka One'; - font-style: normal; - font-weight: 400; - src: url('../fonts/fredoka-one-v7-latin-regular.eot'); /* IE9 Compat Modes */ - src: local('Fredoka One'), local('FredokaOne-Regular'), - url('../fonts/fredoka-one-v7-latin-regular.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ - url('../fonts/fredoka-one-v7-latin-regular.woff2') format('woff2'), /* Super Modern Browsers */ - url('../fonts/fredoka-one-v7-latin-regular.woff') format('woff'), /* Modern Browsers */ - url('../fonts/fredoka-one-v7-latin-regular.ttf') format('truetype'), /* Safari, Android, iOS */ - url('../fonts/fredoka-one-v7-latin-regular.svg#FredokaOne') format('svg'); /* Legacy iOS */ - } - + font-family: "Fredoka One"; + font-style: normal; + font-weight: 400; + src: url("../fonts/fredoka-one-v7-latin-regular.eot"); /* IE9 Compat Modes */ + src: + local("Fredoka One"), + local("FredokaOne-Regular"), + url("../fonts/fredoka-one-v7-latin-regular.eot?#iefix") + format("embedded-opentype"), + /* IE6-IE8 */ url("../fonts/fredoka-one-v7-latin-regular.woff2") + format("woff2"), + /* Super Modern Browsers */ + url("../fonts/fredoka-one-v7-latin-regular.woff") format("woff"), + /* Modern Browsers */ url("../fonts/fredoka-one-v7-latin-regular.ttf") + format("truetype"), + /* Safari, Android, iOS */ + url("../fonts/fredoka-one-v7-latin-regular.svg#FredokaOne") + format("svg"); /* Legacy iOS */ +} @media (max-width: 576px) { - #container { - height: auto !important; - overflow-x: hidden; - } + #container { + height: auto !important; + overflow-x: hidden; + } } #content { - display: flex; - min-height: 100vh; - flex-direction: column; + display: flex; + min-height: 100vh; + flex-direction: column; } #bodyContainer { - flex: 1; + flex: 1; } #fixedContainer { - position: sticky; - top: 0; + position: sticky; + top: 0; } #footerContainer { - border-top: 1px solid #e0e0e0; - text-align: center; - padding: 5px 0; + border-top: 1px solid #e0e0e0; + text-align: center; + padding: 5px 0; } #sidebar { - background: #f5f5f5; - border-bottom: 2px solid #e0e0e0; + background: #f5f5f5; + border-bottom: 2px solid #e0e0e0; } #sidebar h1 { - font-family: "Fredoka One", sans-serif; - font-weight: 700; - text-align: center; - letter-spacing: -0.5px; - font-size: 3rem; - color: transparent !important; - margin-bottom: 0; + font-family: "Fredoka One", sans-serif; + font-weight: 700; + text-align: center; + letter-spacing: -0.5px; + font-size: 3rem; + color: transparent !important; + margin-bottom: 0; } #sidebar h1 a { - background: rgb(33, 37, 41); - background-clip: text; - -webkit-background-clip: text; - color: transparent !important; + background: rgb(33, 37, 41); + background-clip: text; + -webkit-background-clip: text; + color: transparent !important; } #sidebar h1 a:hover { - text-decoration: none; - background: linear-gradient(to right, #27aa45, #7fe0c8, #5d26c1); - background-size: 100% 100%; - background-clip: text; - -webkit-background-clip: text; - color: transparent !important; + text-decoration: none; + background: linear-gradient(to right, #27aa45, #7fe0c8, #5d26c1); + background-size: 100% 100%; + background-clip: text; + -webkit-background-clip: text; + color: transparent !important; } #content { - background: #ffffff; - box-shadow: -8px 0 6px -6px rgba(0,0,0,0.3); + background: #ffffff; + box-shadow: -8px 0 6px -6px rgba(0, 0, 0, 0.3); } #genericEventImageContainer { - height:150px; - border-radius: 5px; + height: 150px; + border-radius: 5px; } #genericEventImageContainer:before { - content: ''; - background: linear-gradient(to bottom, rgba(30,87,153,0) 0%,rgba(242,245,249,0) 75%,rgba(255,255,255,1) 95%,rgba(255,255,255,1) 100%); - position: absolute; - width: 97%; - height: 150px; + content: ""; + background: linear-gradient( + to bottom, + rgba(30, 87, 153, 0) 0%, + rgba(242, 245, 249, 0) 75%, + rgba(255, 255, 255, 1) 95%, + rgba(255, 255, 255, 1) 100% + ); + position: absolute; + width: 97%; + height: 150px; } #eventImageContainer { - height:300px; - background-size: cover; - background-repeat: no-repeat; - background-position: center; - border-radius: 5px; + height: 300px; + background-size: cover; + background-repeat: no-repeat; + background-position: center; + border-radius: 5px; } #eventImageContainer:before { - content: ''; - background: linear-gradient(to bottom, rgba(30,87,153,0) 0%,rgba(242,245,249,0) 85%,rgba(255,255,255,1) 95%,rgba(255,255,255,1) 100%); - position: absolute; - width: 100%; - height: 300px; + content: ""; + background: linear-gradient( + to bottom, + rgba(30, 87, 153, 0) 0%, + rgba(242, 245, 249, 0) 85%, + rgba(255, 255, 255, 1) 95%, + rgba(255, 255, 255, 1) 100% + ); + position: absolute; + width: 100%; + height: 300px; } #eventName { - padding: 0 0 0 10px; - width: 100%; - display: flex; - justify-content: space-between; + padding: 0 0 0 10px; + width: 100%; + display: flex; + justify-content: space-between; } #eventPrivacy { - text-transform:capitalize; + text-transform: capitalize; } #eventFromNow { - padding-left: 25px; + padding-left: 25px; } #eventFromNow::first-letter { - text-transform:capitalize; + text-transform: capitalize; } #eventActions { - padding-left: 0; - margin-top: 1rem; + padding-left: 0; + margin-top: 1rem; } /* @@ -145,129 +164,127 @@ body, html { */ .attendeesList { - margin: 0; - padding: 0; - list-style-type: none; - display: flex; - flex-wrap: wrap; + margin: 0; + padding: 0; + list-style-type: none; + display: flex; + flex-wrap: wrap; } .attendeesList > li { - border: 4px solid #0ea130; - border-radius: 2em; - padding: .5em 1em; - margin-right: 5px; - margin-bottom: 10px; - background: #57b76d; - color: white; - font-size: 0.95em; - font-weight: bold; + border: 4px solid #0ea130; + border-radius: 2em; + padding: 0.5em 1em; + margin-right: 5px; + margin-bottom: 10px; + background: #57b76d; + color: white; + font-size: 0.95em; + font-weight: bold; } .expand { - -webkit-transition: height 0.2s; - -moz-transition: height 0.2s; - transition: height 0.2s; + -webkit-transition: height 0.2s; + -moz-transition: height 0.2s; + transition: height 0.2s; } .eventInformation { - margin-left: 1.6em; + margin-left: 1.6em; } .eventInformation > li { -/* line-height: 2.1em;*/ - margin-bottom: 0.8em; + /* line-height: 2.1em;*/ + margin-bottom: 0.8em; } #copyEventLink { - margin-left: 5px; + margin-left: 5px; } .commentContainer { - background: #fafafa; - border-radius: 5px; - padding: 10px; - margin-bottom: 10px; - border: 1px solid #dfdfdf; + background: #fafafa; + border-radius: 5px; + padding: 10px; + margin-bottom: 10px; + border: 1px solid #dfdfdf; } .replyContainer { - display: none; - background: #efefef; - padding: 10px; - border-radius: 0 0 5px 5px; - border-bottom: 1px solid #d2d2d2; - border-left: 1px solid #d2d2d2; - border-right: 1px solid #d2d2d2; - width: 95%; - margin: -10px auto 10px auto; + display: none; + background: #efefef; + padding: 10px; + border-radius: 0 0 5px 5px; + border-bottom: 1px solid #d2d2d2; + border-left: 1px solid #d2d2d2; + border-right: 1px solid #d2d2d2; + width: 95%; + margin: -10px auto 10px auto; } .repliesContainer { - font-size: smaller; - padding-left:20px; + font-size: smaller; + padding-left: 20px; } /* IMAGE UPLOAD FORM */ - - .image-preview { - width: 100%; - height: 200px; - position: relative; - overflow: hidden; - background-color: #ffffff; - color: #ecf0f1; - border-radius: 5px; - border: 1px dashed #ced4da; + width: 920px; + height: 200px; + position: relative; + overflow: hidden; + background-color: #ffffff; + color: #ecf0f1; + border-radius: 5px; + border: 1px dashed #ced4da; } .image-preview input { - line-height: 200px; - font-size: 200px; - position: absolute; - opacity: 0; - z-index: 10; + line-height: 200px; + font-size: 200px; + position: absolute; + opacity: 0; + z-index: 10; } .image-preview label { - position: absolute; - z-index: 5; - opacity: 0.8; - cursor: pointer; - background-color: #ced4da; - color: #555; - width: 200px; - height: 50px; - font-size: 20px; - line-height: 50px; - text-transform: uppercase; - top: 0; - left: 0; - right: 0; - bottom: 0; - margin: auto; - text-align: center; - border-radius: 5px; + position: absolute; + z-index: 5; + opacity: 0.8; + cursor: pointer; + background-color: #ced4da; + color: #555; + width: 200px; + height: 50px; + font-size: 20px; + line-height: 50px; + text-transform: uppercase; + top: 0; + left: 0; + right: 0; + bottom: 0; + margin: auto; + text-align: center; + border-radius: 5px; } .datepickers-container { - z-index: 1600 !important; /* has to be larger than 1050 */ + z-index: 1600 !important; /* has to be larger than 1050 */ } #newEventFormContainer, #importEventFormContainer, #newEventGroupFormContainer { - display: none; + display: none; } #icsImportLabel { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - color: #6c757d; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #6c757d; } .select2-container { - width: 100% !important; + width: 100% !important; } .select2-selection__rendered { line-height: 2.25rem !important; @@ -280,86 +297,102 @@ body, html { } .attendee-name { - white-space: nowrap; - overflow: hidden; - text-overflow: ""; - overflow: hidden; - max-width: 62px; - color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ""; + overflow: hidden; + max-width: 62px; + color: #fff; } .remove-attendee { - color: #fff; + color: #fff; } .remove-attendee:hover { - color: #016418; + color: #016418; } #eventAttendees h5 { - display: flex; - flex-direction: column; - align-items: flex-start; + display: flex; + flex-direction: column; + align-items: flex-start; } #eventAttendees h5 .btn-group { - margin-top: 0.5rem; -} - -#maxAttendeesContainer { - display: none; -} -/* #maxAttendeesCheckboxContainer { - display: none; -} */ - -#eventGroupData { - display: none; + margin-top: 0.5rem; } .edit-buttons { - text-align: right; + text-align: right; } @media (max-width: 1199.98px) { - .edit-buttons { - text-align: left; - } + .edit-buttons { + text-align: left; + } } @media (min-width: 1120px) { - #eventActions { - margin-top: 0; - padding-left: 1rem; - } + #eventActions { + margin-top: 0; + padding-left: 1rem; + } } @media (min-width: 577px) { - #sidebar { - border-right: 2px solid #e0e0e0; - min-height: 100vh; - } - body { - background: #f5f5f5; /* Old browsers */ - background: -moz-linear-gradient(left, #f5f5f5 0%, #f5f5f5 50%, #ffffff 51%, #ffffff 100%); /* FF3.6-15 */ - background: -webkit-linear-gradient(left, #f5f5f5 0%,#f5f5f5 50%,#ffffff 51%,#ffffff 100%); /* Chrome10-25,Safari5.1-6 */ - background: linear-gradient(to right, #f5f5f5 0%,#f5f5f5 50%,#ffffff 51%,#ffffff 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */ - filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#f5f5f5', endColorstr='#ffffff',GradientType=1 ); /* IE6-9 */ - } - #eventAttendees h5 { - flex-direction: row; - justify-content: space-between; - align-items: center; - } - #eventAttendees h5 .btn-group { - margin-top: 0; - } + #sidebar { + border-right: 2px solid #e0e0e0; + min-height: 100vh; + } + body { + background: #f5f5f5; /* Old browsers */ + background: -moz-linear-gradient( + left, + #f5f5f5 0%, + #f5f5f5 50%, + #ffffff 51%, + #ffffff 100% + ); /* FF3.6-15 */ + background: -webkit-linear-gradient( + left, + #f5f5f5 0%, + #f5f5f5 50%, + #ffffff 51%, + #ffffff 100% + ); /* Chrome10-25,Safari5.1-6 */ + background: linear-gradient( + to right, + #f5f5f5 0%, + #f5f5f5 50%, + #ffffff 51%, + #ffffff 100% + ); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */ + filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#f5f5f5', endColorstr='#ffffff',GradientType=1 ); /* IE6-9 */ + } + #eventAttendees h5 { + flex-direction: row; + justify-content: space-between; + align-items: center; + } + #eventAttendees h5 .btn-group { + margin-top: 0; + } } .list-group-item-action:hover { - background-color: #d4edda; + background-color: #d4edda; } .code { - font-family: 'Courier New', Courier, monospace; - overflow-wrap: anywhere; + font-family: "Courier New", Courier, monospace; + overflow-wrap: anywhere; +} + +/* FORMS */ +label:not(.form-check-label) { + font-weight: 500; +} + +input[type="datetime-local"] { + max-width: 20rem; } diff --git a/public/js/generate-timezones.js b/public/js/generate-timezones.js index 01c9989..02607a9 100644 --- a/public/js/generate-timezones.js +++ b/public/js/generate-timezones.js @@ -373,6 +373,4 @@ const timezones = [ document.querySelector("#timezone").innerHTML = selectorOptions; document.querySelector("#timezone").value = moment.tz.guess(); - - $("#timezone").select2();
\ No newline at end of file diff --git a/public/js/modules/event-edit.js b/public/js/modules/event-edit.js new file mode 100644 index 0000000..65d9889 --- /dev/null +++ b/public/js/modules/event-edit.js @@ -0,0 +1,91 @@ +$(document).ready(function () { + $.uploadPreview({ + input_field: "#event-image-upload", + preview_box: "#event-image-preview", + label_field: "#event-image-label", + label_default: "Choose file", + label_selected: "Change file", + no_label: false, + }); + autosize($("textarea")); + if (window.eventData.image) { + $("#event-image-preview").css( + "background-image", + `url('/events/${window.eventData.image}')`, + ); + $("#event-image-preview").css("background-size", "cover"); + $("#event-image-preview").css("background-position", "center center"); + } + $("#timezone").val(window.eventData.timezone).trigger("change"); +}); + +function editEventForm() { + return { + data: { + eventName: window.eventData.name, + eventLocation: window.eventData.location, + eventStart: window.eventData.startForDateInput, + eventEnd: window.eventData.endForDateInput, + timezone: window.eventData.timezone, + eventDescription: window.eventData.description, + eventURL: window.eventData.url, + hostName: window.eventData.hostName, + creatorEmail: window.eventData.creatorEmail, + eventGroupID: window.eventData.eventGroupID, + eventGroupEditToken: window.eventData.eventGroupEditToken, + interactionCheckbox: window.eventData.usersCanComment, + joinCheckbox: window.eventData.usersCanAttend, + maxAttendeesCheckbox: window.eventData.maxAttendees !== null, + maxAttendees: window.eventData.maxAttendees, + }, + errors: [], + submitting: false, + init() { + // Set up Select2 + this.select2 = $(this.$refs.timezone).select2(); + this.select2.on("select2:select", (event) => { + this.data.timezone = event.target.value; + }); + this.data.timezone = this.select2.val(); + }, + async submitForm() { + this.submitting = true; + this.errors = []; + const formData = new FormData(); + for (const [key, value] of Object.entries(this.data)) { + formData.append(key, value); + } + formData.append( + "imageUpload", + this.$refs.eventImageUpload.files[0], + ); + formData.append("editToken", window.eventData.editToken); + try { + const response = await fetch(`/event/${window.eventData.id}`, { + method: "PUT", + body: formData, + }); + this.submitting = false; + if (!response.ok) { + if (response.status !== 400) { + this.errors = unexpectedError; + return; + } + const json = await response.json(); + this.errors = json.errors; + // Set Bootstrap validation classes using 'field' property + $("input, textarea").removeClass("is-invalid"); + this.errors.forEach((error) => { + $(`#${error.field}`).addClass("is-invalid"); + }); + return; + } + window.location.reload(); + } catch (error) { + console.log(error); + this.errors = unexpectedError; + this.submitting = false; + } + }, + }; +} diff --git a/public/js/modules/group-edit.js b/public/js/modules/group-edit.js new file mode 100644 index 0000000..1a2c1db --- /dev/null +++ b/public/js/modules/group-edit.js @@ -0,0 +1,72 @@ +$(document).ready(function () { + $.uploadPreview({ + input_field: "#group-image-upload", + preview_box: "#group-image-preview", + label_field: "#group-image-label", + label_default: "Choose file", + label_selected: "Change file", + no_label: false, + }); + autosize($("textarea")); + if (window.groupData.image) { + $("#group-image-preview").css( + "background-image", + `url('/events/${window.groupData.image}')`, + ); + $("#group-image-preview").css("background-size", "cover"); + $("#group-image-preview").css("background-position", "center center"); + } + $("#timezone").val(window.groupData.timezone).trigger("change"); +}); + +function editEventGroupForm() { + return { + data: { + eventGroupName: window.groupData.name, + eventGroupDescription: window.groupData.description, + eventGroupURL: window.groupData.url, + hostName: window.groupData.hostName, + creatorEmail: window.groupData.creatorEmail, + }, + errors: [], + submitting: false, + async submitForm() { + this.submitting = true; + this.errors = []; + const formData = new FormData(); + for (const [key, value] of Object.entries(this.data)) { + formData.append(key, value); + } + formData.append( + "imageUpload", + this.$refs.eventGroupImageUpload.files[0], + ); + formData.append("editToken", window.groupData.editToken); + try { + const response = await fetch(`/group/${window.groupData.id}`, { + method: "PUT", + body: formData, + }); + this.submitting = false; + if (!response.ok) { + if (response.status !== 400) { + this.errors = unexpectedError; + return; + } + const json = await response.json(); + this.errors = json.errors; + $("input, textarea").removeClass("is-invalid"); + this.errors.forEach((error) => { + $(`#${error.field}`).addClass("is-invalid"); + }); + return; + } + window.location.reload(); + } catch (error) { + console.log(error); + this.errors = unexpectedError; + this.submitting = false; + } + }, + }; +} diff --git a/public/js/modules/new.js b/public/js/modules/new.js new file mode 100644 index 0000000..84391b7 --- /dev/null +++ b/public/js/modules/new.js @@ -0,0 +1,245 @@ +$(document).ready(function () { + if ($("#icsImportControl")[0].files[0] != null) { + var file = $("#icsImportControl")[0].files[0].name; + $("#icsImportControl") + .next("label") + .html('<i class="far fa-file-alt"></i> ' + file); + } + $("#showNewEventFormButton").click(function () { + $("button").removeClass("active"); + $( + "#showImportEventFormButton #showNewEventGroupFormButton", + ).removeClass("active"); + if ($("#newEventFormContainer").is(":visible")) { + $("#newEventFormContainer").slideUp("fast"); + } else { + $("#newEventFormContainer").slideDown("fast"); + $("#importEventFormContainer").slideUp("fast"); + $("#newEventGroupFormContainer").slideUp("fast"); + $(this).addClass("active"); + } + }); + $("#showImportEventFormButton").click(function () { + $("button").removeClass("active"); + $("#showNewEventFormButton #showNewEventGroupFormButton").removeClass( + "active", + ); + if ($("#importEventFormContainer").is(":visible")) { + $("#importEventFormContainer").slideUp("fast"); + } else { + $("#importEventFormContainer").slideDown("fast"); + $("#newEventFormContainer").slideUp("fast"); + $("#newEventGroupFormContainer").slideUp("fast"); + $(this).addClass("active"); + } + }); + $("#showNewEventGroupFormButton").click(function () { + $("button").removeClass("active"); + $("#showNewEventFormButton #showImportEventFormButton").removeClass( + "active", + ); + if ($("#newEventGroupFormContainer").is(":visible")) { + $("#newEventGroupFormContainer").slideUp("fast"); + } else { + $("#newEventGroupFormContainer").slideDown("fast"); + $("#newEventFormContainer").slideUp("fast"); + $("#importEventFormContainer").slideUp("fast"); + $(this).addClass("active"); + } + }); + $("#icsImportControl").change(function () { + var file = $("#icsImportControl")[0].files[0].name; + $(this) + .next("label") + .html('<i class="far fa-file-alt"></i> ' + file); + }); + + $.uploadPreview({ + input_field: "#event-image-upload", + preview_box: "#event-image-preview", + label_field: "#event-image-label", + label_default: "Choose file", + label_selected: "Change file", + no_label: false, + }); + $.uploadPreview({ + input_field: "#group-image-upload", + preview_box: "#group-image-preview", + label_field: "#group-image-label", + label_default: "Choose file", + label_selected: "Change file", + no_label: false, + }); + autosize($("textarea")); +}); + +function newEventForm() { + return { + data: { + eventName: "", + eventLocation: "", + eventStart: "", + eventEnd: "", + timezone: "", + eventDescription: "", + eventURL: "", + hostName: "", + creatorEmail: "", + eventGroupID: "", + eventGroupEditToken: "", + interactionCheckbox: false, + joinCheckbox: false, + maxAttendeesCheckbox: false, + maxAttendees: "", + }, + errors: [], + submitting: false, + init() { + // Set up Select2 + this.select2 = $(this.$refs.timezone).select2(); + this.select2.on("select2:select", (event) => { + this.data.timezone = event.target.value; + }); + this.data.timezone = this.select2.val(); + // Reset checkboxes + this.data.eventGroupCheckbox = false; + this.data.interactionCheckbox = false; + this.data.joinCheckbox = false; + this.data.maxAttendeesCheckbox = false; + }, + async submitForm() { + this.submitting = true; + this.errors = []; + const formData = new FormData(); + for (const [key, value] of Object.entries(this.data)) { + formData.append(key, value); + } + formData.append( + "imageUpload", + this.$refs.eventImageUpload.files[0], + ); + try { + const response = await fetch("/event", { + method: "POST", + body: formData, + }); + this.submitting = false; + if (!response.ok) { + if (response.status !== 400) { + this.errors = unexpectedError; + return; + } + const json = await response.json(); + this.errors = json.errors; + $("input, textarea").removeClass("is-invalid"); + this.errors.forEach((error) => { + $(`#${error.field}`).addClass("is-invalid"); + }); + return; + } + const json = await response.json(); + window.location.assign(json.url); + } catch (error) { + console.log(error); + this.errors = unexpectedError; + this.submitting = false; + } + }, + }; +} +function newEventGroupForm() { + return { + data: { + eventGroupName: "", + eventGroupDescription: "", + eventGroupURL: "", + hostName: "", + creatorEmail: "", + }, + errors: [], + submitting: false, + async submitForm() { + this.submitting = true; + this.errors = []; + console.log(this.data); + const formData = new FormData(); + for (const [key, value] of Object.entries(this.data)) { + formData.append(key, value); + } + formData.append( + "imageUpload", + this.$refs.eventGroupImageUpload.files[0], + ); + try { + const response = await fetch("/group", { + method: "POST", + body: formData, + }); + this.submitting = false; + if (!response.ok) { + if (response.status !== 400) { + this.errors = unexpectedError; + return; + } + const json = await response.json(); + this.errors = json.errors; + $("input, textarea").removeClass("is-invalid"); + this.errors.forEach((error) => { + $(`#${error.field}`).addClass("is-invalid"); + }); + return; + } + const json = await response.json(); + window.location.assign(json.url); + } catch (error) { + console.log(error); + this.errors = unexpectedError; + this.submitting = false; + } + }, + }; +} + +function importEventForm() { + return { + data: { + creatorEmail: "", + }, + errors: [], + submitting: false, + async submitForm() { + this.submitting = true; + this.errors = []; + const formData = new FormData(); + for (const [key, value] of Object.entries(this.data)) { + formData.append(key, value); + } + formData.append( + "icsImportControl", + this.$refs.icsImportControl.files[0], + ); + try { + const response = await fetch("/import/event", { + method: "POST", + body: formData, + }); + this.submitting = false; + if (!response.ok) { + if (response.status !== 400) { + this.errors = unexpectedError; + return; + } + const json = await response.json(); + this.errors = json.errors; + return; + } + const json = await response.json(); + window.location.assign(json.url); + } catch (error) { + console.log(error); + this.errors = unexpectedError; + this.submitting = false; + } + }, + }; +} diff --git a/public/js/util.js b/public/js/util.js index cbfd239..0499a4d 100644 --- a/public/js/util.js +++ b/public/js/util.js @@ -1,31 +1,38 @@ -const getStoredToken = function(eventID) { +const getStoredToken = function (eventID) { try { - let editTokens = JSON.parse(localStorage.getItem('editTokens')); + let editTokens = JSON.parse(localStorage.getItem("editTokens")); return editTokens[eventID]; - } catch(e) { - localStorage.setItem('editTokens', JSON.stringify({})); + } catch (e) { + localStorage.setItem("editTokens", JSON.stringify({})); return false; } -} +}; -const addStoredToken = function(eventID, token) { +const addStoredToken = function (eventID, token) { try { - let editTokens = JSON.parse(localStorage.getItem('editTokens')); + let editTokens = JSON.parse(localStorage.getItem("editTokens")); editTokens[eventID] = token; - localStorage.setItem('editTokens', JSON.stringify(editTokens)); - } catch(e) { - localStorage.setItem('editTokens', JSON.stringify({ [eventID]: token })); + localStorage.setItem("editTokens", JSON.stringify(editTokens)); + } catch (e) { + localStorage.setItem( + "editTokens", + JSON.stringify({ [eventID]: token }), + ); return false; } -} +}; -const removeStoredToken = function(eventID) { +const removeStoredToken = function (eventID) { try { - let editTokens = JSON.parse(localStorage.getItem('editTokens')); + let editTokens = JSON.parse(localStorage.getItem("editTokens")); delete editTokens[eventID]; - localStorage.setItem('editTokens', JSON.stringify(editTokens)); - } catch(e) { - localStorage.setItem('editTokens', JSON.stringify({})); + localStorage.setItem("editTokens", JSON.stringify(editTokens)); + } catch (e) { + localStorage.setItem("editTokens", JSON.stringify({})); return false; } -} +}; + +const unexpectedError = [ + { message: "An unexpected error has occurred. Please try again later." }, +]; @@ -3,9 +3,16 @@ import hbs from "express-handlebars"; import routes from "./routes.js"; import frontend from "./routes/frontend.js"; +import activitypub from "./routes/activitypub.js"; +import event from "./routes/event.js"; +import group from "./routes/group.js"; + +import { initEmailService } from "./lib/email.js"; const app = express(); +app.locals.sendEmails = initEmailService(); + // View engine // const hbsInstance = hbs.create({ defaultLayout: "main", @@ -27,6 +34,9 @@ const hbsInstance = hbs.create({ match[1] + (match[3] || "s") ); // Plural case: 'bagel(s)' or 'bagel' --> bagels }, + json: function (context: any) { + return JSON.stringify(context); + }, }, }); app.engine("handlebars", hbsInstance.engine); @@ -37,11 +47,16 @@ app.set("hbsInstance", hbsInstance); app.use(express.static("public")); // Body parser // -app.use(express.json({ type: "application/activity+json" })); // support json encoded bodies +app.use(express.json({ type: "application/activity+json" })); +app.use(express.json({ type: "application/ld+json" })); +app.use(express.json({ type: "application/json" })); app.use(express.urlencoded({ extended: true })); // Router // app.use("/", frontend); +app.use("/", activitypub); +app.use("/", event); +app.use("/", group); app.use("/", routes); export default app; diff --git a/src/helpers.ts b/src/helpers.ts index 72bbd17..6eda3d0 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -21,7 +21,7 @@ export function addToLog(process: string, status: string, message: string) { }); } -export function exportIcal(events: IEvent[], calendarName: string) { +export function exportICal(events: IEvent[], calendarName: string) { if (!events || events.length < 1) return; // Create a new icalGenerator... generator diff --git a/src/lib/activitypub.ts b/src/lib/activitypub.ts new file mode 100644 index 0000000..0a3db7b --- /dev/null +++ b/src/lib/activitypub.ts @@ -0,0 +1,9 @@ +import { Request } from "express"; + +export const acceptsActivityPub = (req: Request) => { + return ( + req.headers.accept && + (req.headers.accept.includes("application/activity+json") || + req.headers.accept.includes("application/ld+json")) + ); +}; diff --git a/src/lib/config.ts b/src/lib/config.ts index 9577fd6..7b35b98 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -1,5 +1,6 @@ import fs from "fs"; import toml from "toml"; +import { exitWithError } from "./process.js"; interface GathioConfig { general: { @@ -46,8 +47,8 @@ export const getConfig = (): GathioConfig => { ) as GathioConfig; return config; } catch { - console.error( - "\x1b[31mConfiguration file not found! Have you renamed './config/config-example.toml' to './config/config.toml'?", + exitWithError( + "Configuration file not found! Have you renamed './config/config-example.toml' to './config/config.toml'?", ); return process.exit(1); } diff --git a/src/lib/email.ts b/src/lib/email.ts new file mode 100644 index 0000000..f1dc1ae --- /dev/null +++ b/src/lib/email.ts @@ -0,0 +1,151 @@ +import { Request } from "express"; +import sgMail from "@sendgrid/mail"; +import nodemailer, { TransportOptions } from "nodemailer"; +import { getConfig } from "./config.js"; +import SMTPTransport from "nodemailer/lib/smtp-transport/index.js"; +import { exitWithError } from "./process.js"; +import { renderTemplate } from "./handlebars.js"; +const config = getConfig(); + +type EmailTemplate = + | "addEventAttendee" + | "addEventComment" + | "createEvent" + | "createEventGroup" + | "deleteEvent" + | "editEvent" + | "eventGroupUpdated" + | "subscribed" + | "unattendEvent"; + +export const initEmailService = async (): Promise<boolean> => { + if (process.env.CYPRESS || process.env.CI) { + console.log( + "Running in Cypress or CI, not initializing email service.", + ); + return false; + } + switch (config.general.mail_service) { + case "sendgrid": + if (!config.sendgrid?.api_key) { + return exitWithError( + "Sendgrid is configured as the email service, but no API key is provided. Please provide an API key in the config file.", + ); + } + sgMail.setApiKey(config.sendgrid.api_key); + console.log("Sendgrid is ready to send emails."); + return true; + case "nodemailer": + if ( + !config.nodemailer?.smtp_server || + !config.nodemailer?.smtp_port || + !config.nodemailer?.smtp_username || + !config.nodemailer?.smtp_password + ) { + return exitWithError( + "Nodemailer is configured as the email service, but not all required fields are provided. Please provide all required fields in the config file.", + ); + } + const nodemailerConfig = { + host: config.nodemailer?.smtp_server, + port: Number(config.nodemailer?.smtp_port) || 587, + auth: { + user: config.nodemailer?.smtp_username, + pass: config.nodemailer?.smtp_password, + }, + } as SMTPTransport.Options; + const nodemailerTransporter = + nodemailer.createTransport(nodemailerConfig); + const nodemailerVerified = await nodemailerTransporter.verify(); + if (nodemailerVerified) { + console.log("Nodemailer is ready to send emails."); + return true; + } else { + return exitWithError( + "Error verifying Nodemailer transporter. Please check your Nodemailer configuration.", + ); + } + default: + console.warn( + "You have not configured this Gathio instance to send emails! This means that event creators will not receive emails when their events are created, which means they may end up locked out of editing events. Consider setting up an email service.", + ); + return false; + } +}; + +export const sendEmail = async ( + to: string, + subject: string, + text: string, + html?: string, +): Promise<boolean> => { + switch (config.general.mail_service) { + case "sendgrid": + try { + await sgMail.send({ + to, + from: config.general.email, + subject: `${config.general.site_name}: ${subject}`, + text, + html, + }); + return true; + } catch (e: any) { + if (e.response) { + console.error(e.response.body); + } else { + console.error(e); + } + return false; + } + case "nodemailer": + try { + const nodemailerConfig = { + host: config.nodemailer?.smtp_server, + port: Number(config.nodemailer?.smtp_port) || 587, + auth: { + user: config.nodemailer?.smtp_username, + pass: config.nodemailer?.smtp_password, + }, + } as SMTPTransport.Options; + const nodemailerTransporter = + nodemailer.createTransport(nodemailerConfig); + await nodemailerTransporter.sendMail({ + from: config.general.email, + to, + subject, + text, + html, + }); + return true; + } catch (e) { + console.error(e); + return false; + } + default: + return false; + } +}; + +export const sendEmailFromTemplate = async ( + to: string, + subject: string, + template: EmailTemplate, + templateData: Record<string, unknown>, + req: Request, +): Promise<boolean> => { + const html = await renderTemplate(req, `${template}/${template}Html`, { + siteName: config.general.site_name, + siteLogo: config.general.email_logo_url, + domain: config.general.domain, + cache: true, + layout: "email.handlebars", + ...templateData, + }); + const text = await renderTemplate( + req, + `${template}/${template}Text`, + templateData, + ); + return await sendEmail(to, subject, text, html); +}; diff --git a/src/lib/handlebars.ts b/src/lib/handlebars.ts new file mode 100644 index 0000000..d5a8b6e --- /dev/null +++ b/src/lib/handlebars.ts @@ -0,0 +1,23 @@ +import { Request } from "express"; + +export const renderTemplate = async ( + req: Request, + templateName: string, + data: Record<string, unknown>, +): Promise<string> => { + return new Promise<string>((resolve, reject) => { + req.app + .get("hbsInstance") + .renderView( + `./views/emails/${templateName}.handlebars`, + data, + (err: any, html: string) => { + if (err) { + console.error(err); + reject(err); + } + resolve(html); + }, + ); + }); +}; diff --git a/src/lib/process.ts b/src/lib/process.ts new file mode 100644 index 0000000..d43b3c7 --- /dev/null +++ b/src/lib/process.ts @@ -0,0 +1,4 @@ +export const exitWithError = (message: string) => { + console.error(`\x1b[31m${message}`); + process.exit(1); +}; diff --git a/src/routes.js b/src/routes.js index 7257bdb..5371e0e 100755 --- a/src/routes.js +++ b/src/routes.js @@ -3,10 +3,8 @@ import express from "express"; import { customAlphabet } from "nanoid"; import randomstring from "randomstring"; import { getConfig } from "./lib/config.js"; -import { addToLog, exportIcal } from "./helpers.js"; +import { addToLog } from "./helpers.js"; import moment from "moment-timezone"; -import { marked } from "marked"; -import generateRSAKeypair from "generate-rsa-keypair"; import crypto from "crypto"; import request from "request"; import niceware from "niceware"; @@ -17,16 +15,8 @@ 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, } from "./activitypub.js"; import Event from "./models/Event.js"; @@ -40,7 +30,6 @@ const siteName = config.general.site_name; const mailService = config.general.mail_service; const siteLogo = config.general.email_logo_url; const isFederated = config.general.is_federated || true; -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 @@ -193,864 +182,7 @@ schedule.scheduleJob("59 23 * * *", function (fireDate) { // 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); - } -}); - -// 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; - }); -}); - -// 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, - }) - .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("/: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 { - return res.status(400).send("Bad request."); - } - }); -}); - -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; - } 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 { - 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); - } - }) - .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); - } - }) - .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); - } - }) - .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; - } - } - - // 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( - 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; - } - 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, - ); - }); -}); - -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:", - "", - ); - } - - 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, - }); - 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 - }); - 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; - } - }, - ); - } - 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, @@ -1071,441 +203,9 @@ router.post("/verifytoken/group/:eventGroupID", (req, res) => { }); }); -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 = - "<p>This event was just updated with new information.</p><ul>"; - let displayDate; - if (event.name !== updatedEvent.name) { - diffText += `<li>the event name changed to ${updatedEvent.name}</li>`; - } - if (event.location !== updatedEvent.location) { - diffText += `<li>the location changed to ${updatedEvent.location}</li>`; - } - if ( - event.start.toISOString() !== - updatedEvent.start.toISOString() - ) { - displayDate = moment - .tz(updatedEvent.start, updatedEvent.timezone) - .format("dddd D MMMM YYYY h:mm a"); - diffText += `<li>the start time changed to ${displayDate}</li>`; - } - if ( - event.end.toISOString() !== updatedEvent.end.toISOString() - ) { - displayDate = moment - .tz(updatedEvent.end, updatedEvent.timezone) - .format("dddd D MMMM YYYY h:mm a"); - diffText += `<li>the end time changed to ${displayDate}</li>`; - } - if (event.timezone !== updatedEvent.timezone) { - diffText += `<li>the time zone changed to ${updatedEvent.timezone}</li>`; - } - if (event.description !== updatedEvent.description) { - diffText += `<li>the event description changed</li>`; - } - diffText += `</ul>`; - 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: <a href="https://${domain}/${req.params.eventID}">https://${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 - 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: `<span class=\"h-card\"><a href="${attendee.id}" class="u-url mention">@<span>${attendee.name}</span></a></span> ${diffText} See here: <a href="https://${domain}/${req.params.eventID}">https://${domain}/${req.params.eventID}</a>`, - 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", - ); - } - }) - .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); - } - }, - ) - .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", - ); - } - }) - .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; + let eventImage; Event.findOne({ id: req.params.eventID, }).then((event) => { @@ -1652,7 +352,7 @@ router.post("/deleteevent/:eventID/:editToken", (req, res) => { attendeeEmails, ); req.app.get("hbsInstance").renderView( - "./views/emails/deleteevent.handlebars", + "./views/emails/deleteEvent/deleteEventHtml.handlebars", { siteName, siteLogo, @@ -1999,7 +699,7 @@ router.post("/attendevent/:eventID", async (req, res) => { if (sendEmails) { if (req.body.attendeeEmail) { req.app.get("hbsInstance").renderView( - "./views/emails/addeventattendee.handlebars", + "./views/emails/addEventAttendee/addEventAttendeeHtml.handlebars", { eventID: req.params.eventID, siteName, @@ -2075,7 +775,7 @@ router.post("/unattendevent/:eventID", (req, res) => { if (sendEmails) { if (req.body.attendeeEmail) { req.app.get("hbsInstance").renderView( - "./views/emails/unattendevent.handlebars", + "./views/emails/unattendEvent/unattendEventHtml.handlebars", { eventID: req.params.eventID, siteName, @@ -2157,7 +857,7 @@ router.get("/oneclickunattendevent/:eventID/:attendeeID", (req, res) => { // 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", + "./views/emails/removeEventAttendee/removeEventAttendeeHtml.handlebars", { eventName: req.params.eventName, siteName, @@ -2229,7 +929,7 @@ router.post("/removeattendee/:eventID/:attendeeID", (req, res) => { // 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", + "./views/emails/removeEventAttendee/removeEventAttendeeHtml.handlebars", { eventName: req.params.eventName, siteName, @@ -2308,7 +1008,7 @@ router.post("/subscribe/:eventGroupID", (req, res) => { eventGroup.save(); if (sendEmails) { req.app.get("hbsInstance").renderView( - "./views/emails/subscribed.handlebars", + "./views/emails/subscribed/subscribedHtml.handlebars", { eventGroupName: eventGroup.name, eventGroupID: eventGroup.id, @@ -2451,7 +1151,7 @@ router.post("/post/comment/:eventID", (req, res) => { "Sending emails to: " + attendeeEmails, ); req.app.get("hbsInstance").renderView( - "./views/emails/addeventcomment.handlebars", + "./views/emails/addEventComment/addEventCommentHtml.handlebars", { siteName, siteLogo, @@ -2583,7 +1283,7 @@ router.post("/post/reply/:eventID/:commentID", (req, res) => { "Sending emails to: " + attendeeEmails, ); req.app.get("hbsInstance").renderView( - "./views/emails/addeventcomment.handlebars", + "./views/emails/addEventComment/addEventCommentHtml.handlebars", { siteName, siteLogo, diff --git a/src/routes/activitypub.ts b/src/routes/activitypub.ts new file mode 100644 index 0000000..2c4231a --- /dev/null +++ b/src/routes/activitypub.ts @@ -0,0 +1,174 @@ +import { Router, Request, Response, NextFunction } from "express"; +import { createFeaturedPost, createWebfinger } from "../activitypub.js"; +import { acceptsActivityPub } from "../lib/activitypub.js"; +import getConfig from "../lib/config.js"; +import Event from "../models/Event.js"; +import { addToLog } from "../helpers.js"; + +const config = getConfig(); + +const router = Router(); + +const send404IfNotFederated = ( + req: Request, + res: Response, + next: NextFunction, +) => { + if (!config.general.is_federated) { + res.status(404).render("404", { url: req.url }); + return; + } + next(); +}; + +router.use(send404IfNotFederated); + +// return the JSON for the featured/pinned post for this event +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`, + type: "OrderedCollection", + orderedItems: [createFeaturedPost(eventID)], + }; + if (acceptsActivityPub(req)) { + 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", async (req: Request, res: Response) => { + const { hash, eventID } = req.params; + const id = `https://${config.general.domain}/${eventID}/m/${hash}`; + + try { + const event = await Event.findOne({ + id: eventID, + }); + if (!event) { + return res.status(404).render("404", { url: req.url }); + } else { + if (!event.activityPubMessages) { + return res.status(404).render("404", { url: req.url }); + } + const message = event.activityPubMessages.find( + (el) => el.id === id, + ); + if (message) { + if (acceptsActivityPub(req)) { + 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 { + return res.status(404).render("404", { url: req.url }); + } + } + } catch (err) { + addToLog( + "getActivityPubMessage", + "error", + "Attempt to get Activity Pub Message for " + + id + + " failed with error: " + + err, + ); + return res.status(404).render("404", { url: req.url }); + } +}); + +router.get("/.well-known/webfinger", async (req, res) => { + let resource = req.query.resource as string; + 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(/@.*/, ""); + + try { + const event = await Event.findOne({ id: eventID }); + + if (!event) { + return res.status(404).render("404", { url: req.url }); + } else { + if (acceptsActivityPub(req)) { + res.header( + "Content-Type", + "application/activity+json", + ).send(createWebfinger(eventID, config.general.domain)); + } else { + res.header("Content-Type", "application/json").send( + createWebfinger(eventID, config.general.domain), + ); + } + } + } catch (err) { + addToLog( + "renderWebfinger", + "error", + `Attempt to render webfinger for ${resource} failed with error: ${err}`, + ); + return res.status(404).render("404", { url: req.url }); + } + } +}); + +router.get("/:eventID/followers", async (req, res) => { + const eventID = req.params.eventID; + + try { + const event = await Event.findOne({ id: eventID }); + + if (event && event.followers) { + const followers = event.followers.map((el) => el.actorId); + let followersCollection = { + type: "OrderedCollection", + totalItems: followers.length, + id: `https://${config.general.domain}/${eventID}/followers`, + first: { + type: "OrderedCollectionPage", + totalItems: followers.length, + partOf: `https://${config.general.domain}/${eventID}/followers`, + orderedItems: followers, + id: `https://${config.general.domain}/${eventID}/followers?page=1`, + }, + "@context": ["https://www.w3.org/ns/activitystreams"], + }; + + if (acceptsActivityPub(req)) { + return res + .header("Content-Type", "application/activity+json") + .send(followersCollection); + } else { + return res + .header("Content-Type", "application/json") + .send(followersCollection); + } + } else { + return res.status(400).send("Bad request."); + } + } catch (err) { + addToLog( + "renderFollowers", + "error", + `Attempt to render followers for ${eventID} failed with error: ${err}`, + ); + return res.status(404).render("404", { url: req.url }); + } +}); + +export default router; diff --git a/src/routes/event.ts b/src/routes/event.ts new file mode 100644 index 0000000..2245009 --- /dev/null +++ b/src/routes/event.ts @@ -0,0 +1,640 @@ +import { Router, Response, Request } from "express"; +import multer from "multer"; +import Jimp from "jimp"; +import moment from "moment-timezone"; +import { marked } from "marked"; +import { + generateEditToken, + generateEventID, + generateRSAKeypair, +} from "../util/generator.js"; +import { validateEventData } from "../util/validation.js"; +import { addToLog } from "../helpers.js"; +import Event from "../models/Event.js"; +import EventGroup from "../models/EventGroup.js"; +import { + broadcastCreateMessage, + broadcastUpdateMessage, + createActivityPubActor, + createActivityPubEvent, + createFeaturedPost, + sendDirectMessage, + 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"; + +const config = getConfig(); + +const storage = multer.memoryStorage(); +// Accept only JPEG, GIF or PNG images, up to 10MB +const upload = multer({ + storage: storage, + limits: { fileSize: 10 * 1024 * 1024 }, + fileFilter: function (_, file, cb) { + const filetypes = /jpeg|jpg|png|gif/; + const mimetype = filetypes.test(file.mimetype); + if (!mimetype) { + return cb(new Error("Only JPEG, PNG and GIF images are allowed.")); + } + cb(null, true); + }, +}); +const icsUpload = multer({ + storage: storage, + limits: { fileSize: 10 * 1024 * 1024 }, + fileFilter: function (_, file, cb) { + const filetype = "text/calendar"; + if (file.mimetype !== filetype) { + return cb(new Error("Only ICS files are allowed.")); + } + cb(null, true); + }, +}); + +const router = Router(); + +router.post( + "/event", + upload.single("imageUpload"), + async (req: Request, res: Response) => { + const { data: eventData, errors } = validateEventData(req.body); + if (errors && errors.length > 0) { + return res.status(400).json({ errors }); + } + if (!eventData) { + return res.status(400).json({ + errors: [ + { + message: "No event data was provided.", + }, + ], + }); + } + + let eventID = generateEventID(); + let editToken = generateEditToken(); + let eventImageFilename; + let isPartOfEventGroup = false; + + if (req.file?.buffer) { + eventImageFilename = await Jimp.read(req.file.buffer) + .then((img) => { + img.resize(920, Jimp.AUTO) // resize + .quality(80) // set JPEG quality + .write("./public/events/" + eventID + ".jpg"); // save + return eventID + ".jpg"; + }) + .catch((err) => { + addToLog( + "Jimp", + "error", + "Attempt to edit image failed with error: " + err, + ); + }); + } + + const startUTC = moment.tz(eventData.eventStart, eventData.timezone); + const endUTC = moment.tz(eventData.eventEnd, eventData.timezone); + let eventGroup; + if (eventData?.eventGroupBoolean) { + try { + eventGroup = await EventGroup.findOne({ + id: eventData.eventGroupID, + editToken: eventData.eventGroupEditToken, + }); + if (eventGroup) { + isPartOfEventGroup = true; + } + } catch (err) { + console.error(err); + addToLog( + "createEvent", + "error", + "Attempt to find event group failed with error: " + err, + ); + } + } + + // generate RSA keypair for ActivityPub + let { publicKey, privateKey } = generateRSAKeypair(); + + const event = new Event({ + id: eventID, + type: "public", // This is for backwards compatibility + name: eventData.eventName, + location: eventData.eventLocation, + start: startUTC, + end: endUTC, + timezone: eventData.timezone, + description: eventData.eventDescription, + image: eventImageFilename, + creatorEmail: eventData.creatorEmail, + url: eventData.eventURL, + hostName: eventData.hostName, + viewPassword: "", // Backwards compatibility + editPassword: "", // Backwards compatibility + editToken: editToken, + eventGroup: isPartOfEventGroup ? eventGroup?._id : null, + usersCanAttend: eventData.joinBoolean ? true : false, + showUsersList: false, // Backwards compatibility + usersCanComment: eventData.interactionBoolean ? true : false, + maxAttendees: eventData.maxAttendees, + firstLoad: true, + activityPubActor: createActivityPubActor( + eventID, + config.general.domain, + publicKey, + marked.parse(eventData.eventDescription), + eventData.eventName, + eventData.eventLocation, + eventImageFilename, + startUTC, + endUTC, + eventData.timezone, + ), + activityPubEvent: createActivityPubEvent( + eventData.eventName, + startUTC, + endUTC, + eventData.timezone, + eventData.eventDescription, + eventData.eventLocation, + ), + activityPubMessages: [ + { + id: `https://${config.general.domain}/${eventID}/m/featuredPost`, + content: JSON.stringify( + createFeaturedPost( + eventID, + eventData.eventName, + startUTC, + endUTC, + eventData.timezone, + eventData.eventDescription, + eventData.eventLocation, + ), + ), + }, + ], + publicKey, + privateKey, + }); + try { + const savedEvent = await event.save(); + addToLog("createEvent", "success", "Event " + eventID + "created"); + // Send email with edit link + if (eventData.creatorEmail && req.app.locals.sendEmails) { + sendEmailFromTemplate( + eventData.creatorEmail, + `${eventData.eventName}`, + "createEvent", + { + eventID, + editToken, + siteName: config.general.site_name, + siteLogo: config.general.email_logo_url, + domain: config.general.domain, + }, + req, + ); + } + // If the event was added to a group, send an email to any group + // subscribers + if (event.eventGroup && req.app.locals.sendEmails) { + try { + const eventGroup = await EventGroup.findOne({ + _id: event.eventGroup.toString(), + }); + if (!eventGroup) { + throw new Error( + "Event group not found for event " + eventID, + ); + } + const subscribers = eventGroup?.subscribers?.reduce( + (acc: string[], current) => { + if (current.email && !acc.includes(current.email)) { + return [current.email, ...acc]; + } + return acc; + }, + [] as string[], + ); + subscribers?.forEach((emailAddress) => { + sendEmailFromTemplate( + emailAddress, + `New event in ${eventGroup.name}`, + "eventGroupUpdated", + { + siteName: config.general.site_name, + siteLogo: config.general.email_logo_url, + domain: config.general.domain, + eventGroupName: eventGroup.name, + eventName: event.name, + eventID: event.id, + eventGroupID: eventGroup.id, + emailAddress: encodeURIComponent(emailAddress), + }, + req, + ); + }); + } catch (err) { + console.error(err); + addToLog( + "createEvent", + "error", + "Attempt to send event group emails failed with error: " + + err, + ); + } + } + return res.json({ + eventID: eventID, + editToken: editToken, + url: `/${eventID}?e=${editToken}`, + }); + } catch (err) { + console.error(err); + addToLog( + "createEvent", + "error", + "Attempt to create event failed with error: " + err, + ); + return res.status(500).json({ + errors: [ + { + message: err, + }, + ], + }); + } + }, +); + +router.put( + "/event/:eventID", + upload.single("imageUpload"), + async (req: Request, res: Response) => { + const { data: eventData, errors } = validateEventData(req.body); + if (errors && errors.length > 0) { + return res.status(400).json({ errors }); + } + if (!eventData) { + return res.status(400).json({ + errors: [ + { + message: "No event data was provided.", + }, + ], + }); + } + + try { + const submittedEditToken = req.body.editToken; + const event = await Event.findOne({ + id: req.params.eventID, + }); + if (!event) { + return res.status(404).json({ + errors: [ + { + message: "Event not found.", + }, + ], + }); + } + if (event.editToken !== submittedEditToken) { + // Token doesn't match + addToLog( + "editEvent", + "error", + `Attempt to edit event ${req.params.eventID} failed with error: token does not match`, + ); + return res.status(403).json({ + errors: [ + { + message: "Edit token is invalid.", + }, + ], + }); + } + // Token matches + // If there is a new image, upload that first + let eventID = req.params.eventID; + let eventImageFilename = event.image; + if (req.file?.buffer) { + Jimp.read(req.file.buffer) + .then((img) => { + img.resize(920, Jimp.AUTO) // resize + .quality(80) // set JPEG quality + .write(`./public/events/${eventID}.jpg`); // save + }) + .catch((err) => { + addToLog( + "Jimp", + "error", + "Attempt to edit image failed with error: " + err, + ); + }); + eventImageFilename = eventID + ".jpg"; + } + + const startUTC = moment.tz( + eventData.eventStart, + eventData.timezone, + ); + const endUTC = moment.tz(eventData.eventEnd, eventData.timezone); + + let isPartOfEventGroup = false; + let eventGroup; + if (eventData.eventGroupBoolean) { + eventGroup = await EventGroup.findOne({ + id: eventData.eventGroupID, + editToken: eventData.eventGroupEditToken, + }); + if (eventGroup) { + isPartOfEventGroup = true; + } + } + const updatedEvent = { + name: eventData.eventName, + location: eventData.eventLocation, + start: startUTC.toDate(), + end: endUTC.toDate(), + timezone: eventData.timezone, + description: eventData.eventDescription, + url: eventData.eventURL, + hostName: eventData.hostName, + image: eventImageFilename, + usersCanAttend: eventData.joinBoolean, + showUsersList: false, // Backwards compatibility + usersCanComment: eventData.interactionBoolean, + maxAttendees: eventData.maxAttendeesBoolean + ? eventData.maxAttendees + : undefined, + eventGroup: isPartOfEventGroup ? eventGroup?._id : null, + activityPubActor: event.activityPubActor + ? updateActivityPubActor( + JSON.parse(event.activityPubActor), + eventData.eventDescription, + eventData.eventName, + eventData.eventLocation, + eventImageFilename, + startUTC, + endUTC, + eventData.timezone, + ) + : undefined, + activityPubEvent: event.activityPubEvent + ? updateActivityPubEvent( + JSON.parse(event.activityPubEvent), + eventData.eventName, + startUTC, + endUTC, + eventData.timezone, + ) + : undefined, + }; + let diffText = + "<p>This event was just updated with new information.</p><ul>"; + let displayDate; + if (event.name !== updatedEvent.name) { + diffText += `<li>the event name changed to ${updatedEvent.name}</li>`; + } + if (event.location !== updatedEvent.location) { + diffText += `<li>the location changed to ${updatedEvent.location}</li>`; + } + if ( + event.start.toISOString() !== updatedEvent.start.toISOString() + ) { + displayDate = moment + .tz(updatedEvent.start, updatedEvent.timezone) + .format("dddd D MMMM YYYY h:mm a"); + diffText += `<li>the start time changed to ${displayDate}</li>`; + } + if (event.end.toISOString() !== updatedEvent.end.toISOString()) { + displayDate = moment + .tz(updatedEvent.end, updatedEvent.timezone) + .format("dddd D MMMM YYYY h:mm a"); + diffText += `<li>the end time changed to ${displayDate}</li>`; + } + if (event.timezone !== updatedEvent.timezone) { + diffText += `<li>the time zone changed to ${updatedEvent.timezone}</li>`; + } + if (event.description !== updatedEvent.description) { + diffText += `<li>the event description changed</li>`; + } + diffText += `</ul>`; + const updatedEventObject = await Event.findOneAndUpdate( + { id: req.params.eventID }, + updatedEvent, + { new: true }, + ); + if (!updatedEventObject) { + throw new Error("Event not found"); + } + addToLog( + "editEvent", + "success", + "Event " + req.params.eventID + " edited", + ); + // send update to ActivityPub subscribers + let attendees = updatedEventObject.attendees?.filter((el) => el.id); + // broadcast an identical message to all followers, will show in home timeline + const guidObject = crypto.randomBytes(16).toString("hex"); + const jsonObject = { + "@context": "https://www.w3.org/ns/activitystreams", + id: `https://${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>`, + }; + 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 + if (attendees?.length) { + for (const attendee of attendees) { + const jsonObject = { + "@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>`, + 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 (req.app.locals.sendEmails) { + const attendeeEmails = event.attendees + ?.filter((o) => o.status === "attending" && o.email) + .map((o) => o.email); + if (attendeeEmails?.length) { + sendEmailFromTemplate( + attendeeEmails.join(","), + `${event.name} was just edited`, + "editEvent", + { + diffText, + eventID: req.params.eventID, + siteName: config.general.site_name, + siteLogo: config.general.email_logo_url, + domain: config.general.domain, + }, + req, + ); + } + } + res.sendStatus(200); + } catch (err) { + console.error(err); + addToLog( + "editEvent", + "error", + "Attempt to edit event " + + req.params.eventID + + " failed with error: " + + err, + ); + return res.status(500).json({ + errors: [ + { + message: err, + }, + ], + }); + } + }, +); + +router.post( + "/import/event", + icsUpload.single("icsImportControl"), + async (req: Request, res: Response) => { + if (!req.file) { + return res.status(400).json({ + errors: [ + { + message: "No file was provided.", + }, + ], + }); + } + + let eventID = generateEventID(); + let editToken = generateEditToken(); + + let iCalObject = ical.parseICS(req.file.buffer.toString("utf8")); + + let importedEventData = iCalObject[Object.keys(iCalObject)[0]]; + + let creatorEmail: string | undefined; + if (req.body.creatorEmail) { + creatorEmail = req.body.creatorEmail; + } else if (importedEventData.organizer) { + if (typeof importedEventData.organizer === "string") { + creatorEmail = importedEventData.organizer.replace( + "MAILTO:", + "", + ); + } else { + creatorEmail = importedEventData.organizer.val.replace( + "MAILTO:", + "", + ); + } + } + + let hostName: string | undefined; + if (importedEventData.organizer) { + if (typeof importedEventData.organizer === "string") { + hostName = importedEventData.organizer.replace(/["]+/g, ""); + } else { + hostName = importedEventData.organizer.params.CN.replace( + /["]+/g, + "", + ); + } + } + + const event = new Event({ + id: eventID, + type: "public", + name: importedEventData.summary, + location: importedEventData.location, + start: importedEventData.start, + end: importedEventData.end, + timezone: "Etc/UTC", // TODO: get timezone from ics file + description: importedEventData.description, + image: "", + creatorEmail, + url: "", + hostName, + viewPassword: "", + editPassword: "", + editToken: editToken, + usersCanAttend: false, + showUsersList: false, + usersCanComment: false, + firstLoad: true, + }); + try { + await event.save(); + addToLog("createEvent", "success", `Event ${eventID} created`); + // Send email with edit link + if (creatorEmail && req.app.locals.sendEmails) { + sendEmailFromTemplate( + creatorEmail, + `${importedEventData.summary}`, + "createEvent", + { + eventID, + editToken, + siteName: config.general.site_name, + siteLogo: config.general.email_logo_url, + domain: config.general.domain, + }, + req, + ); + } + return res.json({ + eventID: eventID, + editToken: editToken, + url: `/${eventID}?e=${editToken}`, + }); + } catch (err) { + console.error(err); + addToLog( + "createEvent", + "error", + "Attempt to create event failed with error: " + err, + ); + return res.status(500).json({ + errors: [ + { + message: err, + }, + ], + }); + } + }, +); + +export default router; diff --git a/src/routes/frontend.ts b/src/routes/frontend.ts index 71984ec..c9594ef 100644 --- a/src/routes/frontend.ts +++ b/src/routes/frontend.ts @@ -1,11 +1,12 @@ 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"; +import { addToLog, exportICal } from "../helpers.js"; +import Event from "../models/Event.js"; +import EventGroup, { IEventGroup } from "../models/EventGroup.js"; const config = getConfig(); @@ -69,6 +70,13 @@ router.get("/:eventID", async (req: Request, res: Response) => { let parsedEnd = moment .tz(event.end, event.timezone) .format("YYYYMMDD[T]HHmmss"); + // See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/datetime-local + const parsedStartForDateInput = moment + .tz(event.start, event.timezone) + .format("YYYY-MM-DDTHH:mm"); + const parsedEndForDateInput = moment + .tz(event.end, event.timezone) + .format("YYYY-MM-DDTHH:mm"); let eventHasConcluded = false; if ( moment @@ -194,6 +202,8 @@ router.get("/:eventID", async (req: Request, res: Response) => { parsedLocation: parsedLocation, parsedStart: parsedStart, parsedEnd: parsedEnd, + parsedStartForDateInput, + parsedEndForDateInput, displayDate: displayDate, fromNow: fromNow, timezone: event.timezone, @@ -205,6 +215,31 @@ router.get("/:eventID", async (req: Request, res: Response) => { eventHasConcluded: eventHasConcluded, eventHasBegun: eventHasBegun, metadata: metadata, + jsonData: { + name: event.name, + id: event.id, + description: event.description, + location: event.location, + timezone: event.timezone, + url: event.url, + hostName: event.hostName, + creatorEmail: event.creatorEmail, + eventGroupID: event.eventGroup + ? (event.eventGroup as unknown as IEventGroup).id + : null, + eventGroupEditToken: event.eventGroup + ? (event.eventGroup as unknown as IEventGroup).editToken + : null, + usersCanAttend: event.usersCanAttend, + usersCanComment: event.usersCanComment, + maxAttendees: event.maxAttendees, + startISO: eventStartISO, + endISO: eventEndISO, + startForDateInput: parsedStartForDateInput, + endForDateInput: parsedEndForDateInput, + image: event.image, + editToken: editingEnabled ? eventEditToken : null, + }, }); } } catch (err) { @@ -221,4 +256,192 @@ router.get("/:eventID", async (req: Request, res: Response) => { } }); +router.get("/group/:eventGroupID", async (req: Request, res: Response) => { + try { + const eventGroup = await EventGroup.findOne({ + id: req.params.eventGroupID, + }).lean(); + + if (!eventGroup) { + return res.status(404).render("404", { url: req.url }); + } + const parsedDescription = marked.parse(eventGroup.description); + const eventGroupEditToken = eventGroup.editToken; + const escapedName = eventGroup.name.replace(/\s+/g, "+"); + const eventGroupHasCoverImage = !!eventGroup.image; + const eventGroupHasHost = !!eventGroup.hostName; + + const events = await Event.find({ eventGroup: eventGroup._id }) + .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, + displayDate: isSameDay + ? startMoment.format("D MMM YYYY") + : `${startMoment.format("D MMM YYYY")} - ${endMoment.format( + "D MMM YYYY", + )}`, + eventHasConcluded: endMoment.isBefore( + moment.tz(event.timezone), + ), + }; + }); + + const upcomingEventsExist = updatedEvents.some( + (e) => !e.eventHasConcluded, + ); + + let firstLoad = false; + if (eventGroup.firstLoad === true) { + firstLoad = true; + await EventGroup.findOneAndUpdate( + { id: req.params.eventGroupID }, + { firstLoad: false }, + ); + } + + let editingEnabled = false; + if (Object.keys(req.query).length !== 0) { + if (!req.query.e) { + editingEnabled = false; + } else { + editingEnabled = req.query.e === eventGroupEditToken; + } + } + + const metadata = { + title: eventGroup.name, + description: marked + .parse(eventGroup.description, { + renderer: renderPlain(), + }) + .split(" ") + .splice(0, 40) + .join(" ") + .trim(), + image: eventGroupHasCoverImage + ? `https://${config.general.domain}/events/` + eventGroup.image + : null, + url: `https://${config.general.domain}/` + req.params.eventID, + }; + + res.set("X-Robots-Tag", "noindex"); + res.render("eventgroup", { + domain: config.general.domain, + title: eventGroup.name, + eventGroupData: eventGroup, + escapedName: escapedName, + events: updatedEvents, + upcomingEventsExist: upcomingEventsExist, + parsedDescription: parsedDescription, + editingEnabled: editingEnabled, + eventGroupHasCoverImage: eventGroupHasCoverImage, + eventGroupHasHost: eventGroupHasHost, + firstLoad: firstLoad, + metadata: metadata, + jsonData: { + name: eventGroup.name, + id: eventGroup.id, + description: eventGroup.description, + url: eventGroup.url, + hostName: eventGroup.hostName, + creatorEmail: eventGroup.creatorEmail, + image: eventGroup.image, + editToken: editingEnabled ? eventGroupEditToken : null, + }, + }); + } catch (err) { + addToLog( + "displayEventGroup", + "error", + `Attempt to display event group ${req.params.eventGroupID} failed with error: ${err}`, + ); + console.log(err); + return res.status(404).render("404", { url: req.url }); + } +}); + +router.get( + "/group/:eventGroupID/feed.ics", + async (req: Request, res: Response) => { + try { + const eventGroup = await EventGroup.findOne({ + id: req.params.eventGroupID, + }).lean(); + + if (eventGroup) { + const events = await Event.find({ + eventGroup: eventGroup._id, + }).sort("start"); + const string = exportICal(events, eventGroup.name); + res.set("Content-Type", "text/calendar"); + 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).render("404", { url: req.url }); + } + }, +); + +router.get("/export/event/:eventID", async (req: Request, res: Response) => { + try { + const event = await Event.findOne({ + id: req.params.eventID, + }).populate("eventGroup"); + + if (event) { + const string = exportICal([event], event.name); + 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).render("404", { url: req.url }); + } +}); + +router.get( + "/export/group/:eventGroupID", + async (req: Request, res: Response) => { + try { + const eventGroup = await EventGroup.findOne({ + id: req.params.eventGroupID, + }).lean(); + + if (eventGroup) { + const events = await Event.find({ + eventGroup: eventGroup._id, + }).sort("start"); + const string = exportICal(events, eventGroup.name); + 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).render("404", { url: req.url }); + } + }, +); + export default router; diff --git a/src/routes/group.ts b/src/routes/group.ts new file mode 100644 index 0000000..2801248 --- /dev/null +++ b/src/routes/group.ts @@ -0,0 +1,240 @@ +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"; +import Jimp from "jimp"; +import { addToLog } from "../helpers.js"; +import EventGroup from "../models/EventGroup.js"; +import { sendEmailFromTemplate } from "../lib/email.js"; + +const config = getConfig(); + +const storage = multer.memoryStorage(); +// Accept only JPEG, GIF or PNG images, up to 10MB +const upload = multer({ + storage: storage, + limits: { fileSize: 10 * 1024 * 1024 }, + fileFilter: function (_, file, cb) { + const filetypes = /jpeg|jpg|png|gif/; + const mimetype = filetypes.test(file.mimetype); + if (!mimetype) { + return cb(new Error("Only JPEG, PNG and GIF images are allowed.")); + } + cb(null, true); + }, +}); + +const router = Router(); + +router.post( + "/group", + upload.single("imageUpload"), + async (req: Request, res: Response) => { + const { data: groupData, errors } = validateGroupData(req.body); + if (errors && errors.length > 0) { + return res.status(400).json({ errors }); + } + if (!groupData) { + return res.status(400).json({ + errors: [ + { + message: "No group data was provided.", + }, + ], + }); + } + + try { + const groupID = generateEventID(); + const editToken = generateEditToken(); + let groupImageFilename; + + if (req.file?.buffer) { + groupImageFilename = await Jimp.read(req.file.buffer) + .then((img) => { + img.resize(920, Jimp.AUTO) // resize + .quality(80) // set JPEG quality + .write("./public/events/" + groupID + ".jpg"); // save + return groupID + ".jpg"; + }) + .catch((err) => { + addToLog( + "Jimp", + "error", + "Attempt to edit image failed with error: " + err, + ); + }); + } + + const eventGroup = new EventGroup({ + id: groupID, + name: groupData.eventGroupName, + description: groupData.eventGroupDescription, + image: groupImageFilename, + creatorEmail: groupData.creatorEmail, + url: groupData.eventGroupURL, + hostName: groupData.hostName, + editToken: editToken, + firstLoad: true, + }); + + await eventGroup.save(); + + addToLog( + "createEventGroup", + "success", + "Event group " + groupID + " created", + ); + + // Send email with edit link + if (groupData.creatorEmail && req.app.locals.sendEmails) { + sendEmailFromTemplate( + groupData.creatorEmail, + `${eventGroup.name}`, + "createEventGroup", + { + eventGroupID: eventGroup.id, + editToken: eventGroup.editToken, + siteName: config.general.site_name, + siteLogo: config.general.email_logo_url, + domain: config.general.domain, + }, + req, + ); + } + + res.status(200).json({ + id: groupID, + editToken: editToken, + url: `/group/${groupID}?e=${editToken}`, + }); + } catch (err) { + console.error(err); + addToLog( + "createEvent", + "error", + "Attempt to create event failed with error: " + err, + ); + return res.status(500).json({ + errors: [ + { + message: err, + }, + ], + }); + } + }, +); + +router.put( + "/group/:eventGroupID", + upload.single("imageUpload"), + async (req: Request, res: Response) => { + const { data: groupData, errors } = validateGroupData(req.body); + if (errors && errors.length > 0) { + return res.status(400).json({ errors }); + } + if (!groupData) { + return res.status(400).json({ + errors: [ + { + message: "No group data was provided.", + }, + ], + }); + } + + try { + const submittedEditToken = req.body.editToken; + const eventGroup = await EventGroup.findOne({ + id: req.params.eventGroupID, + }); + if (!eventGroup) { + return res.status(404).json({ + errors: [ + { + message: "Event group not found.", + }, + ], + }); + } + + if (eventGroup.editToken !== submittedEditToken) { + // Token doesn't match + addToLog( + "editEventGroup", + "error", + `Attempt to edit event group ${req.params.eventGroupID} failed with error: token does not match`, + ); + return res.status(403).json({ + errors: [ + { + message: "Edit token is invalid.", + }, + ], + }); + } + // Token matches + // If there is a new image, upload that first + let eventGroupID = req.params.eventGroupID; + let eventGroupImageFilename = eventGroup.image; + if (req.file?.buffer) { + Jimp.read(req.file.buffer) + .then((img) => { + img.resize(920, Jimp.AUTO) // resize + .quality(80) // set JPEG quality + .write(`./public/events/${eventGroupID}.jpg`); // save + }) + .catch((err) => { + addToLog( + "Jimp", + "error", + "Attempt to edit image failed with error: " + err, + ); + }); + eventGroupImageFilename = eventGroupID + ".jpg"; + } + + const updatedEventGroup = { + name: req.body.eventGroupName, + description: req.body.eventGroupDescription, + url: req.body.eventGroupURL, + hostName: req.body.hostName, + image: eventGroupImageFilename, + }; + + await EventGroup.findOneAndUpdate( + { id: req.params.eventGroupID }, + updatedEventGroup, + ); + + addToLog( + "editEventGroup", + "success", + "Event group " + req.params.eventGroupID + " edited", + ); + + res.sendStatus(200); + } catch (err) { + console.error(err); + addToLog( + "editEventGroup", + "error", + "Attempt to edit event group " + + req.params.eventGroupID + + " failed with error: " + + err, + ); + return res.status(500).json({ + errors: [ + { + message: err, + }, + ], + }); + } + }, +); + +export default router; diff --git a/src/util/config.ts b/src/util/config.ts index c65fdb0..d1fd05b 100644 --- a/src/util/config.ts +++ b/src/util/config.ts @@ -7,6 +7,7 @@ interface FrontendConfig { email: string; siteName: string; showKofi: boolean; + isFederated: boolean; } export const frontendConfig = (): FrontendConfig => ({ @@ -14,4 +15,5 @@ export const frontendConfig = (): FrontendConfig => ({ email: config.general.email, siteName: config.general.site_name, showKofi: config.general.show_kofi, + isFederated: config.general.is_federated, }); diff --git a/src/util/generator.ts b/src/util/generator.ts new file mode 100644 index 0000000..596110d --- /dev/null +++ b/src/util/generator.ts @@ -0,0 +1,34 @@ +import crypto from "crypto"; +import { customAlphabet } from "nanoid"; + +// 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, +); + +const generateAlphanumericString = (length: number) => { + return Array(length) + .fill(0) + .map((x) => Math.random().toString(36).charAt(2)) + .join(""); +}; + +export const generateEventID = () => nanoid(); + +export const generateEditToken = () => generateAlphanumericString(32); + +export const generateRSAKeypair = () => { + return crypto.generateKeyPairSync("rsa", { + modulusLength: 4096, + publicKeyEncoding: { + type: "spki", + format: "pem", + }, + privateKeyEncoding: { + type: "pkcs8", + format: "pem", + }, + }); +}; diff --git a/src/util/validation.ts b/src/util/validation.ts new file mode 100644 index 0000000..732fbf3 --- /dev/null +++ b/src/util/validation.ts @@ -0,0 +1,216 @@ +import moment from "moment-timezone"; + +type Error = { + message?: string; + field?: string; +}; + +type ValidationResponse = { + data?: ValidatedEventData; + errors?: Error[]; +}; + +interface EventData { + eventName: string; + eventLocation: string; + eventStart: string; + eventEnd: string; + timezone: string; + eventDescription: string; + eventURL: string; + imagePath: string; + hostName: string; + creatorEmail: string; + eventGroupCheckbox: string; + eventGroupID: string; + eventGroupEditToken: string; + interactionCheckbox: string; + joinCheckbox: string; + maxAttendeesCheckbox: string; + maxAttendees: number; +} + +// EventData without the 'checkbox' fields +export type ValidatedEventData = Omit< + EventData, + | "eventGroupCheckbox" + | "interactionCheckbox" + | "joinCheckbox" + | "maxAttendeesCheckbox" +> & { + eventGroupBoolean: boolean; + interactionBoolean: boolean; + joinBoolean: boolean; + maxAttendeesBoolean: boolean; +}; + +interface EventGroupData { + eventGroupName: string; + eventGroupDescription: string; + eventGroupURL: string; + hostName: string; + creatorEmail: string; +} + +const validateEmail = (email: string) => { + if (!email || email.length === 0 || typeof email !== "string") { + return false; + } + var re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return re.test(email); +}; + +export const validateEventTime = (start: Date, end: Date): Error | boolean => { + if (moment(start).isAfter(moment(end))) { + return { + message: "Start time must be before end time.", + field: "eventStart", + }; + } + if (moment(start).isBefore(moment())) { + return { + message: "Start time must be in the future.", + field: "eventStart", + }; + } + if (moment(end).isBefore(moment())) { + return { + message: "End time must be in the future.", + field: "eventEnd", + }; + } + // Duration cannot be longer than 1 year + if (moment(end).diff(moment(start), "years") > 1) { + return { + message: "Event duration cannot be longer than 1 year.", + field: "eventEnd", + }; + } + return true; +}; + +export const validateEventData = (eventData: EventData): ValidationResponse => { + const validatedData: ValidatedEventData = { + ...eventData, + eventGroupBoolean: eventData.eventGroupCheckbox === "true", + interactionBoolean: eventData.interactionCheckbox === "true", + joinBoolean: eventData.joinCheckbox === "true", + maxAttendeesBoolean: eventData.maxAttendeesCheckbox === "true", + }; + const errors: Error[] = []; + if (!validatedData.eventName) { + errors.push({ + message: "Event name is required.", + field: "eventName", + }); + } + if (!validatedData.eventLocation) { + errors.push({ + message: "Event location is required.", + field: "eventLocation", + }); + } + if (!validatedData.eventStart) { + errors.push({ + message: "Event start time is required.", + field: "eventStart", + }); + } + if (!validatedData.eventEnd) { + errors.push({ + message: "Event end time is required.", + field: "eventEnd", + }); + } + const timeValidation = validateEventTime( + new Date(validatedData.eventStart), + new Date(validatedData.eventEnd), + ); + if (timeValidation !== true && timeValidation !== false) { + errors.push({ + message: timeValidation.message, + }); + } + if (!validatedData.timezone) { + errors.push({ + message: "Event timezone is required.", + field: "timezone", + }); + } + if (!validatedData.eventDescription) { + errors.push({ + message: "Event description is required.", + field: "eventDescription", + }); + } + if (validatedData.eventGroupBoolean) { + if (!validatedData.eventGroupID) { + errors.push({ + message: "Event group ID is required.", + field: "eventGroupID", + }); + } + if (!validatedData.eventGroupEditToken) { + errors.push({ + message: "Event group edit token is required.", + field: "eventGroupEditToken", + }); + } + } + if (validatedData.maxAttendeesBoolean) { + if (!validatedData.maxAttendees) { + errors.push({ + message: "Max number of attendees is required.", + field: "maxAttendees", + }); + } + if (isNaN(validatedData.maxAttendees)) { + errors.push({ + message: "Max number of attendees must be a number.", + field: "maxAttendees", + }); + } + } + if (validatedData.creatorEmail) { + if (!validateEmail(validatedData.creatorEmail)) { + errors.push({ + message: "Email address is invalid.", + field: "creatorEmail", + }); + } + } + + return { + data: validatedData, + errors: errors, + }; +}; + +export const validateGroupData = (groupData: EventGroupData) => { + const errors: Error[] = []; + if (!groupData.eventGroupName) { + errors.push({ + message: "Event group name is required.", + field: "eventGroupName", + }); + } + if (!groupData.eventGroupDescription) { + errors.push({ + message: "Event group description is required.", + field: "eventGroupDescription", + }); + } + if (groupData.creatorEmail) { + if (!validateEmail(groupData.creatorEmail)) { + errors.push({ + message: "Email address is invalid.", + field: "creatorEmail", + }); + } + } + + return { + data: groupData, + errors: errors, + }; +}; diff --git a/tsconfig.json b/tsconfig.json index 4e6ba76..6d59c57 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,7 @@ "sourceMap": true, "outDir": "dist", "strict": true, - "baseUrl": ".", + "baseUrl": "./", "forceConsistentCasingInFileNames": true, "esModuleInterop": true, "experimentalDecorators": true, @@ -18,5 +18,5 @@ "moduleResolution": "nodenext", "skipLibCheck": true }, - "include": ["src/**/*"] + "include": ["./src/**/*"] } diff --git a/views/emails/addeventattendee.handlebars b/views/emails/addEventAttendee/addEventAttendeeHtml.handlebars index 971364c..971364c 100644 --- a/views/emails/addeventattendee.handlebars +++ b/views/emails/addEventAttendee/addEventAttendeeHtml.handlebars diff --git a/views/emails/addEventAttendee/addEventAttendeeText.handlebars b/views/emails/addEventAttendee/addEventAttendeeText.handlebars new file mode 100644 index 0000000..2e0eca7 --- /dev/null +++ b/views/emails/addEventAttendee/addEventAttendeeText.handlebars @@ -0,0 +1,11 @@ +You just marked yourself as attending an event on {{siteName}}. Thank you! We'll send you another email if there are any updates to the event. Your email will be automatically removed from the database once the event finishes. + +Follow this link to open the event page any time: https://{{domain}}/{{eventID}} + +Need to remove yourself from this event? Head to the event page and use this deletion password: {{removalPassword}} + +Love, + +{{siteName}} + +If you didn't mark yourself as attending an event on {{siteName}}, someone may have accidentally typed your email instead of theirs. If you don't want to attend this event, use the deletion password above to remove yourself from the event page. diff --git a/views/emails/addeventcomment.handlebars b/views/emails/addEventComment/addEventCommentHtml.handlebars index 8ab7ec1..8ab7ec1 100644 --- a/views/emails/addeventcomment.handlebars +++ b/views/emails/addEventComment/addEventCommentHtml.handlebars diff --git a/views/emails/addEventComment/addEventCommentText.handlebars b/views/emails/addEventComment/addEventCommentText.handlebars new file mode 100644 index 0000000..d7c045e --- /dev/null +++ b/views/emails/addEventComment/addEventCommentText.handlebars @@ -0,0 +1,9 @@ +{{commentAuthor}} has just posted a comment on an event you're attending on {{siteName}}. + +Click here to see the comment: https://{{domain}}/{{eventID}} + +Love, + +{{siteName}} + +If you didn't mark yourself as attending an event on {{siteName}}, someone may have accidentally typed your email instead of theirs. Don't worry - there isn't anything you need to do. Your email will be removed from the system when the event finishes. diff --git a/views/emails/createevent.handlebars b/views/emails/createEvent/createEventHtml.handlebars index 030ee58..030ee58 100644 --- a/views/emails/createevent.handlebars +++ b/views/emails/createEvent/createEventHtml.handlebars diff --git a/views/emails/createEvent/createEventText.handlebars b/views/emails/createEvent/createEventText.handlebars new file mode 100644 index 0000000..e3c3a91 --- /dev/null +++ b/views/emails/createEvent/createEventText.handlebars @@ -0,0 +1,7 @@ +Your event has been created! + +Use this link to share it with people: https://{{domain}}/{{eventID}} + +Use the following link to edit your event. DO NOT SHARE THIS, as anyone with this link can edit your event. + +https://{{domain}}/{{eventID}}?e={{editToken}} diff --git a/views/emails/createeventgroup.handlebars b/views/emails/createEventGroup/createEventGroupHtml.handlebars index 3f03345..0a12e91 100644 --- a/views/emails/createeventgroup.handlebars +++ b/views/emails/createEventGroup/createEventGroupHtml.handlebars @@ -1,6 +1,6 @@ <p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; Margin-bottom: 15px;">You just created a new event group on {{siteName}}! Thanks a bunch - we're delighted to have you.</p> <p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; Margin-bottom: 15px;">You can edit your event group by clicking the button below, or just following this link: <a href="https://{{domain}}/group/{{eventGroupID}}?e={{editToken}}">https://{{domain}}/{{eventGroupID}}?e={{editToken}}</a></p> -<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; Margin-bottom: 15px;">To add events to this group (whether brand new events or ones you've already made), click the 'This event is part of an event group' checkbox. You will need to copy the following two codes into the dark grey box which opens:</p> +<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; Margin-bottom: 15px;">To add events to this group (whether brand new events or ones you've already made), click the 'This event is part of an event group' checkbox. You will need to copy the following two codes into the box which opens:</p> <p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; Margin-bottom: 15px;"><strong>Event group ID</strong>: {{eventGroupID}}</p> <p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; Margin-bottom: 15px;"><strong>Event group secret editing code</strong>: {{editToken}}</p> <table border="0" cellpadding="0" cellspacing="0" class="btn btn-primary" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; box-sizing: border-box;"> @@ -24,4 +24,4 @@ <p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; Margin-bottom: 15px;">{{siteName}}</p> <hr/> <p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; Margin-bottom: 15px;"><strong>Hold up - I have no idea what this email is about!</strong></p> -<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; Margin-bottom: 15px;">If you didn't make an event group on {{siteName}}, someone may have accidentally typed your email instead of theirs when they were making an event. Don't worry - there isn't anything you need to do. Your email will be removed from the system when the event finishes, and if you're still worried, just click on the edit link above and delete that event group, which removes your email from the system as well.</p> +<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; Margin-bottom: 15px;">If you didn't make an event group on {{siteName}}, someone may have accidentally typed your email instead of theirs when they were making the group. Just click on the edit link above and delete that event group, which removes your email from the system as well.</p> diff --git a/views/emails/createEventGroup/createEventGroupText.handlebars b/views/emails/createEventGroup/createEventGroupText.handlebars new file mode 100644 index 0000000..34ad618 --- /dev/null +++ b/views/emails/createEventGroup/createEventGroupText.handlebars @@ -0,0 +1,21 @@ +You just created a new event group on {{siteName}}! Thanks a bunch - we're delighted to have you. + +You can edit your event group by clicking the button below, or just following this link: https://{{domain}}/{{eventGroupID}}?e={{editToken}} + +To add events to this group (whether brand new events or ones you've already made), click the 'This event is part of an event group' checkbox. You will need to copy the following two codes into the box which opens: + +Event group ID: {{eventGroupID}} + +Event group secret editing code: {{editToken}} + +Edit the event group here: https://{{domain}}/group/{{eventGroupID}}?e={{editToken}} + +To let others know about your event group, send them this link: https://{{domain}}/{{eventGroupID}} + +And that's it - have a great day! + +Love, + +{{siteName}} + +If you didn't make an event group on {{siteName}}, someone may have accidentally typed your email instead of theirs when they were making the group. Just click on the edit link above and delete that event group, which removes your email from the system as well. diff --git a/views/emails/deleteevent.handlebars b/views/emails/deleteEvent/deleteEventHtml.handlebars index 5a3670c..5a3670c 100644 --- a/views/emails/deleteevent.handlebars +++ b/views/emails/deleteEvent/deleteEventHtml.handlebars diff --git a/views/emails/deleteEvent/deleteEventText.handlebars b/views/emails/deleteEvent/deleteEventText.handlebars new file mode 100644 index 0000000..77c1cc3 --- /dev/null +++ b/views/emails/deleteEvent/deleteEventText.handlebars @@ -0,0 +1,3 @@ +The {{eventName}} event you're attending on {{siteName}} was just deleted by its creator. + +If you didn't mark yourself as attending an event on {{siteName}}, someone may have accidentally typed your email instead of theirs. Don't worry - that event, and your email, is deleted from the system now. diff --git a/views/emails/editevent.handlebars b/views/emails/editEvent/editEventHtml.handlebars index ddb9885..ddb9885 100644 --- a/views/emails/editevent.handlebars +++ b/views/emails/editEvent/editEventHtml.handlebars diff --git a/views/emails/editEvent/editEventText.handlebars b/views/emails/editEvent/editEventText.handlebars new file mode 100644 index 0000000..cdcffd3 --- /dev/null +++ b/views/emails/editEvent/editEventText.handlebars @@ -0,0 +1,11 @@ +An event you're attending on {{siteName}} has just been edited. + +{{{diffText}}} + +Click here to see the event: https://{{domain}}/{{eventID}} + +Love, + +{{siteName}} + +If you didn't mark yourself as attending an event on {{siteName}}, someone may have accidentally typed your email instead of theirs. Don't worry - there isn't anything you need to do. Your email will be removed from the system when the event finishes. diff --git a/views/emails/eventgroupupdated.handlebars b/views/emails/eventGroupUpdated/eventGroupUpdatedHtml.handlebars index 3231327..3231327 100644 --- a/views/emails/eventgroupupdated.handlebars +++ b/views/emails/eventGroupUpdated/eventGroupUpdatedHtml.handlebars diff --git a/views/emails/eventGroupUpdated/eventGroupUpdatedText.handlebars b/views/emails/eventGroupUpdated/eventGroupUpdatedText.handlebars new file mode 100644 index 0000000..3ed5cb2 --- /dev/null +++ b/views/emails/eventGroupUpdated/eventGroupUpdatedText.handlebars @@ -0,0 +1,11 @@ +A new event has been added to the event group '{{eventGroupName}}' on {{siteName}}. + +The event is '{{eventName}}': https://{{domain}}/{{eventID}}. + +Click here to see the event group: https://{{domain}}/group/{{eventGroupID}} + +Love, + +{{siteName}} + +If you didn't subscribe yourself to this event group on {{siteName}}, someone may have accidentally typed your email instead of theirs. Click here to unsubscribe: https://{{domain}}/unsubscribe/{{eventGroupID}}?email={{emailAddress}}. diff --git a/views/emails/removeeventattendee.handlebars b/views/emails/removeEventAttendee/removeEventAttendeeHtml.handlebars index 66ca858..66ca858 100644 --- a/views/emails/removeeventattendee.handlebars +++ b/views/emails/removeEventAttendee/removeEventAttendeeHtml.handlebars diff --git a/views/emails/removeEventAttendee/removeEventAttendeeText.handlebars b/views/emails/removeEventAttendee/removeEventAttendeeText.handlebars new file mode 100644 index 0000000..0a94121 --- /dev/null +++ b/views/emails/removeEventAttendee/removeEventAttendeeText.handlebars @@ -0,0 +1,3 @@ +You have been removed from the event {{eventName}} on {{siteName}} by the organizer of the event. + +If you didn't mark yourself as attending an event on {{siteName}}, someone may have accidentally typed your email instead of theirs. Don't worry - you won't receive any more of these emails for this event, and your email has been removed from the database. diff --git a/views/emails/subscribed.handlebars b/views/emails/subscribed/subscribedHtml.handlebars index 3a3c4ad..3a3c4ad 100644 --- a/views/emails/subscribed.handlebars +++ b/views/emails/subscribed/subscribedHtml.handlebars diff --git a/views/emails/subscribed/subscribedText.handlebars b/views/emails/subscribed/subscribedText.handlebars new file mode 100644 index 0000000..68418bc --- /dev/null +++ b/views/emails/subscribed/subscribedText.handlebars @@ -0,0 +1,9 @@ +You have been subscribed to the event group '{{eventGroupName}}' on {{siteName}}. + +You will receive emails when new events are added to the group, and can unsubscribe at any time. + +Love, + +{{siteName}} + +If you didn't subscribe yourself to this event group on {{siteName}}, someone may have accidentally typed your email instead of theirs. Click here to unsubscribe: https://{{domain}}/unsubscribe/{{eventGroupID}}?email={{emailAddress}}. diff --git a/views/emails/unattendevent.handlebars b/views/emails/unattendEvent/unattendEventHtml.handlebars index 62dac8a..62dac8a 100644 --- a/views/emails/unattendevent.handlebars +++ b/views/emails/unattendEvent/unattendEventHtml.handlebars diff --git a/views/emails/unattendEvent/unattendEventText.handlebars b/views/emails/unattendEvent/unattendEventText.handlebars new file mode 100644 index 0000000..dbe83b4 --- /dev/null +++ b/views/emails/unattendEvent/unattendEventText.handlebars @@ -0,0 +1,11 @@ +You just removed yourself from an event on {{siteName}}. You will no longer receive update emails for this event. + +If you didn't mean to do this, someone else who knows your email removed you from the event. + +Follow this link to open the event page any time: https://{{domain}}/{{eventID}} + +Love, + +{{siteName}} + +If you didn't mark yourself as attending an event on {{siteName}}, someone may have accidentally typed your email instead of theirs, then removed it. Don't worry - you won't receive any more emails linked to this event. diff --git a/views/event.handlebars b/views/event.handlebars index 1576647..ae6674a 100755 --- a/views/event.handlebars +++ b/views/event.handlebars @@ -136,7 +136,7 @@ {{#unless noMoreSpots}} <button type="button" id="attendEvent" class="btn btn-success" data-event-id="{{eventData.id}}"><i class="fas fa-user-plus"></i> Add me</button> {{/unless}} - <button type="button" id="unattendEvent" class="btn btn-seco.split("?")[0];dary" data-toggle="modal" data-target="#unattendModal"><i class="fas fa-user-times"></i> Remove me</button> + <button type="button" id="unattendEvent" class="btn btn-secondary" data-toggle="modal" data-target="#unattendModal"><i class="fas fa-user-times"></i> Remove me</button> </div> </h5> <div class="card-body"> @@ -397,18 +397,12 @@ {{/if}} <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script> -{{#unless eventHasConcluded}} -{{#if editingEnabled}} -<script type="text/javascript" src="/js/generate-timezones.js"></script> -{{/if}} -{{/unless}} + +<script> +window.eventData = {{{ json jsonData }}}; +</script> + <script> - $.validate({ - lang: 'en', - errorElementClass: "is-invalid", - errorMessageClass: "text-danger", - successElementClass: "is-valid" - }); {{#if editingEnabled}} $('#removeAttendeeModal').on('show.bs.modal', function (event) { @@ -419,24 +413,6 @@ modal.find('.modal-title').text('Remove ' + attendeeName + ' from {{eventData.name}}') modal.find('#removeAttendeeForm').attr('action', '/removeattendee/{{eventData.id}}/' + attendeeID); }) - {{#unless eventHasConcluded}} - $('#eventStart').datepicker({ - language: 'en', - timepicker: true, - dateFormat: 'd MM yyyy', - dateTimeSeparator: ', ', - onSelect: function(formattedDate, rawDate){ - $('#eventEnd').datepicker().data('datepicker').update('minDate', rawDate).clear(); - } - }); - $('#eventEnd').datepicker({ - language: 'en', - timepicker: true, - dateFormat: 'd MM yyyy', - dateTimeSeparator: ', ' - }); - $("#timezone").val('{{eventData.timezone}}').trigger('change'); - {{/unless}} {{/if}} $(".commentTimestamp").html(function(){ parsedDate = moment($(this).html()).fromNow(); @@ -512,26 +488,10 @@ document.body.removeChild(a); } - $.uploadPreview({ - input_field: "#image-upload", - preview_box: "#image-preview", - label_field: "#image-label", - label_default: "Choose file", - label_selected: "Change file", - no_label: false - }); - $("#image-preview").css("background-image", "url('/events/{{eventData.image}}')"); - $("#image-preview").css("background-size", "cover"); - $("#image-preview").css("background-position", "center center"); - {{#if editingEnabled}} - $('#eventStart').datepicker().data('datepicker').selectDate(moment('{{parsedStart}}', 'YYYYMMDD[T]HHmmss').toDate()); - $('#eventEnd').datepicker().data('datepicker').selectDate(moment('{{parsedEnd}}', 'YYYYMMDD[T]HHmmss').toDate()); - {{/if}} new ClipboardJS('#copyEventLink'); - autosize($('textarea')); $("#exportICS").click(function(){ let eventID = $(this).attr('data-event-id'); - $.get('/exportevent/' + eventID, function(response) { + $.get('/export/event/' + eventID, function(response) { downloadFile(response, eventID + '.ics'); }) }) @@ -548,39 +508,6 @@ if ($("#joinCheckbox").is(':checked')){ $("#maxAttendeesCheckboxContainer").css("display","flex"); } - $("#maxAttendeesCheckbox").on("click", function() { - if ($(this).is(':checked')) { - $("#maxAttendeesContainer").slideDown('fast').css("display","flex"); - $("#maxAttendees").attr("data-validation-optional","false"); - } - else { - $("#maxAttendeesContainer").slideUp('fast'); - $("#maxAttendees").attr("data-validation-optional","true").val("").removeClass('is-valid is-invalid'); - } - }); - $("#joinCheckbox").on("click", function() { - if ($(this).is(':checked')) { - $("#maxAttendeesCheckboxContainer").slideDown('fast').css("display","flex"); - } - else { - $("#maxAttendeesCheckboxContainer").slideUp('fast'); - $("#maxAttendeesCheckbox").prop("checked",false); - $("#maxAttendeesContainer").slideUp('fast'); - $("#maxAttendees").attr("data-validation-optional","true").val("").removeClass('is-valid is-invalid'); - } - }); - $("#eventGroupCheckbox").on("click", function() { - if ($(this).is(':checked')) { - $("#eventGroupData").slideDown('fast'); - $("#eventGroupID").removeAttr("data-validation-optional").attr("data-validation","required"); - $("#eventGroupEditToken").removeAttr("data-validation-optional").attr("data-validation","required"); - } - else { - $("#eventGroupData").slideUp('fast'); - $("#eventGroupID").removeAttr("data-validation").attr("data-validation-optional","true").val(""); - $("#eventGroupEditToken").removeAttr("data-validation").attr("data-validation-optional","true").val(""); - } - }); $('#attendEvent').on('click', function(event) { const modal = $('#attendModal'); const eventID = $(this).attr('data-event-id'); diff --git a/views/eventgroup.handlebars b/views/eventgroup.handlebars index 9afee2c..0643ed6 100755 --- a/views/eventgroup.handlebars +++ b/views/eventgroup.handlebars @@ -5,7 +5,7 @@ {{/if}} <div class="row"> <div class="col-lg"> - <h3 id="eventName" data-event-id="{{eventGroupData.id}}">{{eventGroupData.name}}</h3> + <h3 id="eventGroupName" data-event-id="{{eventGroupData.id}}">{{eventGroupData.name}}</h3> </div> <div class="col-lg-2 ml-2 edit-buttons"> {{#if editingEnabled}} @@ -16,7 +16,7 @@ </div> </div> {{#if firstLoad}} -<div class="alert alert-success alert-dismissible fade show" role="alert"> +<div class="alert alert-success alert-dismissible fade show mt-4" role="alert"> <button type="button" class="close" data-dismiss="alert" aria-label="Close"> <span aria-hidden="true">×</span> </button> @@ -30,7 +30,7 @@ <div class="card-body"> <ul class="fa-ul eventInformation"> {{#if eventGroupHasHost}} - <li> + <li id="hostName"> <span class="fa-li"> <i class="fas fa-fw fa-user-circle"></i> </span> @@ -38,7 +38,7 @@ </li> {{/if}} {{#if eventGroupData.url}} - <li> + <li id="eventGroupURL"> <span class="fa-li"> <i class="fas fa-link"></i> </span> @@ -91,17 +91,17 @@ </div> {{#if editingEnabled}} - <div class="alert alert-success"> + <div class="alert alert-info"> <p>To add an event to this group, copy and paste the two codes below into the 'Event Group' box when creating a new event or editing an existing event.</p> <div class="table-responsive"> <table style="width:100%"> <tr style="border-bottom:1px solid rgba(0,0,0,0.2)"> <td><strong>Event group ID</strong></td> - <td><span class="code">{{eventGroupData.id}}</span></td> + <td><span class="code" id="eventGroupID">{{eventGroupData.id}}</span></td> </tr> <tr> <td><strong>Event group editing password</strong></td> - <td><span class="code">{{eventGroupData.editToken}}</span></td> + <td><span class="code" id="eventGroupEditToken">{{eventGroupData.editToken}}</span></td> </tr> </table> </div> @@ -222,15 +222,11 @@ </div> </div> - <script> - $.validate({ - lang: 'en', - errorElementClass: "is-invalid", - errorMessageClass: "text-danger", - successElementClass: "is-valid" - }); +window.groupData = {{{ json jsonData }}}; +</script> +<script> $(document).ready(function() { // Save the editing token from the URL, if it is valid const eventID = $('#eventName').attr('data-event-id'); @@ -273,23 +269,12 @@ $('#editModal').modal('show'); } - $.uploadPreview({ - input_field: "#eventGroupImageUpload", - preview_box: "#eventGroupImagePreview", - label_field: "#eventGroupImageLabel", - label_default: "Choose file", - label_selected: "Change file", - no_label: false - }); - $("#eventGroupImagePreview").css("background-image", "url('/events/{{eventGroupData.image}}')"); - $("#eventGroupImagePreview").css("background-size", "cover"); - $("#eventGroupImagePreview").css("background-position", "center center"); new ClipboardJS('#copyEventLink'); new ClipboardJS('#copyFeedLink'); autosize($('textarea')); $("#exportICS").click(function(){ let eventGroupID = $(this).attr('data-event-id'); - $.get('/exportgroup/' + eventGroupID, function(response) { + $.get('/export/group/' + eventGroupID, function(response) { downloadFile(response, eventGroupID + '.ics'); }) }) diff --git a/views/layouts/main.handlebars b/views/layouts/main.handlebars index ee6817b..daa5a37 100755 --- a/views/layouts/main.handlebars +++ b/views/layouts/main.handlebars @@ -53,6 +53,8 @@ <script src="/js/moment-timezone.js"></script> <script src="/js/util.js"></script> + <script src="//unpkg.com/alpinejs" defer></script> + </head> <body> diff --git a/views/newevent.handlebars b/views/newevent.handlebars index 5e7752f..349c355 100755 --- a/views/newevent.handlebars +++ b/views/newevent.handlebars @@ -1,30 +1,36 @@ -<h2>New event</h2> -<hr> -<div class="alert alert-info mb-4 text-center" role="alert"> - <i class="fas fa-exclamation-circle"></i> Events are visible to anyone who knows the link. -</div> - - -{{#each errors}} - <div class="alert alert-danger" role="alert">{{this.msg}}</div> -{{/each}} - <div class="container mb-4"> <div class="row"> <div class="col-sm-4 p-2"> - <button type="button" id="showNewEventFormButton" class="btn btn-secondary w-100"><i class="fas fa-file"></i> Create a new event</button> + <button type="button" id="showNewEventFormButton" class="btn btn-secondary w-100"><i class="fas fa-calendar-day"></i> Create a new event</button> </div> <div class="col-sm-4 p-2"> <button type="button" id="showImportEventFormButton" class="btn btn-secondary w-100"><i class="fas fa-file-import"></i> Import an existing event</button> </div> <div class="col-sm-4 p-2"> - <button type="button" id="showNewEventGroupFormButton" class="btn btn-secondary w-100"><i class="fas fa-folder-open"></i> Create a new event group </button> + <button type="button" id="showNewEventGroupFormButton" class="btn btn-secondary w-100"><i class="fas fa-calendar-alt"></i> Create a new event group </button> </div> </div> </div> +<div class="alert alert-info mb-4 text-center" role="alert"> + <i class="fas fa-exclamation-circle"></i> Events are visible to anyone who knows the link. +</div> + <div id="newEventFormContainer"> - {{>neweventform}} + <h4 class="mb-2">Create an event</h4> + <form id="newEventForm" enctype="multipart/form-data" x-data="newEventForm()" x-init="init()" @submit.prevent="submitForm"> + {{>eventForm}} + <div class="form-group row"> + <div class="col-sm-12 pt-3 pb-3 text-center"> + <button + id="newEventFormSubmit" + type="submit" + class="btn btn-primary w-50" + x-bind:disabled="submitting" + >Create</button> + </div> + </div> + </form> </div> <div id="importEventFormContainer"> @@ -32,80 +38,18 @@ </div> <div id="newEventGroupFormContainer"> - {{>neweventgroupform}} + <h4 class="mb-2">Create an event group</h4> + <p class="text-muted">An event group is a holding area for a set of linked events, like a recurring game night, a festival, or a band tour. You can share a public link to your event group just like an individual event link, and people who know the secret editing code will be able to add future events to the group.</p> + <p class="text-muted">Event groups do not get automatically removed like events do, but events which have been removed from {{siteName}} will of course not show up in an event group.</p> + <form id="newEventGroupForm" enctype="multipart/form-data" x-data="newEventGroupForm()" @submit.prevent="submitForm"> + {{> eventGroupForm }} + <div class="form-group row"> + <div class="col-sm-12 pt-3 pb-3 text-center"> + <button type="submit" class="btn btn-primary w-50" x-bind:disabled="submitting">Create</button> + </div> + </div> + </form> </div> - <script> - $.validate({ - lang: 'en', - errorElementClass: "is-invalid", - errorMessageClass: "text-danger", - successElementClass: "is-valid" - }); - $(document).ready(function(){ - if ($('#icsImportControl')[0].files[0] != null){ - var file = $('#icsImportControl')[0].files[0].name; - $('#icsImportControl').next('label').html('<i class="far fa-file-alt"></i> ' + file); - } - $('#eventStart').datepicker({ - language: 'en', - minDate: new Date(), - timepicker: true, - dateFormat: 'd MM yyyy', - dateTimeSeparator: ', ', - onSelect: function(formattedDate, rawDate){ - $('#eventEnd').datepicker().data('datepicker').update('minDate', rawDate).clear(); - } - }); - $('#eventEnd').datepicker({ - language: 'en', - minDate: new Date(), - timepicker: true, - dateFormat: 'd MM yyyy', - dateTimeSeparator: ', ' - }); - $("#showNewEventFormButton").click(function(){ - $("button").removeClass("active"); - $("#showImportEventFormButton #showNewEventGroupFormButton").removeClass("active"); - if ($("#newEventFormContainer").is(":visible")){ - $("#newEventFormContainer").slideUp("fast"); - } - else { - $("#newEventFormContainer").slideDown("fast"); - $("#importEventFormContainer").slideUp("fast"); - $("#newEventGroupFormContainer").slideUp("fast"); - $(this).addClass("active"); - } - }) - $("#showImportEventFormButton").click(function(){ - $("button").removeClass("active"); - $("#showNewEventFormButton #showNewEventGroupFormButton").removeClass("active"); - if ($("#importEventFormContainer").is(":visible")){ - $("#importEventFormContainer").slideUp("fast"); - } - else { - $("#importEventFormContainer").slideDown("fast"); - $("#newEventFormContainer").slideUp("fast"); - $("#newEventGroupFormContainer").slideUp("fast"); - $(this).addClass("active"); - } - }) - $("#showNewEventGroupFormButton").click(function(){ - $("button").removeClass("active"); - $("#showNewEventFormButton #showImportEventFormButton").removeClass("active"); - if ($("#newEventGroupFormContainer").is(":visible")){ - $("#newEventGroupFormContainer").slideUp("fast"); - } - else { - $("#newEventGroupFormContainer").slideDown("fast"); - $("#newEventFormContainer").slideUp("fast"); - $("#importEventFormContainer").slideUp("fast"); - $(this).addClass("active"); - } - }) - $('#icsImportControl').change(function(){ - var file = $('#icsImportControl')[0].files[0].name; - $(this).next('label').html('<i class="far fa-file-alt"></i> ' + file); - }); - }) - </script> +<script src="/js/generate-timezones.js"></script> +<script src="/js/modules/new.js"></script>
\ No newline at end of file diff --git a/views/partials/editeventgroupmodal.handlebars b/views/partials/editeventgroupmodal.handlebars index 3b8f55a..2506e26 100644 --- a/views/partials/editeventgroupmodal.handlebars +++ b/views/partials/editeventgroupmodal.handlebars @@ -8,32 +8,10 @@ </button> </div> <div class="modal-body"> - <form id="editEventForm" action="/editeventgroup/{{eventGroupData.id}}/{{eventGroupData.editToken}}" method="post" enctype="multipart/form-data" autocomplete="off"> - <div class="form-group"> - <label for="eventGroupName" >Name</label> - <input type="text" class="form-control" id="eventGroupName" name="eventGroupName" placeholder="Make it snappy." value="{{eventGroupData.name}}" data-validation="required length" data-validation-length="3-120"> - </div> - <div class="form-group"> - <label for="eventGroupDescription" >Description</label> - <textarea class="form-control" id="eventGroupDescription" name="eventGroupDescription" data-validation="required">{{eventGroupData.description}}</textarea> - <small class="form-text"><a href="https://commonmark.org/help/">Markdown</a> formatting supported.</small> - </div> - <div class="form-group"> - <label for="eventGroupURL" >Link</label> - <input type="url" class="form-control" id="eventURL" name="eventGroupURL" value="{{eventGroupData.url}}" placeholder="For tickets or a page with more information (optional)." data-validation="url" data-validation-optional="true"> - </div> - <div class="form-group"> - <label for="hostName" >Host or organisation name</label> - <input type="text" class="form-control" id="hostName" name="hostName" placeholder="Will be shown on the event group page (optional)." value="{{eventGroupData.hostName}}" data-validation="length" data-validation-length="3-120" data-validation-optional="true"> - </div> - <div class="form-group"> - <label>Cover image</label> - <div class="image-preview" id="eventGroupImagePreview"> - <label for="eventGroupImageUpload" id="eventGroupImageLabel">Choose file</label> - <input type="file" name="eventGroupImageUpload" id="eventGroupImageUpload" /> - </div> - <small class="form-text">Recommended dimensions (w x h): 920px by 300px.</small> - </div> + <form id="editEventForm" enctype="multipart/form-data" x-data="editEventGroupForm()" @submit.prevent="submitForm"> + + {{> eventGroupForm }} + <div class="form-group"> <div class="card border-danger mb-3"> <div class="card-header text-danger">Delete this event group</div> @@ -51,3 +29,5 @@ </div> </div> </div> + +<script type="text/javascript" src="/js/modules/group-edit.js"></script>
\ No newline at end of file diff --git a/views/partials/editeventmodal.handlebars b/views/partials/editeventmodal.handlebars index b4b0ea6..a36cd98 100644 --- a/views/partials/editeventmodal.handlebars +++ b/views/partials/editeventmodal.handlebars @@ -8,140 +8,21 @@ </button> </div> <div class="modal-body"> - <form id="editEventForm" action="/editevent/{{eventData.id}}/{{eventData.editToken}}" method="post" - enctype="multipart/form-data" autocomplete="off"> - <div class="form-group"> - <label for="eventName" class="col-form-label">Event name</label> - <input type="text" class="form-control" id="eventName" name="eventName" - placeholder="Make it snappy." value="{{eventData.name}}" data-validation="required length" - data-validation-length="3-120"> - </div> - <div class="form-group"> - <label for="eventLocation" class="col-form-label">Location</label> - <input type="text" class="form-control" id="eventLocation" name="eventLocation" - placeholder="Be specific." value="{{eventData.location}}" data-validation="required length" - data-validation-length="3-120"> - </div> - <div class="form-group"> - <label for="eventStart" class="col-form-label">Starts</label> - <input readonly type="text" class="form-control" id="eventStart" name="eventStart" value="" - data-validation="required"> - </div> - <div class="form-group"> - <label for="eventEnd" class="col-form-label">Ends</label> - <input readonly type="text" class="form-control" id="eventEnd" name="eventEnd" value="" - data-validation="required"> - </div> - <div class="form-group"> - <label for="timezone" class="col-form-label">Timezone</label> - <select class="select2" id="timezone" name="timezone"></select> - </div> - <div class="form-group"> - <label for="eventDescription" class="col-form-label">Description</label> - <textarea class="form-control" id="eventDescription" name="eventDescription" - data-validation="required">{{eventData.description}}</textarea> - <small class="form-text"><a href="https://commonmark.org/help/">Markdown</a> formatting - supported.</small> - </div> - <div class="form-group"> - <label for="eventURL" class="col-form-label">Link</label> - <input type="url" class="form-control" id="eventURL" name="eventURL" value="{{eventData.url}}" - placeholder="For tickets or another event page (optional)." data-validation="url" - data-validation-optional="true"> - </div> - <div class="form-group"> - <label for="hostName" class="col-form-label">Host name</label> - <input type="text" class="form-control" id="hostName" name="hostName" - placeholder="Will be shown on the event page (optional)." value="{{eventData.hostName}}" - data-validation="length" data-validation-length="3-120" data-validation-optional="true"> - </div> - <div class="form-group"> - <label for="eventImage" class="col-form-label">Cover image</label> - <div class="image-preview" id="image-preview"> - <label for="image-upload" id="image-label">Choose file</label> - <input type="file" name="imageUpload" id="image-upload" /> - </div> - <small class="form-text">Recommended dimensions (w x h): 920px by 300px.</small> - {{#if eventData.image}} - <button type="button" class="btn btn-danger" id="deleteImage">Delete image</button> - {{/if}} - </div> - <div class="form-group"> - <div class="mb-2">Options</div> - <div class="form-check"> - <input class="form-check-input" type="checkbox" id="eventGroupCheckbox" - name="eventGroupCheckbox" {{#if eventData.eventGroup}}checked{{/if}}> - <label class="form-check-label" for="eventGroupCheckbox"> - This event is part of an event group - </label> - </div> - <div class="card text-white bg-secondary my-2" id="eventGroupData" {{#if eventData.eventGroup}}style="display:flex" {{/if}}> - <div class="card-header"> - <strong>Link this event to an event group</strong> - </div> - <div class="card-body"> - <div class="form-group"> - <label for="eventGroupID" class="form-label">Event group ID</label> - <div class="form-group"> - <input type="text" class="form-control" id="eventGroupID" name="eventGroupID" - placeholder="" data-validation-optional="true" value="{{eventData.eventGroup.id}}"> - <small class="form-text">You can find this short string of characters in the - event group's link, in your confirmation email, or on the event group's - page.</small> - </div> - </div> - <div class="form-group"> - <label for="eventGroupEditToken" class="form-label">Event group secret - editing code</label> - <div class="form-group"> - <input type="text" class="form-control" id="eventGroupEditToken" - name="eventGroupEditToken" placeholder="" data-validation-optional="true" value="{{eventData.eventGroup.editToken}}"> - <small class="form-text">You can find this long string of characters in the - confirmation email you received when you created the event group.</small> - </div> - </div> - </div> - </div> - <div class="form-check"> - <input class="form-check-input" type="checkbox" id="interactionCheckbox" - name="interactionCheckbox" {{#if eventData.usersCanComment}}checked{{/if}}> - <label class="form-check-label" for="interactionCheckbox"> - Users can post comments on this event - </label> - </div> - <div class="form-check"> - <input class="form-check-input {{#unless eventData.usersCanAttend}}unchecked{{/unless}}" - type="checkbox" id="joinCheckbox" name="joinCheckbox" - {{#if eventData.usersCanAttend}}checked{{/if}}> - <label class="form-check-label" for="joinCheckbox"> - Users can mark themselves as attending this event - </label> - </div> - <div class="form-check" id="maxAttendeesCheckboxContainer" - {{#if eventData.maxAttendees}}style="display:flex" {{/if}}> - <input class="form-check-input" type="checkbox" id="maxAttendeesCheckbox" - name="maxAttendeesCheckbox" {{#if eventData.maxAttendees}}checked{{/if}}> - <label class="form-check-label" for="maxAttendeesCheckbox"> - Set a limit on the maximum number of attendees - </label> - </div> - </div> - <div class="form-group" id="maxAttendeesContainer" - {{#if eventData.maxAttendees}}style="display:flex" {{/if}}> - <label for="maxAttendees" class="col-form-label">Attendee limit</label> - <input type="number" class="form-control" id="maxAttendees" name="maxAttendees" - placeholder="Enter a number." data-validation="number" data-validation-optional="true" - value="{{eventData.maxAttendees}}"> - </div> + <form id="editEventForm" enctype="multipart/form-data" x-data="editEventForm()" x-init="init()" + @submit.prevent="submitForm"> - <div class="form-group"> - <div class="card border-danger mb-3"> - <div class="card-header text-danger">Delete this event</div> - <div class="card-body text-danger"> - <button type="button" id="deleteEvent" class="btn btn-danger" data-toggle="modal" data-target="#deleteModal" data-event-id="{{eventData.id}}"><i class="fas fa-trash"></i> Delete</button> + {{> eventForm }} + + <div class="form-group"> + <div class="card border-danger mb-3"> + <div class="card-header text-danger">Delete this event</div> + <div class="card-body text-danger"> + <button type="button" id="deleteEvent" class="btn btn-danger" data-toggle="modal" + data-target="#deleteModal" data-event-id="{{eventData.id}}"><i class="fas fa-trash"></i> + Delete</button> + </div> </div> </div> - </div> </div> <div class="modal-footer"> <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button> @@ -153,13 +34,16 @@ </div> <script> -$('#deleteImage').click(function() { - $.post('/deleteimage/{{eventData.id}}/{{eventData.editToken}}', function(response) { - if (response === "Success") { - location.reload(); - } else { - alert(response); - } - }); -}) + $('#deleteImage').click(function () { + $.post('/deleteimage/{{eventData.id}}/{{eventData.editToken}}', function (response) { + if (response === "Success") { + location.reload(); + } else { + alert(response); + } + }); + }) </script> + +<script type="text/javascript" src="/js/generate-timezones.js"></script> +<script type="text/javascript" src="/js/modules/event-edit.js"></script> diff --git a/views/partials/eventForm.handlebars b/views/partials/eventForm.handlebars new file mode 100755 index 0000000..93d679d --- /dev/null +++ b/views/partials/eventForm.handlebars @@ -0,0 +1,141 @@ +<div class="form-group"> + <label for="eventName" >Event name</label> + <div class="form-group "> + <input type="text" class="form-control" id="eventName" name="eventName" placeholder="Make it snappy." x-model="data.eventName" > + </div> +</div> +<div class="form-group"> + <label for="eventLocation" >Location</label> + <div class="form-group "> + <input type="text" class="form-control" id="eventLocation" name="eventLocation" placeholder="Be specific." x-model="data.eventLocation"> + </div> +</div> +<div class="form-group"> + <label for="eventStart" >Starts</label> + <div class="form-group"> + <input type="datetime-local" class="form-control" id="eventStart" name="eventStart" x-model="data.eventStart"> + </div> +</div> +<div class="form-group"> + <label for="eventEnd" >Ends</label> + <div class="form-group "> + <input type="datetime-local" class="form-control" id="eventEnd" name="eventEnd" x-model="data.eventEnd"> + </div> +</div> +<div class="form-group"> + <label for="timezone" >Timezone</label> + <div class="form-group "> + <select class="select2" id="timezone" name="timezone" x-ref="timezone"></select> + </div> +</div> +<div class="form-group"> + <label for="eventDescription" >Description</label> + <div class="form-group "> + <textarea class="form-control expand" id="eventDescription" name="eventDescription" placeholder="You can always edit it later." x-model="data.eventDescription" ></textarea> + <small class="form-text"><a href="https://commonmark.org/help/">Markdown</a> formatting supported.</small> + </div> +</div> +<div class="form-group"> + <label for="eventURL" >Link</label> + <div class="form-group "> + <input type="url" class="form-control" id="eventURL" name="eventURL" placeholder="For tickets or another event page (optional)." x-model="data.eventURL" > + </div> +</div> +<div class="form-group"> + <label for="eventImage" >Cover image</label> + <div class="form-group "> + <div class="image-preview" id="event-image-preview"> + <label for="image-upload" id="event-image-label">Choose file</label> + <input type="file" name="imageUpload" id="event-image-upload" accept="image/jpeg,image/gif,image/png" x-ref="eventImageUpload" /> + </div> + <small class="form-text">Recommended dimensions (w x h): 920px by 300px.</small> + {{#if eventData.image}} + <button type="button" class="btn btn-danger" id="deleteImage">Delete image</button> + {{/if}} + </div> +</div> +<div class="form-group"> + <label for="hostName" >Host name</label> + <div class="form-group "> + <input type="text" class="form-control" id="hostName" name="hostName" placeholder="Will be shown on the event page (optional)." x-model="data.hostName" > + </div> +</div> +<div class="form-group"> + <label for="creatorEmail" >Your email</label> + <div class="form-group "> + <input type="email" class="form-control" id="creatorEmail" name="creatorEmail" placeholder="Will not be shown anywhere (optional)." x-model="data.creatorEmail" > + <small class="form-text">If you provide your email, we will send your secret editing password here, and use it to notify you of updates to the event.</small> + </div> +</div> +<div class="form-group"> + <label>Options</label> + <div > + <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"> + This event is part of an event group + </label> + </div> + <div class="card my-2" id="eventGroupData" x-show="data.eventGroupCheckbox"> + <div class="card-header"> + <strong>Link this event to an event group</strong> + </div> + <div class="card-body"> + <div class="form-group"> + <label for="eventGroupID" class="col-12">Event group ID</label> + <div class="form-group col-12"> + <input type="text" class="form-control" id="eventGroupID" name="eventGroupID" placeholder="" x-model="data.eventGroupID" > + <small class="form-text">You can find this short string of characters in the event group's link, in your confirmation email, or on the event group's page.</small> + </div> + </div> + <div class="form-group"> + <label for="eventGroupEditToken" class="col-12">Event group secret editing code</label> + <div class="form-group col-12"> + <input type="text" class="form-control" id="eventGroupEditToken" name="eventGroupEditToken" placeholder="" x-model="data.eventGroupEditToken" > + <small class="form-text">You can find this long string of characters in the confirmation email you received when you created the event group.</small> + </div> + </div> + </div> + </div> + <div class="form-check"> + <input class="form-check-input" type="checkbox" id="interactionCheckbox" name="interactionCheckbox" x-model="data.interactionCheckbox"> + <label class="form-check-label" for="interactionCheckbox"> + Users can post comments on this event + </label> + </div> + <div class="form-check"> + <input class="form-check-input" type="checkbox" id="joinCheckbox" name="joinCheckbox" x-model="data.joinCheckbox"> + <label class="form-check-label" for="joinCheckbox"> + Users can mark themselves as attending this event + </label> + </div> + <div class="form-check" id="maxAttendeesCheckboxContainer" x-show="data.joinCheckbox"> + <input class="form-check-input" type="checkbox" id="maxAttendeesCheckbox" name="maxAttendeesCheckbox" x-model="data.maxAttendeesCheckbox"> + <label class="form-check-label" for="maxAttendeesCheckbox"> + Set a limit on the maximum number of attendees + </label> + </div> + </div> +</div> +<div class="form-group" id="maxAttendeesContainer" x-show="data.maxAttendeesCheckbox && data.joinCheckbox"> + <label for="maxAttendees" >Attendee limit</label> + <div class="form-group "> + <input type="number" class="form-control" id="maxAttendees" name="maxAttendees" placeholder="Enter a number." x-model="data.maxAttendees" > + </div> +</div> +<div class="form-group"> + <div class="col-12"> + <div + class="alert alert-danger" + role="alert" + x-show="errors.length > 0" + > + <p><i class="fas fa-exclamation-triangle"></i> Please fix these errors:</p> + <ul> + <template x-for="error in errors"> + <li x-text="error.message"></li> + </template> + </ul> + </div> + </div> +</div> diff --git a/views/partials/eventGroupForm.handlebars b/views/partials/eventGroupForm.handlebars new file mode 100644 index 0000000..0b18bba --- /dev/null +++ b/views/partials/eventGroupForm.handlebars @@ -0,0 +1,47 @@ +<div class="form-group"> + <label for="eventGroupName">Name</label> + <input type="text" class="form-control" id="eventGroupName" name="eventGroupName" placeholder="Make it snappy." x-model="data.eventGroupName"> +</div> +<div class="form-group"> + <label for="eventGroupDescription">Description</label> + <textarea class="form-control" id="eventGroupDescription" name="eventGroupDescription" x-model="data.eventGroupDescription">{{eventGroupData.description}}</textarea> + <small class="form-text"><a href="https://commonmark.org/help/">Markdown</a> formatting supported.</small> +</div> +<div class="form-group"> + <label for="eventGroupURL">Link</label> + <input type="url" class="form-control" id="eventGroupURL" name="eventGroupURL" placeholder="For tickets or a page with more information (optional)." x-model="data.eventGroupURL"> +</div> +<div class="form-group"> + <label for="hostName">Host or organisation name</label> + <input type="text" class="form-control" id="eventGroupHostName" name="hostName" placeholder="Will be shown on the event group page (optional)." x-model="data.hostName"> +</div> +<div class="form-group"> + <label for="creatorEmail">Your email</label> + <div class="form-group"> + <input type="email" class="form-control" id="eventGroupCreatorEmail" name="creatorEmail" placeholder="Will not be shown anywhere (optional)." x-model="data.creatorEmail"> + <small class="form-text">If you provide your email, we will send your secret editing password here, and use it to notify you of updates to the event.</small> + </div> +</div> +<div class="form-group"> + <label>Cover image</label> + <div class="image-preview" id="group-image-preview"> + <label for="eventGroupImageUpload" id="group-image-label">Choose file</label> + <input type="file" name="imageUpload" id="group-image-upload" accept="image/jpeg,image/gif,image/png" x-ref="eventGroupImageUpload"/> + </div> + <small class="form-text">Recommended dimensions (w x h): 920px by 300px.</small> +</div> +<div class="form-group"> + <div class="col-12"> + <div + class="alert alert-danger" + role="alert" + x-show="errors.length > 0" + > + <p><i class="fas fa-exclamation-triangle"></i> Please fix these errors:</p> + <ul> + <template x-for="error in errors"> + <li x-text="error.message"></li> + </template> + </ul> + </div> + </div>
\ No newline at end of file diff --git a/views/partials/importeventform.handlebars b/views/partials/importeventform.handlebars index 9ad038a..13fd2ac 100644 --- a/views/partials/importeventform.handlebars +++ b/views/partials/importeventform.handlebars @@ -5,10 +5,10 @@ <img class="img-thumbnail mb-3 d-block mx-auto" src="/images/facebook-export.png" alt="Image showing the location of the export option on Facebook" /> -<form id="icsImportForm" action="/importevent" method="post" enctype="multipart/form-data"> +<form id="icsImportForm" enctype="multipart/form-data" x-data="importEventForm()" @submit.prevent="submitForm"> <div class="form-group"> <div class="custom-file" id="icsImportContainer"> - <input required name="icsImportControl" type="file" class="custom-file-input" id="icsImportControl" aria-describedby="fileHelp" accept="text/calendar"> + <input required name="icsImportControl" type="file" class="custom-file-input" id="icsImportControl" aria-describedby="fileHelp" accept="text/calendar" x-ref="icsImportControl"/> <label name="icsImportLabel" class="custom-file-label" id="icsImportLabel" for="icsImportControl"> <i class="far fa-file-alt"></i> Select file </label> @@ -17,9 +17,25 @@ <div class="form-group"> <label for="creatorEmail" class="form-label">Your email</label> <div class="form-group"> - <input type="email" class="form-control" id="creatorEmail" name="creatorEmail" placeholder="We won't spam you <3" data-validation="email" data-validation-optional="true"> - <small class="form-text">We will send your secret editing link to this email address.</small> + <input type="email" class="form-control" id="importCreatorEmail" name="creatorEmail" placeholder="Will not be shown anywhere (optional)." x-model="data.creatorEmail" > + <small class="form-text">If you provide your email, we will send your secret editing password here, and use it to notify you of updates to the event.</small> </div> </div> - <button type="submit" class="d-block mt-3 mx-auto btn btn-primary w-50">Import</button> + <div class="form-group"> + <div class="col-12"> + <div + class="alert alert-danger" + role="alert" + x-show="errors.length > 0" + > + <p><i class="fas fa-exclamation-triangle"></i> Please fix these errors:</p> + <ul> + <template x-for="error in errors"> + <li x-text="error.message"></li> + </template> + </ul> + </div> + </div> + </div> + <button type="submit" class="d-block mt-3 mx-auto btn btn-primary w-50 mb-4">Import</button> </form> diff --git a/views/partials/neweventform.handlebars b/views/partials/neweventform.handlebars deleted file mode 100755 index 3c7e060..0000000 --- a/views/partials/neweventform.handlebars +++ /dev/null @@ -1,179 +0,0 @@ -<h4 class="mb-2">Create an event</h4> -<form id="newEventForm" action="/newevent" method="post" enctype="multipart/form-data"> - <div class="form-group row"> - <label for="eventName" class="col-sm-2 col-form-label">Event name</label> - <div class="form-group col-sm-10"> - <input type="text" class="form-control" id="eventName" name="eventName" placeholder="Make it snappy." value="{{data.eventName}}" data-validation="required length" data-validation-length="3-120"> - </div> - </div> - <div class="form-group row"> - <label for="eventLocation" class="col-sm-2 col-form-label">Location</label> - <div class="form-group col-sm-10"> - <input type="text" class="form-control" id="eventLocation" name="eventLocation" placeholder="Be specific." value="{{data.eventLocation}}" data-validation="required length" data-validation-length="3-120"> - </div> - </div> - <div class="form-group row"> - <label for="eventStart" class="col-sm-2 col-form-label">Starts</label> - <div class="form-group col-sm-10"> - <input readonly type="text" class="form-control" id="eventStart" name="eventStart" placeholder="Click me!" value="{{data.eventStart}}" data-validation="required"> - </div> - </div> - <div class="form-group row"> - <label for="eventEnd" class="col-sm-2 col-form-label">Ends</label> - <div class="form-group col-sm-10"> - <input readonly type="text" class="form-control" id="eventEnd" name="eventEnd" placeholder="Click me!" value="{{data.eventEnd}}" data-validation="required"> - </div> - </div> - <div class="form-group row"> - <label for="timezone" class="col-sm-2 col-form-label">Timezone</label> - <div class="form-group col-sm-10"> - <select class="select2" id="timezone" name="timezone"></select> - </div> - </div> - <div class="form-group row"> - <label for="eventDescription" class="col-sm-2 col-form-label">Description</label> - <div class="form-group col-sm-10"> - <textarea class="form-control expand" id="eventDescription" name="eventDescription" data-validation="required" placeholder="You can always edit it later."></textarea> - <small class="form-text"><a href="https://commonmark.org/help/">Markdown</a> formatting supported.</small> - </div> - </div> - <div class="form-group row"> - <label for="eventURL" class="col-sm-2 col-form-label">Link</label> - <div class="form-group col-sm-10"> - <input type="url" class="form-control" id="eventURL" name="eventURL" placeholder="For tickets or another event page (optional)." data-validation="url" data-validation-optional="true"> - </div> - </div> - <div class="form-group row"> - <label for="eventImage" class="col-sm-2 col-form-label">Cover image</label> - <div class="form-group col-sm-10"> - <div class="image-preview" id="eventImagePreview"> - <label for="image-upload" id="eventImageLabel">Choose file</label> - <input type="file" name="imageUpload" id="eventImageUpload" accept="image/jpeg,image/gif,image/png" /> - </div> - <small class="form-text">Recommended dimensions (w x h): 920px by 300px.</small> - </div> - </div> - <div class="form-group row"> - <label for="hostName" class="col-sm-2 col-form-label">Host name</label> - <div class="form-group col-sm-10"> - <input type="text" class="form-control" id="hostName" name="hostName" placeholder="Will be shown on the event page (optional)." data-validation="length" data-validation-length="2-60" data-validation-optional="true"> - </div> - </div> - <div class="form-group row"> - <label for="creatorEmail" class="col-sm-2 col-form-label">Your email</label> - <div class="form-group col-sm-10"> - <input type="email" class="form-control" id="creatorEmail" name="creatorEmail" placeholder="We won't spam you <3 (optional)" data-validation="email" data-validation-optional="true"> - <small class="form-text">If you provide your email, we will send your secret editing password here, and use it to notify you of updates to the event.</small> - </div> - </div> - <div class="form-group row"> - <div class="col-sm-2">Options</div> - <div class="col-sm-10"> - <div class="form-check"> - <input class="form-check-input" type="checkbox" id="eventGroupCheckbox" name="eventGroupCheckbox"> - <label class="form-check-label" for="eventGroupCheckbox"> - This event is part of an event group - </label> - </div> - <div class="card text-white bg-secondary my-2" id="eventGroupData"> - <div class="card-header"> - <strong>Link this event to an event group</strong> - </div> - <div class="card-body"> - <div class="form-group row"> - <label for="eventGroupID" class="col-sm-2 col-form-label">Event group ID</label> - <div class="form-group col-sm-10"> - <input type="text" class="form-control" id="eventGroupID" name="eventGroupID" placeholder="" data-validation-optional="true"> - <small class="form-text">You can find this short string of characters in the event group's link, in your confirmation email, or on the event group's page.</small> - </div> - </div> - <div class="form-group row"> - <label for="eventGroupEditToken" class="col-sm-2 col-form-label">Event group secret editing code</label> - <div class="form-group col-sm-10"> - <input type="text" class="form-control" id="eventGroupEditToken" name="eventGroupEditToken" placeholder="" data-validation-optional="true"> - <small class="form-text">You can find this long string of characters in the confirmation email you received when you created the event group.</small> - </div> - </div> - </div> - </div> - <div class="form-check"> - <input class="form-check-input" type="checkbox" id="interactionCheckbox" name="interactionCheckbox"> - <label class="form-check-label" for="interactionCheckbox"> - Users can post comments on this event - </label> - </div> - <div class="form-check"> - <input class="form-check-input" type="checkbox" id="joinCheckbox" name="joinCheckbox"> - <label class="form-check-label" for="joinCheckbox"> - Users can mark themselves as attending this event - </label> - </div> - <div class="form-check" id="maxAttendeesCheckboxContainer"> - <input class="form-check-input" type="checkbox" id="maxAttendeesCheckbox" name="maxAttendeesCheckbox"> - <label class="form-check-label" for="maxAttendeesCheckbox"> - Set a limit on the maximum number of attendees - </label> - </div> - </div> - </div> - <div class="form-group row" id="maxAttendeesContainer"> - <label for="maxAttendees" class="col-sm-2 col-form-label">Attendee limit</label> - <div class="form-group col-sm-10"> - <input type="number" class="form-control" id="maxAttendees" name="maxAttendees" placeholder="Enter a number." data-validation="number" data-validation-optional="true"> - </div> - </div> - <div class="form-group row"> - <div class="col-sm-12 pt-3 pb-3 text-center"> - <button id="newEventFormSubmit" type="submit" class="btn btn-primary w-50">Create</button> - </div> - </div> -</form> - -<script type="text/javascript" src="/js/generate-timezones.js"></script> - -<script> - $(document).ready(function() { - $.uploadPreview({ - input_field: "#eventImageUpload", - preview_box: "#eventImagePreview", - label_field: "#eventImageLabel", - label_default: "Choose file", - label_selected: "Change file", - no_label: false - }); - autosize($('textarea')); - $("#maxAttendeesCheckbox").on("click", function() { - if ($(this).is(':checked')) { - $("#maxAttendeesContainer").slideDown('fast').css("display","flex"); - $("#maxAttendees").attr("data-validation-optional","false"); - } - else { - $("#maxAttendeesContainer").slideUp('fast'); - $("#maxAttendees").attr("data-validation-optional","true").val("").removeClass('is-valid is-invalid'); - } - }); - $("#joinCheckbox").on("click", function() { - if ($(this).is(':checked')) { - $("#maxAttendeesCheckboxContainer").slideDown('fast').css("display","flex"); - } - else { - $("#maxAttendeesCheckboxContainer").slideUp('fast'); - $("#maxAttendeesCheckbox").prop("checked",false); - $("#maxAttendeesContainer").slideUp('fast'); - $("#maxAttendees").attr("data-validation-optional","true").val("").removeClass('is-valid is-invalid'); - } - }); - $("#eventGroupCheckbox").on("click", function() { - if ($(this).is(':checked')) { - $("#eventGroupData").slideDown('fast'); - $("#eventGroupID").removeAttr("data-validation-optional").attr("data-validation","required"); - $("#eventGroupEditToken").removeAttr("data-validation-optional").attr("data-validation","required"); - } - else { - $("#eventGroupData").slideUp('fast'); - $("#eventGroupID").removeAttr("data-validation").attr("data-validation-optional","true").val(""); - $("#eventGroupEditToken").removeAttr("data-validation").attr("data-validation-optional","true").val(""); - } - }); - }); -</script> diff --git a/views/partials/neweventgroupform.handlebars b/views/partials/neweventgroupform.handlebars deleted file mode 100755 index 616b8ca..0000000 --- a/views/partials/neweventgroupform.handlebars +++ /dev/null @@ -1,66 +0,0 @@ -<h4 class="mb-2">Create an event group</h4> -<p>An event group is a holding area for a set of linked events, like a series of film nights, a festival, or a band tour. You can share a public link to your event group just like an individual event link, and people who know the secret editing code (sent in an email when you create the event group) will be able to add future events to the group.</p> -<p>Event groups do not get automatically removed like events do, but events which have been removed from {{siteName}} will of course not show up in an event group.</p> -<form id="newEventForm" action="/neweventgroup" method="post" enctype="multipart/form-data"> - <div class="form-group row"> - <label for="eventGroupName" class="col-sm-2 col-form-label">Event group name</label> - <div class="form-group col-sm-10"> - <input type="text" class="form-control" id="eventGroupName" name="eventGroupName" placeholder="Make it snappy." value="{{data.eventName}}" data-validation="required length" data-validation-length="3-120"> - </div> - </div> - <div class="form-group row"> - <label for="eventGroupDescription" class="col-sm-2 col-form-label">Description</label> - <div class="form-group col-sm-10"> - <textarea class="form-control expand" id="eventGroupDescription" name="eventGroupDescription" data-validation="required" placeholder="You can always edit it later."></textarea> - <small class="form-text"><a href="https://commonmark.org/help/">Markdown</a> formatting supported.</small> - </div> - </div> - <div class="form-group row"> - <label for="eventGroupURL" class="col-sm-2 col-form-label">Link</label> - <div class="form-group col-sm-10"> - <input type="url" class="form-control" id="eventGroupURL" name="eventGroupURL" placeholder="For tickets or a page with more information (optional)." data-validation="url" data-validation-optional="true"> - </div> - </div> - <div class="form-group row"> - <label for="eventGroupImage" class="col-sm-2 col-form-label">Cover image</label> - <div class="form-group col-sm-10"> - <div class="image-preview" id="eventGroupImagePreview"> - <label for="image-upload" id="eventGroupImageLabel">Choose file</label> - <input type="file" name="imageUpload" id="eventGroupImageUpload" /> - </div> - <small class="form-text">Recommended dimensions (w x h): 920px by 300px.</small> - </div> - </div> - <div class="form-group row"> - <label for="hostName" class="col-sm-2 col-form-label">Host or organisation name</label> - <div class="form-group col-sm-10"> - <input type="text" class="form-control" id="hostName" name="hostName" placeholder="Will be shown on the event group page (optional)." data-validation="length" data-validation-length="2-60" data-validation-optional="true"> - </div> - </div> - <div class="form-group row"> - <label for="creatorEmail" class="col-sm-2 col-form-label">Your email</label> - <div class="form-group col-sm-10"> - <input type="email" class="form-control" id="creatorEmail" name="creatorEmail" placeholder="We won't spam you <3 (optional)" data-validation="email" data-validation-optional="true"> - <small class="form-text">If you provide your email, we will send your secret editing password here, and use it to notify you of updates to the event.</small> - </div> - </div> - <div class="form-group row"> - <div class="col-sm-12 pt-3 pb-3 text-center"> - <button type="submit" class="btn btn-primary w-50">Create</button> - </div> - </div> -</form> - -<script> - $(document).ready(function() { - $.uploadPreview({ - input_field: "#eventGroupImageUpload", - preview_box: "#eventGroupImagePreview", - label_field: "#eventGroupImageLabel", - label_default: "Choose file", - label_selected: "Change file", - no_label: false - }); - autosize($('textarea')); - }); -</script> diff --git a/views/partials/sidebar.handlebars b/views/partials/sidebar.handlebars index 980e699..5d8e847 100755 --- a/views/partials/sidebar.handlebars +++ b/views/partials/sidebar.handlebars @@ -3,5 +3,5 @@ <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 event</a> + <a class="btn btn-success mb-2 btn-block" href="/new"><i class="far fa-calendar-plus"></i> New</a> </div> |