/** * Required External Modules and Interfaces */ import express, {Request, Response} from 'express'; import {Event} from './event.interface.js'; import * as EventService from './events.service.js'; import * as iCalService from './icalgenerator.service.js'; import * as CredentialService from './credentials.service.js'; import {requireAppAccess, resolveAccess, AdminAccess} from '../../admin/admin.middleware.js'; import {Guid} from 'guid-typescript'; import logger from '../../../middleware/logger.js'; /** * Router Definition */ export const eventsRouter = express.Router(); /** * Constants */ export const calendarNames = new Map([ ['public', {id: 1, name: 'Nachklang_calendar'}], ['members', {id: 2, name: 'Nachklang_internal_calendar'}], ['choir', {id: 4, name: 'Nachklang_choir_calendar'}], ['management', {id: 3, name: 'Nachklang_management_calendar'}], ['birthdays', {id: 5, name: 'Nachklang_birthday_calendar'}] ]); /** * The gate on everything that writes. Step 4 of * docs/calendar-auth-migration.md replaced a sessionId/sessionKey pair in the * query string (DEFERRED_SECURITY.md item 1) with the same session cookie the * other three apps use, and "any activated @nachklang.art account" with an * explicit per-user calendar permission. */ const requireCalendarAccess = requireAppAccess('calendar'); /** Set by requireCalendarAccess; the writer's admin identity. */ const adminOf = (res: Response): AdminAccess => res.locals.admin as AdminAccess; /** * Resolves a signed-in calendar user for the *read* routes, or null. * * Reads cannot use the middleware: the same URL serves an anonymous visitor * (the public calendar the website polls), someone holding a shared password * (an iCal subscription), and a signed-in editor who should see drafts. So it * answers "who is this, if anyone?" instead of refusing the request, and each * handler decides what that means. * * A failure to reach the admin database is swallowed for the same reason the * name lookup in events.service.ts swallows one: it must not be able to take * the anonymous public calendar down. */ const signedInEditor = async (req: Request): Promise => { try { const access = await resolveAccess(req); if (!access || access.disabled || !access.apps.includes('calendar')) { return null; } return access; } catch (e: any) { logger.warn('Calendar: could not resolve the session, continuing as anonymous: ' + e.message); return null; } }; /** * Controller Definitions */ /** * @swagger * /calendar/events/{calendar}/json: * get: * summary: Get all events from a specific calendar in JSON format * description: > * Returns the calendar's events. The public calendar is open to everyone; the * others need either a signed-in account with the calendar permission - which * also unlocks drafts - or the calendar's shared password. * tags: * - calendar * parameters: * - in: path * name: calendar * required: true * schema: * type: string * enum: [public, members, choir, management, birthdays] * description: The name of the calendar to get events from * - in: query * name: password * schema: * type: string * description: The calendar's shared password, for callers with no account * responses: * 200: * description: Success * content: * application/json: * schema: * type: array * items: * $ref: '#/components/schemas/Event' * 400: * description: Bad request - missing or invalid parameters * content: * application/json: * schema: * type: object * properties: * message: * type: string * example: Please state the name of the calendar you want events from. * 403: * description: Forbidden - no access to the calendar * content: * application/json: * schema: * type: object * properties: * message: * type: string * example: You do not have access to the specified calendar. * 500: * description: Server error * content: * application/json: * schema: * type: object * properties: * message: * type: string * example: Internal Server Error. Try again later. */ eventsRouter.get('/:calendar/json', async (req: Request, res: Response) => { try { // Get request params let calendarName: string = req.params.calendar as string ?? ''; let password: string = req.query.password as string ?? ''; if (calendarName.length < 1) { res.status(400).send({'message': 'Please state the name of the calendar you want events from.'}); return; } if (!Array.from(calendarNames.keys()).includes(calendarName)) { res.status(400).send({'message': 'Unknown calendar.'}); return; } let calendarId: number = calendarNames.get(calendarName)!.id; const editor = await signedInEditor(req); // Not signed in: fall back to the shared password for this calendar. if (!editor && ! await CredentialService.hasAccess(calendarName, password)) { res.status(403).send({'message': 'You do not have access to the specified calendar.'}); return; } // Editors get the admin view (drafts included, calendar includes ignored); // everyone else gets published events only. let events: Event[] = editor ? await EventService.getAllEventsAdmin(calendarId) : await EventService.getAllEvents(calendarId); // Send the events back res.status(200).send(events); } catch (e: any) { let errorGuid = Guid.create().toString(); logger.error('Error handling a request: ' + e.message, {reference: errorGuid}); res.status(500).send({'message': 'Internal Server Error. Try again later.'}); } }); /** * @swagger * /calendar/events/{calendar}/json/next: * get: * summary: Get the next upcoming event from a calendar * description: > * The next upcoming event. The public calendar is open to everyone; the * others need either a signed-in account with the calendar permission or the * calendar's shared password. * tags: * - calendar * parameters: * - in: path * name: calendar * required: true * schema: * type: string * enum: [public, members, choir, management, birthdays] * description: The name of the calendar to get the next event from * - in: query * name: password * schema: * type: string * description: The calendar's shared password, for callers with no account * responses: * 200: * description: Success * content: * application/json: * schema: * $ref: '#/components/schemas/Event' * 400: * description: Bad request - missing or invalid parameters * content: * application/json: * schema: * type: object * properties: * message: * type: string * example: Please state the name of the calendar you want events from. * 403: * description: Forbidden - no access to the calendar * content: * application/json: * schema: * type: object * properties: * message: * type: string * example: You do not have access to the specified calendar. * 404: * description: Not found - no upcoming events * content: * application/json: * schema: * type: object * properties: * message: * type: string * example: No upcoming events found. * 500: * description: Server error * content: * application/json: * schema: * type: object * properties: * status: * type: string * example: PROCESSING_ERROR * message: * type: string * example: Internal Server Error. Try again later. * reference: * type: string * example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3 */ eventsRouter.get('/:calendar/json/next', async (req: Request, res: Response) => { try { // Get request params let calendarName: string = req.params.calendar as string ?? ''; let password: string = req.query.password as string ?? ''; if (calendarName.length < 1) { res.status(400).send({'message': 'Please state the name of the calendar you want events from.'}); return; } if (!Array.from(calendarNames.keys()).includes(calendarName)) { res.status(400).send({'message': 'Unknown calendar.'}); return; } let calendarId: number = calendarNames.get(calendarName)!.id; // Holding the calendar's shared password, or signed in. The password path // is what keeps iCal subscriptions working - a calendar client cannot // send a cookie. // // The password is checked FIRST so that `public`, which needs no // credential at all, short-circuits before signedInEditor runs. Otherwise // every request from a browser that happens to hold a .nachklang.art // cookie - which is any signed-in user on any of the four apps - would put // an admin-database query in front of the anonymous public feed, with no // timeout. Both operands are side-effect free, so the order is free to // choose; this order is the one that keeps the public calendar // independent of the admin database. if (! await CredentialService.hasAccess(calendarName, password) && !await signedInEditor(req)) { res.status(403).send({'message': 'You do not have access to the specified calendar.'}); return; } // Get next upcoming event let event = await EventService.getNextUpcomingEvent(calendarId); if (event === null) { res.status(404).send({'message': 'No upcoming events found.'}); return; } // Send the event back res.status(200).send(event); } catch (e: any) { let errorGuid = Guid.create().toString(); logger.error('Error handling a request: ' + e.message, {reference: errorGuid}); res.status(500).send({ 'status': 'PROCESSING_ERROR', 'message': 'Internal Server Error. Try again later.', 'reference': errorGuid }); } }); /** * @swagger * /calendar/events/{calendar}/ical: * get: * summary: Get all events from a specific calendar in iCal format * description: > * The calendar in iCal format. The public calendar is open to everyone; the * others take the calendar's shared password in the query string, which is * why that mechanism survives - an iCal client cannot send a cookie. * tags: * - calendar * parameters: * - in: path * name: calendar * required: true * schema: * type: string * enum: [public, members, choir, management, birthdays] * description: The name of the calendar to get events from * - in: query * name: password * schema: * type: string * description: The calendar's shared password, for callers with no account * responses: * 200: * description: Success - returns iCal file * content: * text/calendar: * schema: * type: string * format: binary * 400: * description: Bad request - missing or invalid parameters * content: * application/json: * schema: * type: object * properties: * message: * type: string * example: Please state the name of the calendar you want events from. * 403: * description: Forbidden - no access to the calendar * content: * application/json: * schema: * type: object * properties: * message: * type: string * example: You do not have access to the specified calendar. * 500: * description: Server error * content: * application/json: * schema: * type: object * properties: * status: * type: string * example: PROCESSING_ERROR * message: * type: string * example: Internal Server Error. Try again later. * reference: * type: string * example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3 */ eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => { try { // Get request params let calendarName: string = req.params.calendar as string ?? ''; let password: string = req.query.password as string ?? ''; if (calendarName.length < 1) { res.status(400).send({'message': 'Please state the name of the calendar you want events from.'}); return; } if (!Array.from(calendarNames.keys()).includes(calendarName)) { res.status(400).send({'message': 'Unknown calendar.'}); return; } let calendarId: number = calendarNames.get(calendarName)!.id; // Holding the calendar's shared password, or signed in. The password path // is what keeps iCal subscriptions working - a calendar client cannot // send a cookie. // // The password is checked FIRST so that `public`, which needs no // credential at all, short-circuits before signedInEditor runs. Otherwise // every request from a browser that happens to hold a .nachklang.art // cookie - which is any signed-in user on any of the four apps - would put // an admin-database query in front of the anonymous public feed, with no // timeout. Both operands are side-effect free, so the order is free to // choose; this order is the one that keeps the public calendar // independent of the admin database. if (! await CredentialService.hasAccess(calendarName, password) && !await signedInEditor(req)) { res.status(403).send({'message': 'You do not have access to the specified calendar.'}); return; } // Get events let events = await EventService.getAllEvents(calendarId); // generate file name let fileName = calendarNames.get(calendarName)!.name; let file = await iCalService.convertToIcal(events); // Send the ical file back res.set({'Content-Disposition': 'attachment; filename=' + fileName + '.ics'}); res.status(200).send(file); } catch (e: any) { let errorGuid = Guid.create().toString(); logger.error('Error handling a request: ' + e.message, {reference: errorGuid}); res.status(500).send({ 'status': 'PROCESSING_ERROR', 'message': 'Internal Server Error. Try again later.', 'reference': errorGuid }); } }); /** * @swagger * /calendar/events: * post: * summary: Create a new event * description: Creates a new event. Requires a signed-in account with the calendar permission. * tags: * - calendar * security: * - AdminSessionCookie: [] * requestBody: * required: true * content: * application/json: * schema: * type: object * required: * - calendarId * - name * - startDateTime * - endDateTime * properties: * calendarId: * type: integer * example: 1 * name: * type: string * example: "Concert at Musikhochschule" * description: * type: string * example: "Annual concert at the Musikhochschule" * startDateTime: * type: string * format: date-time * example: "2023-06-15T19:00:00.000Z" * endDateTime: * type: string * format: date-time * example: "2023-06-15T21:00:00.000Z" * location: * type: string * example: "Musikhochschule, Karlsruhe" * url: * type: string * example: "https://www.nachklang.art/events/concert" * wholeDay: * type: boolean * example: false * status: * type: string * enum: [PUBLIC, PRIVATE, DRAFT, DELETED] * example: "PUBLIC" * responses: * 201: * description: Event created successfully * content: * application/json: * schema: * type: object * properties: * message: * type: string * example: Event with id 123 was created successfully. * eventId: * type: integer * example: 123 * 400: * description: Bad request - missing or invalid parameters * content: * application/json: * schema: * type: object * properties: * message: * type: string * example: Required parameters missing * 401: * description: Unauthorized - not signed in * content: * application/json: * schema: * type: object * properties: * status: * type: string * example: UNAUTHORIZED * message: * type: string * example: Anmeldung erforderlich. * 403: * description: Forbidden - the account lacks the calendar permission * content: * application/json: * schema: * type: object * properties: * status: * type: string * example: FORBIDDEN * message: * type: string * example: "Für diesen Bereich fehlt dir die Berechtigung." * 500: * description: Server error * content: * application/json: * schema: * type: object * properties: * status: * type: string * example: PROCESSING_ERROR * message: * type: string * example: Internal Server Error. Try again later. * reference: * type: string * example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3 */ eventsRouter.post('/', requireCalendarAccess, async (req: Request, res: Response) => { try { const admin = adminOf(res); if ( req.body.calendarId === undefined || isNullOrBlank(req.body.name) || req.body.startDateTime === undefined || req.body.endDateTime === undefined ) { res.status(400).send({'message': 'Required parameters missing'}); return; } let event: Event = { eventId: -1, calendarId: req.body.calendarId, uuid: '', name: req.body.name, description: req.body.description ?? '', startDateTime: new Date(req.body.startDateTime), endDateTime: new Date(req.body.endDateTime), createdDate: new Date(), location: req.body.location ?? '', // LEGACY createdById is deliberately not set: there is no calendar // user id any more, and migration 003 made the column nullable. createdByUserId: admin.id, url: req.body.url ?? '', wholeDay: req.body.wholeDay ?? false, repeatFrequency: req.body.repeatFrequency ?? '', status: req.body.status ?? 'PUBLIC' }; let eventId = await EventService.createEvent(event); res.status(201).send({ 'message': 'Event with id ' + eventId + ' was created successfully.', 'eventId': eventId }); } catch (e: any) { let errorGuid = Guid.create().toString(); logger.error('Error handling a request: ' + e.message, {reference: errorGuid}); res.status(500).send({ 'status': 'PROCESSING_ERROR', 'message': 'Internal Server Error. Try again later.', 'reference': errorGuid }); } }); /** * @swagger * /calendar/events/{eventId}: * put: * summary: Update an existing event * description: Updates an existing event. Requires a signed-in account with the calendar permission. * tags: * - calendar * security: * - AdminSessionCookie: [] * parameters: * - in: path * name: eventId * required: true * schema: * type: integer * description: The ID of the event to update * requestBody: * required: true * content: * application/json: * schema: * type: object * required: * - calendarId * - name * - startDateTime * - endDateTime * properties: * calendarId: * type: integer * example: 1 * name: * type: string * example: "Updated Concert at Musikhochschule" * description: * type: string * example: "Updated annual concert at the Musikhochschule" * startDateTime: * type: string * format: date-time * example: "2023-06-15T19:00:00.000Z" * endDateTime: * type: string * format: date-time * example: "2023-06-15T21:00:00.000Z" * location: * type: string * example: "Musikhochschule, Karlsruhe" * url: * type: string * example: "https://www.nachklang.art/events/concert" * wholeDay: * type: boolean * example: false * status: * type: string * enum: [PUBLIC, PRIVATE, DRAFT, DELETED] * example: "PUBLIC" * responses: * 200: * description: Event updated successfully * content: * application/json: * schema: * type: object * properties: * message: * type: string * example: Event was successfully updated * 400: * description: Bad request - missing or invalid parameters * content: * application/json: * schema: * type: object * properties: * message: * type: string * example: Required parameters missing * 401: * description: Unauthorized - not signed in * content: * application/json: * schema: * type: object * properties: * status: * type: string * example: UNAUTHORIZED * message: * type: string * example: Anmeldung erforderlich. * 403: * description: Forbidden - the account lacks the calendar permission * content: * application/json: * schema: * type: object * properties: * status: * type: string * example: FORBIDDEN * message: * type: string * example: "Für diesen Bereich fehlt dir die Berechtigung." * 500: * description: Server error * content: * application/json: * schema: * type: object * properties: * status: * type: string * example: PROCESSING_ERROR * message: * type: string * example: Internal Server Error. Try again later. * reference: * type: string * example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3 */ eventsRouter.put('/:eventId', requireCalendarAccess, async (req: Request, res: Response) => { try { const admin = adminOf(res); if ( req.params.eventId === undefined || req.body.calendarId === undefined || isNullOrBlank(req.body.name) || req.body.startDateTime === undefined || req.body.endDateTime === undefined ) { res.status(400).send({'message': 'Required parameters missing'}); return; } let event: Event = { eventId: parseInt(req.params.eventId, 10), calendarId: req.body.calendarId, uuid: '', name: req.body.name, description: req.body.description ?? '', startDateTime: new Date(req.body.startDateTime), endDateTime: new Date(req.body.endDateTime), createdDate: new Date(), location: req.body.location ?? '', // LEGACY createdById is deliberately not set: there is no calendar // user id any more, and migration 003 made the column nullable. createdByUserId: admin.id, url: req.body.url ?? '', wholeDay: req.body.wholeDay ?? false, repeatFrequency: req.body.repeatFrequency ?? '', status: req.body.status ?? 'PUBLIC' }; let successRows = await EventService.updateEvent(event); if (successRows === 1) { res.status(200).send({'message': 'Event was successfully updated'}); } else if (successRows === 0) { res.status(200).send({'message': '0 rows were updated'}); } else { res.status(500).send({'message': 'An error occurred during the update process. Please try again.'}); } } catch (e: any) { let errorGuid = Guid.create().toString(); logger.error('Error handling a request: ' + e.message, {reference: errorGuid}); res.status(500).send({ 'status': 'PROCESSING_ERROR', 'message': 'Internal Server Error. Try again later.', 'reference': errorGuid }); } }); /** * @swagger * /calendar/events/move/{eventId}: * put: * summary: Move an event to a different calendar * description: Moves an event to a different calendar. Requires a signed-in account with the calendar permission. * tags: * - calendar * security: * - AdminSessionCookie: [] * parameters: * - in: path * name: eventId * required: true * schema: * type: integer * description: The ID of the event to move * requestBody: * required: true * content: * application/json: * schema: * type: object * required: * - calendarId * properties: * calendarId: * type: integer * example: 2 * description: The ID of the calendar to move the event to * name: * type: string * example: "Concert at Musikhochschule" * description: * type: string * example: "Annual concert at the Musikhochschule" * startDateTime: * type: string * format: date-time * example: "2023-06-15T19:00:00.000Z" * endDateTime: * type: string * format: date-time * example: "2023-06-15T21:00:00.000Z" * location: * type: string * example: "Musikhochschule, Karlsruhe" * url: * type: string * example: "https://www.nachklang.art/events/concert" * wholeDay: * type: boolean * example: false * status: * type: string * enum: [PUBLIC, PRIVATE, DRAFT, DELETED] * example: "PUBLIC" * responses: * 200: * description: Event moved successfully * content: * application/json: * schema: * type: object * properties: * message: * type: string * example: Event was successfully moved * 400: * description: Bad request - missing or invalid parameters * content: * application/json: * schema: * type: object * properties: * message: * type: string * example: Required parameters missing * 401: * description: Unauthorized - not signed in * content: * application/json: * schema: * type: object * properties: * status: * type: string * example: UNAUTHORIZED * message: * type: string * example: Anmeldung erforderlich. * 403: * description: Forbidden - the account lacks the calendar permission * content: * application/json: * schema: * type: object * properties: * status: * type: string * example: FORBIDDEN * message: * type: string * example: "Für diesen Bereich fehlt dir die Berechtigung." * 500: * description: Server error * content: * application/json: * schema: * type: object * properties: * status: * type: string * example: PROCESSING_ERROR * message: * type: string * example: Internal Server Error. Try again later. * reference: * type: string * example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3 */ eventsRouter.put('/move/:eventId', requireCalendarAccess, async (req: Request, res: Response) => { try { const admin = adminOf(res); if ( req.params.eventId === undefined || req.body.calendarId === undefined ) { res.status(400).send({'message': 'Required parameters missing'}); return; } let event: Event = { eventId: parseInt(req.params.eventId, 10), calendarId: req.body.calendarId, uuid: '', name: req.body.name, description: req.body.description ?? '', startDateTime: new Date(req.body.startDateTime), endDateTime: new Date(req.body.endDateTime), createdDate: new Date(), location: req.body.location ?? '', // LEGACY createdById is deliberately not set: there is no calendar // user id any more, and migration 003 made the column nullable. createdByUserId: admin.id, url: req.body.url ?? '', wholeDay: req.body.wholeDay ?? false, repeatFrequency: req.body.repeatFrequency ?? '', status: req.body.status ?? 'PUBLIC' }; let success = await EventService.moveEvent(event); if (success) { res.status(200).send({'message': 'Event was successfully moved'}); } else { res.status(500).send({'message': 'An error occurred during the moving process. Please try again.'}); } } catch (e: any) { let errorGuid = Guid.create().toString(); logger.error('Error handling a request: ' + e.message, {reference: errorGuid}); res.status(500).send({ 'status': 'PROCESSING_ERROR', 'message': 'Internal Server Error. Try again later.', 'reference': errorGuid }); } }); /** * @swagger * /calendar/events/{eventId}: * delete: * summary: Delete an event * description: Deletes an event. Requires a signed-in account with the calendar permission. * tags: * - calendar * security: * - AdminSessionCookie: [] * parameters: * - in: path * name: eventId * required: true * schema: * type: integer * description: The ID of the event to delete * responses: * 200: * description: Event deleted successfully * content: * application/json: * schema: * type: object * properties: * message: * type: string * example: Event was successfully deleted * 400: * description: Bad request - missing or invalid parameters * content: * application/json: * schema: * type: object * properties: * message: * type: string * example: Required parameters missing * 401: * description: Unauthorized - not signed in * content: * application/json: * schema: * type: object * properties: * status: * type: string * example: UNAUTHORIZED * message: * type: string * example: Anmeldung erforderlich. * 403: * description: Forbidden - the account lacks the calendar permission * content: * application/json: * schema: * type: object * properties: * status: * type: string * example: FORBIDDEN * message: * type: string * example: "Für diesen Bereich fehlt dir die Berechtigung." * 500: * description: Server error * content: * application/json: * schema: * type: object * properties: * status: * type: string * example: PROCESSING_ERROR * message: * type: string * example: Internal Server Error. Try again later. * reference: * type: string * example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3 */ eventsRouter.delete('/:eventId', requireCalendarAccess, async (req: Request, res: Response) => { try { const admin = adminOf(res); if ( req.params.eventId === undefined ) { res.status(400).send({'message': 'Required parameters missing'}); return; } let event: Event = { eventId: parseInt(req.params.eventId, 10), calendarId: -1, uuid: '', name: '', description: '', startDateTime: new Date(), endDateTime: new Date(), createdDate: new Date(), location: '', createdBy: '', // LEGACY createdById is deliberately not set: there is no calendar // user id any more, and migration 003 made the column nullable. createdByUserId: admin.id, url: '', wholeDay: false, repeatFrequency: '', status: 'DELETED' }; let success = await EventService.deleteEvent(event); if (success) { res.status(200).send({'message': 'Event was successfully deleted'}); } else { res.status(500).send({'message': 'An error occurred during deletion. Please try again.'}); } } catch (e: any) { let errorGuid = Guid.create().toString(); logger.error('Error handling a request: ' + e.message, {reference: errorGuid}); res.status(500).send({ 'status': 'PROCESSING_ERROR', 'message': 'Internal Server Error. Try again later.', 'reference': errorGuid }); } }); /** * Checks if a given string is null, undefined or blank * @param str The string to check */ function isNullOrBlank(str: string | null): boolean { return str === null || str === undefined || str.trim() === ''; }