Read calendar event creators from the admin module, and archive the old ones (#14)
Jenkins Production Deployment

Reviewed-on: #14
Co-authored-by: Patrick Müller <mail@pmueller.me>
Co-committed-by: Patrick Müller <mail@pmueller.me>
This commit was merged in pull request #14.
This commit is contained in:
2026-09-06 21:12:16 +00:00
committed by Patrick Müller
parent 3c892d02ed
commit 13a0c07d1b
18 changed files with 1346 additions and 483 deletions
+201 -190
View File
@@ -7,7 +7,7 @@ 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 * as UserService from '../users/users.service.js';
import {requireAppAccess, resolveAccess, AdminAccess} from '../../admin/admin.middleware.js';
import {Guid} from 'guid-typescript';
import logger from '../../../middleware/logger.js';
@@ -29,6 +29,44 @@ export const calendarNames = new Map<string, any>([
['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<AdminAccess | null> => {
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
@@ -39,7 +77,10 @@ export const calendarNames = new Map<string, any>([
* /calendar/events/{calendar}/json:
* get:
* summary: Get all events from a specific calendar in JSON format
* description: Returns all events from the specified calendar in JSON format. Authentication required.
* 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:
@@ -48,23 +89,13 @@ export const calendarNames = new Map<string, any>([
* required: true
* schema:
* type: string
* enum: [public, members, choir, management]
* enum: [public, members, choir, management, birthdays]
* description: The name of the calendar to get events from
* - in: query
* name: sessionId
* schema:
* type: string
* description: Session ID for authentication
* - in: query
* name: sessionKey
* schema:
* type: string
* description: Session key for authentication
* - in: query
* name: password
* schema:
* type: string
* description: Password for calendar access (if not using session authentication)
* description: The calendar's shared password, for callers with no account
* responses:
* 200:
* description: Success
@@ -109,10 +140,7 @@ eventsRouter.get('/:calendar/json', async (req: Request, res: Response) => {
try {
// Get request params
let calendarName: string = req.params.calendar as string ?? '';
let sessionId: string = req.query.sessionId as string ?? '';
let sessionKey: string = req.query.sessionKey as string ?? '';
let password: string = req.query.password as string ?? '';
let ip: string = req.socket.remoteAddress ?? '';
if (calendarName.length < 1) {
res.status(400).send({'message': 'Please state the name of the calendar you want events from.'});
@@ -126,23 +154,19 @@ eventsRouter.get('/:calendar/json', async (req: Request, res: Response) => {
let calendarId: number = calendarNames.get(calendarName)!.id;
let user = await UserService.checkSession(sessionId, sessionKey, ip);
const editor = await signedInEditor(req);
// If no user was found, check if the password gives access to the calendar
if(user === null || !user.isActive) {
if (! await CredentialService.hasAccess(calendarName, sessionId, sessionKey, password, ip)) {
res.status(403).send({'message': 'You do not have access to the specified calendar.'});
return;
}
// 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;
}
let events: Event[];
if(user?.isActive) {
events = await EventService.getAllEventsAdmin(calendarId);
} else {
events = await EventService.getAllEvents(calendarId);
}
// 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);
@@ -158,7 +182,10 @@ eventsRouter.get('/:calendar/json', async (req: Request, res: Response) => {
* /calendar/events/{calendar}/json/next:
* get:
* summary: Get the next upcoming event from a calendar
* description: Returns the next upcoming event from the specified calendar. Authentication required.
* 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:
@@ -167,23 +194,13 @@ eventsRouter.get('/:calendar/json', async (req: Request, res: Response) => {
* required: true
* schema:
* type: string
* enum: [public, members, choir, management]
* enum: [public, members, choir, management, birthdays]
* description: The name of the calendar to get the next event from
* - in: query
* name: sessionId
* schema:
* type: string
* description: Session ID for authentication
* - in: query
* name: sessionKey
* schema:
* type: string
* description: Session key for authentication
* - in: query
* name: password
* schema:
* type: string
* description: Password for calendar access (if not using session authentication)
* description: The calendar's shared password, for callers with no account
* responses:
* 200:
* description: Success
@@ -242,10 +259,7 @@ eventsRouter.get('/:calendar/json/next', async (req: Request, res: Response) =>
try {
// Get request params
let calendarName: string = req.params.calendar as string ?? '';
let sessionId: string = req.query.sessionId as string ?? '';
let sessionKey: string = req.query.sessionKey as string ?? '';
let password: string = req.query.password as string ?? '';
let ip: string = req.socket.remoteAddress ?? '';
if (calendarName.length < 1) {
res.status(400).send({'message': 'Please state the name of the calendar you want events from.'});
@@ -259,7 +273,19 @@ eventsRouter.get('/:calendar/json/next', async (req: Request, res: Response) =>
let calendarId: number = calendarNames.get(calendarName)!.id;
if (! await CredentialService.hasAccess(calendarName, sessionId, sessionKey, password, ip)) {
// 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;
}
@@ -290,7 +316,10 @@ eventsRouter.get('/:calendar/json/next', async (req: Request, res: Response) =>
* /calendar/events/{calendar}/ical:
* get:
* summary: Get all events from a specific calendar in iCal format
* description: Returns all events from the specified calendar in iCal format for calendar applications. Authentication required.
* 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:
@@ -299,23 +328,13 @@ eventsRouter.get('/:calendar/json/next', async (req: Request, res: Response) =>
* required: true
* schema:
* type: string
* enum: [public, members, choir, management]
* enum: [public, members, choir, management, birthdays]
* description: The name of the calendar to get events from
* - in: query
* name: sessionId
* schema:
* type: string
* description: Session ID for authentication
* - in: query
* name: sessionKey
* schema:
* type: string
* description: Session key for authentication
* - in: query
* name: password
* schema:
* type: string
* description: Password for calendar access (if not using session authentication)
* description: The calendar's shared password, for callers with no account
* responses:
* 200:
* description: Success - returns iCal file
@@ -365,10 +384,7 @@ eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => {
try {
// Get request params
let calendarName: string = req.params.calendar as string ?? '';
let sessionId: string = req.query.sessionId as string ?? '';
let sessionKey: string = req.query.sessionKey as string ?? '';
let password: string = req.query.password as string ?? '';
let ip: string = req.socket.remoteAddress ?? '';
if (calendarName.length < 1) {
res.status(400).send({'message': 'Please state the name of the calendar you want events from.'});
@@ -382,7 +398,19 @@ eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => {
let calendarId: number = calendarNames.get(calendarName)!.id;
if (! await CredentialService.hasAccess(calendarName, sessionId, sessionKey, password, ip)) {
// 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;
}
@@ -413,22 +441,11 @@ eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => {
* /calendar/events:
* post:
* summary: Create a new event
* description: Creates a new event in the specified calendar. Authentication required.
* description: Creates a new event. Requires a signed-in account with the calendar permission.
* tags:
* - calendar
* parameters:
* - in: query
* name: sessionId
* required: true
* schema:
* type: string
* description: Session ID for authentication
* - in: query
* name: sessionKey
* required: true
* schema:
* type: string
* description: Session key for authentication
* security:
* - AdminSessionCookie: []
* requestBody:
* required: true
* content:
@@ -495,16 +512,32 @@ eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => {
* message:
* type: string
* example: Required parameters missing
* 403:
* description: Forbidden - no access to create events
* 401:
* description: Unauthorized - not signed in
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: UNAUTHORIZED
* message:
* type: string
* example: You do not have access to the specified calendar.
* 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:
@@ -522,19 +555,9 @@ eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => {
* type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/
eventsRouter.post('/', async (req: Request, res: Response) => {
eventsRouter.post('/', requireCalendarAccess, async (req: Request, res: Response) => {
try {
// Get params
let sessionId: string = req.query.sessionId as string ?? '';
let sessionKey: string = req.query.sessionKey as string ?? '';
let ip: string = req.socket.remoteAddress ?? '';
let user = await UserService.checkSession(sessionId, sessionKey, ip);
if (!user?.isActive) {
res.status(403).send({'message': 'You do not have access to the specified calendar.'});
return;
}
const admin = adminOf(res);
if (
req.body.calendarId === undefined ||
@@ -556,7 +579,9 @@ eventsRouter.post('/', async (req: Request, res: Response) => {
endDateTime: new Date(req.body.endDateTime),
createdDate: new Date(),
location: req.body.location ?? '',
createdById: user.userId ?? -1,
// 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 ?? '',
@@ -585,9 +610,11 @@ eventsRouter.post('/', async (req: Request, res: Response) => {
* /calendar/events/{eventId}:
* put:
* summary: Update an existing event
* description: Updates an existing event with the provided data. Authentication required.
* description: Updates an existing event. Requires a signed-in account with the calendar permission.
* tags:
* - calendar
* security:
* - AdminSessionCookie: []
* parameters:
* - in: path
* name: eventId
@@ -595,18 +622,6 @@ eventsRouter.post('/', async (req: Request, res: Response) => {
* schema:
* type: integer
* description: The ID of the event to update
* - in: query
* name: sessionId
* required: true
* schema:
* type: string
* description: Session ID for authentication
* - in: query
* name: sessionKey
* required: true
* schema:
* type: string
* description: Session key for authentication
* requestBody:
* required: true
* content:
@@ -639,9 +654,6 @@ eventsRouter.post('/', async (req: Request, res: Response) => {
* location:
* type: string
* example: "Musikhochschule, Karlsruhe"
* createdBy:
* type: string
* example: "John Doe"
* url:
* type: string
* example: "https://www.nachklang.art/events/concert"
@@ -673,16 +685,32 @@ eventsRouter.post('/', async (req: Request, res: Response) => {
* message:
* type: string
* example: Required parameters missing
* 403:
* description: Forbidden - no access to update events
* 401:
* description: Unauthorized - not signed in
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: UNAUTHORIZED
* message:
* type: string
* example: You do not have access to the specified calendar.
* 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:
@@ -700,19 +728,9 @@ eventsRouter.post('/', async (req: Request, res: Response) => {
* type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/
eventsRouter.put('/:eventId', async (req: Request, res: Response) => {
eventsRouter.put('/:eventId', requireCalendarAccess, async (req: Request, res: Response) => {
try {
// Get params
let sessionId: string = req.query.sessionId as string ?? '';
let sessionKey: string = req.query.sessionKey as string ?? '';
let ip: string = req.socket.remoteAddress ?? '';
let user = await UserService.checkSession(sessionId, sessionKey, ip);
if (!user?.isActive) {
res.status(403).send({'message': 'You do not have access to the specified calendar.'});
return;
}
const admin = adminOf(res);
if (
req.params.eventId === undefined ||
@@ -735,8 +753,9 @@ eventsRouter.put('/:eventId', async (req: Request, res: Response) => {
endDateTime: new Date(req.body.endDateTime),
createdDate: new Date(),
location: req.body.location ?? '',
createdBy: req.body.createdBy ?? '',
createdById: user.userId ?? -1,
// 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 ?? '',
@@ -768,9 +787,11 @@ eventsRouter.put('/:eventId', async (req: Request, res: Response) => {
* /calendar/events/move/{eventId}:
* put:
* summary: Move an event to a different calendar
* description: Moves an existing event to a different calendar. Authentication required.
* 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
@@ -778,18 +799,6 @@ eventsRouter.put('/:eventId', async (req: Request, res: Response) => {
* schema:
* type: integer
* description: The ID of the event to move
* - in: query
* name: sessionId
* required: true
* schema:
* type: string
* description: Session ID for authentication
* - in: query
* name: sessionKey
* required: true
* schema:
* type: string
* description: Session key for authentication
* requestBody:
* required: true
* content:
@@ -820,9 +829,6 @@ eventsRouter.put('/:eventId', async (req: Request, res: Response) => {
* location:
* type: string
* example: "Musikhochschule, Karlsruhe"
* createdBy:
* type: string
* example: "John Doe"
* url:
* type: string
* example: "https://www.nachklang.art/events/concert"
@@ -854,16 +860,32 @@ eventsRouter.put('/:eventId', async (req: Request, res: Response) => {
* message:
* type: string
* example: Required parameters missing
* 403:
* description: Forbidden - no access to move events
* 401:
* description: Unauthorized - not signed in
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: UNAUTHORIZED
* message:
* type: string
* example: You do not have access to the specified calendar.
* 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:
@@ -881,19 +903,9 @@ eventsRouter.put('/:eventId', async (req: Request, res: Response) => {
* type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/
eventsRouter.put('/move/:eventId', async (req: Request, res: Response) => {
eventsRouter.put('/move/:eventId', requireCalendarAccess, async (req: Request, res: Response) => {
try {
// Get params
let sessionId: string = req.query.sessionId as string ?? '';
let sessionKey: string = req.query.sessionKey as string ?? '';
let ip: string = req.socket.remoteAddress ?? '';
let user = await UserService.checkSession(sessionId, sessionKey, ip);
if (!user?.isActive) {
res.status(403).send({'message': 'You do not have access to the specified calendar.'});
return;
}
const admin = adminOf(res);
if (
req.params.eventId === undefined ||
@@ -913,8 +925,9 @@ eventsRouter.put('/move/:eventId', async (req: Request, res: Response) => {
endDateTime: new Date(req.body.endDateTime),
createdDate: new Date(),
location: req.body.location ?? '',
createdBy: req.body.createdBy ?? '',
createdById: user.userId ?? -1,
// 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 ?? '',
@@ -944,9 +957,11 @@ eventsRouter.put('/move/:eventId', async (req: Request, res: Response) => {
* /calendar/events/{eventId}:
* delete:
* summary: Delete an event
* description: Deletes an existing event. Authentication required.
* description: Deletes an event. Requires a signed-in account with the calendar permission.
* tags:
* - calendar
* security:
* - AdminSessionCookie: []
* parameters:
* - in: path
* name: eventId
@@ -954,18 +969,6 @@ eventsRouter.put('/move/:eventId', async (req: Request, res: Response) => {
* schema:
* type: integer
* description: The ID of the event to delete
* - in: query
* name: sessionId
* required: true
* schema:
* type: string
* description: Session ID for authentication
* - in: query
* name: sessionKey
* required: true
* schema:
* type: string
* description: Session key for authentication
* responses:
* 200:
* description: Event deleted successfully
@@ -987,16 +990,32 @@ eventsRouter.put('/move/:eventId', async (req: Request, res: Response) => {
* message:
* type: string
* example: Required parameters missing
* 403:
* description: Forbidden - no access to delete events
* 401:
* description: Unauthorized - not signed in
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: UNAUTHORIZED
* message:
* type: string
* example: You do not have access to the specified calendar.
* 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:
@@ -1014,19 +1033,9 @@ eventsRouter.put('/move/:eventId', async (req: Request, res: Response) => {
* type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/
eventsRouter.delete('/:eventId', async (req: Request, res: Response) => {
eventsRouter.delete('/:eventId', requireCalendarAccess, async (req: Request, res: Response) => {
try {
// Get params
let sessionId: string = req.query.sessionId as string ?? '';
let sessionKey: string = req.query.sessionKey as string ?? '';
let ip: string = req.socket.remoteAddress ?? '';
let user = await UserService.checkSession(sessionId, sessionKey, ip);
if (!user?.isActive) {
res.status(403).send({'message': 'You do not have access to the specified calendar.'});
return;
}
const admin = adminOf(res);
if (
req.params.eventId === undefined
@@ -1046,7 +1055,9 @@ eventsRouter.delete('/:eventId', async (req: Request, res: Response) => {
createdDate: new Date(),
location: '',
createdBy: '',
createdById: user.userId ?? -1,
// 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: '',