From be317850f203c428f77394f824908ecff500cf78 Mon Sep 17 00:00:00 2001 From: Raphael Date: Mon, 5 Feb 2024 09:18:41 +0000 Subject: Fix Pleroma federation, add hidden RSVP option --- package.json | 1 + pnpm-lock.yaml | 7 ++ src/activitypub.js | 189 +++++++++------------------------------ src/lib/activitypub.ts | 168 ++++++++++++++++++++++++++++++++++ src/lib/activitypub/templates.ts | 14 +++ src/routes.js | 30 +++---- src/start.ts | 1 + 7 files changed, 249 insertions(+), 161 deletions(-) create mode 100644 src/lib/activitypub/templates.ts diff --git a/package.json b/package.json index d573f7c..60fa957 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "license": "GPL-3.0-or-later", "dependencies": { "@sendgrid/mail": "^6.5.5", + "activitypub-types": "^1.0.3", "cors": "^2.8.5", "dompurify": "^3.0.6", "express": "^4.18.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f303bc0..51126fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ dependencies: '@sendgrid/mail': specifier: ^6.5.5 version: 6.5.5 + activitypub-types: + specifier: ^1.0.3 + version: 1.0.3 cors: specifier: ^2.8.5 version: 2.8.5 @@ -875,6 +878,10 @@ packages: hasBin: true dev: true + /activitypub-types@1.0.3: + resolution: {integrity: sha512-70PzXqhskrXebCcIAxyvKeQAR8myxLlSF5GKcyN/5UkTpTPTlQyzehzd4tXFgR6ZE+Tvsy3/1WWKSPTQPXvFwA==} + dev: false + /agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} diff --git a/src/activitypub.js b/src/activitypub.js index a6f4ada..88e3768 100644 --- a/src/activitypub.js +++ b/src/activitypub.js @@ -10,7 +10,7 @@ const domain = config.general.domain; const siteName = config.general.site_name; const isFederated = config.general.is_federated; import Event from "./models/Event.js"; -import { activityPubContentType, alternateActivityPubContentType } from "./lib/activitypub.js"; +import { handlePollResponse, activityPubContentType, alternateActivityPubContentType, getEventId, getNoteRecipient } from "./lib/activitypub.js"; // This alphabet (used to generate all event, group, etc. IDs) is missing '-' // because ActivityPub doesn't like it in IDs @@ -660,7 +660,7 @@ export function sendAcceptMessage(thebody, eventID, targetDomain, callback) { function _handleFollow(req, res) { const myURL = new URL(req.body.actor); let targetDomain = myURL.hostname; - let eventID = req.body.object.replace(`https://${domain}/`, ""); + let eventID = getEventId(req.body.object); // Add the user to the DB of accounts that follow the account // get the follower's username request( @@ -735,11 +735,22 @@ function _handleFollow(req, res) { "https://www.w3.org/ns/activitystreams", name: `RSVP to ${event.name}`, type: "Question", - content: `@${name} Will you attend ${event.name}? (If you reply "Yes", you'll be listed as an attendee on the event page.)`, + content: `@${name} Will you attend ${event.name}?`, oneOf: [ { type: "Note", - name: "Yes", + name: "Yes, and show me in the public list", + "replies": { "type": "Collection", "totalItems": 0 } + }, + { + type: "Note", + name: "Yes, but hide me from the public list", + "replies": { "type": "Collection", "totalItems": 0 } + }, + { + type: "Note", + name: "No", + "replies": { "type": "Collection", "totalItems": 0 } }, ], endTime: @@ -807,7 +818,7 @@ function _handleFollow(req, res) { }); } else { // this person is already a follower so just say "ok" - return res.status(200); + return res.sendStatus(200); } }, ); @@ -867,11 +878,15 @@ function _handleUndoFollow(req, res) { } function _handleAcceptEvent(req, res) { - let { name, attributedTo, inReplyTo, to, actor } = req.body; - if (Array.isArray(to)) { - to = to[0]; + let { name, attributedTo, inReplyTo, actor } = req.body; + const recipient = getNoteRecipient(req.body); + if (!recipient) { + return res.status(400).send("No recipient found in the object"); + } + const eventID = getEventId(recipient); + if (!eventID) { + return res.status(400).send("No event ID found in the recipient"); } - const eventID = to.replace(`https://${domain}/`, ""); Event.findOne( { id: eventID, @@ -989,7 +1004,7 @@ function _handleUndoAcceptEvent(req, res) { ); if (message) { // it's a match - Event.update( + Event.updateOne( { id: eventID }, { $pull: { attendees: { id: actor } } }, ).then((response) => { @@ -1005,134 +1020,6 @@ function _handleUndoAcceptEvent(req, res) { ); } -function _handleCreateNote(req, res) { - // figure out what this is in reply to -- it should be addressed specifically to us - let { name, attributedTo, inReplyTo, to } = req.body.object; - // if it's an array just grab the first element, since a poll should only broadcast back to the pollster - if (Array.isArray(to)) { - to = to[0]; - } - const eventID = to.replace(`https://${domain}/`, ""); - // make sure this person is actually a follower - Event.findOne( - { - id: eventID, - }, - function (err, event) { - if (!event) return; - // is this even someone who follows us - const indexOfFollower = event.followers.findIndex( - (el) => el.actorId === req.body.object.attributedTo, - ); - if (indexOfFollower !== -1) { - // compare the inReplyTo to its stored message, if it exists and it's going to the right follower then this is a valid reply - const message = event.activityPubMessages.find((el) => { - const content = JSON.parse(el.content); - return inReplyTo === (content.object && content.object.id); - }); - if (message) { - const content = JSON.parse(message.content); - // check if the message we sent out was sent to the actor this incoming message is attributedTo - if (content.to[0] === attributedTo) { - // it's a match, this is a valid poll response, add RSVP to database - // fetch the profile information of the user - request( - { - url: attributedTo, - headers: { - Accept: activityPubContentType, - "Content-Type": activityPubContentType, - }, - }, - function (error, response, body) { - body = JSON.parse(body); - // if this account is NOT already in our attendees list, add it - if ( - !event.attendees - .map((el) => el.id) - .includes(attributedTo) - ) { - const attendeeName = - body.preferredUsername || - body.name || - attributedTo; - const newAttendee = { - name: attendeeName, - status: "attending", - id: attributedTo, - number: 1, - }; - event.attendees.push(newAttendee); - event - .save() - .then((fullEvent) => { - addToLog( - "addEventAttendee", - "success", - "Attendee added to event " + - req.params.eventID, - ); - // get the new attendee with its hidden id from the full event - let fullAttendee = - fullEvent.attendees.find( - (el) => - el.id === attributedTo, - ); - // send a "click here to remove yourself" link back to the user as a DM - const jsonObject = { - "@context": - "https://www.w3.org/ns/activitystreams", - name: `RSVP to ${event.name}`, - type: "Note", - content: `@${newAttendee.name} Thanks for RSVPing! You can remove yourself from the RSVP list by clicking here: https://${domain}/oneclickunattendevent/${event.id}/${fullAttendee._id}`, - tag: [ - { - type: "Mention", - href: newAttendee.id, - name: newAttendee.name, - }, - ], - }; - // send direct message to user - sendDirectMessage( - jsonObject, - newAttendee.id, - event.id, - ); - return res.sendStatus(200); - }) - .catch((err) => { - addToLog( - "addEventAttendee", - "error", - "Attempt to add attendee to event " + - req.params.eventID + - " failed with error: " + - err, - ); - return res - .status(500) - .send( - "Database error, please try again :(", - ); - }); - } else { - // it's a duplicate and this person is already rsvped so just say OK - return res - .status(200) - .send( - "Attendee is already registered.", - ); - } - }, - ); - } - } - } - }, - ); -} - function _handleDelete(req, res) { const deleteObjectId = req.body.object.id; // find all events with comments from the author @@ -1221,7 +1108,10 @@ function _handleCreateNoteComment(req, res) { cc.includes("https://www.w3.org/ns/activitystreams#Public")) ) { // figure out which event(s) of ours it was addressing + // Mastodon seems to put the event ID in the to field, Pleroma in the cc field + // This is because ActivityPub is a mess (love you ActivityPub) let ourEvents = cc + .concat(to) .filter((el) => el.includes(`https://${domain}/`)) .map((el) => el.replace(`https://${domain}/`, "")); // comments should only be on one event. if more than one, ignore (spam, probably) @@ -1320,28 +1210,31 @@ export function processInbox(req, res) { try { // if a Follow activity hits the inbox if (typeof req.body.object === "string" && req.body.type === "Follow") { + console.log("Sending to _handleFollow"); _handleFollow(req, res); } // if an Undo activity with a Follow object hits the inbox - if ( + else if ( req.body && req.body.type === "Undo" && req.body.object && req.body.object.type === "Follow" ) { + console.log("Sending to _handleUndoFollow"); _handleUndoFollow(req, res); } // if an Accept activity with the id of the Event we sent out hits the inbox, it is an affirmative RSVP - if ( + else if ( req.body && req.body.type === "Accept" && req.body.object && typeof req.body.object === "string" ) { + console.log("Sending to _handleAcceptEvent"); _handleAcceptEvent(req, res); } // if an Undo activity containing an Accept containing the id of the Event we sent out hits the inbox, it is an undo RSVP - if ( + else if ( req.body && req.body.type === "Undo" && req.body.object && @@ -1349,10 +1242,11 @@ export function processInbox(req, res) { typeof req.body.object.object === "string" && req.body.object.type === "Accept" ) { + console.log("Sending to _handleUndoAcceptEvent"); _handleUndoAcceptEvent(req, res); } // if a Create activity with a Note object hits the inbox, and it's a reply, it might be a vote in a poll - if ( + else if ( req.body && req.body.type === "Create" && req.body.object && @@ -1360,22 +1254,27 @@ export function processInbox(req, res) { req.body.object.inReplyTo && req.body.object.to ) { - _handleCreateNote(req, res); + handlePollResponse(req, res); } // if a Delete activity hits the inbox, it might a deletion of a comment - if (req.body && req.body.type === "Delete") { + else if (req.body && req.body.type === "Delete") { + console.log("Sending to _handleDelete"); _handleDelete(req, res); } // if we are CC'ed on a public or unlisted Create/Note, then this is a comment to us we should boost (Announce) to our followers - if ( + else if ( req.body && req.body.type === "Create" && req.body.object && req.body.object.type === "Note" && req.body.object.to ) { + console.log("Sending to _handleCreateNoteComment"); _handleCreateNoteComment(req, res); } // CC'ed + else { + console.log("No action taken"); + } } catch (e) { console.log("Error in processing inbox:", e); } diff --git a/src/lib/activitypub.ts b/src/lib/activitypub.ts index 11f0770..31c75ce 100644 --- a/src/lib/activitypub.ts +++ b/src/lib/activitypub.ts @@ -1,4 +1,18 @@ import { Request, Response } from "express"; +import Event from "../models/Event.js"; +import { sendDirectMessage } from "../activitypub.js"; +import { successfulRSVPResponse } from "./activitypub/templates.js"; + +interface APObject { + type: "Note"; + actor?: string; + id: string; + to?: string | string[]; + cc?: string | string[]; + attributedTo: string; + inReplyTo: string; + name: string; +} // From https://www.w3.org/TR/activitypub/#client-to-server-interactions: // "Servers MAY interpret a Content-Type or Accept header of application/activity+json @@ -20,3 +34,157 @@ export const acceptsActivityPub = (req: Request) => { (header) => req.headers.accept?.includes(header), ); }; + +// At least for poll responses, Mastodon stores the recipient (the poll-maker) +// in the 'to' field, while Pleroma stores it in 'cc' +export const getNoteRecipient = (object: APObject): string | null => { + const { to, cc } = object; + if (!to && !cc) { + return ""; + } + if (to && to.length > 0) { + if (Array.isArray(to)) { + return to[0]; + } + if (typeof to === "string") { + return to; + } + return null; + } else if (cc && cc.length > 0) { + if (Array.isArray(cc)) { + return cc[0]; + } + return cc; + } + return null; +}; + +// Returns the event ID from a URL like http://localhost:3000/123abc +// or https://gath.io/123abc +export const getEventId = (url: string): string => { + try { + return new URL(url).pathname.replace("/", ""); + } catch (error) { + // Apparently not a URL so maybe it's just the ID + return url; + } +}; + +export const handlePollResponse = async (req: Request, res: Response) => { + try { + // figure out what this is in reply to -- it should be addressed specifically to us + const { attributedTo, inReplyTo, name } = req.body.object as APObject; + const recipient = getNoteRecipient(req.body.object); + if (!recipient) throw new Error("No recipient found"); + + const eventID = getEventId(recipient); + const event = await Event.findOne({ id: eventID }); + if (!event) throw new Error("Event not found"); + + // make sure this person is actually a follower of the event + const senderAlreadyFollows = event.followers?.some( + (el) => el.actorId === attributedTo, + ); + if (!senderAlreadyFollows) { + throw new Error("Poll response sender does not follow event"); + } + + // compare the inReplyTo to its stored message, if it exists and + // it's going to the right follower then this is a valid reply + const matchingMessage = event.activityPubMessages?.find((el) => { + const content = JSON.parse(el.content || ""); + return inReplyTo === content?.object?.id; + }); + if (!matchingMessage) throw new Error("No matching message found"); + const messageContent = JSON.parse(matchingMessage.content || ""); + // check if the message we sent out was sent to the actor this incoming + // message is attributedTo + const messageRecipient = getNoteRecipient(messageContent.object); + if (!messageRecipient || messageRecipient !== attributedTo) { + throw new Error("Message recipient does not match attributedTo"); + } + + // it's a match, this is a valid poll response, add RSVP to database + + // 'name' is the poll response + // - "Yes, and show me in the public list", + // - "Yes, but hide me from the public list", + // - "No" + if ( + name !== "Yes, and show me in the public list" && + name !== "Yes, but hide me from the public list" && + name !== "No" + ) { + throw new Error("Invalid poll response"); + } + + if (name === "No") { + // Why did you even respond? + return res.status(200).send("Thanks I guess?"); + } + + const visibility = + name === "Yes, and show me in the public list" + ? "public" + : "private"; + + // fetch the profile information of the user + const response = await fetch(attributedTo, { + headers: { + Accept: activityPubContentType, + "Content-Type": activityPubContentType, + }, + }); + if (!response.ok) throw new Error("Actor not found"); + const apActor = await response.json(); + + // If the actor is not already attending the event, add them + if (!event.attendees?.some((el) => el.id === attributedTo)) { + const attendeeName = + apActor.preferredUsername || apActor.name || attributedTo; + const newAttendee = { + name: attendeeName, + status: "attending", + id: attributedTo, + number: 1, + visibility, + }; + const updatedEvent = await Event.findOneAndUpdate( + { id: eventID }, + { $push: { attendees: newAttendee } }, + { new: true }, + ).exec(); + const fullAttendee = updatedEvent?.attendees?.find( + (el) => el.id === attributedTo, + ); + if (!fullAttendee) throw new Error("Full attendee not found"); + + // send a "click here to remove yourself" link back to the user as a DM + const jsonObject = { + "@context": "https://www.w3.org/ns/activitystreams", + name: `RSVP to ${event.name}`, + type: "Note", + content: successfulRSVPResponse({ + event, + newAttendee, + fullAttendee, + }), + tag: [ + { + type: "Mention", + href: newAttendee.id, + name: newAttendee.name, + }, + ], + }; + // send direct message to user + sendDirectMessage(jsonObject, newAttendee.id, event.id); + return res.sendStatus(200); + } else { + return res.status(200).send("Attendee is already registered."); + } + } catch (error) { + console.error(error); + return res.status(500).send("An unexpected error occurred."); + } +}; diff --git a/src/lib/activitypub/templates.ts b/src/lib/activitypub/templates.ts new file mode 100644 index 0000000..cab9ada --- /dev/null +++ b/src/lib/activitypub/templates.ts @@ -0,0 +1,14 @@ +import { IEvent } from "../../models/Event.js"; +import getConfig from "../config.js"; +const config = getConfig(); + +export const successfulRSVPResponse = ({ + event, + newAttendee, + fullAttendee, +}: { + event: IEvent; + newAttendee: { id: string; name: string }; + fullAttendee: { _id: string }; +}) => + `@${newAttendee.name} Thanks for RSVPing! You can remove yourself from the RSVP list by clicking here.`; diff --git a/src/routes.js b/src/routes.js index 360f387..475f5a0 100755 --- a/src/routes.js +++ b/src/routes.js @@ -505,13 +505,12 @@ router.post("/deleteeventgroup/:eventGroupID/:editToken", (req, res) => { }, ); } - Event.update( + Event.updateOne( { _id: { $in: linkedEventIDs } }, { $set: { eventGroup: null } }, { multi: true }, ) .then((response) => { - console.log(response); addToLog( "deleteEventGroup", "success", @@ -762,12 +761,11 @@ router.post("/unattendevent/:eventID", (req, res) => { return res.sendStatus(500); } - Event.update( + Event.updateOne( { id: req.params.eventID }, { $pull: { attendees: { removalPassword } } }, ) .then((response) => { - console.log(response); addToLog( "unattendEvent", "success", @@ -836,15 +834,16 @@ router.post("/unattendevent/:eventID", (req, res) => { // this is a one-click unattend that requires a secret URL that only the person who RSVPed over // activitypub knows router.get("/oneclickunattendevent/:eventID/:attendeeID", (req, res) => { - // Mastodon will "click" links that sent to its users, presumably as a prefetch? + // Mastodon and Pleroma will "click" links that sent to its users, presumably as a prefetch? // Anyway, this ignores the automated clicks that are done without the user's knowledge if ( req.headers["user-agent"] && - req.headers["user-agent"].includes("Mastodon") + (req.headers["user-agent"].toLowerCase().includes("mastodon") || + req.headers["user-agent"].toLowerCase().includes("pleroma")) ) { return res.sendStatus(200); } - Event.update( + Event.updateOne( { id: req.params.eventID }, { $pull: { attendees: { _id: req.params.attendeeID } } }, ) @@ -915,12 +914,11 @@ router.get("/oneclickunattendevent/:eventID/:attendeeID", (req, res) => { }); router.post("/removeattendee/:eventID/:attendeeID", (req, res) => { - Event.update( + Event.updateOne( { id: req.params.eventID }, { $pull: { attendees: { _id: req.params.attendeeID } } }, ) .then((response) => { - console.log(response); addToLog( "removeEventAttendee", "success", @@ -1071,12 +1069,11 @@ router.post("/subscribe/:eventGroupID", (req, res) => { */ router.get("/unsubscribe/:eventGroupID", (req, res) => { const email = req.query.email; - console.log(email); if (!email) { return res.sendStatus(500); } - EventGroup.update( + EventGroup.updateOne( { id: req.params.eventGroupID }, { $pull: { subscribers: { email } } }, ) @@ -1434,7 +1431,10 @@ router.post("/deletecomment/:eventID/:commentID/:editToken", (req, res) => { router.post("/activitypub/inbox", (req, res) => { if (!isFederated) return res.sendStatus(404); // validate the incoming message - const signature = req.get("Signature"); + const signature = req.get("signature"); + if (!signature) { + return res.status(401).send("No signature provided."); + } let signature_header = signature .split(",") .map((pair) => { @@ -1446,7 +1446,6 @@ router.post("/activitypub/inbox", (req, res) => { acc[el[0]] = el[1]; return acc; }, {}); - // get the actor // TODO if this is a Delete for an Actor this won't work request( @@ -1478,11 +1477,10 @@ router.post("/activitypub/inbox", (req, res) => { } }) .join("\n"); - const verifier = crypto.createVerify("RSA-SHA256"); verifier.update(comparison_string, "ascii"); - const publicKeyBuf = new Buffer(publicKey, "ascii"); - const signatureBuf = new Buffer( + const publicKeyBuf = Buffer.from(publicKey, "ascii"); + const signatureBuf = Buffer.from( signature_header.signature, "base64", ); diff --git a/src/start.ts b/src/start.ts index a6399ac..124a2fb 100755 --- a/src/start.ts +++ b/src/start.ts @@ -9,6 +9,7 @@ mongoose.connect(config.database.mongodb_url, { useUnifiedTopology: true, }); mongoose.set("useCreateIndex", true); +mongoose.set("useFindAndModify", false); mongoose.Promise = global.Promise; mongoose.connection .on("connected", () => { -- cgit v1.2.3 From 2402f88e0e907877a295350c57d2dd88edff6cd0 Mon Sep 17 00:00:00 2001 From: Raphael Kabo Date: Mon, 5 Feb 2024 19:11:10 +0000 Subject: Handle visibility in attendee model --- src/lib/activitypub.ts | 4 ++-- src/models/Event.ts | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/lib/activitypub.ts b/src/lib/activitypub.ts index 31c75ce..a06991d 100644 --- a/src/lib/activitypub.ts +++ b/src/lib/activitypub.ts @@ -1,5 +1,5 @@ import { Request, Response } from "express"; -import Event from "../models/Event.js"; +import Event, { IAttendee } from "../models/Event.js"; import { sendDirectMessage } from "../activitypub.js"; import { successfulRSVPResponse } from "./activitypub/templates.js"; @@ -142,7 +142,7 @@ export const handlePollResponse = async (req: Request, res: Response) => { if (!event.attendees?.some((el) => el.id === attributedTo)) { const attendeeName = apActor.preferredUsername || apActor.name || attributedTo; - const newAttendee = { + const newAttendee: Partial = { name: attendeeName, status: "attending", id: attributedTo, diff --git a/src/models/Event.ts b/src/models/Event.ts index 94be087..f67d40b 100644 --- a/src/models/Event.ts +++ b/src/models/Event.ts @@ -9,6 +9,7 @@ export interface IAttendee { number?: number; created?: Date; _id: string; + visibility?: "public" | "private"; } export interface IReply { @@ -105,6 +106,11 @@ const Attendees = new mongoose.Schema({ trim: true, default: 1, }, + visibility: { + type: String, + trim: true, + default: "public", + }, created: Date, }); -- cgit v1.2.3 From 2b47f4f8141820835c778ea748f8ae7f47ef261d Mon Sep 17 00:00:00 2001 From: Raphael Kabo Date: Mon, 5 Feb 2024 19:26:34 +0000 Subject: Handle hidden attendees in frontend --- src/routes/frontend.ts | 22 +++++++++++++++++++++- views/event.handlebars | 14 +++++++++++--- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/routes/frontend.ts b/src/routes/frontend.ts index cc97ab8..23418df 100644 --- a/src/routes/frontend.ts +++ b/src/routes/frontend.ts @@ -159,6 +159,24 @@ router.get("/:eventID", async (req: Request, res: Response) => { } return acc; }, 0) || 0; + const visibleAttendees = event.attendees?.filter( + (attendee) => attendee.visibility === "public", + ); + const hiddenAttendees = event.attendees?.filter( + (attendee) => attendee.visibility === "private", + ); + const numberOfHiddenAttendees = event.attendees?.reduce( + (acc, attendee) => { + if ( + attendee.status === "attending" && + attendee.visibility === "private" + ) { + return acc + (attendee.number || 1); + } + return acc; + }, + 0, + ); if (event.maxAttendees) { spotsRemaining = event.maxAttendees - numberOfAttendees; if (spotsRemaining <= 0) { @@ -189,8 +207,10 @@ router.get("/:eventID", async (req: Request, res: Response) => { title: event.name, escapedName: escapedName, eventData: event, - eventAttendees: eventAttendees, + visibleAttendees, + hiddenAttendees, numberOfAttendees, + numberOfHiddenAttendees, spotsRemaining: spotsRemaining, noMoreSpots: noMoreSpots, eventStartISO: eventStartISO, diff --git a/views/event.handlebars b/views/event.handlebars index 999a12b..4a732e2 100755 --- a/views/event.handlebars +++ b/views/event.handlebars @@ -131,7 +131,7 @@ {{#if eventData.usersCanAttend}}
-
Attendees {{#if eventAttendees}}({{numberOfAttendees}}){{/if}} +
Attendees {{#if numberOfAttendees}}({{numberOfAttendees}}){{/if}}
{{#unless noMoreSpots}} @@ -147,12 +147,20 @@
{{spotsRemaining}} {{plural spotsRemaining "spot(s)"}} remaining - add yourself now!
{{/if}} {{/if}} - {{#if eventAttendees}} + {{#if visibleAttendees}}
    - {{#each eventAttendees}} + {{#each visibleAttendees}} {{this.name}}{{#if ../editingEnabled}} {{/if}} {{/each}} + {{#if editingEnabled}} + {{#each visibleAttendees}} + {{this.name}} (Hidden from public list){{#if ../editingEnabled}} {{/if}} + {{/each}} + {{/if}}
+ {{#if numberOfHiddenAttendees}} +

+{{numberOfHiddenAttendees}} hidden attendee{{plural numberOfHiddenAttendees ""}}

+ {{/if}} {{else}}

No attendees yet!

{{/if}} -- cgit v1.2.3 From f11a16ea501ca7f792337751a0365215ac7aafc4 Mon Sep 17 00:00:00 2001 From: Raphael Kabo Date: Mon, 5 Feb 2024 20:56:00 +0000 Subject: Backwards compatibility for attendee visibility --- src/routes/frontend.ts | 12 ++++++++---- views/event.handlebars | 12 +++++++----- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/routes/frontend.ts b/src/routes/frontend.ts index 23418df..8ddfbf6 100644 --- a/src/routes/frontend.ts +++ b/src/routes/frontend.ts @@ -142,7 +142,11 @@ router.get("/:eventID", async (req: Request, res: Response) => { if (el.number && el.number > 1) { el.name = `${el.name} (${el.number} people)`; } - return el; + return { + ...el, + // Backwards compatibility - if visibility is not set, default to public + visibility: el.visibility || "public", + }; }) .filter((obj, pos, arr) => { return ( @@ -159,13 +163,13 @@ router.get("/:eventID", async (req: Request, res: Response) => { } return acc; }, 0) || 0; - const visibleAttendees = event.attendees?.filter( + const visibleAttendees = eventAttendees?.filter( (attendee) => attendee.visibility === "public", ); - const hiddenAttendees = event.attendees?.filter( + const hiddenAttendees = eventAttendees?.filter( (attendee) => attendee.visibility === "private", ); - const numberOfHiddenAttendees = event.attendees?.reduce( + const numberOfHiddenAttendees = eventAttendees?.reduce( (acc, attendee) => { if ( attendee.status === "attending" && diff --git a/views/event.handlebars b/views/event.handlebars index 4a732e2..2440623 100755 --- a/views/event.handlebars +++ b/views/event.handlebars @@ -153,14 +153,16 @@ {{this.name}}{{#if ../editingEnabled}} {{/if}} {{/each}} {{#if editingEnabled}} - {{#each visibleAttendees}} - {{this.name}} (Hidden from public list){{#if ../editingEnabled}} {{/if}} + {{#each hiddenAttendees}} + {{this.name}} (hidden from public list){{#if ../editingEnabled}} {{/if}} {{/each}} {{/if}} - {{#if numberOfHiddenAttendees}} -

+{{numberOfHiddenAttendees}} hidden attendee{{plural numberOfHiddenAttendees ""}}

- {{/if}} + {{#unless editingEnabled}} + {{#if numberOfHiddenAttendees}} +

+{{numberOfHiddenAttendees}} hidden attendee{{plural numberOfHiddenAttendees ""}}

+ {{/if}} + {{/unless}} {{else}}

No attendees yet!

{{/if}} -- cgit v1.2.3 From 456de15aea325cec94a6c2c83c01442665670efb Mon Sep 17 00:00:00 2001 From: Raphael Kabo Date: Mon, 5 Feb 2024 21:25:29 +0000 Subject: Styling for hidden attendees elements --- public/css/style.css | 26 ++++++++++++++++++++++++++ views/event.handlebars | 8 ++++---- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/public/css/style.css b/public/css/style.css index d50ab11..dd59d6b 100755 --- a/public/css/style.css +++ b/public/css/style.css @@ -187,6 +187,28 @@ body, html { font-weight: bold; } +.attendeesList > li.hidden-attendee { + border: 4px solid #ccc; + background: #eee; +} + +.attendeesList > li.hidden-attendee a { + color: #555; +} + +.hidden-attendees-message { + display: inline-block; + border: 4px solid #ccc; + text-align: center; + border-radius: 2em; + padding: 0.5em 1em; + background: #eee; + color: #555; + font-size: 0.95em; + font-weight: bold; + margin: 0; +} + .expand { -webkit-transition: height 0.2s; -moz-transition: height 0.2s; @@ -321,6 +343,10 @@ body, html { color: #fff; } +li.hidden-attendee .attendee-name { + color: #555; +} + .remove-attendee { color: #fff; } diff --git a/views/event.handlebars b/views/event.handlebars index 2440623..763aa22 100755 --- a/views/event.handlebars +++ b/views/event.handlebars @@ -139,7 +139,7 @@
-
+
{{#if eventData.maxAttendees}} {{#if noMoreSpots}}
This event is at capacity.
@@ -147,20 +147,20 @@
{{spotsRemaining}} {{plural spotsRemaining "spot(s)"}} remaining - add yourself now!
{{/if}} {{/if}} - {{#if visibleAttendees}} + {{#if numberOfAttendees}}
    {{#each visibleAttendees}} {{this.name}}{{#if ../editingEnabled}} {{/if}} {{/each}} {{#if editingEnabled}} {{#each hiddenAttendees}} - {{this.name}} (hidden from public list){{#if ../editingEnabled}} {{/if}} + {{this.name}} (hidden from public list){{#if ../editingEnabled}} {{/if}} {{/each}} {{/if}}
{{#unless editingEnabled}} {{#if numberOfHiddenAttendees}} -

+{{numberOfHiddenAttendees}} hidden attendee{{plural numberOfHiddenAttendees ""}}

+
{{numberOfHiddenAttendees}} hidden attendee{{plural numberOfHiddenAttendees ""}}
{{/if}} {{/unless}} {{else}} -- cgit v1.2.3 From c0f58b298248ba8682b556389525280c7088e025 Mon Sep 17 00:00:00 2001 From: Raphael Kabo Date: Tue, 6 Feb 2024 08:55:32 +0000 Subject: Allow setting attendee visibility from UI --- src/routes.js | 1 + views/event.handlebars | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/src/routes.js b/src/routes.js index 475f5a0..ab12a3a 100755 --- a/src/routes.js +++ b/src/routes.js @@ -687,6 +687,7 @@ router.post("/attendevent/:eventID", async (req, res) => { "attendees.$.name": req.body.attendeeName, "attendees.$.email": req.body.attendeeEmail, "attendees.$.number": req.body.attendeeNumber, + "attendees.$.visibility": !!req.body.attendeeVisible ? "public" : "private", }, }, ) diff --git a/views/event.handlebars b/views/event.handlebars index 763aa22..4402578 100755 --- a/views/event.handlebars +++ b/views/event.handlebars @@ -199,6 +199,13 @@
+
+ + +

If you choose to hide your name, only the event organiser will be able to see it.

+

You will need this password if you want to remove yourself from the list of event attendees. If you provided your email, you'll receive it by email. Otherwise, write it down now because it will not be shown again.

-- cgit v1.2.3 From e40ef51f26d04620b85fcbb15b5c9de857fcbf7b Mon Sep 17 00:00:00 2001 From: Raphael Kabo Date: Tue, 6 Feb 2024 09:06:33 +0000 Subject: Add tests --- cypress/e2e/event.cy.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/cypress/e2e/event.cy.ts b/cypress/e2e/event.cy.ts index 78cc2ca..8870164 100644 --- a/cypress/e2e/event.cy.ts +++ b/cypress/e2e/event.cy.ts @@ -86,7 +86,7 @@ describe("Events", () => { ); }); - it("allows you to attend an event", function () { + it("allows you to attend an event - visible in public list", function () { cy.get("button#attendEvent").click(); cy.get("#attendeeName").type("Test Attendee"); cy.get("#attendeeNumber").focus().clear(); @@ -99,6 +99,20 @@ describe("Events", () => { ); }); + it("allows you to attend an event - hidden from public list", function () { + cy.get("button#attendEvent").click(); + cy.get("#attendeeName").type("Test Attendee"); + cy.get("#attendeeNumber").focus().clear(); + cy.get("#attendeeNumber").type("2"); + cy.get("#attendeeVisible").uncheck(); + 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) (hidden from public list)", + ); + }); + it("allows you to comment on an event", function () { cy.get("#commentAuthor").type("Test Author"); cy.get("#commentContent").type("Test Comment"); -- cgit v1.2.3