diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 00000000..1c805c57 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,9 @@ +FROM node:22-alpine +WORKDIR /app +RUN apk add --no-cache python3 build-base +ADD package.json pnpm-lock.yaml /app/ +RUN npm install -g pnpm +RUN pnpm install +COPY . /app/ +# Dev: do not perform full build fail-stop; allow partial TS +CMD ["pnpm", "run", "dev"] diff --git a/docker-compose.yml b/docker-compose.yml index f799a9ed..f51fcc36 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,9 +4,13 @@ volumes: services: gathio: container_name: gathio-app - image: ghcr.io/lowercasename/gathio:latest + build: + context: . + dockerfile: Dockerfile links: - mongo + environment: + - NODE_ENV=production ports: - 3000:3000 volumes: @@ -17,6 +21,26 @@ services: - ./gathio-docker/static:/app/static # The path to Gathio's user-uploaded event images folder - change to match your system - ./gathio-docker/images:/app/public/events + # Use Dockerfile default CMD (npm run start); build already performed in multi-stage. + gathio-dev: + container_name: gathio-app-dev + build: + context: . + dockerfile: Dockerfile.dev + links: + - mongo + ports: + - 3001:3000 + environment: + - NODE_ENV=development + volumes: + - ./gathio-docker/config:/app/config + - ./gathio-docker/static:/app/static + - ./gathio-docker/images:/app/public/events + - ./src:/app/src + - ./views:/app/views + - ./locales:/app/locales + command: pnpm run dev mongo: container_name: gathio-db image: mongo:latest diff --git a/locales/en.json b/locales/en.json index fc7eba0f..52e50dc7 100644 --- a/locales/en.json +++ b/locales/en.json @@ -199,6 +199,9 @@ "views.event.started": "Started", "views.event.welcome": "Welcome to your event!", "views.event.currentlyediting": "You are currently editing this event. Do not share this link!", + "views.event.approveattendee": "Approve attendee", + "views.event.pending": "Pending", + "views.event.requireapproval": "Require host approvals for attendees to see location", "views.eventgroup.about": "About", "views.eventgroup.addevent": "To link an existing 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.", "views.eventgroup.del": "Delete this event group", @@ -307,5 +310,7 @@ "views.publiceventlist.numoevents_zero": "No event", "views.publiceventlist.pastevents": "Past events", "views.publiceventlist.upcomingevents": "Upcoming events", - "views.right": "Get it right!" + "views.right": "Get it right!", + "views.event.location_hidden": "Location hidden until approved", + "views.event.location_hidden_desc": "Your registration is pending approval. Once approved, revisit this page using your secret link (?p= parameter) to view the event address." } diff --git a/public/js/modules/event-edit.js b/public/js/modules/event-edit.js index 313e8f2f..3531826f 100644 --- a/public/js/modules/event-edit.js +++ b/public/js/modules/event-edit.js @@ -46,6 +46,7 @@ function editEventForm() { joinCheckbox: window.eventData.usersCanAttend, maxAttendeesCheckbox: window.eventData.maxAttendees !== null, maxAttendees: window.eventData.maxAttendees, + approveRegistrationsCheckbox: window.eventData.approveRegistrations, }, errors: [], submitting: false, @@ -64,6 +65,7 @@ function editEventForm() { this.data.maxAttendeesCheckbox = window.eventData.maxAttendees !== null; this.data.publicCheckbox = window.eventData.showOnPublicList; + this.data.approveRegistrationsCheckbox = window.eventData.approveRegistrations; }, updateEventEnd() { if (this.data.eventEnd === "" || this.data.eventEnd < this.data.eventStart) { diff --git a/public/js/modules/new.js b/public/js/modules/new.js index 70df641d..46309ac7 100644 --- a/public/js/modules/new.js +++ b/public/js/modules/new.js @@ -50,6 +50,7 @@ function newEventForm() { joinCheckbox: false, maxAttendeesCheckbox: false, maxAttendees: "", + approveRegistrationsCheckbox: false, }, errors: [], submitting: false, @@ -67,6 +68,7 @@ function newEventForm() { this.data.joinCheckbox = false; this.data.maxAttendeesCheckbox = false; this.data.publicCheckbox = false; + this.data.approveRegistrationsCheckbox = false; }, updateEventEnd() { if (this.data.eventEnd === "" || this.data.eventEnd < this.data.eventStart) { diff --git a/src/lib/config.ts b/src/lib/config.ts index ca6df769..6f6e07dc 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -200,16 +200,16 @@ export const getConfig = (): GathioConfig => { const config = toml.parse( fs.readFileSync("./config/config.toml", "utf-8"), ) as GathioConfig; - const resolvedConfig = { + const resolvedConfig: GathioConfig = { ...defaultConfig, ...config, - } + }; if (process.env.CYPRESS || process.env.CI) { - config.general.mail_service = "none"; + resolvedConfig.general.mail_service = "none"; console.log( "Running in Cypress or CI, not initializing email service.", ); - } else if (config.general.mail_service === "none") { + } else if (resolvedConfig.general.mail_service === "none") { console.warn( "You have not configured this Gathio instance to send emails! This means that event creators will not receive emails when their events are created, which means they may end up locked out of editing events. Consider setting up an email service.", ); diff --git a/src/models/Event.ts b/src/models/Event.ts index 57316803..d44e2a6f 100644 --- a/src/models/Event.ts +++ b/src/models/Event.ts @@ -10,6 +10,7 @@ export interface IAttendee { created?: Date; _id: string; visibility?: "public" | "private"; + approved?: boolean; // Host has approved this attendee to view protected info } export interface IReply { @@ -74,6 +75,7 @@ export interface IEvent extends mongoose.Document { followers?: IFollower[]; activityPubMessages?: IActivityPubMessage[]; showOnPublicList?: boolean; + approveRegistrations?: boolean; // Per-event: hide location until attendee approved } const Attendees = new mongoose.Schema({ @@ -113,6 +115,10 @@ const Attendees = new mongoose.Schema({ default: "public", }, created: Date, + approved: { + type: Boolean, + default: false, + }, }); const Followers = new mongoose.Schema( @@ -339,6 +345,10 @@ const EventSchema = new mongoose.Schema({ type: Boolean, default: false, }, + approveRegistrations: { + type: Boolean, + default: false, + }, }); export default mongoose.model("Event", EventSchema); diff --git a/src/routes.js b/src/routes.js index 218fc506..7b4d35d0 100755 --- a/src/routes.js +++ b/src/routes.js @@ -506,12 +506,6 @@ router.post("/deleteeventgroup/:eventGroupID/:editToken", (req, res) => { router.post("/attendee/provision", async (req, res) => { const removalPassword = niceware.generatePassphrase(6).join("-"); - const newAttendee = { - status: "provisioned", - removalPassword, - created: Date.now(), - }; - const event = await Event.findOne({ id: req.query.eventID }).catch((e) => { addToLog( "provisionEventAttendee", @@ -528,6 +522,13 @@ router.post("/attendee/provision", async (req, res) => { return res.sendStatus(404); } + const newAttendee = { + status: "provisioned", + removalPassword, + created: Date.now(), + approved: !event.approveRegistrations, // Auto approve if this event does not require approvals + }; + event.attendees.push(newAttendee); await event.save().catch((e) => { console.log(e); @@ -617,6 +618,7 @@ router.post("/attendevent/:eventID", async (req, res) => { "attendees.$.visibility": req.body.attendeeVisible ? "public" : "private", + // Do not modify approval status here }, }, ) @@ -654,7 +656,8 @@ router.post("/attendevent/:eventID", async (req, res) => { res.status(500).end(); }); } - res.redirect(`/${req.params.eventID}`); + // Redirect with ?p so attendee immediately has credential in URL + res.redirect(`/${req.params.eventID}?p=${encodeURIComponent(req.body.removalPassword)}`); }) .catch((error) => { res.send("Database error, please try again :("); @@ -782,6 +785,45 @@ router.post("/removeattendee/:eventID/:attendeeID", (req, res) => { }); }); +// Approve an attendee (allow them to view hidden location) when instance requires approval +router.post("/approveattendee/:eventID/:attendeeID", async (req, res) => { + try { + const event = await Event.findOne({ id: req.params.eventID }); + if (!event) { + return res.sendStatus(404); + } + // Require edit token in query (?e=) + const editToken = req.query.e; + if (!editToken || editToken !== event.editToken) { + return res.sendStatus(403); + } + // Find attendee + const attendee = event.attendees?.find((a) => a._id.toString() === req.params.attendeeID); + if (!attendee) { + return res.sendStatus(404); + } + attendee.approved = true; + await event.save(); + addToLog( + "approveEventAttendee", + "success", + "Attendee approved in event " + req.params.eventID, + ); + return res.redirect(`/${req.params.eventID}?e=${event.editToken}&m=approved`); + } catch (error) { + console.error(error); + addToLog( + "approveEventAttendee", + "error", + "Attempt to approve attendee in event " + + req.params.eventID + + " failed with error: " + + error, + ); + return res.sendStatus(500); + } +}); + /* * Create an email subscription on an event group. */ diff --git a/src/routes/event.ts b/src/routes/event.ts index be27590f..4e060dbd 100644 --- a/src/routes/event.ts +++ b/src/routes/event.ts @@ -188,6 +188,7 @@ router.post( ], publicKey, privateKey, + approveRegistrations: eventData.approveRegistrationsBoolean && eventData.joinBoolean, }); try { await event.save(); @@ -396,6 +397,8 @@ router.put( eventData.timezone, ) : undefined, + approveRegistrations: + eventData.approveRegistrationsBoolean && eventData.joinBoolean, }; let diffText = "

" + i18next.t("routes.event.difftext") + "

+