9 Commits

Author SHA1 Message Date
Paddy 3fc6894070 Merge pull request 'Record the calendar migration as complete and deployed' (#16) from docs/calendar-migration-complete into master
Reviewed-on: #16
2026-09-06 21:53:12 +00:00
Paddy 69c1e4926c Record the calendar migration as complete and deployed
Every step is live as of 2026-09-06. The status header said "awaiting deploy",
which is exactly the kind of staleness that nearly caused a bad deploy earlier
in this project - the step 4 checklist was written because "done" had been read
as "in production".

Also records the one surprise from the deploy: a cached pre-cutover bundle
looked signed in and showed every event's status as "Error", because the
legacy checkSessionValid route still answered between steps 4 and 5 while the
events call went out without a cookie and came back without a status field.
That route is gone now, so a stale bundle fails honestly instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 23:49:25 +02:00
Paddy a49b939fdc Merge pull request 'Drop the calendar's legacy authentication path' (#15) from feature/calendar-drop-legacy-path into master
Jenkins Production Deployment
Reviewed-on: #15
2026-09-06 21:41:01 +00:00
Paddy aa95ab2745 Drop the calendar's legacy authentication path
Step 5, the last one, of docs/calendar-auth-migration.md. Step 4 is deployed
and verified, which is what this was waiting on: it removes the fallbacks that
step 4 still leaned on.

Gone: src/models/calendar/users/ entirely - registration, login, activation,
both password-reset routes, and the session checking that the feedback and
tickets admin areas used to authenticate against - along with its mount. That
was the API's last unauthenticated account-creation and mail-sending endpoint.
A survey confirmed nothing outside that directory imported it and nothing else
touched its tables.

Also gone: the two joins against the calendar users table in events.service.ts
and the created_by_id / version_created_by_id columns they read, from the SQL,
the row mapper, the Event interface and the swagger schema; and X-Session-Id /
X-Session-Key from the CORS allowedHeaders, which nothing has read since the
first cutover and nothing has sent since the second.

An event's author still renders, because migration 002 snapshotted the names
before this could erase them. That was brought forward from this step on
purpose, and it is the reason 004 can rename the accounts aside at all.

The accounts are renamed rather than dropped - they still hold e-mail addresses
and password hashes, and a rename makes them unreachable without destroying
anything. InnoDB rewires the sessions foreign key to the new name; verified on
MariaDB 11, along with the whole 001-004 chain from the pre-cutover production
schema, which lands byte-identical to a fresh dev database.

Migration 004 must be applied AFTER deploying, not before - the reverse of step
4, whose migration only added things. Its own header and the runbook both say
so, since getting it wrong by analogy is the obvious mistake.

DEFERRED_SECURITY.md items 3 and 4 close with it: the activation and reset
tokens that never expired are gone along with the code that issued them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 23:32:59 +02:00
Paddy 13a0c07d1b 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>
2026-09-06 21:12:16 +00:00
Paddy 3c892d02ed Put the feedback and tickets admin areas behind the shared identity (#13)
Jenkins Production Deployment
Reviewed-on: #13
Co-authored-by: Patrick Müller <mail@pmueller.me>
Co-committed-by: Patrick Müller <mail@pmueller.me>
2026-09-06 19:06:30 +00:00
Paddy bf7be65b03 Add admin identity module: better-auth, per-app permissions, invitations (#12)
Jenkins Production Deployment
Reviewed-on: #12
Co-authored-by: Patrick Müller <mail@pmueller.me>
Co-committed-by: Patrick Müller <mail@pmueller.me>
2026-09-06 10:41:54 +00:00
Paddy ce9b173c71 Merge pull request 'Document dotenv 16 quoting rule for .env values' (#11) from feature/api-esm-prep into master
Reviewed-on: #11
2026-09-05 14:13:29 +00:00
Paddy 10c459db0f Merge pull request 'Migrate the API to native ESM and vitest; pin Node 26' (#10) from feature/api-esm-prep into master
Jenkins Production Deployment
Reviewed-on: #10
2026-09-05 14:05:13 +00:00
41 changed files with 1831 additions and 1919 deletions
+23 -10
View File
@@ -33,15 +33,15 @@ Express.js REST API in TypeScript with a service-oriented layering. Domains: `Ca
| Layer | Location | | Layer | Location |
|---|---| |---|---|
| Router | `src/models/calendar/Calendar.router.ts`, `…/events/events.router.ts`, `…/users/users.router.ts` | | Router | `src/models/calendar/Calendar.router.ts`, `…/events/events.router.ts` |
| Services | `…/events/events.service.ts`, `…/users/users.service.ts`, `…/events/credentials.service.ts`, `…/events/icalgenerator.service.ts` | | Services | `…/events/events.service.ts`, `…/events/credentials.service.ts`, `…/events/icalgenerator.service.ts` |
| DB pool | `src/models/calendar/Calendar.db.ts` (MariaDB, pool size 5) | | DB pool | `src/models/calendar/Calendar.db.ts` (MariaDB, pool size 5) |
| Shared | `src/common/` (base route class, nodemailer wrapper), `src/middleware/logger.ts` (Winston) | | Shared | `src/common/` (base route class, nodemailer wrapper), `src/middleware/logger.ts` (Winston) |
**Auth model:** Two of them, on purpose. **Auth model:** One, since the calendar migration completed.
*Admin module (`src/models/admin/`)*the current one, used by feedback, tickets and the *Admin module (`src/models/admin/`)*used by every app: calendar, feedback, tickets and the
admin app. better-auth 1.7 on its own `nachklang_admin` database (Kysely + mysql2; every admin app itself. better-auth 1.7 on its own `nachklang_admin` database (Kysely + mysql2; every
other domain keeps the `mariadb` driver), mounted at `/admin/auth/*` for the auth handler other domain keeps the `mariadb` driver), mounted at `/admin/auth/*` for the auth handler
and `/admin` for the JSON routes. Sessions are httpOnly cookies scoped to and `/admin` for the JSON routes. Sessions are httpOnly cookies scoped to
`.nachklang.art`, so one sign-in covers every app. Accounts are **invite-only** — public `.nachklang.art`, so one sign-in covers every app. Accounts are **invite-only** — public
@@ -59,11 +59,24 @@ two and the last-admin guard stops guarding; and both write endpoints accept
same at the `access` role. `ADMIN_BOOTSTRAP_EMAIL` same at the `access` role. `ADMIN_BOOTSTRAP_EMAIL`
makes sure someone can always get in on a fresh database. makes sure someone can always get in on a fresh database.
*Legacy calendar* — unchanged: users need a `@nachklang.art` email, and after activation *The calendar* used to be the exception - its own `users`/`sessions` tables, and a session
get a session token (30-day window, hash + IP stored in the DB), passed as query token passed in **query parameters**. That is gone: `docs/calendar-auth-migration.md` records
parameters. Migration is planned but not started: `docs/calendar-auth-migration.md`. the migration, finished 2026-09-06. Writes sit behind `requireAppAccess('calendar')`; reads
Credentials for non-user calendar access (`MEMBER_CREDENTIAL`, `CHOIR_CREDENTIAL`, resolve the same cookie optionally, because one URL serves an anonymous visitor, an iCal
`MANAGEMENT_CREDENTIAL`) come from `.env`. subscription and a signed-in editor.
Two calendar-specific things survive that migration and are easy to break:
- **The `public` calendar answers with no credential of any kind.** nachklang.art reads it to
show the next upcoming event. Pinned by `test/calendar/credentials.service.test.ts` and
`test/calendar/events.router.test.ts`.
- **The shared passwords (`MEMBER_CREDENTIAL`, `CHOIR_CREDENTIAL`, `MANAGEMENT_CREDENTIAL`,
from `.env`) still open the restricted calendars for reading**, because an iCal client
cannot send a cookie. They can never write.
An event's creator is a display name and nothing else - nothing authorises on it. It resolves
from the admin module's `user.name` when the row carries an admin id, and otherwise from
`created_by_name`, a snapshot taken before the legacy `users` table was renamed aside.
**Admin database driver:** the admin pool is the **callback-style** `mysql2`, never **Admin database driver:** the admin pool is the **callback-style** `mysql2`, never
`mysql2/promise`. Kysely's `MysqlDialect` calls `pool.getConnection((err, conn) => ...)`; `mysql2/promise`. Kysely's `MysqlDialect` calls `pool.getConnection((err, conn) => ...)`;
+31 -32
View File
@@ -5,15 +5,28 @@ These items were identified during a security review on 2026-05-02 and conscious
--- ---
## 1. Session credentials in URL query parameters (logged-in users) ## 1. Session credentials in URL query parameters (logged-in users) — CLOSED 2026-09-06
**Files:** `src/models/calendar/events/events.router.ts` — all GET/PUT/DELETE handlers **Files:** `src/models/calendar/events/events.router.ts` — all GET/PUT/DELETE handlers
`sessionId` and `sessionKey` are currently read from query parameters, which means they appear in server access logs, browser history, proxy logs, and `Referer` headers. `sessionId` and `sessionKey` were read from query parameters, which meant they appeared in
server access logs, browser history, proxy logs, and `Referer` headers.
**Fix:** Move to request headers (`X-Session-Id` / `X-Session-Key`) or the request body. Requires a corresponding frontend update. **Fixed** by step 4 of `docs/calendar-auth-migration.md`: the calendar's write routes now sit
behind `requireAppAccess('calendar')` against the better-auth session cookie, and the read
routes resolve the same cookie optionally. No route reads `sessionId`/`sessionKey` any more,
and the Angular frontend sends `withCredentials` instead of appending them to every URL. That
closed the item outright rather than moving the credential somewhere safer.
> Note: the shared calendar `password` parameter in query params is intentional (iCal clients don't support headers) and is acceptable for the current setup. Two things this did *not* change, both deliberate:
- The shared calendar `password` parameter stays. An iCal client cannot send a cookie, so
this is the one caller that genuinely needs a credential in the URL. It grants read access
to one calendar and nothing else - `test/calendar/events.router.test.ts` pins that it can
never be used to write.
- ~~The legacy `/calendar/users/*` routes still exist.~~ Removed by step 5 on 2026-09-06,
along with the `users` and `sessions` tables they used - renamed aside rather than dropped,
so nothing was destroyed.
--- ---
@@ -24,43 +37,29 @@ These items were identified during a security review on 2026-05-02 and conscious
- `PUT /move/:eventId` (move) - `PUT /move/:eventId` (move)
- `DELETE /:eventId` (delete) - `DELETE /:eventId` (delete)
Currently any active user can edit, move, or delete any event regardless of who created it. This is acceptable while all users are trusted admins. Currently any account holding the `calendar` permission can edit, move, or delete any event regardless of who created it. This is acceptable while everyone holding it is a trusted admin.
**Fix:** When non-admin users are introduced, fetch the event first and verify `event.createdById === user.userId` before allowing the mutation. Add an `isAdmin` flag to the user model to let admins bypass the check. **Fix (updated 2026-09-06):** fetch the event first and verify `event.createdByUserId === res.locals.admin.id` before allowing the mutation`createdById`, the legacy INT, is no longer written and is gone at step 5. Rather than an `isAdmin` flag, the bypass belongs in the permission model that already exists: `requireAppAccess('calendar', 'manage')` alongside the current `access` role, which needs a row in `APP_ROLES` on both sides and nothing else.
--- ---
## 3. Activation token has no expiry ## 3. Activation token has no expiry — CLOSED 2026-09-06
> **Superseded for new accounts (2026-09-05).** The admin module **File:** ~~`src/models/calendar/users/users.service.ts`~~ — deleted.
> (`src/models/admin/`) replaced account creation for the feedback, tickets and admin
> apps: accounts now come from `invitations`, whose tokens expire after 7 days and are
> stored only as a SHA-256 hash. The item below still stands for the legacy calendar
> `users` table, which the admin module deliberately left alone - see
> `docs/calendar-auth-migration.md`.
**File:** `src/models/calendar/users/users.service.ts``createUser` / `activateUser` The e-mail activation link was valid indefinitely. Closed not by adding an expiry but by
removing the thing that issued it: step 5 of `docs/calendar-auth-migration.md` deleted the
calendar's own account system. Accounts now come only from the admin module's `invitations`,
whose tokens expire after 7 days and are stored as a SHA-256 hash.
The email activation link is valid indefinitely. Acceptable for a small, trusted userbase. Any activation link still sitting in an inbox now 404s. It only ever activated a legacy
account, which no longer opens anything.
**Fix:**
1. Add an `activation_expires` column to the `users` table (e.g. `DATETIME`).
2. Set it to `NOW() + INTERVAL 24 HOUR` in `createUser`.
3. Check `activation_expires > NOW()` in `activateUser` before accepting the token.
--- ---
## 4. Password reset token has no expiry ## 4. Password reset token has no expiry — CLOSED 2026-09-06
> **Superseded for new accounts (2026-09-05).** Password resets for admin-module accounts **File:** ~~`src/models/calendar/users/users.service.ts`~~ — deleted.
> go through better-auth, whose reset tokens expire after one hour. As with item 3, the
> text below still applies to the legacy calendar `users` table.
**File:** `src/models/calendar/users/users.service.ts``initiatePasswordReset` / `finalizePasswordReset` Same as item 3: `pw_reset_token_hash` never expired, and the code that set it no longer
exists. Password resets go through better-auth, whose reset tokens expire after one hour.
The reset token stored in `pw_reset_token_hash` never expires. Acceptable for a small, trusted userbase.
**Fix:**
1. Add a `pw_reset_expires` column to the `users` table (e.g. `DATETIME`).
2. Set it to `NOW() + INTERVAL 15 MINUTE` in `initiatePasswordReset`.
3. Check `pw_reset_expires > NOW()` in `finalizePasswordReset` before accepting the token.
+36 -46
View File
@@ -1,5 +1,13 @@
-- Local dev only. Real schema, provided directly by the repo owner -- Local dev only. Derived from the real schema, which was provided directly by
-- (calendars, events, event_versions, sessions, users) - not a guess. -- the repo owner (calendars, events, event_versions, and the sessions/users
-- tables that step 5 of docs/calendar-auth-migration.md renamed aside).
--
-- There is no users or sessions table here: a fresh dev database has no legacy
-- accounts to archive, so it starts where production ends up.
--
-- Changes made by this repo's own migrations under sql/calendar/ are folded in
-- here rather than appended, so a fresh dev container matches production once
-- every migration has been applied. Keep the two in step.
USE nachklang_calendar; USE nachklang_calendar;
CREATE TABLE `calendars` ( CREATE TABLE `calendars` (
@@ -9,41 +17,20 @@ CREATE TABLE `calendars` (
PRIMARY KEY (`calendar_id`) PRIMARY KEY (`calendar_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE `users` (
`user_id` int(11) NOT NULL AUTO_INCREMENT,
`full_name` text NOT NULL,
`password_hash` text DEFAULT NULL,
`email` text NOT NULL,
`is_active` tinyint(1) DEFAULT 0,
`pw_reset_token_hash` text DEFAULT NULL,
`activation_token` text DEFAULT NULL,
PRIMARY KEY (`user_id`),
UNIQUE KEY `email` (`email`) USING HASH
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE `sessions` (
`session_id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) NOT NULL,
`session_key_hash` text DEFAULT NULL,
`created_date` datetime DEFAULT current_timestamp(),
`valid_until` datetime DEFAULT (current_timestamp() + interval 30 day),
`last_ip` text DEFAULT NULL,
PRIMARY KEY (`session_id`),
KEY `sessions_users_user_id_fk` (`user_id`),
CONSTRAINT `sessions_users_user_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE `events` ( CREATE TABLE `events` (
`event_id` int(11) NOT NULL AUTO_INCREMENT, `event_id` int(11) NOT NULL AUTO_INCREMENT,
`calendar_id` int(11) NOT NULL, `calendar_id` int(11) NOT NULL,
`uuid` text NOT NULL, `uuid` text NOT NULL,
`created_date` datetime DEFAULT current_timestamp(), `created_date` datetime DEFAULT current_timestamp(),
`created_by_id` int(11) NOT NULL, -- Bridge to the admin module's user ids; see sql/calendar/001_add_admin_user_bridge.sql.
`created_by_user_id` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL,
-- Creator name, archived before the legacy users table went away;
-- see sql/calendar/002_snapshot_legacy_creator_names.sql.
`created_by_name` varchar(255) DEFAULT NULL,
PRIMARY KEY (`event_id`), PRIMARY KEY (`event_id`),
KEY `events_calendars_calendar_id_fk` (`calendar_id`), KEY `events_calendars_calendar_id_fk` (`calendar_id`),
KEY `events_users_user_id_fk` (`created_by_id`), KEY `events_created_by_user_idx` (`created_by_user_id`),
CONSTRAINT `events_calendars_calendar_id_fk` FOREIGN KEY (`calendar_id`) REFERENCES `calendars` (`calendar_id`), CONSTRAINT `events_calendars_calendar_id_fk` FOREIGN KEY (`calendar_id`) REFERENCES `calendars` (`calendar_id`)
CONSTRAINT `events_users_user_id_fk` FOREIGN KEY (`created_by_id`) REFERENCES `users` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE `event_versions` ( CREATE TABLE `event_versions` (
@@ -57,14 +44,16 @@ CREATE TABLE `event_versions` (
`repeat_frequency` text DEFAULT NULL, `repeat_frequency` text DEFAULT NULL,
`location` text DEFAULT NULL, `location` text DEFAULT NULL,
`url` text DEFAULT NULL, `url` text DEFAULT NULL,
`version_created_by_id` int(11) DEFAULT NULL, -- Bridge to the admin module's user ids; see sql/calendar/001_add_admin_user_bridge.sql.
`version_created_by_user_id` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL,
-- Archived editor name; see sql/calendar/002_snapshot_legacy_creator_names.sql.
`version_created_by_name` varchar(255) DEFAULT NULL,
`status` text DEFAULT NULL, `status` text DEFAULT NULL,
`version_created_at` datetime DEFAULT current_timestamp(), `version_created_at` datetime DEFAULT current_timestamp(),
PRIMARY KEY (`event_version_id`), PRIMARY KEY (`event_version_id`),
KEY `event_versions_events_event_id_fk` (`event_id`), KEY `event_versions_events_event_id_fk` (`event_id`),
KEY `event_versions_users_user_id_fk` (`version_created_by_id`), KEY `event_versions_created_by_user_idx` (`version_created_by_user_id`),
CONSTRAINT `event_versions_events_event_id_fk` FOREIGN KEY (`event_id`) REFERENCES `events` (`event_id`) ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT `event_versions_events_event_id_fk` FOREIGN KEY (`event_id`) REFERENCES `events` (`event_id`) ON DELETE CASCADE ON UPDATE CASCADE
CONSTRAINT `event_versions_users_user_id_fk` FOREIGN KEY (`version_created_by_id`) REFERENCES `users` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
INSERT INTO calendars (calendar_id, name, includes_calendars) VALUES INSERT INTO calendars (calendar_id, name, includes_calendars) VALUES
@@ -74,16 +63,17 @@ INSERT INTO calendars (calendar_id, name, includes_calendars) VALUES
(4, 'choir', '[]'), (4, 'choir', '[]'),
(5, 'birthdays', '[]'); (5, 'birthdays', '[]');
-- Dev admin, password: devpassword -- Two events carry only an archived creator name, as every pre-cutover event
INSERT INTO users (email, password_hash, full_name, is_active) VALUES -- does, and one carries an admin user id whose name is resolved live. Dev
('dev@nachklang.art', '$2b$10$vmj7POS/68SGE.eI7pGjMegrw0vNNZ2HVSUTra5NRsl8iOLwiMgZK', 'Dev Admin', 1); -- therefore exercises both name sources rather than only one. The one with an
-- id is deliberately a PUBLIC event, so the anonymous listing the website uses
-- covers both. The id is the dev admin from 04-admin-schema.sql.
INSERT INTO events (calendar_id, uuid, created_by_user_id, created_by_name) VALUES
(1, UUID(), NULL, 'Dev Admin'),
(1, UUID(), 'dev-user-0000-0000-0000-000000000001', NULL),
(1, UUID(), NULL, 'Dev Admin');
INSERT INTO events (calendar_id, uuid, created_by_id) VALUES INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, location, url, status, version_created_by_user_id, version_created_by_name) VALUES
(1, UUID(), 1), (1, 'Frühlingskonzert 2026', 'Erstes Konzert der Reihe', '2026-04-18 19:00:00', '2026-04-18 21:00:00', 0, 'Musikhochschule, Karlsruhe', 'https://www.nachklang.art/events/fruehlingskonzert-2026', 'PUBLIC', NULL, 'Dev Admin'),
(1, UUID(), 1), (2, 'Sommerkonzert 2026', 'Zweites Konzert der Reihe', '2026-07-11 19:00:00', '2026-07-11 21:00:00', 0, 'Christuskirche, Karlsruhe', 'https://www.nachklang.art/events/sommerkonzert-2026', 'PUBLIC', 'dev-user-0000-0000-0000-000000000001', NULL),
(1, UUID(), 1); (3, 'Adventskonzert 2026', 'Drittes Konzert der Reihe', '2026-12-05 19:00:00', '2026-12-05 21:00:00', 0, 'Stadtkirche, Karlsruhe', 'https://www.nachklang.art/events/adventskonzert-2026', 'DRAFT', NULL, 'Dev Admin');
INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, location, url, status, version_created_by_id) VALUES
(1, 'Frühlingskonzert 2026', 'Erstes Konzert der Reihe', '2026-04-18 19:00:00', '2026-04-18 21:00:00', 0, 'Musikhochschule, Karlsruhe', 'https://www.nachklang.art/events/fruehlingskonzert-2026', 'PUBLIC', 1),
(2, 'Sommerkonzert 2026', 'Zweites Konzert der Reihe', '2026-07-11 19:00:00', '2026-07-11 21:00:00', 0, 'Christuskirche, Karlsruhe', 'https://www.nachklang.art/events/sommerkonzert-2026', 'PUBLIC', 1),
(3, 'Adventskonzert 2026', 'Drittes Konzert der Reihe', '2026-12-05 19:00:00', '2026-12-05 21:00:00', 0, 'Stadtkirche, Karlsruhe', 'https://www.nachklang.art/events/adventskonzert-2026', 'DRAFT', 1);
+238 -34
View File
@@ -1,8 +1,27 @@
# Migrating the Calendar domain onto the admin identity module # Migrating the Calendar domain onto the admin identity module
Status: **not started.** Written 2026-09-05 alongside the admin module (step 2 of Status: **complete and deployed, 2026-09-06.** Every step is live. Step 2 was dropped by
`docs/plan-admin-auth.md` in the nachklang-admin repo), which deliberately left the decision and part of step 5 brought forward; the rest went out as written.
calendar alone.
The calendar now shares one identity with the tickets, feedback and admin apps: writes sit
behind `requireAppAccess('calendar')` against the shared session cookie, reads resolve that
cookie optionally, and the calendar's own `users`/`sessions` tables are renamed aside and
referenced by nothing. `DEFERRED_SECURITY.md` items 1, 3 and 4 closed with it.
Verified in production after the final deploy: the public calendar answers anonymously on all
three endpoints (23 events, 23 VEVENTs in the iCal feed, the next-event teaser intact),
restricted calendars still refuse without a credential, unauthenticated writes answer 401,
all six `/calendar/users/*` routes answer 404, CORS grants only `Content-Type`, sign-out from
`calendar.nachklang.art` succeeds, and **every event kept its author** - rendered from the
`created_by_name` snapshot, since the table it was copied from no longer exists under that
name. That last one is the whole reason part of step 5 was brought forward.
Remaining, at your leisure: `DROP TABLE sessions_legacy_archive, users_legacy_archive;`
Written 2026-09-05 alongside the admin module (step 2 of `docs/plan-admin-auth.md` in the
nachklang-admin repo), which deliberately left the calendar alone. Steps 1-4 of that plan
are now live, so the calendar is the last module still on the legacy query-parameter
sessions.
## Why the calendar was left out ## Why the calendar was left out
@@ -38,37 +57,222 @@ permissions can be granted before anything else moves.
Each step is meant to leave production working on its own. Each step is meant to leave production working on its own.
1. **Add a bridging column.** `ALTER TABLE events ADD COLUMN created_by_user_id 1. **Add a bridging column.** ~~`ALTER TABLE events ADD COLUMN created_by_user_id
VARCHAR(36) NULL`, indexed. Nothing reads it yet. VARCHAR(36) NULL`, indexed. Nothing reads it yet.~~ **Done 2026-09-06**, as
2. **Map the accounts.** For every legacy `users` row that should survive, invite the `sql/calendar/001_add_admin_user_bridge.sql` - the first migration this repo owns for the
person through the admin UI. On acceptance, backfill `events.created_by_user_id` from calendar schema, mirrored into `docker/init/01-calendar-schema-dev.sql`. It covers both
`events.created_by_id` via an email-to-new-id mapping. Everyone not re-invited keeps `events.created_by_user_id` and `event_versions.version_created_by_user_id`, and carries
working on the legacy path until step 4. no foreign key (see "What the code actually looks like" below). The dev seed leaves two
3. **Dual-read.** Change `events.service.ts` to prefer `created_by_user_id` and fall back events on the legacy path and gives one an admin id, so step 3's dual-read has both cases
to `created_by_id`. Writes fill both. This is the only step that is temporary code, and to exercise. Verified by applying the pre-migration schema and then the migration to a
it should carry a removal note pointing at step 5. throwaway MariaDB 11 container, and diffing `SHOW CREATE TABLE` against a fresh dev
4. **Switch the routes.** Replace the query-parameter session checks in schema: identical. Applied to the running dev database on the same day; a dev container
`events.router.ts` and `users.router.ts` with `requireAppAccess('calendar')`, and change created before then needs it applied, or recreating.
the Angular frontend to `withCredentials: true` against the same origin list. Deploy the 2. ~~**Map the accounts.**~~ **Dropped 2026-09-06.** There is no backfill: since the
API first; the calendar frontend is broken between the two deploys, so pick a quiet creator is only ever a display name (see below), old events keep resolving through the
time. This closes `DEFERRED_SECURITY.md` item 1. legacy join until step 5 and then simply lose the name. Re-inviting the people who
5. **Drop the legacy path.** Remove `users.service.ts`'s session handling, the `sessions` actually still need calendar access remains an operational task, but it is no longer a
table, `created_by_id`, and the dual-read from step 3. Legacy `/calendar/users/*` stays migration step and nothing is blocked on it.
only if something still calls it - otherwise delete it too. `X-Session-Id` / 3. **Dual-read.** ~~Change `events.service.ts` to prefer `created_by_user_id` and fall back
`X-Session-Key` can then come out of the CORS `allowedHeaders` list in to `created_by_id`. Writes fill both.~~ **Done 2026-09-06.** `events.service.ts` now reads
`src/app.factory.ts`. both columns and prefers the admin one, resolving the name through a single
`findDisplayNames` lookup against the admin database per result set (added to
`users.admin.service.ts` for this). Four copies of the same SELECT and four copies of the
row mapper were collapsed into one of each first - the dual read would otherwise have had
to be written four times.
A name now has three possible sources, tried weakest first: the legacy join, then the
`created_by_name` snapshot from migration 002, then the live admin lookup - which wins
because it is the only one that follows an account being renamed. An admin id that no
longer resolves falls back rather than blanking, and a failure to reach the admin database
is caught and logged rather than propagated, so an anonymous read of the public calendar
never depends on the admin database being up. Covered by
`test/calendar/events.service.test.ts`.
**Writes are not dual-written**, contrary to the original plan: before the cutover the
request only ever carries a legacy session, so there is no admin id available to write.
Writes start filling `created_by_user_id` (and stop filling `created_by_id`) in step 4.
4. **Switch the routes.** ~~Replace the query-parameter session checks in `events.router.ts`
and `users.router.ts` with `requireAppAccess('calendar')`, and change the Angular frontend
to `withCredentials: true`.~~ **Done 2026-09-06.** `DEFERRED_SECURITY.md` item 1 is closed:
no route reads `sessionId`/`sessionKey` any more.
How it came out, route by route:
- The four write routes sit behind `requireAppAccess('calendar')` as middleware. They
answer 401 when signed out and 403 without the permission, where they used to answer 403
for both.
- The three read routes cannot use middleware - the same URL serves an anonymous visitor,
an iCal subscription holding a shared password, and a signed-in editor who should see
drafts. They call `resolveAccess` optionally instead (`signedInEditor` in the router),
and a signed-in user *without* the calendar permission is treated as anonymous rather
than refused, so they keep their access to the public calendar.
- `credentials.service.ts` lost its session half entirely and is now just the password
table. `hasAccess(calendar, password)`.
- `/calendar/users/*` was left alone. Nothing calls it and a session it mints opens
nothing, but they are live password-accepting endpoints - step 5 removes them.
Also: `calendar.nachklang.art` joined `DEFAULT_APP_ORIGINS` (better-auth `trustedOrigins`,
without which sign-out from the calendar fails while everything else works), and
`localhost:4200` joined the dev origins for the same reason.
Two things this step had to carry that the original sequence put in step 5:
- **`sql/calendar/003_allow_null_legacy_creator.sql` makes `events.created_by_id` nullable**
(`MODIFY created_by_id INT NULL`). It is `NOT NULL` today, so the first event created after the
cutover would otherwise fail to insert - there is no legacy int id to write any more.
`event_versions.version_created_by_id` is already nullable. The foreign key can stay
until step 5; it permits NULL. It also re-runs 002's idempotent name backfill, to catch
anything created between the two migrations. Applying it early is safe - widening a
column to accept NULL cannot break the running pre-cutover build.
- **The public calendar stays anonymous.** `hasAccess('public')` returns true before any
credential check, and nachklang.art reads `/calendar/events/public/json` and
`/public/json/next` with no session at all. Pinned at both levels - the password table in
`test/calendar/credentials.service.test.ts`, the routes themselves in
`test/calendar/events.router.test.ts` - so this cannot regress quietly.
### Deploy checklist
Production has **none** of the three migrations: 001 and 002 were only ever applied to the
dev database. The API build below selects `created_by_user_id` and `created_by_name` on
every read, so deploying it against a database missing them fails every calendar request
including the anonymous public feed the website uses. In order:
1. **Apply `sql/calendar/001`, `002`, `003`, in that order**, against `CALENDAR_DB`. All
three are re-runnable, so applying one that is already applied is a no-op. Verify
before continuing:
`SHOW COLUMNS FROM events LIKE '%by_user%'; SHOW COLUMNS FROM events LIKE '%by_name%';`
- four rows across the two tables, and `created_by_id` nullable.
2. **Check `APP_ORIGINS` on the API vhost.** `calendar.nachklang.art` is in the code's
default list, but the environment variable *replaces* that list rather than adding to
it - so if it is set at all (the tickets/feedback cutover may have set it), append
`https://calendar.nachklang.art` or the calendar's sign-out will 403 while everything
else works. That is the failure mode the comment in `admin.config.ts` warns about.
3. **Deploy the API.**
4. **Deploy the calendar frontend immediately after.** Do not leave a gap - see below.
5. **Re-run 002's two `UPDATE` statements.** Between step 1 and step 3 the old API was
still writing `created_by_id` with no snapshot; those few rows would otherwise lose
their author at step 5.
6. **Rebuild the admin app** if `NEXT_PUBLIC_ALLOWED_REDIRECT_ORIGINS` does not already
contain `https://calendar.nachklang.art`. It is a **build-time** value, so a restart
does nothing.
**The window between steps 3 and 4 does not look broken, which is the danger.** The old
Angular bundle starts by calling `POST /calendar/users/checkSessionValid`, and those
legacy routes are untouched - so it still succeeds and the page renders as signed in. What
the user then sees is an empty event table and saves that silently do nothing. It looks
like the calendar lost its data, not like a deploy in progress. Keep the gap to minutes,
or take the frontend offline for it.
**One-way door:** any iCal subscription whose URL carries `?sessionId=&sessionKey=` rather
than `?password=` stops working permanently. The shared-password URLs are unaffected.
5. **Drop the legacy path.** **Deployed 2026-09-06.** Gated on step 4 being live, which it
was.
What went:
- `src/models/calendar/users/` in its entirety - registration, login, activation, both
password-reset routes, and the session checking the feedback and tickets admin areas
used to authenticate against - plus its mount in `Calendar.router.ts`. That was the
API's last unauthenticated account-creation and mail-sending endpoint.
- The two `LEFT OUTER JOIN users` clauses in `events.service.ts` and the `legacy_*`
aliases they fed, along with `created_by_id` / `version_created_by_id` in the SELECT, the
row mapper and the `Event` interface. One name source remains besides the live admin
lookup: the snapshot, which is what made this safe.
- `X-Session-Id` / `X-Session-Key` from the CORS `allowedHeaders`. Nothing had read them
since the tickets and feedback cutover, or sent them since this one.
- The tripwire in `test/admin/auth-binding.ts` asserting neither module fell back to a
calendar header session. There is nothing left to fall back to.
`DEFERRED_SECURITY.md` items **3** and **4** (activation and reset tokens never expiring)
close with it - not by adding expiries but by deleting the code that issued them.
### Deploy checklist — kept for the record; the order was REVERSED from step 4
Step 4's migration only added columns, so it went first. `004` *removes* columns and a
table that the currently running build still selects and joins, so running it first fails
every calendar read including the public feed. The step 5 build references none of them and
runs happily against the old schema. Therefore:
1. **Confirm the snapshot is complete.** Both must return 0:
```sql
SELECT SUM(created_by_id IS NOT NULL AND created_by_name IS NULL) FROM events;
SELECT SUM(version_created_by_id IS NOT NULL AND version_created_by_name IS NULL) FROM event_versions;
```
A non-zero count is an event whose author `004` would erase. Re-run 002's backfill first.
2. **Deploy the API.** No frontend deploy is needed: the calendar frontend never read
`createdById` (its `Event` model has only the name), and nothing else is known to.
3. **Confirm the calendar still works** - the public feed, a signed-in read, and one save.
At this point the old columns and tables still exist, unused, so this step is fully
reversible by redeploying the previous build.
Note that `tsc` does not remove output for deleted sources, so a build over an existing
`dist/` leaves `dist/src/models/calendar/users/*.js` behind. Nothing imports it and the
routes 404, but the deployed artifact still contains the code - clear `dist/` in the
pipeline if you want the artifact to match the source.
4. **Apply `sql/calendar/004_drop_legacy_auth.sql`.** This is the point of no return for
the columns; the accounts themselves are only renamed aside.
5. Optionally, later and at a quiet moment:
`DROP TABLE sessions_legacy_archive, users_legacy_archive;`
**One-way door:** `Event.createdById` and `lastModifiedById` leave the API response. Check
anything reading `/calendar/events/*/json` that is not the calendar frontend.
**Observed during the deploy, worth keeping.** A browser holding a *cached pre-cutover*
Angular bundle looked signed in and showed every event's status as "Error". The old bundle
called `/calendar/users/checkSessionValid`, which still existed between step 4 and step 5,
so it rendered as authenticated - then fetched events with no cookie, got the anonymous
listing, which omits `status`, and the UI's status switch fell through to its error label.
Signing out and back in fixed it. After this step that route 404s, so a stale bundle now
fails honestly instead of faking a session. This is the same "does not look broken" window
the step 4 checklist warns about, seen from the other side.
## What the code actually looks like (surveyed 2026-09-06)
Four things found while doing step 1 that change how the later steps should be built:
- **`created_by_id` is display-only.** Nothing authorises on it. `events.router.ts` gates
PUT, POST, DELETE and `/move` on `user?.isActive` alone - there is no "only the creator may
edit" rule anywhere - and the column is read back solely to render `created_by_name` and
`last_modified_by_name`. That de-risks steps 2, 3 and 5 considerably: an event whose
creator never gets re-invited loses a name in the UI, it does not become uneditable or
invisible. It also means the step 2 backfill is best-effort, not a precondition.
- **The two schemas are separate databases.** `nachklang_calendar` and `nachklang_admin`
have their own connection pools (`Calendar.db.ts` vs the admin module's Kysely instance).
So the bridging columns get no foreign key, and - the part the original sequence missed -
**the `LEFT OUTER JOIN users` that produces the creator's name cannot simply be repointed**.
It would have to become a cross-schema join, which hardcodes the admin database name into
calendar SQL and ties the two schemas together exactly as an FK would. Recommendation for
step 3: drop the join for the new path and resolve names in the service layer instead -
collect the distinct ids from the result set and do one lookup against the admin users
service. One extra query per listing, no coupling, and it keeps working if the admin
database ever moves.
- **`events.created_by_id` is `NOT NULL`.** Step 5 cannot simply stop writing it; that step
has to drop the column (and its FK to `users`) in the same migration that stops the writes,
or make it nullable first.
- **Every calendar read already hits the session table.** `/:calendar/json` calls
`UserService.checkSession` before falling back to `credentials.service.ts`, so the shared
credentials are the *fallback*, not the primary path. Step 4 replaces the first half of
that with `requireAppAccess('calendar')` and has to decide what happens to the second half
- which is the first open question below.
## Open questions to settle before starting ## Open questions to settle before starting
- **The shared calendar credentials.** Do `MEMBER_CREDENTIAL` and friends stay as a **Settled 2026-09-06:**
separate mechanism (they serve people with no account at all, and iCal clients that
cannot send headers), or do read-only accounts replace them? This is a product decision, - **The shared calendar credentials keep working, but only for iCal.** The web app goes
not a technical one, and it decides how much of `credentials.service.ts` survives. cookie-only at step 4; `MEMBER_CREDENTIAL` and friends survive on
- **The iCal export.** `GET /calendar/events/{calendar}/ical` takes a password in the query `GET /calendar/events/{calendar}/ical`, which is the one case where the client genuinely
string on purpose, because iCal clients cannot send headers. Cookie sessions do not help cannot send a cookie. Everything else in `credentials.service.ts` goes with step 5.
here; this endpoint likely keeps its own scheme. `public` stays anonymous everywhere - see the note under step 4.
- **Which legacy accounts to keep.** Step 2 is the moment to not re-invite people who no - **The iCal export keeps its own scheme.** Same reasoning; it is the reason the shared
longer need access. credentials survive at all rather than an exception to their removal.
- **`event_versions.version_created_by_id`.** The same INT reference again, joined in - **No account backfill.** See step 2 above.
`events.service.ts` for the "last modified by" name. It has to move with `events`, and it - **Pre-cutover authorship is archived, not discarded.** `events.created_by_name` and
is the reason step 1's bridging column needs a sibling on `event_versions`. `event_versions.version_created_by_name`, backfilled once by migration 002 and never
written again. This was originally listed as a step 5 question; it was brought forward so
the data is safe well before the table that holds it is dropped.
**Nothing is open.** The last one - ~~`event_versions.version_created_by_id`~~, the same INT
reference on the version rows - was handled in passing: step 1 gave it a sibling bridging
column, step 2 a sibling snapshot, and step 3 reads it exactly like `events`.
@@ -0,0 +1,38 @@
-- Nachklang e.V. Calendar module — step 1 of docs/calendar-auth-migration.md.
-- Adds the bridging columns that let an event record who created it as an
-- *admin* user id (VARCHAR(36)) alongside the legacy calendar users.user_id
-- (INT). Apply manually against the CALENDAR_DB database:
-- mysql -h <DB_HOST> -u <DB_USER> -p <CALENDAR_DB> < 001_add_admin_user_bridge.sql
--
-- Numbered 001 because this is the first migration this repo owns for the
-- calendar schema: the tables themselves predate it and were provided by the
-- repo owner (mirrored for dev in docker/init/01-calendar-schema-dev.sql).
--
-- Nothing reads these columns yet — step 3 introduces the dual-read. Adding
-- them first means the backfill in step 2 has somewhere to write, and this
-- migration can be applied to production on its own without any code change.
--
-- No foreign key, on purpose. The admin `user` table lives in a *different*
-- database (nachklang_admin) behind a different connection pool, and a
-- cross-schema FK would tie the two schemas' lifecycles together: you could no
-- longer dump, restore or move one without the other. The reference is
-- enforced in application code, which is also where the legacy/new fallback
-- lives.
--
-- The collation is pinned to the admin database's (utf8mb4_unicode_ci) rather
-- than inherited from the calendar tables' utf8mb4_general_ci. These columns
-- hold ids that only ever compare against nachklang_admin.user.id, and a
-- mismatched collation makes any such comparison fail at runtime with
-- "Illegal mix of collations" instead of at review time.
ALTER TABLE `events`
ADD COLUMN IF NOT EXISTS `created_by_user_id` VARCHAR(36)
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
NULL DEFAULT NULL AFTER `created_by_id`,
ADD KEY IF NOT EXISTS `events_created_by_user_idx` (`created_by_user_id`);
ALTER TABLE `event_versions`
ADD COLUMN IF NOT EXISTS `version_created_by_user_id` VARCHAR(36)
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
NULL DEFAULT NULL AFTER `version_created_by_id`,
ADD KEY IF NOT EXISTS `event_versions_created_by_user_idx` (`version_created_by_user_id`);
@@ -0,0 +1,42 @@
-- Nachklang e.V. Calendar module — step 5 preparation, brought forward.
-- Apply manually against the CALENDAR_DB database, after 001:
-- mysql -h <DB_HOST> -u <DB_USER> -p <CALENDAR_DB> < 002_snapshot_legacy_creator_names.sql
--
-- Snapshots the creator's and last editor's *name* onto the event itself.
--
-- Why: the creator is only ever rendered as a name (nothing authorises on it),
-- and today that name comes from joining the calendar's own `users` table.
-- Step 5 drops that table, which would silently erase the authorship of every
-- event created before the cutover. There is no account backfill to save them
-- either - that was dropped deliberately, see docs/calendar-auth-migration.md.
-- One text column per reference keeps the history at no ongoing cost.
--
-- These columns are an archive, not a source of truth. Nothing writes them
-- after this backfill: events created from the cutover onwards carry an admin
-- user id, whose name is resolved live so that renaming an account updates
-- everywhere. The read path prefers the live admin name, falls back to this
-- snapshot, and falls back again to the join until step 5 removes it.
--
-- The whole file is re-runnable: IF NOT EXISTS on the columns, and the backfill
-- only touches rows with no snapshot yet. Step 4's migration re-runs the
-- backfill, to catch anything created between this migration and the cutover.
--
-- No charset clause: unlike 001's id columns these hold display text that is
-- only ever compared against other calendar data, so they inherit the tables'
-- utf8mb4_general_ci like the columns they are copied from.
ALTER TABLE `events`
ADD COLUMN IF NOT EXISTS `created_by_name` VARCHAR(255) NULL DEFAULT NULL AFTER `created_by_user_id`;
ALTER TABLE `event_versions`
ADD COLUMN IF NOT EXISTS `version_created_by_name` VARCHAR(255) NULL DEFAULT NULL AFTER `version_created_by_user_id`;
UPDATE `events` e
JOIN `users` u ON u.user_id = e.created_by_id
SET e.created_by_name = u.full_name
WHERE e.created_by_name IS NULL;
UPDATE `event_versions` v
JOIN `users` u ON u.user_id = v.version_created_by_id
SET v.version_created_by_name = u.full_name
WHERE v.version_created_by_name IS NULL;
@@ -0,0 +1,32 @@
-- Nachklang e.V. Calendar module — step 4 of docs/calendar-auth-migration.md,
-- the cutover. Apply manually against the CALENDAR_DB database, after 002,
-- and BEFORE deploying the API build that goes with it:
-- mysql -h <DB_HOST> -u <DB_USER> -p <CALENDAR_DB> < 003_allow_null_legacy_creator.sql
--
-- From the cutover on, an event's creator is an admin-module user id. There is
-- no legacy calendar user id to write any more, and `events.created_by_id` is
-- NOT NULL - so without this the very first event created after the deploy
-- fails to insert. `event_versions.version_created_by_id` is already nullable.
--
-- The foreign key to `users` is kept: it permits NULL, so it costs nothing
-- until step 5 drops the column and the table together.
--
-- Applying this early is harmless. Widening a column to accept NULL cannot
-- break the running pre-cutover build, which always supplies a value, so this
-- can go out ahead of the deploy rather than during it.
ALTER TABLE `events`
MODIFY COLUMN `created_by_id` INT(11) NULL DEFAULT NULL;
-- Re-run of 002's backfill, to catch anything created between the two
-- migrations while the legacy path was still writing events. Idempotent by
-- construction: it only touches rows that have no snapshot yet.
UPDATE `events` e
JOIN `users` u ON u.user_id = e.created_by_id
SET e.created_by_name = u.full_name
WHERE e.created_by_name IS NULL;
UPDATE `event_versions` v
JOIN `users` u ON u.user_id = v.version_created_by_id
SET v.version_created_by_name = u.full_name
WHERE v.version_created_by_name IS NULL;
+60
View File
@@ -0,0 +1,60 @@
-- Nachklang e.V. Calendar module — step 5 of docs/calendar-auth-migration.md.
-- Apply manually against the CALENDAR_DB database, after 003:
-- mysql -h <DB_HOST> -u <DB_USER> -p <CALENDAR_DB> < 004_drop_legacy_auth.sql
--
-- *** APPLY THIS AFTER DEPLOYING THE API, NOT BEFORE. ***
--
-- This is the opposite order from the step 4 cutover, and getting it wrong by
-- analogy is the obvious mistake. Step 4's migration only *added* things, so it
-- was safe ahead of the deploy. This one removes columns and a table that the
-- currently running build still selects and joins - applying it first fails
-- every calendar read, including the anonymous public feed the website uses.
-- The step 5 build touches none of them, so it runs happily against the old
-- schema; deploy it, confirm the calendar works, then run this.
--
-- Nothing here loses information that is still reachable: the creators' display
-- names were snapshotted into events.created_by_name and
-- event_versions.version_created_by_name by migration 002, and the step 4
-- runbook re-ran that backfill after the deploy. Verify before running:
--
-- SELECT SUM(created_by_id IS NOT NULL AND created_by_name IS NULL) FROM events;
-- SELECT SUM(version_created_by_id IS NOT NULL AND version_created_by_name IS NULL) FROM event_versions;
--
-- Both must be 0. A non-zero count is an event whose author this migration
-- would erase; re-run 002's backfill first.
-- The foreign keys have to go before the columns they are declared on.
-- IF EXISTS so that a re-run after a partial failure gets past them.
ALTER TABLE `events`
DROP FOREIGN KEY IF EXISTS `events_users_user_id_fk`;
ALTER TABLE `event_versions`
DROP FOREIGN KEY IF EXISTS `event_versions_users_user_id_fk`;
ALTER TABLE `events`
DROP INDEX IF EXISTS `events_users_user_id_fk`,
DROP COLUMN IF EXISTS `created_by_id`;
ALTER TABLE `event_versions`
DROP INDEX IF EXISTS `event_versions_users_user_id_fk`,
DROP COLUMN IF EXISTS `version_created_by_id`;
-- The accounts themselves are renamed aside rather than dropped.
--
-- Nothing visible depends on them any more - the names are snapshotted, and no
-- code has referenced these tables since the step 4 cutover. But they still
-- hold e-mail addresses and password hashes, and a rename makes them
-- unreachable without destroying anything.
--
-- `sessions` has a foreign key into `users`; InnoDB rewires it to the new name
-- on rename, so after this it reads REFERENCES `users_legacy_archive` and the
-- pair stays internally consistent whichever order they are renamed in.
-- Verified on MariaDB 11.
--
-- Unlike the statements above this is not re-runnable, and that is the safe
-- behaviour: a second run fails on a missing `sessions` rather than doing
-- anything. Drop them for real whenever you like, at a moment when nobody is
-- mid-deploy:
-- DROP TABLE `sessions_legacy_archive`, `users_legacy_archive`;
RENAME TABLE `sessions` TO `sessions_legacy_archive`;
RENAME TABLE `users` TO `users_legacy_archive`;
+6 -3
View File
@@ -63,9 +63,12 @@ export const createApp = (): express.Application => {
// the dev machine's LAN IP, never "localhost"). Dev-only, same as above. // the dev machine's LAN IP, never "localhost"). Dev-only, same as above.
const lanIpRegex = /^http:\/\/(192\.168\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}):\d+$/; const lanIpRegex = /^http:\/\/(192\.168\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}):\d+$/;
app.use(cors({ app.use(cors({
// X-Session-* stay allowed until the calendar module is migrated off the // Content-Type alone. X-Session-Id / X-Session-Key were allowed here
// legacy header sessions (see docs/calendar-auth-migration.md). // through the two cutovers so that a browser still holding a pre-cutover
allowedHeaders: ['Content-Type', 'X-Session-Id', 'X-Session-Key'], // bundle got a clean 401 rather than a confusing CORS preflight failure.
// Nothing has read them since the first cutover and nothing has sent them
// since the second, so they came out with the rest of the legacy path.
allowedHeaders: ['Content-Type'],
// The admin session lives in a cookie, so browsers must be allowed to send // The admin session lives in a cookie, so browsers must be allowed to send
// it cross-origin - this is what makes credentials: 'include' work. // it cross-origin - this is what makes credentials: 'include' work.
credentials: true, credentials: true,
+5 -1
View File
@@ -33,7 +33,11 @@ const localhostOrigins = [
'http://localhost:3000', 'http://localhost:3000',
'http://localhost:3001', 'http://localhost:3001',
'http://localhost:3002', 'http://localhost:3002',
'http://localhost:3003' 'http://localhost:3003',
// The Angular calendar frontend; `ng serve` defaults to 4200. Missing from
// this list, sign-out from the calendar answers 403 in dev only, which is a
// confusing thing to debug against a production config that is fine.
'http://localhost:4200'
]; ];
const trustedOrigins = isProd const trustedOrigins = isProd
+23 -2
View File
@@ -73,8 +73,29 @@ const parseList = (value: string | undefined, fallback: string[]): string[] => {
return parsed.length > 0 ? parsed : fallback; return parsed.length > 0 ? parsed : fallback;
}; };
// The apps whose frontends may talk to /admin/* with credentials. /**
export const APP_ORIGINS = parseList(process.env.APP_ORIGINS, []).map(origin => origin.replace(/\/$/, '')); * The apps whose frontends may talk to /admin/* with credentials.
*
* They feed better-auth's `trustedOrigins`, which is what lets the tickets and
* feedback admin areas call /admin/auth/sign-out from their own origin. That
* became load-bearing with the step 4 cutover: before it, the only browser
* origin that ever reached /admin/auth was the admin app itself. The calendar
* joined them with its own cutover (docs/calendar-auth-migration.md step 4).
*
* Hence the production default rather than an empty list. An origin missing
* here fails in a way that is easy to misread - sign-in works, the app works,
* and only sign-out returns an origin error - so the two frontends we know
* about are named here and APP_ORIGINS overrides them for a staging host.
* Dev adds the localhost ports separately (see admin.auth.ts).
*/
const DEFAULT_APP_ORIGINS = [
'https://tickets.nachklang.art',
'https://feedback.nachklang.art',
'https://calendar.nachklang.art'
];
export const APP_ORIGINS = parseList(process.env.APP_ORIGINS, DEFAULT_APP_ORIGINS)
.map(origin => origin.replace(/\/$/, ''));
// Kept in sync by construction rather than by three separate lists: the admin // Kept in sync by construction rather than by three separate lists: the admin
// app itself always counts, and dev adds the local ports. // app itself always counts, and dev adds the local ports.
@@ -437,3 +437,30 @@ export const findUserByEmail = async (email: string): Promise<{id: string; email
return row ?? null; return row ?? null;
}; };
/**
* Display names for a set of user ids, as an id -> name map. Ids that no
* longer exist are simply absent from the map rather than mapping to a
* placeholder, so callers can distinguish "deleted account" from "never had
* one" and choose their own fallback.
*
* This exists for the calendar migration (docs/calendar-auth-migration.md
* step 3): the calendar lives in a different database, so it cannot join
* against `user` to render "created by". One lookup per result set keeps that
* cheap without coupling the two schemas.
*/
export const findDisplayNames = async (ids: readonly string[]): Promise<Map<string, string>> => {
const distinct = Array.from(new Set(ids.filter(id => id)));
if (distinct.length === 0) {
// Kysely renders `in ()` for an empty list, which MariaDB rejects.
return new Map();
}
const rows = await db
.selectFrom('user')
.select(['id', 'name'])
.where('id', 'in', distinct)
.execute();
return new Map(rows.map(row => [row.id, row.name]));
};
+7 -2
View File
@@ -5,7 +5,6 @@ import express, {Request, Response} from 'express';
import {Guid} from 'guid-typescript'; import {Guid} from 'guid-typescript';
import logger from '../../middleware/logger.js'; import logger from '../../middleware/logger.js';
import {eventsRouter} from './events/events.router.js'; import {eventsRouter} from './events/events.router.js';
import {usersRouter} from './users/users.router.js';
/** /**
* Router Definition * Router Definition
@@ -13,7 +12,13 @@ import {usersRouter} from './users/users.router.js';
export const calendarRouter = express.Router(); export const calendarRouter = express.Router();
calendarRouter.use('/events', eventsRouter); calendarRouter.use('/events', eventsRouter);
calendarRouter.use('/users', usersRouter);
/*
* There is no /calendar/users any more. It held this module's own accounts -
* registration, login, activation, password reset, and the session table the
* feedback and tickets admin areas used to authenticate against - and every one
* of those moved to the admin module. See docs/calendar-auth-migration.md.
*/
/** /**
@@ -1,73 +1,55 @@
import * as dotenv from 'dotenv'; import * as dotenv from 'dotenv';
import * as UserService from '../users/users.service.js';
dotenv.config(); dotenv.config();
/** /**
* Checks if the password gives admin privileges (view / create / edit / delete) * The shared calendar passwords, and nothing else.
* @param password *
* Before the step 4 cutover each function here also took a sessionId/sessionKey
* pair and checked it against the calendar's own sessions table, so "is this a
* signed-in user?" and "did they send the right shared password?" were tangled
* together in five places. Signed-in access is now decided by
* requireAppAccess('calendar') before the handler runs; what is left is the
* fallback for people who have no account at all.
*
* That fallback survives on purpose, for one reason: an iCal client subscribing
* to a calendar URL cannot send a cookie. Everything the Angular app does goes
* through the session cookie instead. See docs/calendar-auth-migration.md.
*
* `public` is deliberately open to everyone with no credential of any kind -
* nachklang.art reads it anonymously to show the next upcoming event. Pinned by
* test/calendar/credentials.service.test.ts.
*/ */
export const checkAdminPrivileges = async (sessionId: string, sessionKey: string, ip: string) => {
if(sessionId) {
let user = await UserService.checkSession(sessionId, sessionKey, ip);
return user?.isActive ?? false;
}
return false;
}
/** const credentialFor = (calendarName: string): string | undefined => {
* Checks if the password gives member view privileges
* @param password
*/
export const checkMemberPrivileges = async (sessionId: string, sessionKey: string, password: string, ip: string) => {
if(sessionId) {
let user = await UserService.checkSession(sessionId, sessionKey, ip);
return user?.isActive ?? false;
}
return password == process.env.MEMBER_CREDENTIAL;
}
/**
* Checks if the password gives choir view privileges
* @param password
*/
export const checkChoirPrivileges = async (sessionId: string, sessionKey: string, password: string, ip: string) => {
if(sessionId) {
let user = await UserService.checkSession(sessionId, sessionKey, ip);
return user?.isActive ?? false;
}
return password == process.env.CHOIR_CREDENTIAL;
}
/**
* Checks if the password gives management view privileges
* @param password
*/
export const checkManagementPrivileges = async (sessionId: string, sessionKey: string, password: string, ip: string) => {
if(sessionId) {
let user = await UserService.checkSession(sessionId, sessionKey, ip);
return user?.isActive ?? false;
}
return password == process.env.MANAGEMENT_CREDENTIAL;
}
export const hasAccess = async (calendarName: string, sessionId: string, sessionKey: string, password: string, ip: string) => {
switch (calendarName) { switch (calendarName) {
case 'public':
return true;
case 'members': case 'members':
return await checkMemberPrivileges(sessionId, sessionKey, password, ip); return process.env.MEMBER_CREDENTIAL;
case 'choir': case 'choir':
return await checkChoirPrivileges(sessionId, sessionKey, password, ip); case 'birthdays':
return process.env.CHOIR_CREDENTIAL;
case 'management': case 'management':
return await checkManagementPrivileges(sessionId, sessionKey, password, ip); return process.env.MANAGEMENT_CREDENTIAL;
case 'birthdays':
return await checkChoirPrivileges(sessionId, sessionKey, password, ip);
default: default:
return false; return undefined;
} }
} };
/**
* Whether the given shared password opens the given calendar. Answers false
* for an unknown calendar, and - importantly - for a calendar whose credential
* is not configured at all: an unset MEMBER_CREDENTIAL must not turn into
* "everyone with an empty password gets in".
*/
export const hasAccess = async (calendarName: string, password: string): Promise<boolean> => {
if (calendarName === 'public') {
return true;
}
const expected = credentialFor(calendarName);
if (!expected) {
return false;
}
return password === expected;
};
+15 -11
View File
@@ -14,7 +14,6 @@
* - endDateTime * - endDateTime
* - createdDate * - createdDate
* - location * - location
* - createdById
* - url * - url
* - wholeDay * - wholeDay
* properties: * properties:
@@ -65,18 +64,20 @@
* type: string * type: string
* description: The name of the user who created the event * description: The name of the user who created the event
* example: "John Doe" * example: "John Doe"
* createdById: * createdByUserId:
* type: integer * type: string
* description: The ID of the user who created the event * nullable: true
* example: 456 * description: The admin-module user id of the creator, once it has one
* example: "8f1c0f2e-0f1a-4b9e-9a7c-2d5f1b3c4d5e"
* lastModifiedBy: * lastModifiedBy:
* type: string * type: string
* description: The name of the user who last modified the event * description: The name of the user who last modified the event
* example: "John Doe" * example: "John Doe"
* lastModifiedById: * lastModifiedByUserId:
* type: integer * type: string
* description: The ID of the user who last modified the event * nullable: true
* example: 456 * description: The admin-module user id of the last editor, once it has one
* example: "8f1c0f2e-0f1a-4b9e-9a7c-2d5f1b3c4d5e"
* url: * url:
* type: string * type: string
* description: A URL with more information about the event * description: A URL with more information about the event
@@ -102,10 +103,13 @@ export interface Event {
createdDate: Date; createdDate: Date;
lastModifiedDate?: Date; lastModifiedDate?: Date;
location: string; location: string;
/** Display name of the creator: the live admin name when the id below
* resolves, otherwise the name archived before the legacy users table was
* removed. See docs/calendar-auth-migration.md. */
createdBy?: string; createdBy?: string;
createdById: number; createdByUserId?: string | null;
lastModifiedBy?: string; lastModifiedBy?: string;
lastModifiedById?: number; lastModifiedByUserId?: string | null;
url: string; url: string;
wholeDay: boolean; wholeDay: boolean;
repeatFrequency: string; repeatFrequency: string;
+201 -190
View File
@@ -7,7 +7,7 @@ import {Event} from './event.interface.js';
import * as EventService from './events.service.js'; import * as EventService from './events.service.js';
import * as iCalService from './icalgenerator.service.js'; import * as iCalService from './icalgenerator.service.js';
import * as CredentialService from './credentials.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 {Guid} from 'guid-typescript';
import logger from '../../../middleware/logger.js'; import logger from '../../../middleware/logger.js';
@@ -29,6 +29,44 @@ export const calendarNames = new Map<string, any>([
['birthdays', {id: 5, name: 'Nachklang_birthday_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<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 * Controller Definitions
@@ -39,7 +77,10 @@ export const calendarNames = new Map<string, any>([
* /calendar/events/{calendar}/json: * /calendar/events/{calendar}/json:
* get: * get:
* summary: Get all events from a specific calendar in JSON format * 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: * tags:
* - calendar * - calendar
* parameters: * parameters:
@@ -48,23 +89,13 @@ export const calendarNames = new Map<string, any>([
* required: true * required: true
* schema: * schema:
* type: string * type: string
* enum: [public, members, choir, management] * enum: [public, members, choir, management, birthdays]
* description: The name of the calendar to get events from * description: The name of the calendar to get events from
* - in: query * - 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 * name: password
* schema: * schema:
* type: string * type: string
* description: Password for calendar access (if not using session authentication) * description: The calendar's shared password, for callers with no account
* responses: * responses:
* 200: * 200:
* description: Success * description: Success
@@ -109,10 +140,7 @@ eventsRouter.get('/:calendar/json', async (req: Request, res: Response) => {
try { try {
// Get request params // Get request params
let calendarName: string = req.params.calendar as string ?? ''; 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 password: string = req.query.password as string ?? '';
let ip: string = req.socket.remoteAddress ?? '';
if (calendarName.length < 1) { if (calendarName.length < 1) {
res.status(400).send({'message': 'Please state the name of the calendar you want events from.'}); 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 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 // Not signed in: fall back to the shared password for this calendar.
if(user === null || !user.isActive) { if (!editor && ! await CredentialService.hasAccess(calendarName, password)) {
if (! await CredentialService.hasAccess(calendarName, sessionId, sessionKey, password, ip)) { res.status(403).send({'message': 'You do not have access to the specified calendar.'});
res.status(403).send({'message': 'You do not have access to the specified calendar.'}); return;
return;
}
} }
let events: Event[]; // Editors get the admin view (drafts included, calendar includes ignored);
// everyone else gets published events only.
if(user?.isActive) { let events: Event[] = editor
events = await EventService.getAllEventsAdmin(calendarId); ? await EventService.getAllEventsAdmin(calendarId)
} else { : await EventService.getAllEvents(calendarId);
events = await EventService.getAllEvents(calendarId);
}
// Send the events back // Send the events back
res.status(200).send(events); res.status(200).send(events);
@@ -158,7 +182,10 @@ eventsRouter.get('/:calendar/json', async (req: Request, res: Response) => {
* /calendar/events/{calendar}/json/next: * /calendar/events/{calendar}/json/next:
* get: * get:
* summary: Get the next upcoming event from a calendar * 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: * tags:
* - calendar * - calendar
* parameters: * parameters:
@@ -167,23 +194,13 @@ eventsRouter.get('/:calendar/json', async (req: Request, res: Response) => {
* required: true * required: true
* schema: * schema:
* type: string * 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 * description: The name of the calendar to get the next event from
* - in: query * - 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 * name: password
* schema: * schema:
* type: string * type: string
* description: Password for calendar access (if not using session authentication) * description: The calendar's shared password, for callers with no account
* responses: * responses:
* 200: * 200:
* description: Success * description: Success
@@ -242,10 +259,7 @@ eventsRouter.get('/:calendar/json/next', async (req: Request, res: Response) =>
try { try {
// Get request params // Get request params
let calendarName: string = req.params.calendar as string ?? ''; 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 password: string = req.query.password as string ?? '';
let ip: string = req.socket.remoteAddress ?? '';
if (calendarName.length < 1) { if (calendarName.length < 1) {
res.status(400).send({'message': 'Please state the name of the calendar you want events from.'}); 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; 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.'}); res.status(403).send({'message': 'You do not have access to the specified calendar.'});
return; return;
} }
@@ -290,7 +316,10 @@ eventsRouter.get('/:calendar/json/next', async (req: Request, res: Response) =>
* /calendar/events/{calendar}/ical: * /calendar/events/{calendar}/ical:
* get: * get:
* summary: Get all events from a specific calendar in iCal format * 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: * tags:
* - calendar * - calendar
* parameters: * parameters:
@@ -299,23 +328,13 @@ eventsRouter.get('/:calendar/json/next', async (req: Request, res: Response) =>
* required: true * required: true
* schema: * schema:
* type: string * type: string
* enum: [public, members, choir, management] * enum: [public, members, choir, management, birthdays]
* description: The name of the calendar to get events from * description: The name of the calendar to get events from
* - in: query * - 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 * name: password
* schema: * schema:
* type: string * type: string
* description: Password for calendar access (if not using session authentication) * description: The calendar's shared password, for callers with no account
* responses: * responses:
* 200: * 200:
* description: Success - returns iCal file * description: Success - returns iCal file
@@ -365,10 +384,7 @@ eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => {
try { try {
// Get request params // Get request params
let calendarName: string = req.params.calendar as string ?? ''; 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 password: string = req.query.password as string ?? '';
let ip: string = req.socket.remoteAddress ?? '';
if (calendarName.length < 1) { if (calendarName.length < 1) {
res.status(400).send({'message': 'Please state the name of the calendar you want events from.'}); 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; 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.'}); res.status(403).send({'message': 'You do not have access to the specified calendar.'});
return; return;
} }
@@ -413,22 +441,11 @@ eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => {
* /calendar/events: * /calendar/events:
* post: * post:
* summary: Create a new event * 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: * tags:
* - calendar * - calendar
* parameters: * security:
* - in: query * - AdminSessionCookie: []
* 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: * requestBody:
* required: true * required: true
* content: * content:
@@ -495,16 +512,32 @@ eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => {
* message: * message:
* type: string * type: string
* example: Required parameters missing * example: Required parameters missing
* 403: * 401:
* description: Forbidden - no access to create events * description: Unauthorized - not signed in
* content: * content:
* application/json: * application/json:
* schema: * schema:
* type: object * type: object
* properties: * properties:
* status:
* type: string
* example: UNAUTHORIZED
* message: * message:
* type: string * 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: * 500:
* description: Server error * description: Server error
* content: * content:
@@ -522,19 +555,9 @@ eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => {
* type: string * type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3 * example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/ */
eventsRouter.post('/', async (req: Request, res: Response) => { eventsRouter.post('/', requireCalendarAccess, async (req: Request, res: Response) => {
try { try {
// Get params const admin = adminOf(res);
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;
}
if ( if (
req.body.calendarId === undefined || req.body.calendarId === undefined ||
@@ -556,7 +579,9 @@ eventsRouter.post('/', async (req: Request, res: Response) => {
endDateTime: new Date(req.body.endDateTime), endDateTime: new Date(req.body.endDateTime),
createdDate: new Date(), createdDate: new Date(),
location: req.body.location ?? '', 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 ?? '', url: req.body.url ?? '',
wholeDay: req.body.wholeDay ?? false, wholeDay: req.body.wholeDay ?? false,
repeatFrequency: req.body.repeatFrequency ?? '', repeatFrequency: req.body.repeatFrequency ?? '',
@@ -585,9 +610,11 @@ eventsRouter.post('/', async (req: Request, res: Response) => {
* /calendar/events/{eventId}: * /calendar/events/{eventId}:
* put: * put:
* summary: Update an existing event * 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: * tags:
* - calendar * - calendar
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - in: path * - in: path
* name: eventId * name: eventId
@@ -595,18 +622,6 @@ eventsRouter.post('/', async (req: Request, res: Response) => {
* schema: * schema:
* type: integer * type: integer
* description: The ID of the event to update * 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: * requestBody:
* required: true * required: true
* content: * content:
@@ -639,9 +654,6 @@ eventsRouter.post('/', async (req: Request, res: Response) => {
* location: * location:
* type: string * type: string
* example: "Musikhochschule, Karlsruhe" * example: "Musikhochschule, Karlsruhe"
* createdBy:
* type: string
* example: "John Doe"
* url: * url:
* type: string * type: string
* example: "https://www.nachklang.art/events/concert" * example: "https://www.nachklang.art/events/concert"
@@ -673,16 +685,32 @@ eventsRouter.post('/', async (req: Request, res: Response) => {
* message: * message:
* type: string * type: string
* example: Required parameters missing * example: Required parameters missing
* 403: * 401:
* description: Forbidden - no access to update events * description: Unauthorized - not signed in
* content: * content:
* application/json: * application/json:
* schema: * schema:
* type: object * type: object
* properties: * properties:
* status:
* type: string
* example: UNAUTHORIZED
* message: * message:
* type: string * 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: * 500:
* description: Server error * description: Server error
* content: * content:
@@ -700,19 +728,9 @@ eventsRouter.post('/', async (req: Request, res: Response) => {
* type: string * type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3 * example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/ */
eventsRouter.put('/:eventId', async (req: Request, res: Response) => { eventsRouter.put('/:eventId', requireCalendarAccess, async (req: Request, res: Response) => {
try { try {
// Get params const admin = adminOf(res);
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;
}
if ( if (
req.params.eventId === undefined || req.params.eventId === undefined ||
@@ -735,8 +753,9 @@ eventsRouter.put('/:eventId', async (req: Request, res: Response) => {
endDateTime: new Date(req.body.endDateTime), endDateTime: new Date(req.body.endDateTime),
createdDate: new Date(), createdDate: new Date(),
location: req.body.location ?? '', location: req.body.location ?? '',
createdBy: req.body.createdBy ?? '', // LEGACY createdById is deliberately not set: there is no calendar
createdById: user.userId ?? -1, // user id any more, and migration 003 made the column nullable.
createdByUserId: admin.id,
url: req.body.url ?? '', url: req.body.url ?? '',
wholeDay: req.body.wholeDay ?? false, wholeDay: req.body.wholeDay ?? false,
repeatFrequency: req.body.repeatFrequency ?? '', repeatFrequency: req.body.repeatFrequency ?? '',
@@ -768,9 +787,11 @@ eventsRouter.put('/:eventId', async (req: Request, res: Response) => {
* /calendar/events/move/{eventId}: * /calendar/events/move/{eventId}:
* put: * put:
* summary: Move an event to a different calendar * 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: * tags:
* - calendar * - calendar
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - in: path * - in: path
* name: eventId * name: eventId
@@ -778,18 +799,6 @@ eventsRouter.put('/:eventId', async (req: Request, res: Response) => {
* schema: * schema:
* type: integer * type: integer
* description: The ID of the event to move * 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: * requestBody:
* required: true * required: true
* content: * content:
@@ -820,9 +829,6 @@ eventsRouter.put('/:eventId', async (req: Request, res: Response) => {
* location: * location:
* type: string * type: string
* example: "Musikhochschule, Karlsruhe" * example: "Musikhochschule, Karlsruhe"
* createdBy:
* type: string
* example: "John Doe"
* url: * url:
* type: string * type: string
* example: "https://www.nachklang.art/events/concert" * example: "https://www.nachklang.art/events/concert"
@@ -854,16 +860,32 @@ eventsRouter.put('/:eventId', async (req: Request, res: Response) => {
* message: * message:
* type: string * type: string
* example: Required parameters missing * example: Required parameters missing
* 403: * 401:
* description: Forbidden - no access to move events * description: Unauthorized - not signed in
* content: * content:
* application/json: * application/json:
* schema: * schema:
* type: object * type: object
* properties: * properties:
* status:
* type: string
* example: UNAUTHORIZED
* message: * message:
* type: string * 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: * 500:
* description: Server error * description: Server error
* content: * content:
@@ -881,19 +903,9 @@ eventsRouter.put('/:eventId', async (req: Request, res: Response) => {
* type: string * type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3 * 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 { try {
// Get params const admin = adminOf(res);
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;
}
if ( if (
req.params.eventId === undefined || req.params.eventId === undefined ||
@@ -913,8 +925,9 @@ eventsRouter.put('/move/:eventId', async (req: Request, res: Response) => {
endDateTime: new Date(req.body.endDateTime), endDateTime: new Date(req.body.endDateTime),
createdDate: new Date(), createdDate: new Date(),
location: req.body.location ?? '', location: req.body.location ?? '',
createdBy: req.body.createdBy ?? '', // LEGACY createdById is deliberately not set: there is no calendar
createdById: user.userId ?? -1, // user id any more, and migration 003 made the column nullable.
createdByUserId: admin.id,
url: req.body.url ?? '', url: req.body.url ?? '',
wholeDay: req.body.wholeDay ?? false, wholeDay: req.body.wholeDay ?? false,
repeatFrequency: req.body.repeatFrequency ?? '', repeatFrequency: req.body.repeatFrequency ?? '',
@@ -944,9 +957,11 @@ eventsRouter.put('/move/:eventId', async (req: Request, res: Response) => {
* /calendar/events/{eventId}: * /calendar/events/{eventId}:
* delete: * delete:
* summary: Delete an event * 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: * tags:
* - calendar * - calendar
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - in: path * - in: path
* name: eventId * name: eventId
@@ -954,18 +969,6 @@ eventsRouter.put('/move/:eventId', async (req: Request, res: Response) => {
* schema: * schema:
* type: integer * type: integer
* description: The ID of the event to delete * 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: * responses:
* 200: * 200:
* description: Event deleted successfully * description: Event deleted successfully
@@ -987,16 +990,32 @@ eventsRouter.put('/move/:eventId', async (req: Request, res: Response) => {
* message: * message:
* type: string * type: string
* example: Required parameters missing * example: Required parameters missing
* 403: * 401:
* description: Forbidden - no access to delete events * description: Unauthorized - not signed in
* content: * content:
* application/json: * application/json:
* schema: * schema:
* type: object * type: object
* properties: * properties:
* status:
* type: string
* example: UNAUTHORIZED
* message: * message:
* type: string * 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: * 500:
* description: Server error * description: Server error
* content: * content:
@@ -1014,19 +1033,9 @@ eventsRouter.put('/move/:eventId', async (req: Request, res: Response) => {
* type: string * type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3 * example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/ */
eventsRouter.delete('/:eventId', async (req: Request, res: Response) => { eventsRouter.delete('/:eventId', requireCalendarAccess, async (req: Request, res: Response) => {
try { try {
// Get params const admin = adminOf(res);
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;
}
if ( if (
req.params.eventId === undefined req.params.eventId === undefined
@@ -1046,7 +1055,9 @@ eventsRouter.delete('/:eventId', async (req: Request, res: Response) => {
createdDate: new Date(), createdDate: new Date(),
location: '', location: '',
createdBy: '', 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: '', url: '',
wholeDay: false, wholeDay: false,
repeatFrequency: '', repeatFrequency: '',
+164 -159
View File
@@ -2,28 +2,42 @@ import * as dotenv from 'dotenv';
import {Guid} from 'guid-typescript'; import {Guid} from 'guid-typescript';
import {Event} from './event.interface.js'; import {Event} from './event.interface.js';
import {NachklangCalendarDB} from '../Calendar.db.js'; import {NachklangCalendarDB} from '../Calendar.db.js';
import * as AdminUsersService from '../../admin/users/users.admin.service.js';
import logger from '../../../middleware/logger.js';
dotenv.config(); dotenv.config();
/** /**
* Returns all events for the given calendar * How an event's creator is resolved, after step 5 of
* @param calendarId The calendar Id * docs/calendar-auth-migration.md removed the legacy path.
*
* The creator is only ever rendered as a name - nothing authorises on it, there
* is no "only the creator may edit" rule anywhere - which is why an unresolvable
* one degrades to blank rather than to an error.
*
* Two sources remain, weaker first:
*
* 1. `created_by_name`, a snapshot of the name as it stood when the calendar
* had its own `users` table. Migration 002 took it, migration 004 dropped
* the table it was taken from, and nothing has written it since. It exists
* so the authorship of pre-cutover events survived that removal.
* 2. The admin module's `user.name`, looked up live for rows carrying an admin
* id. It wins, because it is the only one that follows a rename.
*
* The third source - joining the calendar's own `users` table on
* `created_by_id` - is gone with the table and the column.
*/ */
export const getAllEvents = async (calendarId: number): Promise<Event[]> => {
let conn = await NachklangCalendarDB.getConnection();
let eventRows: Event[] = [];
try {
const calendarQuery = 'SELECT calendar_id, includes_calendars FROM calendars WHERE calendar_id = ?';
const calendarRes = await conn.query(calendarQuery, calendarId);
let calendarsToFetch: number[] = [calendarId];
for(let row of calendarRes) {
let includes: number[] = JSON.parse(row.includes_calendars);
calendarsToFetch = [...calendarsToFetch, ...includes];
}
const eventsQuery = ` /**
SELECT e.calendar_id, e.uuid, e.created_date, e.created_by_id, u.full_name as created_by_name, u2.full_name as last_modified_by_name, v.* FROM events e * The one SELECT the four read paths share; callers append their own WHERE and
* ORDER BY. `v.*` carries the version row's own creator columns, so only the
* `events` columns need naming.
*
* There are no joins to a users table any more. There is no users table.
*/
const EVENT_SELECT = `
SELECT e.calendar_id, e.uuid, e.created_date, e.created_by_user_id, e.created_by_name, v.* FROM events e
INNER JOIN ( INNER JOIN (
SELECT event_id, MAX(event_version_id) AS latest_version SELECT event_id, MAX(event_version_id) AS latest_version
FROM event_versions FROM event_versions
@@ -31,36 +45,120 @@ export const getAllEvents = async (calendarId: number): Promise<Event[]> => {
) latest_versions ) latest_versions
ON e.event_id = latest_versions.event_id ON e.event_id = latest_versions.event_id
INNER JOIN event_versions v INNER JOIN event_versions v
ON v.event_id = latest_versions.event_id AND v.event_version_id = latest_versions.latest_version ON v.event_id = latest_versions.event_id AND v.event_version_id = latest_versions.latest_version`;
LEFT OUTER JOIN users u ON u.user_id = e.created_by_id
LEFT OUTER JOIN users u2 ON u2.user_id = v.version_created_by_id
WHERE e.calendar_id IN (?) AND v.status = 'PUBLIC'
ORDER BY e.event_id`;
const eventsRes = await conn.query(eventsQuery, [calendarsToFetch]);
for (let row of eventsRes) { /**
eventRows.push({ * Maps a result row to an Event. `status` is included only where it always
eventId: row.event_id, * was: the admin views and the by-id lookup return it, the two public listings
calendarId: row.calendar_id, * do not.
uuid: row.uuid, */
name: row.name, const toEvent = (row: any, includeStatus: boolean): Event => {
description: row.description, const event: Event = {
startDateTime: row.start_datetime, eventId: row.event_id,
endDateTime: row.end_datetime, calendarId: row.calendar_id,
createdDate: row.created_date, uuid: row.uuid,
lastModifiedDate: row.version_created_at, name: row.name,
location: row.location, description: row.description,
createdBy: row.created_by_name, startDateTime: row.start_datetime,
createdById: row.created_by_id, endDateTime: row.end_datetime,
lastModifiedBy: row.last_modified_by_name, createdDate: row.created_date,
lastModifiedById: row.version_created_by_id, lastModifiedDate: row.version_created_at,
url: row.url, location: row.location,
wholeDay: row.whole_day, // The archived name. resolveAdminNames below overwrites it for rows that
repeatFrequency: row.repeat_frequency // carry an admin id, which is the only source that follows a rename.
}); createdBy: row.created_by_name,
createdByUserId: row.created_by_user_id ?? null,
lastModifiedBy: row.version_created_by_name,
lastModifiedByUserId: row.version_created_by_user_id ?? null,
url: row.url,
wholeDay: row.whole_day,
repeatFrequency: row.repeat_frequency
};
if (includeStatus) {
event.status = row.status;
}
return event;
};
/**
* Fills in creator/editor names for rows that carry an admin user id, by way
* of a single lookup against the admin database. The calendar cannot join
* against `user` - it is a different schema behind a different pool - and
* making it one would tie the two schemas together as tightly as a foreign key
* would.
*
* A failure here is swallowed on purpose. These endpoints include the public
* calendar the website reads anonymously, and a name is decoration: if the
* admin database is unreachable, an event should still render with whatever
* the snapshot holds rather than 500 the whole listing. The alternative
* would widen the public calendar's blast radius to include the admin
* database, which it has never depended on before.
*/
const resolveAdminNames = async (events: Event[]): Promise<void> => {
const ids = events
.flatMap(event => [event.createdByUserId, event.lastModifiedByUserId])
.filter((id): id is string => Boolean(id));
if (ids.length === 0) {
return;
}
let names: Map<string, string>;
try {
names = await AdminUsersService.findDisplayNames(ids);
} catch (e: any) {
logger.warn('Calendar: could not resolve creator names from the admin database: ' + e.message);
return;
}
for (const event of events) {
const createdBy = event.createdByUserId ? names.get(event.createdByUserId) : undefined;
if (createdBy) {
event.createdBy = createdBy;
} }
return eventRows; const lastModifiedBy = event.lastModifiedByUserId ? names.get(event.lastModifiedByUserId) : undefined;
if (lastModifiedBy) {
event.lastModifiedBy = lastModifiedBy;
}
}
};
/**
* The calendars a listing has to cover: the requested one plus whatever it
* declares in `includes_calendars`.
*/
const calendarsToFetch = async (conn: any, calendarId: number): Promise<number[]> => {
const calendarQuery = 'SELECT calendar_id, includes_calendars FROM calendars WHERE calendar_id = ?';
const calendarRes = await conn.query(calendarQuery, calendarId);
let calendars: number[] = [calendarId];
for (let row of calendarRes) {
let includes: number[] = JSON.parse(row.includes_calendars);
calendars = [...calendars, ...includes];
}
return calendars;
};
/**
* Returns all events for the given calendar
* @param calendarId The calendar Id
*/
export const getAllEvents = async (calendarId: number): Promise<Event[]> => {
let conn = await NachklangCalendarDB.getConnection();
try {
const calendars = await calendarsToFetch(conn, calendarId);
const eventsQuery = `${EVENT_SELECT}
WHERE e.calendar_id IN (?) AND v.status = 'PUBLIC'
ORDER BY e.event_id`;
const eventsRes = await conn.query(eventsQuery, [calendars]);
const events = eventsRes.map((row: any) => toEvent(row, false));
await resolveAdminNames(events);
return events;
} catch (err) { } catch (err) {
throw err; throw err;
} finally { } finally {
@@ -76,48 +174,16 @@ export const getAllEvents = async (calendarId: number): Promise<Event[]> => {
*/ */
export const getAllEventsAdmin = async (calendarId: number): Promise<Event[]> => { export const getAllEventsAdmin = async (calendarId: number): Promise<Event[]> => {
let conn = await NachklangCalendarDB.getConnection(); let conn = await NachklangCalendarDB.getConnection();
let eventRows: Event[] = [];
try { try {
const eventsQuery = ` const eventsQuery = `${EVENT_SELECT}
SELECT e.calendar_id, e.uuid, e.created_date, e.created_by_id, u.full_name as created_by_name, u2.full_name as last_modified_by_name, v.* FROM events e
INNER JOIN (
SELECT event_id, MAX(event_version_id) AS latest_version
FROM event_versions
GROUP BY event_id
) latest_versions
ON e.event_id = latest_versions.event_id
INNER JOIN event_versions v
ON v.event_id = latest_versions.event_id AND v.event_version_id = latest_versions.latest_version
LEFT OUTER JOIN users u ON u.user_id = e.created_by_id
LEFT OUTER JOIN users u2 ON u2.user_id = v.version_created_by_id
WHERE e.calendar_id = ? WHERE e.calendar_id = ?
ORDER BY e.event_id`; ORDER BY e.event_id`;
const eventsRes = await conn.query(eventsQuery, calendarId); const eventsRes = await conn.query(eventsQuery, calendarId);
for (let row of eventsRes) { const events = eventsRes.map((row: any) => toEvent(row, true));
eventRows.push({ await resolveAdminNames(events);
eventId: row.event_id,
calendarId: row.calendar_id,
uuid: row.uuid,
name: row.name,
description: row.description,
startDateTime: row.start_datetime,
endDateTime: row.end_datetime,
createdDate: row.created_date,
lastModifiedDate: row.version_created_at,
location: row.location,
createdBy: row.created_by_name,
createdById: row.created_by_id,
lastModifiedBy: row.last_modified_by_name,
lastModifiedById: row.version_created_by_id,
url: row.url,
wholeDay: row.whole_day,
repeatFrequency: row.repeat_frequency,
status: row.status
});
}
return eventRows; return events;
} catch (err) { } catch (err) {
throw err; throw err;
} finally { } finally {
@@ -136,18 +202,7 @@ export const getAllEventsAdmin = async (calendarId: number): Promise<Event[]> =>
export const getEventById = async (eventId: number): Promise<Event | null> => { export const getEventById = async (eventId: number): Promise<Event | null> => {
let conn = await NachklangCalendarDB.getConnection(); let conn = await NachklangCalendarDB.getConnection();
try { try {
const eventsQuery = ` const eventsQuery = `${EVENT_SELECT}
SELECT e.calendar_id, e.uuid, e.created_date, e.created_by_id, u.full_name as created_by_name, u2.full_name as last_modified_by_name, v.* FROM events e
INNER JOIN (
SELECT event_id, MAX(event_version_id) AS latest_version
FROM event_versions
GROUP BY event_id
) latest_versions
ON e.event_id = latest_versions.event_id
INNER JOIN event_versions v
ON v.event_id = latest_versions.event_id AND v.event_version_id = latest_versions.latest_version
LEFT OUTER JOIN users u ON u.user_id = e.created_by_id
LEFT OUTER JOIN users u2 ON u2.user_id = v.version_created_by_id
WHERE e.event_id = ?`; WHERE e.event_id = ?`;
const eventsRes = await conn.query(eventsQuery, eventId); const eventsRes = await conn.query(eventsQuery, eventId);
@@ -155,27 +210,10 @@ export const getEventById = async (eventId: number): Promise<Event | null> => {
return null; return null;
} }
const row = eventsRes[0]; const event = toEvent(eventsRes[0], true);
return { await resolveAdminNames([event]);
eventId: row.event_id,
calendarId: row.calendar_id, return event;
uuid: row.uuid,
name: row.name,
description: row.description,
startDateTime: row.start_datetime,
endDateTime: row.end_datetime,
createdDate: row.created_date,
lastModifiedDate: row.version_created_at,
location: row.location,
createdBy: row.created_by_name,
createdById: row.created_by_id,
lastModifiedBy: row.last_modified_by_name,
lastModifiedById: row.version_created_by_id,
url: row.url,
wholeDay: row.whole_day,
repeatFrequency: row.repeat_frequency,
status: row.status
} as Event;
} catch (err) { } catch (err) {
throw err; throw err;
} finally { } finally {
@@ -193,11 +231,11 @@ export const createEvent = async (event: Event): Promise<number> => {
try { try {
await conn.beginTransaction(); await conn.beginTransaction();
let eventUUID = Guid.create().toString(); let eventUUID = Guid.create().toString();
const eventsQuery = 'INSERT INTO events (calendar_id, uuid, created_by_id) VALUES (?,?,?) RETURNING event_id'; const eventsQuery = 'INSERT INTO events (calendar_id, uuid, created_by_user_id) VALUES (?,?,?) RETURNING event_id';
const eventsRes = await conn.execute(eventsQuery, [event.calendarId, eventUUID, event.createdById]); const eventsRes = await conn.execute(eventsQuery, [event.calendarId, eventUUID, event.createdByUserId ?? null]);
const versionQuery = 'INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, repeat_frequency, location, url, status, version_created_by_id) VALUES (?,?,?,?,?,?,?,?,?,?,?);' const versionQuery = 'INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, repeat_frequency, location, url, status, version_created_by_user_id) VALUES (?,?,?,?,?,?,?,?,?,?,?);'
await conn.execute(versionQuery, [eventsRes[0].event_id, event.name, event.description, event.startDateTime, event.endDateTime, event.wholeDay, event.repeatFrequency, event.location, event.url, event.status, event.createdById]); await conn.execute(versionQuery, [eventsRes[0].event_id, event.name, event.description, event.startDateTime, event.endDateTime, event.wholeDay, event.repeatFrequency, event.location, event.url, event.status, event.createdByUserId ?? null]);
await conn.commit(); await conn.commit();
@@ -218,8 +256,8 @@ export const updateEvent = async (event: Event): Promise<number> => {
let conn = await NachklangCalendarDB.getConnection(); let conn = await NachklangCalendarDB.getConnection();
try { try {
await conn.beginTransaction(); await conn.beginTransaction();
const versionQuery = 'INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, repeat_frequency, location, url, status, version_created_by_id) VALUES (?,?,?,?,?,?,?,?,?,?,?);' const versionQuery = 'INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, repeat_frequency, location, url, status, version_created_by_user_id) VALUES (?,?,?,?,?,?,?,?,?,?,?);'
const versionRes = await conn.execute(versionQuery, [event.eventId, event.name, event.description, event.startDateTime, event.endDateTime, event.wholeDay, event.repeatFrequency, event.location, event.url, event.status, event.createdById]); const versionRes = await conn.execute(versionQuery, [event.eventId, event.name, event.description, event.startDateTime, event.endDateTime, event.wholeDay, event.repeatFrequency, event.location, event.url, event.status, event.createdByUserId ?? null]);
await conn.commit(); await conn.commit();
@@ -240,8 +278,8 @@ export const deleteEvent = async (event: Event): Promise<boolean> => {
let conn = await NachklangCalendarDB.getConnection(); let conn = await NachklangCalendarDB.getConnection();
try { try {
await conn.beginTransaction(); await conn.beginTransaction();
const versionQuery = 'INSERT INTO event_versions (event_id, status, version_created_by_id) VALUES (?,?,?);' const versionQuery = 'INSERT INTO event_versions (event_id, status, version_created_by_user_id) VALUES (?,?,?);'
const versionRes = await conn.execute(versionQuery, [event.eventId, 'DELETED', event.createdById]); const versionRes = await conn.execute(versionQuery, [event.eventId, 'DELETED', event.createdByUserId ?? null]);
await conn.commit(); await conn.commit();
@@ -283,56 +321,23 @@ export const moveEvent = async (event: Event): Promise<boolean> => {
export const getNextUpcomingEvent = async (calendarId: number): Promise<Event | null> => { export const getNextUpcomingEvent = async (calendarId: number): Promise<Event | null> => {
let conn = await NachklangCalendarDB.getConnection(); let conn = await NachklangCalendarDB.getConnection();
try { try {
const calendarQuery = 'SELECT calendar_id, includes_calendars FROM calendars WHERE calendar_id = ?'; const calendars = await calendarsToFetch(conn, calendarId);
const calendarRes = await conn.query(calendarQuery, calendarId);
let calendarsToFetch: number[] = [calendarId];
for(let row of calendarRes) {
let includes: number[] = JSON.parse(row.includes_calendars);
calendarsToFetch = [...calendarsToFetch, ...includes];
}
const now = new Date(); const now = new Date();
const eventsQuery = ` const eventsQuery = `${EVENT_SELECT}
SELECT e.calendar_id, e.uuid, e.created_date, e.created_by_id, u.full_name as created_by_name, u2.full_name as last_modified_by_name, v.* FROM events e
INNER JOIN (
SELECT event_id, MAX(event_version_id) AS latest_version
FROM event_versions
GROUP BY event_id
) latest_versions
ON e.event_id = latest_versions.event_id
INNER JOIN event_versions v
ON v.event_id = latest_versions.event_id AND v.event_version_id = latest_versions.latest_version
LEFT OUTER JOIN users u ON u.user_id = e.created_by_id
LEFT OUTER JOIN users u2 ON u2.user_id = v.version_created_by_id
WHERE e.calendar_id IN (?) AND v.status = 'PUBLIC' AND v.start_datetime > ? WHERE e.calendar_id IN (?) AND v.status = 'PUBLIC' AND v.start_datetime > ?
ORDER BY v.start_datetime ASC ORDER BY v.start_datetime ASC
LIMIT 1`; LIMIT 1`;
const eventsRes = await conn.query(eventsQuery, [calendarsToFetch, now]); const eventsRes = await conn.query(eventsQuery, [calendars, now]);
if (eventsRes.length === 0) { if (eventsRes.length === 0) {
return null; return null;
} }
const row = eventsRes[0]; const event = toEvent(eventsRes[0], false);
return { await resolveAdminNames([event]);
eventId: row.event_id,
calendarId: row.calendar_id, return event;
uuid: row.uuid,
name: row.name,
description: row.description,
startDateTime: row.start_datetime,
endDateTime: row.end_datetime,
createdDate: row.created_date,
lastModifiedDate: row.version_created_at,
location: row.location,
createdBy: row.created_by_name,
createdById: row.created_by_id,
lastModifiedBy: row.last_modified_by_name,
lastModifiedById: row.version_created_by_id,
url: row.url,
wholeDay: row.whole_day,
repeatFrequency: row.repeat_frequency
} as Event;
} catch (err) { } catch (err) {
throw err; throw err;
} finally { } finally {
@@ -1,53 +0,0 @@
/**
* @swagger
* components:
* schemas:
* Session:
* type: object
* required:
* - sessionId
* - userId
* - sessionKey
* - sessionKeyHash
* - lastIP
* properties:
* sessionId:
* type: integer
* description: The unique identifier for the session
* example: 789
* userId:
* type: integer
* description: The ID of the user this session belongs to
* example: 456
* sessionKey:
* type: string
* description: The session key used for authentication
* example: "abc123def456"
* sessionKeyHash:
* type: string
* description: The hashed session key (not returned in API responses)
* example: "$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG"
* createdDate:
* type: string
* format: date-time
* description: The date and time when the session was created
* example: "2023-05-01T10:00:00.000Z"
* validUntil:
* type: string
* format: date-time
* description: The date and time until when the session is valid
* example: "2023-05-08T10:00:00.000Z"
* lastIP:
* type: string
* description: The last IP address used with this session
* example: "192.168.1.1"
*/
export interface Session {
sessionId: number;
userId: number;
sessionKey: string;
sessionKeyHash: string;
createdDate?: Date;
validUntil?: Date;
lastIP: string;
}
@@ -1,42 +0,0 @@
/**
* @swagger
* components:
* schemas:
* User:
* type: object
* required:
* - userId
* - fullName
* - passwordHash
* - email
* - isActive
* properties:
* userId:
* type: integer
* description: The unique identifier for the user
* example: 456
* fullName:
* type: string
* description: The full name of the user
* example: "John Doe"
* passwordHash:
* type: string
* description: The hashed password of the user (not returned in API responses)
* example: "$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG"
* email:
* type: string
* format: email
* description: The email address of the user
* example: "john.doe@nachklang.art"
* isActive:
* type: boolean
* description: Whether the user account is active
* example: true
*/
export interface User {
userId: number;
fullName: string;
passwordHash: string;
email: string;
isActive: boolean;
}
-671
View File
@@ -1,671 +0,0 @@
/**
* Required External Modules and Interfaces
*/
import express, {Request, Response} from 'express';
import * as UserService from './users.service.js';
import {Session} from './session.interface.js';
import {User} from './user.interface.js';
import {Guid} from 'guid-typescript';
import logger from '../../../middleware/logger.js';
/**
* Router Definition
*/
export const usersRouter = express.Router();
/**
* Controller Definitions
*/
/**
* @swagger
* /calendar/users/register:
* post:
* summary: Register a new user
* description: Creates a new user account with the provided email, password, and full name. Only accepts official Nachklang email addresses.
* tags:
* - calendar
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - email
* - password
* - fullName
* properties:
* email:
* type: string
* format: email
* example: john.doe@nachklang.art
* description: Must be an official Nachklang email address
* password:
* type: string
* format: password
* example: securePassword123
* fullName:
* type: string
* example: John Doe
* responses:
* 201:
* description: User registered successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: integer
* example: 123
* sessionKey:
* type: string
* example: abc123def456
* 400:
* description: Bad request - missing or invalid parameters
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: Missing parameters
* 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
*/
// POST users/register
usersRouter.post('/register', async (req: Request, res: Response) => {
try {
const password: string = req.body.password;
const email: string = req.body.email;
const fullName: string = req.body.fullName;
const ip: string = req.socket.remoteAddress ?? '';
if (!password || !email || !fullName) {
// Missing
res.status(400).send(JSON.stringify({message: 'Missing parameters'}));
return;
}
const emailRegex = /^[a-zA-Z0-9\_\-\.]+@nachklang\.art$/;
if(!emailRegex.test(email)) {
res.status(400).send(JSON.stringify({message: 'Must use an official Nachklang email address'}));
return;
}
// Create the user and a session
const session: Session = await UserService.createUser(email, password, fullName, ip);
// Send the session details back to the user
res.status(201).send({
sessionId: session.sessionId,
sessionKey: session.sessionKey
});
} 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/users/activate:
* get:
* summary: Activate a user account
* description: Activates a user account using the provided user ID and activation token.
* tags:
* - calendar
* parameters:
* - in: query
* name: id
* required: true
* schema:
* type: integer
* description: The ID of the user to activate
* - in: query
* name: token
* required: true
* schema:
* type: string
* description: The activation token sent to the user's email
* responses:
* 200:
* description: User activated successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: OK
* message:
* type: string
* example: User activated
* 400:
* description: Bad request - missing parameters or activation failed
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: PROCESSING_ERROR
* message:
* type: string
* example: Error activating user. Please contact your administrator.
* 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
*/
// GET /users/activate
usersRouter.get('/activate', async (req: Request, res: Response) => {
try {
const userId: number = parseInt(req.query.id as string ?? '-1', 10);
const token: string = req.query.token as string ?? '';
if (!userId || !token) {
// Missing
res.status(400).send(JSON.stringify({message: 'Missing parameters'}));
return;
}
// Create the user and a session
const success: boolean = await UserService.activateUser(userId, token);
// Send the session details back to the user
if(success) {
res.status(200).send({
'status': 'OK',
'message': 'User activated'
});
return;
}
res.status(400).send({'status': 'PROCESSING_ERROR','message': 'Error activating user. Please contact your administrator.'});
} 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/users/login:
* post:
* summary: Login a user
* description: Authenticates a user with the provided email and password and returns a session.
* tags:
* - calendar
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - email
* - password
* properties:
* email:
* type: string
* format: email
* example: john.doe@nachklang.art
* password:
* type: string
* format: password
* example: securePassword123
* responses:
* 200:
* description: Login successful
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: integer
* example: 123
* sessionKey:
* type: string
* example: abc123def456
* 400:
* description: Bad request - missing parameters
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: Missing parameters
* 401:
* description: Unauthorized - invalid credentials
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: Wrong username and / or password
* sessionId:
* type: integer
* example: -1
* sessionKey:
* type: string
* example: ""
* 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
*/
// POST users/login
usersRouter.post('/login', async (req: Request, res: Response) => {
try {
const password: string = req.body.password;
const email: string = req.body.email;
const ip: string = req.socket.remoteAddress ?? '';
if (!password || !email) {
// Missing
res.status(400).send(JSON.stringify({message: 'Missing parameters'}));
return;
}
// Create a session
const session: Session | null = await UserService.login(email, password, ip);
if (!session || !session.sessionId) {
// Error logging in, probably wrong username / password
res.status(401).send(JSON.stringify({message: 'Wrong username and / or password', sessionId: -1, sessionKey: ''}));
return;
}
// Send the session details back to the user
res.status(200).send({
sessionId: session.sessionId,
sessionKey: session.sessionKey
});
} 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/users/checkSessionValid:
* post:
* summary: Check if a session is valid
* description: Checks if the provided session is valid and returns the user information if it is.
* tags:
* - calendar
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - sessionId
* - sessionKey
* properties:
* sessionId:
* type: integer
* example: 123
* sessionKey:
* type: string
* example: abc123def456
* responses:
* 200:
* description: Session is valid
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/User'
* 401:
* description: Unauthorized - invalid session
* content:
* application/json:
* schema:
* type: object
* properties:
* messages:
* type: array
* items:
* type: string
* example: ["Invalid session"]
* 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
*/
// POST users/checkSessionValid
usersRouter.post('/checkSessionValid', async (req: Request, res: Response) => {
try {
const ip: string = req.socket.remoteAddress ?? '';
const session_id = req.body.sessionId;
const session_key = req.body.sessionKey;
if (!session_id || !session_key) {
// Error logging in, probably wrong username / password
res.status(401).send(JSON.stringify({messages: ['No session detected']}));
return;
}
const user: User | null = await UserService.checkSession(session_id, session_key, ip);
if (!user || !user.userId) {
// Error logging in, probably wrong username / password
res.status(401).send(JSON.stringify({messages: ['Invalid session']}));
return;
}
res.status(200).send(user);
} 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/users/initiatePasswordReset:
* post:
* summary: Initiates a password reset
* description: Checks if the user exists and if so, initiates a password reset by sending an email to the user.
* tags:
* - calendar
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* type: object
* properties:
* messages:
* type: array
* items:
* type: string
* example: Success
* description: A list of status messages
* 400:
* description: Problem with the request. Please consider the returned detailed error.
* content:
* application/json:
* schema:
* type: object
* properties:
* messages:
* type: array
* items:
* type: string
* example: Missing parameters
* description: A list of error messages
* 401:
* description: Problem with authorizing the user. Please check the provided credentials.
* content:
* application/json:
* schema:
* type: object
* properties:
* messages:
* type: array
* items:
* type: string
* example: Invalid session
* description: A list of error messages
* 500:
* description: A server error occurred. Please try again. If this issue persists, contact the admin.
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* description: The response status
* example: PROCESSING_ERROR
* message:
* type: string
* description: The detailed error message
* example: Internal Server Error. Try again later.
* reference:
* type: string
* description: An error reference for getting support concerning this error.
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* email:
* type: string
* example: patrick@nachklang.art
*/
usersRouter.post('/initiatePasswordReset', async(req: Request, res: Response) => {
try {
const username = req.body.username;
if (!username) {
// Error logging in, probably wrong username / password
res.status(400).send(JSON.stringify({messages: ['No username given']}));
return;
}
const success: boolean = await UserService.initiatePasswordReset(username);
if (!success) {
// Error logging in, probably wrong username / password
res.status(401).send(JSON.stringify({messages: ['Error']}));
return;
}
res.status(200).send(JSON.stringify({messages: ['Success']}));
} 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/users/finalizePasswordReset:
* post:
* summary: Finalizes the password reset
* description: Checks if the given token is valid and if so, finalizes the password reset by setting the new password.
* tags:
* - calendar
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* type: object
* properties:
* messages:
* type: array
* items:
* type: string
* example: Success
* description: A list of status messages
* 400:
* description: Problem with the request. Please consider the returned detailed error.
* content:
* application/json:
* schema:
* type: object
* properties:
* messages:
* type: array
* items:
* type: string
* example: Missing parameters
* description: A list of error messages
* 401:
* description: Problem with authorizing the user. Please check the provided credentials.
* content:
* application/json:
* schema:
* type: object
* properties:
* messages:
* type: array
* items:
* type: string
* example: Invalid session
* description: A list of error messages
* 500:
* description: A server error occurred. Please try again. If this issue persists, contact the admin.
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* description: The response status
* example: PROCESSING_ERROR
* message:
* type: string
* description: The detailed error message
* example: Internal Server Error. Try again later.
* reference:
* type: string
* description: An error reference for getting support concerning this error.
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* email:
* type: string
* example: patrick@nachklang.art
* token:
* type: string
* example: 3ccd147f-720b-4e29-a8b7-46b63de31555
* password:
* type: string
* example: ExtremelyBadPassword
*/
usersRouter.post('/finalizePasswordReset', async(req: Request, res: Response) => {
try {
const username = req.body.username;
const token = req.body.token;
const newPassword = req.body.password;
if (!username) {
// Error logging in, probably wrong username / password
res.status(400).send(JSON.stringify({messages: ['No username, token or password given']}));
return;
}
const success: boolean = await UserService.finalizePasswordReset(username, token, newPassword);
if (!success) {
// Error logging in, probably wrong username / password
res.status(401).send(JSON.stringify({messages: ['Error']}));
return;
}
res.status(200).send(JSON.stringify({messages: ['Success']}));
} 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
});
}
});
-296
View File
@@ -1,296 +0,0 @@
import * as dotenv from 'dotenv';
import bcrypt from 'bcrypt';
import {Guid} from 'guid-typescript';
import {User} from './user.interface.js';
import {Session} from './session.interface.js';
import {NachklangCalendarDB} from '../Calendar.db.js';
import {MailService} from '../../../common/common.mail.js';
dotenv.config();
/**
* Data Model Interfaces
*/
/**
* Service Methods
*/
/**
* Creates a user record in the database, also creates a session. Returns the session if successful.
*/
export const createUser = async (email: string, password: string, fullName: string, ip: string): Promise<Session> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
// Hash password and generate + hash session key
const pwHash = bcrypt.hashSync(password, 10);
const sessionKey = Guid.create().toString();
const sessionKeyHash = bcrypt.hashSync(sessionKey, 10);
const activationToken = Guid.create().toString();
const activationTokenHash = bcrypt.hashSync(activationToken, 10);
// Create user entry in SQL
const userQuery = 'INSERT INTO users (email, password_hash, full_name, activation_token) VALUES (?, ?, ?, ?) RETURNING user_id';
const userIdRes = await conn.query(userQuery, [email, pwHash, fullName, activationTokenHash]);
// Get user id of the created user
let userId: number = -1;
for (const row of userIdRes) {
userId = row.user_id;
}
// Create session
const sessionQuery = 'INSERT INTO sessions (user_id, session_key_hash, created_date, valid_until, last_ip) VALUES (?,?,NOW(),DATE_ADD(NOW(), INTERVAL 30 DAY),?) RETURNING session_id';
const sessionIdRes = await conn.query(sessionQuery, [userId, sessionKeyHash, ip]);
await conn.commit();
// Get session id of the created session
let sessionId: number = -1;
for (const row of sessionIdRes) {
sessionId = row.session_id;
}
// Send email with activation link (after commit so we don't block on email
// delivery). sendMail never throws on a delivery failure - it logs and
// returns false - so a mail-server problem here can't roll back the
// already-committed user and leave registration reporting a false error.
await MailService.sendMail(email, 'Activate your Nachklang account', `Hi ${fullName},\n\nPlease click on the following link to activate your account:\n\nhttps://api.nachklang.art/calendar/users/activate?id=${userId}&token=${activationToken}`);
return {
sessionId: sessionId,
userId: userId,
sessionKey: sessionKey,
sessionKeyHash: 'HIDDEN',
lastIP: ip
};
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
export const activateUser = async (userId: number, token: string): Promise<boolean> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
const checkTokenQuery = 'SELECT user_id, activation_token FROM users WHERE user_id = ? AND is_active = 0';
const userNameRes = await conn.query(checkTokenQuery, [userId]);
let storedTokenHash = '';
for (const row of userNameRes) {
storedTokenHash = row.activation_token;
}
if (!storedTokenHash || !bcrypt.compareSync(token, storedTokenHash)) {
return false;
}
const activateQuery = 'UPDATE users SET is_active = 1, activation_token = null WHERE user_id = ?';
const activateRes = await conn.execute(activateQuery, [userId]);
await conn.commit();
return activateRes.affectedRows !== 0;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
}
/**
* Checks if the given credentials are valid and creates a new session if they are.
* Returns the session information in case of a successful login
*/
export const login = async (email: string, password: string, ip: string): Promise<Session | null> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
// Get saved password hash
const query = 'SELECT user_id, password_hash FROM users WHERE email = ?';
const userRows = await conn.query(query, email);
let savedHash = '';
let userId = -1;
for (const row of userRows) {
savedHash = row.password_hash;
userId = row.user_id;
}
// Check for correct password
if (!bcrypt.compareSync(password, savedHash)) {
return null;
}
// Generate + hash session key
const sessionKey = Guid.create().toString();
const sessionKeyHash = bcrypt.hashSync(sessionKey, 10);
// Create session
const sessionQuery = 'INSERT INTO sessions (user_id, session_key_hash, created_date, valid_until, last_ip) VALUES (?,?,NOW(),DATE_ADD(NOW(), INTERVAL 30 DAY),?) RETURNING session_id';
const sessionIdRes = await conn.query(sessionQuery, [userId, sessionKeyHash, ip]);
await conn.commit();
// Get session id of the created session
let sessionId: number = -1;
for (const row of sessionIdRes) {
sessionId = row.session_id;
}
return {
sessionId: sessionId,
userId: userId,
sessionKey: sessionKey,
sessionKeyHash: 'HIDDEN',
lastIP: ip
};
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
/**
* Checks if the given session information are valid and returns the user information if they are
*/
export const checkSession = async (sessionId: string, sessionKey: string, ip: string): Promise<User | null> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
// Get saved session key hash
const query = 'SELECT user_id, session_key_hash, valid_until FROM sessions WHERE session_id = ?';
const sessionRows = await conn.query(query, sessionId);
let savedHash = '';
let userId = -1;
let validUntil = new Date();
for (const row of sessionRows) {
savedHash = row.session_key_hash;
userId = row.user_id;
validUntil = row.valid_until;
}
// Check for correct key
if (!bcrypt.compareSync(sessionKey, savedHash)) {
return null;
}
// Check if the session is still valid
if (validUntil <= new Date()) {
return null;
}
// Update session entry in SQL
const updateSessionsQuery = 'UPDATE sessions SET last_IP = ? WHERE session_id = ?';
await conn.query(updateSessionsQuery, [ip, sessionId]);
await conn.commit();
// Get the other required user information
const userQuery = 'SELECT user_id, email, full_name, is_active FROM users WHERE user_id = ?';
const userRows = await conn.query(userQuery, userId);
let email = '';
let fullName = '';
let is_active = false;
for (const row of userRows) {
email = row.email;
fullName = row.full_name;
is_active = row.is_active;
}
// Everything is fine, return user information
return {
userId: userId,
email: email,
passwordHash: 'HIDDEN',
fullName: fullName,
isActive: is_active
};
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
export const initiatePasswordReset = async (email: string): Promise<boolean> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
const checkUsernameQuery = 'SELECT user_id, full_Name FROM users WHERE email = ?';
const userNameRes = await conn.query(checkUsernameQuery, [email]);
if (userNameRes.length === 0) {
return false;
}
let userId: number = -1;
let fullName: string = '';
for(let row of userNameRes) {
userId = row.user_id;
fullName = row.full_Name;
}
let resetToken = Guid.create().toString();
let resetTokenHash = bcrypt.hashSync(resetToken, 10);
const updateQuery = 'UPDATE users SET pw_reset_token_hash = ? WHERE user_id = ?';
const updateRes = await conn.execute(updateQuery, [resetTokenHash, userId]);
if(updateRes.affectedRows === 0) {
return false;
}
await conn.commit();
await MailService.sendMail(email, 'Password Reset', `Hello ${fullName},\n\nYou requested a password reset for your BonkApp account. If you did not request this, please ignore this email.\n\nTo reset your password, please use the following reset token:\n\n${resetToken}`);
return true;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
}
export const finalizePasswordReset = async (email: string, token: string, newPassword: string): Promise<boolean> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
const checkTokenQuery = 'SELECT user_id, pw_reset_token_hash FROM users WHERE email = ?';
const userNameRes = await conn.query(checkTokenQuery, [email]);
if (userNameRes.length === 0) {
return false;
}
let userId: string = '';
let tokenHash: string = '';
for(let row of userNameRes) {
userId = row.user_id;
tokenHash = row.pw_reset_token_hash;
}
if(!bcrypt.compareSync(token, tokenHash)) {
return false;
}
const pwHash = bcrypt.hashSync(newPassword, 10);
const updatePasswordQuery = 'UPDATE users SET password_hash = ?, pw_reset_token_hash = NULL WHERE user_id = ?';
const updateRes = await conn.execute(updatePasswordQuery, [pwHash, userId]);
if(updateRes.affectedRows > 0) {
await conn.commit();
return true;
}
return false;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
}
@@ -1,19 +1,6 @@
/** /**
* @swagger * @swagger
* components: * components:
* parameters:
* SessionIdHeader:
* in: header
* name: X-Session-Id
* required: true
* schema:
* type: string
* SessionKeyHeader:
* in: header
* name: X-Session-Key
* required: true
* schema:
* type: string
* schemas: * schemas:
* EventAdminSummary: * EventAdminSummary:
* type: object * type: object
+8 -5
View File
@@ -26,9 +26,8 @@ adminRouter.use(requireAdminAuth);
* summary: Validate the current admin session * summary: Validate the current admin session
* description: Used by the Next.js middleware/proxy to gate /admin. Returns the authenticated admin's identity. * description: Used by the Next.js middleware/proxy to gate /admin. Returns the authenticated admin's identity.
* tags: [feedback-admin] * tags: [feedback-admin]
* parameters: * security:
* - $ref: '#/components/parameters/SessionIdHeader' * - AdminSessionCookie: []
* - $ref: '#/components/parameters/SessionKeyHeader'
* responses: * responses:
* 200: * 200:
* description: Success * description: Success
@@ -43,6 +42,8 @@ adminRouter.use(requireAdminAuth);
* type: string * type: string
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
adminRouter.get('/me', (req: Request, res: Response) => { adminRouter.get('/me', (req: Request, res: Response) => {
res.status(200).send({email: res.locals.admin.email, fullName: res.locals.admin.displayName}); res.status(200).send({email: res.locals.admin.email, fullName: res.locals.admin.displayName});
@@ -55,9 +56,9 @@ adminRouter.get('/me', (req: Request, res: Response) => {
* summary: Delete a single submission * summary: Delete a single submission
* description: Removes the submission and everything under it (its answers, guest book entry, newsletter signup) - for removing an individual abusive or inappropriate entry. Not a bulk moderation tool. * description: Removes the submission and everything under it (its answers, guest book entry, newsletter signup) - for removing an individual abusive or inappropriate entry. Not a bulk moderation tool.
* tags: [feedback-admin] * tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: submissionId * name: submissionId
* required: true * required: true
@@ -70,6 +71,8 @@ adminRouter.get('/me', (req: Request, res: Response) => {
* description: Unknown submission * description: Unknown submission
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
adminRouter.delete('/submissions/:submissionId', async (req: Request, res: Response) => { adminRouter.delete('/submissions/:submissionId', async (req: Request, res: Response) => {
try { try {
@@ -18,9 +18,8 @@ export const eventsAdminRouter = express.Router();
* summary: List all events (admin) * summary: List all events (admin)
* description: All events, published or not, past or future, with submission counts. * description: All events, published or not, past or future, with submission counts.
* tags: [feedback-admin] * tags: [feedback-admin]
* parameters: * security:
* - $ref: '#/components/parameters/SessionIdHeader' * - AdminSessionCookie: []
* - $ref: '#/components/parameters/SessionKeyHeader'
* responses: * responses:
* 200: * 200:
* description: Success * description: Success
@@ -32,13 +31,14 @@ export const eventsAdminRouter = express.Router();
* $ref: '#/components/schemas/EventAdminSummary' * $ref: '#/components/schemas/EventAdminSummary'
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
* post: * post:
* summary: Create an event * summary: Create an event
* description: Auto-generates the slug from the name and event year; defaults feedback_deadline to event_date + 14 days 23:59:59 unless supplied. * description: Auto-generates the slug from the name and event year; defaults feedback_deadline to event_date + 14 days 23:59:59 unless supplied.
* tags: [feedback-admin] * tags: [feedback-admin]
* parameters: * security:
* - $ref: '#/components/parameters/SessionIdHeader' * - AdminSessionCookie: []
* - $ref: '#/components/parameters/SessionKeyHeader'
* requestBody: * requestBody:
* required: true * required: true
* content: * content:
@@ -68,6 +68,8 @@ export const eventsAdminRouter = express.Router();
* description: Missing required fields * description: Missing required fields
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
eventsAdminRouter.get('/', async (req: Request, res: Response) => { eventsAdminRouter.get('/', async (req: Request, res: Response) => {
try { try {
@@ -101,9 +103,9 @@ eventsAdminRouter.post('/', async (req: Request, res: Response) => {
* summary: Get one event (admin) * summary: Get one event (admin)
* description: Full event detail including setlist and assigned questions. * description: Full event detail including setlist and assigned questions.
* tags: [feedback-admin] * tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: eventId * name: eventId
* required: true * required: true
@@ -120,12 +122,14 @@ eventsAdminRouter.post('/', async (req: Request, res: Response) => {
* description: Unknown event * description: Unknown event
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
* put: * put:
* summary: Update an event * summary: Update an event
* tags: [feedback-admin] * tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: eventId * name: eventId
* required: true * required: true
@@ -138,13 +142,15 @@ eventsAdminRouter.post('/', async (req: Request, res: Response) => {
* description: Unknown event * description: Unknown event
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
* delete: * delete:
* summary: Delete an event * summary: Delete an event
* description: Refuses with 409 if submissions exist unless ?force=true is passed. * description: Refuses with 409 if submissions exist unless ?force=true is passed.
* tags: [feedback-admin] * tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: eventId * name: eventId
* required: true * required: true
@@ -163,6 +169,8 @@ eventsAdminRouter.post('/', async (req: Request, res: Response) => {
* description: Submissions exist and force was not set * description: Submissions exist and force was not set
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
eventsAdminRouter.get('/:eventId', async (req: Request, res: Response) => { eventsAdminRouter.get('/:eventId', async (req: Request, res: Response) => {
try { try {
@@ -214,9 +222,9 @@ eventsAdminRouter.delete('/:eventId', async (req: Request, res: Response) => {
* get: * get:
* summary: Get an event's setlist * summary: Get an event's setlist
* tags: [feedback-admin] * tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: eventId * name: eventId
* required: true * required: true
@@ -227,12 +235,14 @@ eventsAdminRouter.delete('/:eventId', async (req: Request, res: Response) => {
* description: Success * description: Success
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
* post: * post:
* summary: Add a song to an event's setlist * summary: Add a song to an event's setlist
* tags: [feedback-admin] * tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: eventId * name: eventId
* required: true * required: true
@@ -257,6 +267,8 @@ eventsAdminRouter.delete('/:eventId', async (req: Request, res: Response) => {
* description: Missing title * description: Missing title
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
eventsAdminRouter.get('/:eventId/songs', async (req: Request, res: Response) => { eventsAdminRouter.get('/:eventId/songs', async (req: Request, res: Response) => {
try { try {
@@ -292,9 +304,9 @@ eventsAdminRouter.post('/:eventId/songs', async (req: Request, res: Response) =>
* summary: Bulk reorder an event's setlist * summary: Bulk reorder an event's setlist
* description: Rewrites song positions as a dense 0..n-1 sequence in one transaction. * description: Rewrites song positions as a dense 0..n-1 sequence in one transaction.
* tags: [feedback-admin] * tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: eventId * name: eventId
* required: true * required: true
@@ -317,6 +329,8 @@ eventsAdminRouter.post('/:eventId/songs', async (req: Request, res: Response) =>
* description: Reordered * description: Reordered
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
eventsAdminRouter.put('/:eventId/songs/order', async (req: Request, res: Response) => { eventsAdminRouter.put('/:eventId/songs/order', async (req: Request, res: Response) => {
try { try {
@@ -334,9 +348,9 @@ eventsAdminRouter.put('/:eventId/songs/order', async (req: Request, res: Respons
* get: * get:
* summary: Get an event's assigned questions * summary: Get an event's assigned questions
* tags: [feedback-admin] * tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: eventId * name: eventId
* required: true * required: true
@@ -347,13 +361,15 @@ eventsAdminRouter.put('/:eventId/songs/order', async (req: Request, res: Respons
* description: Success * description: Success
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
* put: * put:
* summary: Bulk-set an event's assigned questions * summary: Bulk-set an event's assigned questions
* description: One transaction - inserts new, updates existing, deletes removed. Keeps the admin UI a simple save-the-whole-list form. * description: One transaction - inserts new, updates existing, deletes removed. Keeps the admin UI a simple save-the-whole-list form.
* tags: [feedback-admin] * tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: eventId * name: eventId
* required: true * required: true
@@ -383,6 +399,8 @@ eventsAdminRouter.put('/:eventId/songs/order', async (req: Request, res: Respons
* description: Saved * description: Saved
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
eventsAdminRouter.get('/:eventId/questions', async (req: Request, res: Response) => { eventsAdminRouter.get('/:eventId/questions', async (req: Request, res: Response) => {
try { try {
@@ -16,9 +16,9 @@ export const questionsAdminRouter = express.Router();
* get: * get:
* summary: List the question library * summary: List the question library
* tags: [feedback-admin] * tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: query * - in: query
* name: includeArchived * name: includeArchived
* schema: * schema:
@@ -34,12 +34,13 @@ export const questionsAdminRouter = express.Router();
* $ref: '#/components/schemas/AdminQuestion' * $ref: '#/components/schemas/AdminQuestion'
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
* post: * post:
* summary: Create a question * summary: Create a question
* tags: [feedback-admin] * tags: [feedback-admin]
* parameters: * security:
* - $ref: '#/components/parameters/SessionIdHeader' * - AdminSessionCookie: []
* - $ref: '#/components/parameters/SessionKeyHeader'
* requestBody: * requestBody:
* required: true * required: true
* content: * content:
@@ -61,6 +62,8 @@ export const questionsAdminRouter = express.Router();
* description: Missing or invalid fields * description: Missing or invalid fields
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
questionsAdminRouter.get('/', async (req: Request, res: Response) => { questionsAdminRouter.get('/', async (req: Request, res: Response) => {
try { try {
@@ -94,9 +97,9 @@ questionsAdminRouter.post('/', async (req: Request, res: Response) => {
* summary: Edit a question's label/help text * summary: Edit a question's label/help text
* description: question_type is immutable after creation - the admin UI offers "archive and create new" instead. * description: question_type is immutable after creation - the admin UI offers "archive and create new" instead.
* tags: [feedback-admin] * tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: questionId * name: questionId
* required: true * required: true
@@ -123,13 +126,15 @@ questionsAdminRouter.post('/', async (req: Request, res: Response) => {
* description: Unknown question * description: Unknown question
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
* delete: * delete:
* summary: Archive (or hard-delete) a question * summary: Archive (or hard-delete) a question
* description: Archives the question if it has ever been used; hard-deletes it if it has never been assigned to any event. * description: Archives the question if it has ever been used; hard-deletes it if it has never been assigned to any event.
* tags: [feedback-admin] * tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: questionId * name: questionId
* required: true * required: true
@@ -142,6 +147,8 @@ questionsAdminRouter.post('/', async (req: Request, res: Response) => {
* description: Unknown question * description: Unknown question
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
questionsAdminRouter.put('/:questionId', async (req: Request, res: Response) => { questionsAdminRouter.put('/:questionId', async (req: Request, res: Response) => {
try { try {
@@ -19,9 +19,9 @@ export const reportsAdminRouter = express.Router();
* summary: Aggregated feedback report for one event * summary: Aggregated feedback report for one event
* description: Song-pick vote counts, song-rating averages, capped free-text list, guest book count, and newsletter sync counts. * description: Song-pick vote counts, song-rating averages, capped free-text list, guest book count, and newsletter sync counts.
* tags: [feedback-admin] * tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: eventId * name: eventId
* required: true * required: true
@@ -34,6 +34,8 @@ export const reportsAdminRouter = express.Router();
* description: Unknown event * description: Unknown event
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
reportsAdminRouter.get('/:eventId/report', async (req: Request, res: Response) => { reportsAdminRouter.get('/:eventId/report', async (req: Request, res: Response) => {
try { try {
@@ -55,9 +57,9 @@ reportsAdminRouter.get('/:eventId/report', async (req: Request, res: Response) =
* summary: Guest Book entries for one event * summary: Guest Book entries for one event
* description: Newest first, paginated. * description: Newest first, paginated.
* tags: [feedback-admin] * tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: eventId * name: eventId
* required: true * required: true
@@ -81,6 +83,8 @@ reportsAdminRouter.get('/:eventId/report', async (req: Request, res: Response) =
* description: Success * description: Success
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
reportsAdminRouter.get('/:eventId/guestbook', async (req: Request, res: Response) => { reportsAdminRouter.get('/:eventId/guestbook', async (req: Request, res: Response) => {
try { try {
@@ -101,9 +105,9 @@ reportsAdminRouter.get('/:eventId/guestbook', async (req: Request, res: Response
* summary: Newsletter signups for one event * summary: Newsletter signups for one event
* description: Includes sync_status, so failures can be handled manually. Newest first, paginated. * description: Includes sync_status, so failures can be handled manually. Newest first, paginated.
* tags: [feedback-admin] * tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: eventId * name: eventId
* required: true * required: true
@@ -127,6 +131,8 @@ reportsAdminRouter.get('/:eventId/guestbook', async (req: Request, res: Response
* description: Success * description: Success
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
reportsAdminRouter.get('/:eventId/newsletter', async (req: Request, res: Response) => { reportsAdminRouter.get('/:eventId/newsletter', async (req: Request, res: Response) => {
try { try {
@@ -147,9 +153,9 @@ reportsAdminRouter.get('/:eventId/newsletter', async (req: Request, res: Respons
* summary: CSV export of all answers for one event * summary: CSV export of all answers for one event
* description: Long format, one row per answer. UTF-8 BOM, `;` separator, RFC 4180 escaping, formula-injection guard. * description: Long format, one row per answer. UTF-8 BOM, `;` separator, RFC 4180 escaping, formula-injection guard.
* tags: [feedback-admin] * tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: eventId * name: eventId
* required: true * required: true
@@ -162,6 +168,8 @@ reportsAdminRouter.get('/:eventId/newsletter', async (req: Request, res: Respons
* text/csv: {} * text/csv: {}
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
reportsAdminRouter.get('/:eventId/export/responses.csv', async (req: Request, res: Response) => { reportsAdminRouter.get('/:eventId/export/responses.csv', async (req: Request, res: Response) => {
try { try {
@@ -187,9 +195,9 @@ reportsAdminRouter.get('/:eventId/export/responses.csv', async (req: Request, re
* get: * get:
* summary: CSV export of Guest Book entries for one event * summary: CSV export of Guest Book entries for one event
* tags: [feedback-admin] * tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: eventId * name: eventId
* required: true * required: true
@@ -202,6 +210,8 @@ reportsAdminRouter.get('/:eventId/export/responses.csv', async (req: Request, re
* text/csv: {} * text/csv: {}
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
reportsAdminRouter.get('/:eventId/export/guestbook.csv', async (req: Request, res: Response) => { reportsAdminRouter.get('/:eventId/export/guestbook.csv', async (req: Request, res: Response) => {
try { try {
@@ -16,9 +16,9 @@ export const songsAdminRouter = express.Router();
* put: * put:
* summary: Edit a song's title/composer * summary: Edit a song's title/composer
* tags: [feedback-admin] * tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: songId * name: songId
* required: true * required: true
@@ -45,13 +45,15 @@ export const songsAdminRouter = express.Router();
* description: Unknown song * description: Unknown song
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
* delete: * delete:
* summary: Remove a song * summary: Remove a song
* description: Past answers keep their song_title_snapshot even after the song is removed. * description: Past answers keep their song_title_snapshot even after the song is removed.
* tags: [feedback-admin] * tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: songId * name: songId
* required: true * required: true
@@ -64,6 +66,8 @@ export const songsAdminRouter = express.Router();
* description: Unknown song * description: Unknown song
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
songsAdminRouter.put('/:songId', async (req: Request, res: Response) => { songsAdminRouter.put('/:songId', async (req: Request, res: Response) => {
try { try {
+24 -64
View File
@@ -1,78 +1,38 @@
import express from 'express'; import {requireAppAccess} from '../admin/admin.middleware.js';
import * as UserService from '../calendar/users/users.service.js';
import {sendServerError} from './feedback.errors.js';
/** /**
* This file is the ONLY place in the feedback module that knows how admin * This file is the ONLY place in the feedback module that knows how admin
* authentication works today. No route handler and no service outside this * authentication works. No route handler and no service outside this file may
* file may import users.service, read session headers, or touch bcrypt. * read session headers or resolve a user itself.
* *
* Today: reuses the existing Calendar users/sessions mechanism. Any * Today: the shared admin identity in `src/models/admin/`. A session cookie
* activated @nachklang.art account may administer feedback — no roles. * set by /admin/auth on admin.nachklang.art, plus a `feedback` permission on
* Migrating to Keycloak later means writing a keycloakJwtAuthenticator * the account. Both are re-checked on every request, so disabling a user or
* below and changing the one `activeAuthenticator` binding (plus the * taking their feedback permission away takes effect immediately.
* frontend's login route handler) — nothing else in the feedback module
* needs to change.
* *
* Explicitly forbidden: accepting sessionId/sessionKey from query * Before 2026-09-06 this was a header session against the calendar users
* parameters, even "temporarily". That is the exact mistake documented in * table, and any activated @nachklang.art account could administer feedback.
* DEFERRED_SECURITY.md item 1 for the Calendar domain, where credentials * That is why the swap is a one-line binding: everything downstream only ever
* end up in access logs, browser history, proxy logs, and Referer headers. * saw `requireAdminAuth` and `res.locals.admin`, and both still mean what
* Headers only. * they meant. What changed is that access is now granted per user rather than
* implied by having an account.
*
* Explicitly forbidden: accepting session credentials from query parameters,
* even "temporarily". That is the exact mistake documented in
* DEFERRED_SECURITY.md item 1 for the Calendar domain, where credentials end
* up in access logs, browser history, proxy logs, and Referer headers.
*/ */
// The only thing the rest of the feedback module knows about an admin. // The only thing the rest of the feedback module knows about an admin. The
// shared middleware puts a superset of this on res.locals.admin.
export interface AdminIdentity { export interface AdminIdentity {
id: string; id: string;
email: string; email: string;
displayName: string; displayName: string;
} }
// Pluggable strategy: extract + verify credentials from a request.
// Returns the identity, or null if unauthenticated. Throws only on
// infrastructure errors (e.g. the DB being unreachable).
export type AdminAuthenticator = (req: express.Request) => Promise<AdminIdentity | null>;
// Current implementation: reads X-Session-Id / X-Session-Key headers,
// delegates to the existing calendar UserService.checkSession(...).
export const sessionHeaderAuthenticator: AdminAuthenticator = async (req) => {
const sessionId = req.header('X-Session-Id');
const sessionKey = req.header('X-Session-Key');
if (!sessionId || !sessionKey) {
return null;
}
const ip = req.ip || '';
const user = await UserService.checkSession(sessionId, sessionKey, ip);
// Mirrors the Calendar domain's own convention: a valid session on an
// inactive (not yet activated) account is not sufficient.
if (!user || !user.isActive) {
return null;
}
return {
id: String(user.userId),
email: user.email,
displayName: user.fullName
};
};
// Swap point: change this one binding to migrate to Keycloak.
export const activeAuthenticator: AdminAuthenticator = sessionHeaderAuthenticator;
// Express middleware used by every admin route. On success: // Express middleware used by every admin route. On success:
// res.locals.admin = AdminIdentity, calls next(). On failure: 401. // res.locals.admin = AdminAccess (an AdminIdentity plus permissions), calls
export const requireAdminAuth: express.RequestHandler = async (req, res, next) => { // next(). On failure: 401 when not signed in, 403 when signed in without the
try { // feedback permission.
const identity = await activeAuthenticator(req); export const requireAdminAuth = requireAppAccess('feedback');
if (!identity) {
res.status(401).send({status: 'UNAUTHORIZED', message: 'Anmeldung erforderlich.'});
return;
}
res.locals.admin = identity;
next();
} catch (e: any) {
sendServerError(res, e);
}
};
+4 -3
View File
@@ -16,14 +16,15 @@ adminRouter.use(requireAdminAuth);
* get: * get:
* summary: Validate the current admin session * summary: Validate the current admin session
* tags: [tickets-admin] * tags: [tickets-admin]
* parameters: * security:
* - $ref: '#/components/parameters/SessionIdHeader' * - AdminSessionCookie: []
* - $ref: '#/components/parameters/SessionKeyHeader'
* responses: * responses:
* 200: * 200:
* description: Success * description: Success
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
adminRouter.get('/me', (req: Request, res: Response) => { adminRouter.get('/me', (req: Request, res: Response) => {
res.status(200).send({email: res.locals.admin.email, fullName: res.locals.admin.displayName}); res.status(200).send({email: res.locals.admin.email, fullName: res.locals.admin.displayName});
+20 -12
View File
@@ -11,14 +11,15 @@ export const eventsAdminRouter = express.Router();
* summary: List concerts for the admin event picker * summary: List concerts for the admin event picker
* description: Wraps the Calendar module's public-calendar admin listing (includes DRAFT events). * description: Wraps the Calendar module's public-calendar admin listing (includes DRAFT events).
* tags: [tickets-admin] * tags: [tickets-admin]
* parameters: * security:
* - $ref: '#/components/parameters/SessionIdHeader' * - AdminSessionCookie: []
* - $ref: '#/components/parameters/SessionKeyHeader'
* responses: * responses:
* 200: * 200:
* description: Success * description: Success
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
eventsAdminRouter.get('/', async (req: Request, res: Response) => { eventsAdminRouter.get('/', async (req: Request, res: Response) => {
try { try {
@@ -35,14 +36,15 @@ eventsAdminRouter.get('/', async (req: Request, res: Response) => {
* summary: List public-calendar events not yet added to the ticket shop * summary: List public-calendar events not yet added to the ticket shop
* description: Source list for the "add a concert" picker - the public calendar holds more than concerts, so events only appear in the ticket shop once explicitly added. * description: Source list for the "add a concert" picker - the public calendar holds more than concerts, so events only appear in the ticket shop once explicitly added.
* tags: [tickets-admin] * tags: [tickets-admin]
* parameters: * security:
* - $ref: '#/components/parameters/SessionIdHeader' * - AdminSessionCookie: []
* - $ref: '#/components/parameters/SessionKeyHeader'
* responses: * responses:
* 200: * 200:
* description: Success * description: Success
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
eventsAdminRouter.get('/available', async (req: Request, res: Response) => { eventsAdminRouter.get('/available', async (req: Request, res: Response) => {
try { try {
@@ -58,9 +60,9 @@ eventsAdminRouter.get('/available', async (req: Request, res: Response) => {
* get: * get:
* summary: Get a concert's voucher/capacity stats * summary: Get a concert's voucher/capacity stats
* tags: [tickets-admin] * tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: eventId * name: eventId
* required: true * required: true
@@ -75,6 +77,8 @@ eventsAdminRouter.get('/available', async (req: Request, res: Response) => {
* $ref: '#/components/schemas/EventStats' * $ref: '#/components/schemas/EventStats'
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
eventsAdminRouter.get('/:eventId/stats', async (req: Request, res: Response) => { eventsAdminRouter.get('/:eventId/stats', async (req: Request, res: Response) => {
try { try {
@@ -91,9 +95,9 @@ eventsAdminRouter.get('/:eventId/stats', async (req: Request, res: Response) =>
* summary: Set a concert's voucher settings * summary: Set a concert's voucher settings
* description: Upserts capacity (null = uncapped), redemption deadline (null = none), and whether to collect a mailing address. * description: Upserts capacity (null = uncapped), redemption deadline (null = none), and whether to collect a mailing address.
* tags: [tickets-admin] * tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: eventId * name: eventId
* required: true * required: true
@@ -123,6 +127,8 @@ eventsAdminRouter.get('/:eventId/stats', async (req: Request, res: Response) =>
* description: Saved * description: Saved
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
eventsAdminRouter.put('/:eventId/settings', async (req: Request, res: Response) => { eventsAdminRouter.put('/:eventId/settings', async (req: Request, res: Response) => {
try { try {
@@ -149,9 +155,9 @@ eventsAdminRouter.put('/:eventId/settings', async (req: Request, res: Response)
* summary: Remove an event from the ticket shop * summary: Remove an event from the ticket shop
* description: Deletes its settings row, so it drops out of the picker and reappears in the "add" list. Refused with 409 if vouchers already reference the event - existing vouchers/redemptions stay valid either way. * description: Deletes its settings row, so it drops out of the picker and reappears in the "add" list. Refused with 409 if vouchers already reference the event - existing vouchers/redemptions stay valid either way.
* tags: [tickets-admin] * tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: eventId * name: eventId
* required: true * required: true
@@ -164,6 +170,8 @@ eventsAdminRouter.put('/:eventId/settings', async (req: Request, res: Response)
* description: Vouchers already reference this event * description: Vouchers already reference this event
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
eventsAdminRouter.delete('/:eventId/settings', async (req: Request, res: Response) => { eventsAdminRouter.delete('/:eventId/settings', async (req: Request, res: Response) => {
try { try {
@@ -11,9 +11,9 @@ export const redemptionsAdminRouter = express.Router();
* summary: List redemptions (admin) * summary: List redemptions (admin)
* description: Filterable by event and status (ACTIVE/UNDONE). * description: Filterable by event and status (ACTIVE/UNDONE).
* tags: [tickets-admin] * tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: query * - in: query
* name: eventId * name: eventId
* schema: * schema:
@@ -33,6 +33,8 @@ export const redemptionsAdminRouter = express.Router();
* $ref: '#/components/schemas/RedemptionSummary' * $ref: '#/components/schemas/RedemptionSummary'
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
redemptionsAdminRouter.get('/', async (req: Request, res: Response) => { redemptionsAdminRouter.get('/', async (req: Request, res: Response) => {
try { try {
@@ -50,9 +52,9 @@ redemptionsAdminRouter.get('/', async (req: Request, res: Response) => {
* get: * get:
* summary: Get a single redemption (admin) * summary: Get a single redemption (admin)
* tags: [tickets-admin] * tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: redemptionId * name: redemptionId
* required: true * required: true
@@ -65,13 +67,15 @@ redemptionsAdminRouter.get('/', async (req: Request, res: Response) => {
* description: Unknown redemption * description: Unknown redemption
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
* patch: * patch:
* summary: Edit a redemption's contact info and/or guest list * summary: Edit a redemption's contact info and/or guest list
* description: Only fields present in the body are changed. Growing the guest count is re-checked against the voucher's max guests and the event's remaining capacity. Logs to the audit trail with an optional admin-supplied reason. * description: Only fields present in the body are changed. Growing the guest count is re-checked against the voucher's max guests and the event's remaining capacity. Logs to the audit trail with an optional admin-supplied reason.
* tags: [tickets-admin] * tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: redemptionId * name: redemptionId
* required: true * required: true
@@ -105,6 +109,8 @@ redemptionsAdminRouter.get('/', async (req: Request, res: Response) => {
* description: Not active, exceeds max guests, or exceeds remaining capacity * description: Not active, exceeds max guests, or exceeds remaining capacity
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
redemptionsAdminRouter.get('/:redemptionId', async (req: Request, res: Response) => { redemptionsAdminRouter.get('/:redemptionId', async (req: Request, res: Response) => {
try { try {
@@ -161,9 +167,9 @@ redemptionsAdminRouter.patch('/:redemptionId', async (req: Request, res: Respons
* summary: Undo a redemption * summary: Undo a redemption
* description: Reopens the code (back to UNUSED) and marks the redemption UNDONE. Guest data is kept for the audit trail; a later re-redemption creates a new redemption record. * description: Reopens the code (back to UNUSED) and marks the redemption UNDONE. Guest data is kept for the audit trail; a later re-redemption creates a new redemption record.
* tags: [tickets-admin] * tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: redemptionId * name: redemptionId
* required: true * required: true
@@ -186,6 +192,8 @@ redemptionsAdminRouter.patch('/:redemptionId', async (req: Request, res: Respons
* description: Redemption is not active * description: Redemption is not active
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
redemptionsAdminRouter.post('/:redemptionId/undo', async (req: Request, res: Response) => { redemptionsAdminRouter.post('/:redemptionId/undo', async (req: Request, res: Response) => {
try { try {
@@ -211,9 +219,9 @@ redemptionsAdminRouter.post('/:redemptionId/undo', async (req: Request, res: Res
* summary: Resend the redemption confirmation email * summary: Resend the redemption confirmation email
* description: Rebuilds the confirmation email from the stored redemption data and sends it again, then records the outcome on the redemption. Intended for redemptions whose original confirmation email failed. * description: Rebuilds the confirmation email from the stored redemption data and sends it again, then records the outcome on the redemption. Intended for redemptions whose original confirmation email failed.
* tags: [tickets-admin] * tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: redemptionId * name: redemptionId
* required: true * required: true
@@ -230,6 +238,8 @@ redemptionsAdminRouter.post('/:redemptionId/undo', async (req: Request, res: Res
* description: The email relay rejected the send * description: The email relay rejected the send
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
redemptionsAdminRouter.post('/:redemptionId/resend-confirmation', async (req: Request, res: Response) => { redemptionsAdminRouter.post('/:redemptionId/resend-confirmation', async (req: Request, res: Response) => {
try { try {
@@ -259,9 +269,9 @@ redemptionsAdminRouter.post('/:redemptionId/resend-confirmation', async (req: Re
* get: * get:
* summary: Get a voucher's admin-action audit trail * summary: Get a voucher's admin-action audit trail
* tags: [tickets-admin] * tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: code * name: code
* required: true * required: true
@@ -278,6 +288,8 @@ redemptionsAdminRouter.post('/:redemptionId/resend-confirmation', async (req: Re
* $ref: '#/components/schemas/AuditLogEntry' * $ref: '#/components/schemas/AuditLogEntry'
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
export const voucherHistoryRouter = express.Router(); export const voucherHistoryRouter = express.Router();
voucherHistoryRouter.get('/:code/history', async (req: Request, res: Response) => { voucherHistoryRouter.get('/:code/history', async (req: Request, res: Response) => {
@@ -11,9 +11,9 @@ export const vouchersAdminRouter = express.Router();
* summary: List vouchers (admin) * summary: List vouchers (admin)
* description: Filterable by event and status. * description: Filterable by event and status.
* tags: [tickets-admin] * tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: query * - in: query
* name: eventId * name: eventId
* schema: * schema:
@@ -33,6 +33,8 @@ export const vouchersAdminRouter = express.Router();
* $ref: '#/components/schemas/VoucherCode' * $ref: '#/components/schemas/VoucherCode'
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
vouchersAdminRouter.get('/', async (req: Request, res: Response) => { vouchersAdminRouter.get('/', async (req: Request, res: Response) => {
try { try {
@@ -51,9 +53,8 @@ vouchersAdminRouter.get('/', async (req: Request, res: Response) => {
* summary: Batch-generate wildcard codes * summary: Batch-generate wildcard codes
* description: Generates `quantity` codes sharing the same eligible events and max-guest count, grouped under one batchId. * description: Generates `quantity` codes sharing the same eligible events and max-guest count, grouped under one batchId.
* tags: [tickets-admin] * tags: [tickets-admin]
* parameters: * security:
* - $ref: '#/components/parameters/SessionIdHeader' * - AdminSessionCookie: []
* - $ref: '#/components/parameters/SessionKeyHeader'
* requestBody: * requestBody:
* required: true * required: true
* content: * content:
@@ -87,6 +88,8 @@ vouchersAdminRouter.get('/', async (req: Request, res: Response) => {
* description: Invalid input * description: Invalid input
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
vouchersAdminRouter.post('/wildcard', async (req: Request, res: Response) => { vouchersAdminRouter.post('/wildcard', async (req: Request, res: Response) => {
try { try {
@@ -112,9 +115,8 @@ vouchersAdminRouter.post('/wildcard', async (req: Request, res: Response) => {
* summary: Bulk-create personalized codes * summary: Bulk-create personalized codes
* description: One code per row (name, email, eligible events, max guests), grouped under one batchId. * description: One code per row (name, email, eligible events, max guests), grouped under one batchId.
* tags: [tickets-admin] * tags: [tickets-admin]
* parameters: * security:
* - $ref: '#/components/parameters/SessionIdHeader' * - AdminSessionCookie: []
* - $ref: '#/components/parameters/SessionKeyHeader'
* requestBody: * requestBody:
* required: true * required: true
* content: * content:
@@ -147,6 +149,8 @@ vouchersAdminRouter.post('/wildcard', async (req: Request, res: Response) => {
* description: Invalid input * description: Invalid input
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
vouchersAdminRouter.post('/personalized', async (req: Request, res: Response) => { vouchersAdminRouter.post('/personalized', async (req: Request, res: Response) => {
try { try {
@@ -169,9 +173,9 @@ vouchersAdminRouter.post('/personalized', async (req: Request, res: Response) =>
* get: * get:
* summary: Get a single voucher (admin) * summary: Get a single voucher (admin)
* tags: [tickets-admin] * tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: code * name: code
* required: true * required: true
@@ -184,6 +188,8 @@ vouchersAdminRouter.post('/personalized', async (req: Request, res: Response) =>
* description: Unknown code * description: Unknown code
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
vouchersAdminRouter.get('/:code', async (req: Request, res: Response) => { vouchersAdminRouter.get('/:code', async (req: Request, res: Response) => {
try { try {
@@ -205,9 +211,9 @@ vouchersAdminRouter.get('/:code', async (req: Request, res: Response) => {
* summary: Void an unredeemed code * summary: Void an unredeemed code
* description: Only allowed while the code is UNUSED. Logs to the voucher's audit trail. * description: Only allowed while the code is UNUSED. Logs to the voucher's audit trail.
* tags: [tickets-admin] * tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: code * name: code
* required: true * required: true
@@ -230,6 +236,8 @@ vouchersAdminRouter.get('/:code', async (req: Request, res: Response) => {
* description: Code is not in UNUSED status * description: Code is not in UNUSED status
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
vouchersAdminRouter.post('/:code/void', async (req: Request, res: Response) => { vouchersAdminRouter.post('/:code/void', async (req: Request, res: Response) => {
try { try {
+17 -49
View File
@@ -1,20 +1,23 @@
import express from 'express'; import {requireAppAccess} from '../admin/admin.middleware.js';
import * as UserService from '../calendar/users/users.service.js';
import {sendServerError} from './tickets.errors.js';
/** /**
* Mirrors the Feedback module's feedback.auth.ts: this is the ONLY place in * Mirrors the Feedback module's feedback.auth.ts: this is the ONLY place in
* the tickets module that knows how admin authentication works. No route * the tickets module that knows how admin authentication works. No route
* handler and no service outside this file may import users.service, read * handler and no service outside this file may read session headers or
* session headers, or touch bcrypt. * resolve a user itself.
* *
* Today: reuses the existing Calendar users/sessions mechanism. Any * Today: the shared admin identity in `src/models/admin/`. A session cookie
* activated @nachklang.art account may administer vouchers - no roles, same * set by /admin/auth on admin.nachklang.art, plus a `tickets` permission on
* policy as Feedback (see docs/plan-ticket-shop.md). A dedicated * the account. Both are re-checked on every request, so disabling a user or
* roles/permissions model is explicitly out of scope for v1. * taking their tickets permission away takes effect immediately.
* *
* Explicitly forbidden: accepting sessionId/sessionKey from query * Before 2026-09-06 this was a header session against the calendar users
* parameters - headers only (see DEFERRED_SECURITY.md item 1). * table, and any activated @nachklang.art account could administer vouchers
* (see docs/plan-ticket-shop.md, which called a roles model out of scope for
* v1). It is in scope now, and lives in the admin module rather than here.
*
* Explicitly forbidden: accepting session credentials from query parameters -
* see DEFERRED_SECURITY.md item 1.
*/ */
export interface AdminIdentity { export interface AdminIdentity {
@@ -23,41 +26,6 @@ export interface AdminIdentity {
displayName: string; displayName: string;
} }
export type AdminAuthenticator = (req: express.Request) => Promise<AdminIdentity | null>; // On failure: 401 when not signed in, 403 when signed in without the tickets
// permission.
export const sessionHeaderAuthenticator: AdminAuthenticator = async (req) => { export const requireAdminAuth = requireAppAccess('tickets');
const sessionId = req.header('X-Session-Id');
const sessionKey = req.header('X-Session-Key');
if (!sessionId || !sessionKey) {
return null;
}
const ip = req.ip || '';
const user = await UserService.checkSession(sessionId, sessionKey, ip);
if (!user || !user.isActive) {
return null;
}
return {
id: String(user.userId),
email: user.email,
displayName: user.fullName
};
};
export const activeAuthenticator: AdminAuthenticator = sessionHeaderAuthenticator;
export const requireAdminAuth: express.RequestHandler = async (req, res, next) => {
try {
const identity = await activeAuthenticator(req);
if (!identity) {
res.status(401).send({status: 'UNAUTHORIZED', message: 'Anmeldung erforderlich.'});
return;
}
res.locals.admin = identity;
next();
} catch (e: any) {
sendServerError(res, e);
}
};
+35
View File
@@ -78,6 +78,41 @@ describe('CLIENT_IP_HEADERS', () => {
}); });
}); });
describe('APP_ORIGINS', () => {
beforeEach(() => {
delete process.env.APP_ORIGINS;
});
// These reach better-auth's trustedOrigins, and the step 4 cutover made the
// tickets and feedback origins load-bearing: without them their sign-out
// call is rejected while everything else still works.
it('defaults to the three production frontends', async () => {
const config = await loadConfig();
expect(config.APP_ORIGINS).toEqual([
'https://tickets.nachklang.art',
'https://feedback.nachklang.art',
'https://calendar.nachklang.art'
]);
});
it('is overridden wholesale by the environment, for a staging host', async () => {
process.env.APP_ORIGINS = 'https://tickets.staging.example, https://feedback.staging.example/';
const config = await loadConfig();
expect(config.APP_ORIGINS).toEqual([
'https://tickets.staging.example',
// Trailing slash stripped: an origin with one never matches.
'https://feedback.staging.example'
]);
});
it('always includes the admin app itself in ADMIN_ALLOWED_ORIGINS', async () => {
process.env.ADMIN_APP_URL = 'https://admin.nachklang.art';
const config = await loadConfig();
expect(config.ADMIN_ALLOWED_ORIGINS).toContain('https://admin.nachklang.art');
expect(config.ADMIN_ALLOWED_ORIGINS).toContain('https://tickets.nachklang.art');
});
});
describe('isProd', () => { describe('isProd', () => {
it('is false only for the explicit relaxed environments', async () => { it('is false only for the explicit relaxed environments', async () => {
process.env.NODE_ENV = 'development'; process.env.NODE_ENV = 'development';
+107
View File
@@ -0,0 +1,107 @@
import {vi, describe, it, expect, beforeEach, type Mock} from 'vitest';
import express, {Request, Response} from 'express';
/**
* Shared body for the two cutover tests (2026-09-06). feedback.auth.ts and
* tickets.auth.ts used to carry their own header-session authenticator against
* the calendar users table; both are now one binding to the shared admin gate.
*
* What is worth asserting is not how that gate works - admin.middleware.test.ts
* owns that - but that each module is bound to *its own* app. The mocks live in
* the calling file because vi.mock is per-module-graph; only the assertions are
* shared.
*
* There used to be a tripwire here asserting neither module fell back to the
* calendar's header sessions. It went with step 5: the calendar users service
* no longer exists, so there is nothing left to fall back to and nothing to
* assert against.
*/
export interface BindingMocks {
/** auth.api.getSession from the mocked admin.auth.js */
getSession: Mock;
/** loadAccess from the mocked users.admin.service.js */
loadAccess: Mock;
}
const makeReq = (): Request => ({headers: {cookie: 'nachklang.session_token=abc'}} as unknown as Request);
const makeRes = (): Response => {
const res: any = {};
res.status = vi.fn().mockReturnValue(res);
res.send = vi.fn().mockReturnValue(res);
res.locals = {};
return res as Response;
};
const userWith = (...apps: string[]) => ({
id: 'u1',
email: 'a@nachklang.art',
displayName: 'Anna Admin',
disabled: false,
permissions: apps.map(app => ({app, role: 'access'})),
apps
});
export const describeAdminBinding = (
app: string,
otherApp: string,
middleware: express.RequestHandler,
mocks: () => BindingMocks
): void => {
describe(`${app} requireAdminAuth`, () => {
let m: BindingMocks;
const run = async () => {
const res = makeRes();
const next = vi.fn();
await middleware(makeReq(), res, next);
return {res, next};
};
beforeEach(() => {
m = mocks();
m.getSession.mockReset();
m.loadAccess.mockReset();
});
it('responds 401 and does not call next() without a session', async () => {
m.getSession.mockResolvedValue(null);
const {res, next} = await run();
expect(res.status).toHaveBeenCalledWith(401);
expect(next).not.toHaveBeenCalled();
});
it(`responds 403 for a signed-in user who only has ${otherApp}`, async () => {
m.getSession.mockResolvedValue({user: {id: 'u1'}});
m.loadAccess.mockResolvedValue(userWith(otherApp));
const {res, next} = await run();
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});
it('responds 403 for a disabled user who still holds the permission', async () => {
m.getSession.mockResolvedValue({user: {id: 'u1'}});
m.loadAccess.mockResolvedValue({...userWith(app), disabled: true});
const {res, next} = await run();
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});
it('sets res.locals.admin and calls next() with the permission', async () => {
m.getSession.mockResolvedValue({user: {id: 'u1'}});
m.loadAccess.mockResolvedValue(userWith(app));
const {res, next} = await run();
expect(next).toHaveBeenCalled();
expect(res.locals.admin).toMatchObject({id: 'u1', email: 'a@nachklang.art', displayName: 'Anna Admin'});
});
});
};
+49
View File
@@ -0,0 +1,49 @@
import {describe, expect, it, beforeEach} from 'vitest';
import * as CredentialService from '../../src/models/calendar/events/credentials.service.js';
/**
* The public calendar is read anonymously by nachklang.art to show the next
* upcoming event. That is a load-bearing property, not an accident: the step 4
* cutover moved every signed-in path onto session cookies and left these shared
* passwords behind only for iCal subscriptions, and the failure mode of getting
* it wrong is the public website silently losing its events feed.
*
* So this pins both halves: public needs nothing, and the restricted calendars
* still need something.
*/
describe('hasAccess', () => {
beforeEach(() => {
process.env.MEMBER_CREDENTIAL = 'member-secret';
process.env.CHOIR_CREDENTIAL = 'choir-secret';
process.env.MANAGEMENT_CREDENTIAL = 'management-secret';
});
it('lets anyone read the public calendar with no password at all', async () => {
await expect(CredentialService.hasAccess('public', '')).resolves.toBe(true);
});
it.each([
['members', 'member-secret'],
['choir', 'choir-secret'],
['management', 'management-secret'],
['birthdays', 'choir-secret']
])('refuses %s without the credential and allows it with one', async (calendar, secret) => {
await expect(CredentialService.hasAccess(calendar, '')).resolves.toBe(false);
await expect(CredentialService.hasAccess(calendar, 'wrong')).resolves.toBe(false);
await expect(CredentialService.hasAccess(calendar, secret)).resolves.toBe(true);
});
it('refuses an unknown calendar outright', async () => {
await expect(CredentialService.hasAccess('nope', 'member-secret')).resolves.toBe(false);
});
it('refuses a calendar whose credential is not configured', async () => {
// An unset MEMBER_CREDENTIAL must not become "any password works", and in
// particular must not become "an absent password works".
delete process.env.MEMBER_CREDENTIAL;
await expect(CredentialService.hasAccess('members', '')).resolves.toBe(false);
await expect(CredentialService.hasAccess('members', undefined as any)).resolves.toBe(false);
});
});
+224
View File
@@ -0,0 +1,224 @@
import {describe, expect, it, vi, beforeEach} from 'vitest';
import express from 'express';
import request from 'supertest';
vi.mock('../../src/models/calendar/events/events.service.js', () => ({
getAllEvents: vi.fn(),
getAllEventsAdmin: vi.fn(),
getEventById: vi.fn(),
createEvent: vi.fn(),
updateEvent: vi.fn(),
deleteEvent: vi.fn(),
moveEvent: vi.fn(),
getNextUpcomingEvent: vi.fn()
}));
vi.mock('../../src/models/admin/admin.auth.js', () => ({
auth: {api: {getSession: vi.fn()}}
}));
vi.mock('../../src/models/admin/users/users.admin.service.js', () => ({
loadAccess: vi.fn()
}));
import * as EventService from '../../src/models/calendar/events/events.service.js';
import {auth} from '../../src/models/admin/admin.auth.js';
import * as UsersService from '../../src/models/admin/users/users.admin.service.js';
import {eventsRouter} from '../../src/models/calendar/events/events.router.js';
/**
* Step 4 of docs/calendar-auth-migration.md at the route level. The unit test
* on credentials.service covers the password table; this covers the thing that
* table is wired into, which is where the interesting mistakes live:
*
* - the public calendar has to stay readable with no session and no password,
* - the shared password has to keep working for the restricted calendars,
* because iCal clients cannot send a cookie,
* - and every write has to be behind the session cookie *and* an explicit
* calendar permission, not merely behind "is signed in".
*/
const app = express();
app.use(express.json());
app.use('/calendar/events', eventsRouter);
const signedInAs = (apps: string[], disabled = false) => {
(auth.api.getSession as any).mockResolvedValue({user: {id: 'admin-1'}});
(UsersService.loadAccess as any).mockResolvedValue({
id: 'admin-1',
email: 'a@nachklang.art',
displayName: 'A',
disabled,
permissions: apps.map(app => ({app, role: 'access'})),
apps
});
};
const signedOut = () => {
(auth.api.getSession as any).mockResolvedValue(null);
};
const validEvent = {
calendarId: 1,
name: 'Konzert',
startDateTime: '2026-04-18T19:00:00Z',
endDateTime: '2026-04-18T21:00:00Z'
};
beforeEach(() => {
vi.clearAllMocks();
process.env.MEMBER_CREDENTIAL = 'member-secret';
(EventService.getAllEvents as any).mockResolvedValue([]);
(EventService.getAllEventsAdmin as any).mockResolvedValue([]);
(EventService.getNextUpcomingEvent as any).mockResolvedValue({eventId: 1, name: 'Konzert'});
(EventService.createEvent as any).mockResolvedValue(1);
(EventService.updateEvent as any).mockResolvedValue(1);
(EventService.moveEvent as any).mockResolvedValue(true);
(EventService.deleteEvent as any).mockResolvedValue(true);
signedOut();
});
describe('reading', () => {
it('serves the public calendar anonymously', async () => {
// The property nachklang.art depends on. No cookie, no password.
await request(app).get('/calendar/events/public/json').expect(200);
// And as the non-admin view: an anonymous caller must not see drafts.
expect(EventService.getAllEvents).toHaveBeenCalled();
expect(EventService.getAllEventsAdmin).not.toHaveBeenCalled();
});
it('refuses a restricted calendar with neither session nor password', async () => {
await request(app).get('/calendar/events/members/json').expect(403);
});
it('serves a restricted calendar to a shared password, without drafts', async () => {
await request(app)
.get('/calendar/events/members/json')
.query({password: 'member-secret'})
.expect(200);
expect(EventService.getAllEvents).toHaveBeenCalled();
expect(EventService.getAllEventsAdmin).not.toHaveBeenCalled();
});
it('gives a signed-in editor the admin view instead', async () => {
signedInAs(['calendar']);
await request(app).get('/calendar/events/members/json').expect(200);
expect(EventService.getAllEventsAdmin).toHaveBeenCalled();
expect(EventService.getAllEvents).not.toHaveBeenCalled();
});
it('treats a signed-in user without the calendar permission as anonymous', async () => {
// Not a 403: they may still read the public calendar like anyone else.
signedInAs(['tickets']);
await request(app).get('/calendar/events/public/json').expect(200);
expect(EventService.getAllEvents).toHaveBeenCalled();
expect(EventService.getAllEventsAdmin).not.toHaveBeenCalled();
await request(app).get('/calendar/events/members/json').expect(403);
});
it('still serves the public calendar when the admin database is down', async () => {
(auth.api.getSession as any).mockRejectedValue(new Error('ECONNREFUSED'));
await request(app).get('/calendar/events/public/json').expect(200);
});
// The endpoint www.nachklang.art actually calls for its next-event teaser.
// Tested separately from /json because it takes a different code path - it
// has no admin view and no editor branch - so covering /json proves nothing
// about it, and its failure is invisible until someone notices the website
// has gone quiet.
it('serves the next upcoming event anonymously on the public calendar', async () => {
await request(app).get('/calendar/events/public/json/next').expect(200);
// And without asking the admin database who the caller is: the public
// feed must not acquire a dependency it has never had.
expect(auth.api.getSession).not.toHaveBeenCalled();
});
it('refuses the next upcoming event on a restricted calendar without a credential', async () => {
await request(app).get('/calendar/events/members/json/next').expect(403);
});
it('serves the next upcoming event to a shared password', async () => {
await request(app)
.get('/calendar/events/members/json/next')
.query({password: 'member-secret'})
.expect(200);
});
it('serves the next upcoming event to a signed-in editor', async () => {
signedInAs(['calendar']);
await request(app).get('/calendar/events/members/json/next').expect(200);
});
it('does not consult the admin database for the anonymous public iCal export', async () => {
await request(app).get('/calendar/events/public/ical').expect(200);
expect(auth.api.getSession).not.toHaveBeenCalled();
});
it('keeps the shared password working on the iCal export', async () => {
(EventService.getAllEvents as any).mockResolvedValue([]);
await request(app).get('/calendar/events/public/ical').expect(200);
await request(app).get('/calendar/events/members/ical').expect(403);
await request(app)
.get('/calendar/events/members/ical')
.query({password: 'member-secret'})
.expect(200);
});
});
describe('writing', () => {
it.each([
['post', '/calendar/events'],
['put', '/calendar/events/1'],
['put', '/calendar/events/move/1'],
['delete', '/calendar/events/1']
])('%s %s answers 401 when signed out', async (method, path) => {
await (request(app) as any)[method](path).send(validEvent).expect(401);
});
it.each([
['post', '/calendar/events'],
['put', '/calendar/events/1'],
['put', '/calendar/events/move/1'],
['delete', '/calendar/events/1']
])('%s %s answers 403 without the calendar permission', async (method, path) => {
signedInAs(['tickets', 'feedback', 'admin']);
await (request(app) as any)[method](path).send(validEvent).expect(403);
});
it('answers 403 for a disabled account that still holds the permission', async () => {
signedInAs(['calendar'], true);
await request(app).post('/calendar/events').send(validEvent).expect(403);
});
it('records the writer as an admin user id, never a legacy one', async () => {
signedInAs(['calendar']);
await request(app).post('/calendar/events').send(validEvent).expect(201);
const written = (EventService.createEvent as any).mock.calls[0][0];
expect(written.createdByUserId).toBe('admin-1');
// Migration 003 made the legacy column nullable precisely so this can be unset.
expect(written.createdById).toBeUndefined();
});
it('refuses the shared password as a way to write', async () => {
// The passwords are a read fallback for clients that cannot hold a
// session. They must never become an editing credential.
await request(app)
.post('/calendar/events')
.query({password: 'member-secret'})
.send(validEvent)
.expect(401);
});
});
+169
View File
@@ -0,0 +1,169 @@
import {describe, expect, it, vi, beforeEach} from 'vitest';
const connection = {
query: vi.fn(),
execute: vi.fn(),
beginTransaction: vi.fn(),
commit: vi.fn(),
rollback: vi.fn(),
end: vi.fn()
};
vi.mock('../../src/models/calendar/Calendar.db.js', () => ({
NachklangCalendarDB: {getConnection: vi.fn(async () => connection)}
}));
vi.mock('../../src/models/admin/users/users.admin.service.js', () => ({
findDisplayNames: vi.fn()
}));
import {NachklangCalendarDB} from '../../src/models/calendar/Calendar.db.js';
import * as AdminUsersService from '../../src/models/admin/users/users.admin.service.js';
import * as EventService from '../../src/models/calendar/events/events.service.js';
/**
* Step 3 of docs/calendar-auth-migration.md. The property under test is that
* an event's creator resolves from the stronger of its two remaining sources -
* the live admin name, else the name archived before the legacy users table was
* removed - and that a failure to reach the admin database costs a name rather
* than the whole response: the public calendar is read anonymously by the
* website and has never depended on the admin database being up.
*/
// One row of the shape the shared SELECT produces.
const row = (over: Record<string, unknown> = {}) => ({
event_id: 1,
calendar_id: 1,
uuid: 'uuid-1',
name: 'Konzert',
description: '',
start_datetime: new Date('2026-04-18T19:00:00Z'),
end_datetime: new Date('2026-04-18T21:00:00Z'),
created_date: new Date('2026-01-01T00:00:00Z'),
version_created_at: new Date('2026-01-02T00:00:00Z'),
location: '',
created_by_user_id: null,
created_by_name: 'Archived Person',
version_created_by_user_id: null,
version_created_by_name: 'Archived Person',
url: '',
whole_day: 0,
repeat_frequency: '',
status: 'PUBLIC',
...over
});
/** getAllEvents runs the calendars lookup first, then the events query. */
const givenEvents = (...rows: unknown[]) => {
connection.query.mockReset();
connection.query
.mockResolvedValueOnce([{calendar_id: 1, includes_calendars: '[]'}])
.mockResolvedValueOnce(rows);
};
beforeEach(() => {
vi.clearAllMocks();
connection.end.mockResolvedValue(undefined);
(NachklangCalendarDB.getConnection as any).mockResolvedValue(connection);
});
describe('creator names', () => {
it('uses the archived name when the row has no admin id', async () => {
givenEvents(row());
const events = await EventService.getAllEvents(1);
expect(events[0].createdBy).toBe('Archived Person');
expect(events[0].createdByUserId).toBeNull();
// Nothing to resolve, so the admin database is not touched at all.
expect(AdminUsersService.findDisplayNames).not.toHaveBeenCalled();
});
it('prefers the admin name when the row carries an admin id', async () => {
givenEvents(row({
created_by_user_id: 'admin-1',
version_created_by_user_id: 'admin-2'
}));
(AdminUsersService.findDisplayNames as any).mockResolvedValue(
new Map([['admin-1', 'Neue Person'], ['admin-2', 'Andere Person']])
);
const events = await EventService.getAllEvents(1);
expect(events[0].createdBy).toBe('Neue Person');
expect(events[0].lastModifiedBy).toBe('Andere Person');
expect(events[0].createdByUserId).toBe('admin-1');
});
it('prefers the live admin name over the snapshot', async () => {
// A renamed account has to win over an archive that was correct when it
// was taken - otherwise renaming someone would leave stale names behind.
givenEvents(row({created_by_user_id: 'admin-1', created_by_name: 'Archived Person'}));
(AdminUsersService.findDisplayNames as any).mockResolvedValue(new Map([['admin-1', 'Neue Person']]));
const events = await EventService.getAllEvents(1);
expect(events[0].createdBy).toBe('Neue Person');
});
it('falls back to the archived name when the admin account is gone', async () => {
givenEvents(row({created_by_user_id: 'deleted'}));
(AdminUsersService.findDisplayNames as any).mockResolvedValue(new Map());
const events = await EventService.getAllEvents(1);
expect(events[0].createdBy).toBe('Archived Person');
});
it('leaves the name blank when a row has neither source', async () => {
// A post-cutover event whose author was later deleted from the admin
// module: no snapshot was ever taken for it, and the id resolves to
// nothing. Blank is the designed outcome - the creator is decoration.
givenEvents(row({created_by_user_id: 'deleted', created_by_name: null}));
(AdminUsersService.findDisplayNames as any).mockResolvedValue(new Map());
const events = await EventService.getAllEvents(1);
expect(events[0].createdBy).toBeNull();
expect(events).toHaveLength(1);
});
it('resolves a mixed result set in a single lookup', async () => {
givenEvents(
row({event_id: 1}),
row({event_id: 2, created_by_user_id: 'admin-1', version_created_by_user_id: 'admin-1'}),
row({event_id: 3, created_by_user_id: 'admin-1', version_created_by_user_id: 'admin-1'})
);
(AdminUsersService.findDisplayNames as any).mockResolvedValue(new Map([['admin-1', 'Neue Person']]));
const events = await EventService.getAllEvents(1);
expect(events.map(e => e.createdBy)).toEqual(['Archived Person', 'Neue Person', 'Neue Person']);
expect(AdminUsersService.findDisplayNames).toHaveBeenCalledTimes(1);
});
it('still returns the events when the admin database is unreachable', async () => {
givenEvents(row({created_by_user_id: 'admin-1'}));
(AdminUsersService.findDisplayNames as any).mockRejectedValue(new Error('ECONNREFUSED'));
const events = await EventService.getAllEvents(1);
expect(events).toHaveLength(1);
expect(events[0].name).toBe('Konzert');
// Degrades to the archived name rather than failing the request.
expect(events[0].createdBy).toBe('Archived Person');
});
});
describe('status', () => {
it('is omitted from the public listing and present in the admin one', async () => {
givenEvents(row());
const publicEvents = await EventService.getAllEvents(1);
expect(publicEvents[0].status).toBeUndefined();
connection.query.mockReset();
connection.query.mockResolvedValueOnce([row()]);
const adminEvents = await EventService.getAllEventsAdmin(1);
expect(adminEvents[0].status).toBe('PUBLIC');
});
});
+14 -84
View File
@@ -1,88 +1,18 @@
import {vi, describe, it, expect, beforeEach, type Mock} from 'vitest'; import {vi, type Mock} from 'vitest';
import {Request, Response} from 'express';
vi.mock('../../src/models/calendar/users/users.service.js', () => ({ vi.mock('../../src/models/admin/admin.auth.js', () => ({
checkSession: vi.fn() auth: {api: {getSession: vi.fn()}}
}));
vi.mock('../../src/models/admin/users/users.admin.service.js', () => ({
loadAccess: vi.fn()
})); }));
import * as UserService from '../../src/models/calendar/users/users.service.js'; import {auth} from '../../src/models/admin/admin.auth.js';
import {requireAdminAuth, sessionHeaderAuthenticator} from '../../src/models/feedback/feedback.auth.js'; import * as UsersService from '../../src/models/admin/users/users.admin.service.js';
import {requireAdminAuth} from '../../src/models/feedback/feedback.auth.js';
import {describeAdminBinding} from '../admin/auth-binding.js';
const mockCheckSession = UserService.checkSession as Mock; describeAdminBinding('feedback', 'tickets', requireAdminAuth, () => ({
getSession: auth.api.getSession as unknown as Mock,
const makeReq = (headers: Record<string, string>): Request => { loadAccess: UsersService.loadAccess as Mock
return { }));
header: (name: string) => headers[name],
ip: '203.0.113.42'
} as unknown as Request;
};
const makeRes = (): Response => {
const res: any = {};
res.status = vi.fn().mockReturnValue(res);
res.send = vi.fn().mockReturnValue(res);
res.locals = {};
return res as Response;
};
describe('sessionHeaderAuthenticator', () => {
beforeEach(() => mockCheckSession.mockReset());
it('returns null when headers are missing', async () => {
const identity = await sessionHeaderAuthenticator(makeReq({}));
expect(identity).toBeNull();
expect(mockCheckSession).not.toHaveBeenCalled();
});
it('returns null when checkSession finds no user', async () => {
mockCheckSession.mockResolvedValue(null);
const identity = await sessionHeaderAuthenticator(makeReq({'X-Session-Id': '1', 'X-Session-Key': 'k'}));
expect(identity).toBeNull();
});
it('returns null for a valid session on an inactive account', async () => {
mockCheckSession.mockResolvedValue({userId: 1, email: 'a@nachklang.art', fullName: 'A', isActive: false});
const identity = await sessionHeaderAuthenticator(makeReq({'X-Session-Id': '1', 'X-Session-Key': 'k'}));
expect(identity).toBeNull();
});
it('returns the identity for a valid session on an active account', async () => {
mockCheckSession.mockResolvedValue({userId: 1, email: 'a@nachklang.art', fullName: 'Anna Admin', isActive: true});
const identity = await sessionHeaderAuthenticator(makeReq({'X-Session-Id': '1', 'X-Session-Key': 'k'}));
expect(identity).toEqual({id: '1', email: 'a@nachklang.art', displayName: 'Anna Admin'});
});
it('passes the session id and key from headers through to checkSession, never from query params', async () => {
mockCheckSession.mockResolvedValue({userId: 1, email: 'a@nachklang.art', fullName: 'A', isActive: true});
await sessionHeaderAuthenticator(makeReq({'X-Session-Id': '42', 'X-Session-Key': 'sekret'}));
expect(mockCheckSession).toHaveBeenCalledWith('42', 'sekret', '203.0.113.42');
});
});
describe('requireAdminAuth', () => {
beforeEach(() => mockCheckSession.mockReset());
it('responds 401 and does not call next() when unauthenticated', async () => {
mockCheckSession.mockResolvedValue(null);
const req = makeReq({});
const res = makeRes();
const next = vi.fn();
await requireAdminAuth(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(next).not.toHaveBeenCalled();
});
it('sets res.locals.admin and calls next() when authenticated', async () => {
mockCheckSession.mockResolvedValue({userId: 1, email: 'a@nachklang.art', fullName: 'Anna Admin', isActive: true});
const req = makeReq({'X-Session-Id': '1', 'X-Session-Key': 'k'});
const res = makeRes();
const next = vi.fn();
await requireAdminAuth(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.locals.admin).toEqual({id: '1', email: 'a@nachklang.art', displayName: 'Anna Admin'});
});
});
+24 -8
View File
@@ -221,16 +221,32 @@ describe('requireAppAccess', () => {
expect(Array.isArray(res.body)).toBe(true); expect(Array.isArray(res.body)).toBe(true);
}); });
// Step 2 deliberately does NOT swap the feedback and tickets authenticators: // The step 4 cutover (2026-09-06): the feedback and tickets admin areas now
// they still authenticate against the legacy calendar sessions, so an admin // sit behind this same gate, so one sign-in reaches every app the user has a
// cookie means nothing to them yet. This asserts that boundary rather than // permission for - and reaches no further. Until step 4 these two returned
// the end state - when step 4 lands, these two expectations become 200/403 // 401 for an admin cookie, because each module still ran its own header
// and this comment goes away. // session against the calendar users table.
it('leaves the feedback and tickets admin areas on their legacy authenticator', async () => { it('lets an admin cookie into the feedback and tickets admin areas', async () => {
const user = await createAndAcceptInvitation(app, 'o@nachklang.art', 'O', ['feedback', 'tickets']); const user = await createAndAcceptInvitation(app, 'o@nachklang.art', 'O', ['feedback', 'tickets']);
expect((await user.agent.get('/feedback/admin/me')).status).toBe(401); expect((await user.agent.get('/feedback/admin/me')).status).toBe(200);
expect((await user.agent.get('/tickets/admin/me')).status).toBe(401); expect((await user.agent.get('/tickets/admin/me')).status).toBe(200);
});
it('403s each app separately for a user who only holds the other one', async () => {
const user = await createAndAcceptInvitation(app, 'q@nachklang.art', 'Q', ['feedback']);
expect((await user.agent.get('/feedback/admin/me')).status).toBe(200);
expect((await user.agent.get('/tickets/admin/me')).status).toBe(403);
});
it('401s the feedback and tickets admin areas for a legacy header session', async () => {
const res = await request(app)
.get('/feedback/admin/me')
.set('X-Session-Id', '1')
.set('X-Session-Key', 'whatever');
expect(res.status).toBe(401);
}); });
}); });
+18
View File
@@ -0,0 +1,18 @@
import {vi, type Mock} from 'vitest';
vi.mock('../../src/models/admin/admin.auth.js', () => ({
auth: {api: {getSession: vi.fn()}}
}));
vi.mock('../../src/models/admin/users/users.admin.service.js', () => ({
loadAccess: vi.fn()
}));
import {auth} from '../../src/models/admin/admin.auth.js';
import * as UsersService from '../../src/models/admin/users/users.admin.service.js';
import {requireAdminAuth} from '../../src/models/tickets/tickets.auth.js';
import {describeAdminBinding} from '../admin/auth-binding.js';
describeAdminBinding('tickets', 'feedback', requireAdminAuth, () => ({
getSession: auth.api.getSession as unknown as Mock,
loadAccess: UsersService.loadAccess as Mock
}));