5 Commits

Author SHA1 Message Date
Paddy e62b46945a Add a CLIENT_IP_HEADERS escape hatch and stop the auth handler hanging
Two changes, both from working out how to verify the client-IP configuration
on the Plesk vhost before deploying.

CLIENT_IP_HEADERS=none now trusts no header at all. This covers the one case
where the wrong setting is worse than no setting: if the proxy does not
overwrite the header we trust, any client can send it and mint itself an
unlimited brute-force budget against /sign-in. Falling back to the shared
rate-limit bucket is a nuisance - one noisy client can lock everyone out for
ten seconds at a time - but it fails closed, and it can be reverted from the
environment without a deploy. An empty or unset value still means "use the
default": a stray blank line in a .env must not silently change how requests
are bucketed, so only the explicit word does that. An empty array is what
better-auth reads as "no headers"; it falls back to its own default only when
the option is absent, and [] is truthy.

The TRUSTED_PROXY_IPS boot warning was overstating the risk. It now says that
a single-value header needs no trusted proxies, so seeing it on a plain
single-proxy setup is expected rather than a problem to chase.

Separately: a rejected promise from better-auth's handler used to escape as an
unhandled rejection, leaving the request hanging forever with no response while
the process logged an uncaughtException. Express 4 does not await an async
handler, and nearly every better-auth route touches the admin database, so any
database blip would have done this. Found by pointing ADMIN_DB at a database
the user cannot open while testing the hatch. The handler now answers 503, so
the caller learns and the other domains keep serving; verified as a 24ms
response instead of a hang, with / and /feedback/admin/me unaffected.

test/admin/admin.config.test.ts is new: it pins both branches of the hatch and
the rule that an unset NODE_ENV counts as production. It stubs dotenv, because
admin.config would otherwise read the repo's own .env and quietly reintroduce
NODE_ENV=development - the exact value several of those cases exist to remove.

157 unit tests and 41 integration tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 12:39:33 +02:00
Paddy 27eb301086 Fail CORS closed when NODE_ENV is unset
The CORS origin check used `NODE_ENV !== 'production'` to decide whether to
allow loopback and private-LAN origins. That is not the same question as "is
this a dev machine" when NODE_ENV is unset - which is exactly what a fresh
Plesk vhost gives you. On such a host the check called it dev and answered
`Access-Control-Allow-Origin: http://localhost:<any port>` together with
`Access-Control-Allow-Credentials: true`. With the admin session cookie scoped
to .nachklang.art, that let any page served from localhost on a signed-in
admin's machine read their data cross-origin.

admin.config.ts already resolves this correctly - only an explicit
'development' or 'test' relaxes anything, so unset is treated as production -
so the CORS check now derives from its `isProd` rather than re-deriving its own
answer from NODE_ENV. Two places asking the same question two different ways
was the bug.

Verified against the built app with a vhost-like environment (no NODE_ENV):
before, Origin http://localhost:9999 came back allowed with credentials; after,
it is rejected, while https://tickets.nachklang.art and
https://admin.nachklang.art are still allowed with credentials. With
NODE_ENV=development localhost is still allowed, so local development and the
LAN exception for real-device testing are unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 11:54:42 +02:00
Paddy 33489585a0 Model permissions as (app, role) and name passkeys from their AAGUID
Two changes to the admin module, both made now because it is not deployed yet
and neither is free later.

Permissions were "an app", with a `role` column reserved for a future
fine-grained model. Reviewing whether that reservation was enough found three
problems:

- Every row was written with role = 'admin', hardcoded, and the column
  defaulted to it. On a `tickets` row that reads as "tickets administrator"
  when it only ever meant "has access", and once real roles existed there
  would have been no way to tell an old plain grant from a deliberate one.
- The role never left the database. /admin/me, the user list, the user detail
  and both write endpoints all spoke apps: AppName[]. Adding roles would have
  been a breaking change to /admin/me - and after the cutover that endpoint
  has two more consumers, turning a local edit into a coordinated deploy of
  three apps.
- The key (user_id, app) allowed one role per app, i.e. a tier rather than a
  set of capabilities. Choosing later means an ALTER on a live table.

So: the key is now (user_id, app, role), the role is `access`, and APP_ROLES
in admin.schema.ts is the contract - a role not listed there is rejected with
400 rather than written. permissions: [{app, role}] is on the wire alongside
the derived apps: AppName[], which is kept because the three frontends only
ever ask "may I show this app?". Both write endpoints accept either shape, and
the invitation column (now `permissions`) is parsed leniently: invitations live
seven days, so a deploy that changes the shape has in-flight rows in the old
one. requireAppAccess(app, role?) takes an optional role; nothing passes one
yet.

countActiveAdminsForUpdate now counts DISTINCT users rather than rows. With
several roles per app, counting rows would make a single admin holding two
roles look like two admins and defeat the last-admin guard at exactly the
moment it matters.

Separately, passkey registration now fills `name` from the authenticator's
AAGUID via registration.afterVerification and better-auth's own
getAuthenticatorName, yielding "1Password", "iCloud Keychain", "Windows Hello".
Without it the column stayed NULL and the account page could only label every
passkey "Passkey" - useless when someone has to remove the one on the device
they just lost. A client-supplied name still wins; an unknown AAGUID still
leaves it blank.

148 unit tests (up from 131, including the new admin.schema.test.ts) and 41
integration tests pass. The integration suite applies sql/admin/001_init.sql,
so the new key is exercised rather than trusted.

No production migration is needed - the module is not deployed. An existing dev
database needs three statements: set role = 'access', drop and re-add the
primary key, rename invitations.apps to permissions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 11:23:08 +02:00
Paddy dbcd5b56f6 Harden the admin module after a fresh-context review
Six defects found by an independent review of 7aac07a.

Environment handling now fails safe. NODE_ENV=production was gating the
signing key, the cookie domain, the CORS origin list and invitation-token
logging all at once, and it was documented nowhere - an unset value, which is
what a fresh Plesk vhost gives you, silently degraded all four. Only
'development' and 'test' relax anything now; everything else, unset included,
is strict. The hardcoded fallback secret is gone (dev gets a random
per-process one, so no committed value can ever sign a production cookie),
and invitation-link logging is an explicit ADMIN_LOG_INVITE_LINKS opt-in that
is refused in strict mode.

Rate limiting no longer collapses into a single global bucket. Without
trustedProxies, better-auth rejects a multi-value x-forwarded-for, resolves no
client IP, and keys every request to "no-trusted-ip" - where /sign-in/*
allows 3 requests per 10 seconds, so one noisy client could lock the whole
organisation out. CLIENT_IP_HEADERS and TRUSTED_PROXY_IPS make this explicit,
the unspecified x-forwarded-for fallback is gone, and strict mode warns at
boot when no trusted proxy is configured.

Invite acceptance is transactional. The user and its credential account go in
one runWithTransaction, as better-auth's own sign-up route does. A transaction
cannot span the permission and invitation writes - those use this module's own
pool - so a failure there is compensated: the user row is deleted and the
invitation un-marked, so the link works again instead of leaving the invitee
with a burnt token and an account no route can repair.

The last-admin guards were check-then-act. Two admins each removing the
other's admin permission could both pass the check and both commit, leaving
nobody able to administer anything. The count now runs inside the write
transaction under SELECT ... FOR UPDATE.

Also: lastSignInAt filtered expired sessions in the detail endpoint but not
the list, so the two disagreed; and the integration suite never reset
rateLimit, leaving it one added sign-in away from 429s that look like auth
bugs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 18:28:52 +02:00
Paddy 7aac07a013 Add admin identity module: better-auth, per-app permissions, invitations
Introduces src/models/admin/, a dedicated identity and permissions module on
its own nachklang_admin database, and the shared authenticator that feedback
and tickets will move onto in the cutover step. Nothing swaps over yet:
feedback.auth.ts and tickets.auth.ts still authenticate against the legacy
calendar sessions, so production behaviour is unchanged.

- better-auth 1.7 mounted at /admin/auth/*, sessions as httpOnly cookies
  scoped to .nachklang.art so one sign-in covers every *.nachklang.art app.
- Accounts are invite-only: public sign-up is disabled, and the invitations
  plugin is the only code that creates users. Tokens are stored as SHA-256
  hashes and travel in the request body, never in a URL.
- Per-app permissions in user_app_permissions; requireAppAccess(app) queries
  the database on every request (no cookie cache) so disabling a user or
  revoking a session takes effect immediately.
- ADMIN_BOOTSTRAP_EMAIL guarantees a way in on an empty database, idempotently
  and without crashing the API if the database is unreachable at boot.
- Guards prevent an admin from removing their own admin permission, disabling
  themselves, or stripping the last active admin.

The admin pool uses the callback-style mysql2, not mysql2/promise: Kysely's
MysqlDialect drives the pool with callbacks, and the promise wrapper ignores
them, so every query hangs silently. Only the integration tests caught this.

Schema in sql/admin/001_init.sql, derived from getAuthTables() on the
installed better-auth rather than the published CLI, which lags the library
and omits account.issuer.

app.ts is split into src/app.factory.ts so the integration tests drive the
real middleware order rather than a copy of it.

Tests: 131 unit, plus 41 integration tests against a throwaway MariaDB
started by test/integration/setup.ts (docker or podman).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 18:03:08 +02:00
41 changed files with 1915 additions and 1827 deletions
+10 -23
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` | | Router | `src/models/calendar/Calendar.router.ts`, `…/events/events.router.ts`, `…/users/users.router.ts` |
| Services | `…/events/events.service.ts`, `…/events/credentials.service.ts`, `…/events/icalgenerator.service.ts` | | Services | `…/events/events.service.ts`, `…/users/users.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:** One, since the calendar migration completed. **Auth model:** Two of them, on purpose.
*Admin module (`src/models/admin/`)*used by every app: calendar, feedback, tickets and the *Admin module (`src/models/admin/`)*the current one, used by feedback, tickets and the
admin app itself. better-auth 1.7 on its own `nachklang_admin` database (Kysely + mysql2; every admin app. 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,24 +59,11 @@ 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.
*The calendar* used to be the exception - its own `users`/`sessions` tables, and a session *Legacy calendar* — unchanged: users need a `@nachklang.art` email, and after activation
token passed in **query parameters**. That is gone: `docs/calendar-auth-migration.md` records get a session token (30-day window, hash + IP stored in the DB), passed as query
the migration, finished 2026-09-06. Writes sit behind `requireAppAccess('calendar')`; reads parameters. Migration is planned but not started: `docs/calendar-auth-migration.md`.
resolve the same cookie optionally, because one URL serves an anonymous visitor, an iCal Credentials for non-user calendar access (`MEMBER_CREDENTIAL`, `CHOIR_CREDENTIAL`,
subscription and a signed-in editor. `MANAGEMENT_CREDENTIAL`) come from `.env`.
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) => ...)`;
+32 -31
View File
@@ -5,28 +5,15 @@ These items were identified during a security review on 2026-05-02 and conscious
--- ---
## 1. Session credentials in URL query parameters (logged-in users) — CLOSED 2026-09-06 ## 1. Session credentials in URL query parameters (logged-in users)
**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` were read from query parameters, which meant they appeared in `sessionId` and `sessionKey` are currently read from query parameters, which means they appear in server access logs, browser history, proxy logs, and `Referer` headers.
server access logs, browser history, proxy logs, and `Referer` headers.
**Fixed** by step 4 of `docs/calendar-auth-migration.md`: the calendar's write routes now sit **Fix:** Move to request headers (`X-Session-Id` / `X-Session-Key`) or the request body. Requires a corresponding frontend update.
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.
Two things this did *not* change, both deliberate: > Note: the shared calendar `password` parameter in query params is intentional (iCal clients don't support headers) and is acceptable for the current setup.
- 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.
--- ---
@@ -37,29 +24,43 @@ Two things this did *not* change, both deliberate:
- `PUT /move/:eventId` (move) - `PUT /move/:eventId` (move)
- `DELETE /:eventId` (delete) - `DELETE /:eventId` (delete)
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. 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.
**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. **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.
--- ---
## 3. Activation token has no expiry — CLOSED 2026-09-06 ## 3. Activation token has no expiry
**File:** ~~`src/models/calendar/users/users.service.ts`~~ — deleted. > **Superseded for new accounts (2026-09-05).** The admin module
> (`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`.
The e-mail activation link was valid indefinitely. Closed not by adding an expiry but by **File:** `src/models/calendar/users/users.service.ts``createUser` / `activateUser`
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.
Any activation link still sitting in an inbox now 404s. It only ever activated a legacy The email activation link is valid indefinitely. Acceptable for a small, trusted userbase.
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 — CLOSED 2026-09-06 ## 4. Password reset token has no expiry
**File:** ~~`src/models/calendar/users/users.service.ts`~~ — deleted. > **Superseded for new accounts (2026-09-05).** Password resets for admin-module accounts
> 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.
Same as item 3: `pw_reset_token_hash` never expired, and the code that set it no longer **File:** `src/models/calendar/users/users.service.ts``initiatePasswordReset` / `finalizePasswordReset`
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.
+46 -36
View File
@@ -1,13 +1,5 @@
-- Local dev only. Derived from the real schema, which was provided directly by -- Local dev only. Real schema, provided directly by the repo owner
-- the repo owner (calendars, events, event_versions, and the sessions/users -- (calendars, events, event_versions, sessions, users) - not a guess.
-- 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` (
@@ -17,20 +9,41 @@ 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(),
-- Bridge to the admin module's user ids; see sql/calendar/001_add_admin_user_bridge.sql. `created_by_id` int(11) NOT NULL,
`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_created_by_user_idx` (`created_by_user_id`), KEY `events_users_user_id_fk` (`created_by_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` (
@@ -44,16 +57,14 @@ 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,
-- Bridge to the admin module's user ids; see sql/calendar/001_add_admin_user_bridge.sql. `version_created_by_id` int(11) DEFAULT NULL,
`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_created_by_user_idx` (`version_created_by_user_id`), KEY `event_versions_users_user_id_fk` (`version_created_by_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
@@ -63,17 +74,16 @@ INSERT INTO calendars (calendar_id, name, includes_calendars) VALUES
(4, 'choir', '[]'), (4, 'choir', '[]'),
(5, 'birthdays', '[]'); (5, 'birthdays', '[]');
-- Two events carry only an archived creator name, as every pre-cutover event -- Dev admin, password: devpassword
-- does, and one carries an admin user id whose name is resolved live. Dev INSERT INTO users (email, password_hash, full_name, is_active) VALUES
-- therefore exercises both name sources rather than only one. The one with an ('dev@nachklang.art', '$2b$10$vmj7POS/68SGE.eI7pGjMegrw0vNNZ2HVSUTra5NRsl8iOLwiMgZK', 'Dev Admin', 1);
-- 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 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 INSERT INTO events (calendar_id, uuid, 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', 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'); (1, UUID(), 1);
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);
+34 -238
View File
@@ -1,27 +1,8 @@
# Migrating the Calendar domain onto the admin identity module # Migrating the Calendar domain onto the admin identity module
Status: **complete and deployed, 2026-09-06.** Every step is live. Step 2 was dropped by Status: **not started.** Written 2026-09-05 alongside the admin module (step 2 of
decision and part of step 5 brought forward; the rest went out as written. `docs/plan-admin-auth.md` in the nachklang-admin repo), which deliberately left the
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
@@ -57,222 +38,37 @@ 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.~~ **Done 2026-09-06**, as VARCHAR(36) NULL`, indexed. Nothing reads it yet.
`sql/calendar/001_add_admin_user_bridge.sql` - the first migration this repo owns for the 2. **Map the accounts.** For every legacy `users` row that should survive, invite the
calendar schema, mirrored into `docker/init/01-calendar-schema-dev.sql`. It covers both person through the admin UI. On acceptance, backfill `events.created_by_user_id` from
`events.created_by_user_id` and `event_versions.version_created_by_user_id`, and carries `events.created_by_id` via an email-to-new-id mapping. Everyone not re-invited keeps
no foreign key (see "What the code actually looks like" below). The dev seed leaves two working on the legacy path until step 4.
events on the legacy path and gives one an admin id, so step 3's dual-read has both cases 3. **Dual-read.** Change `events.service.ts` to prefer `created_by_user_id` and fall back
to exercise. Verified by applying the pre-migration schema and then the migration to a to `created_by_id`. Writes fill both. This is the only step that is temporary code, and
throwaway MariaDB 11 container, and diffing `SHOW CREATE TABLE` against a fresh dev it should carry a removal note pointing at step 5.
schema: identical. Applied to the running dev database on the same day; a dev container 4. **Switch the routes.** Replace the query-parameter session checks in
created before then needs it applied, or recreating. `events.router.ts` and `users.router.ts` with `requireAppAccess('calendar')`, and change
2. ~~**Map the accounts.**~~ **Dropped 2026-09-06.** There is no backfill: since the the Angular frontend to `withCredentials: true` against the same origin list. Deploy the
creator is only ever a display name (see below), old events keep resolving through the API first; the calendar frontend is broken between the two deploys, so pick a quiet
legacy join until step 5 and then simply lose the name. Re-inviting the people who time. This closes `DEFERRED_SECURITY.md` item 1.
actually still need calendar access remains an operational task, but it is no longer a 5. **Drop the legacy path.** Remove `users.service.ts`'s session handling, the `sessions`
migration step and nothing is blocked on it. table, `created_by_id`, and the dual-read from step 3. Legacy `/calendar/users/*` stays
3. **Dual-read.** ~~Change `events.service.ts` to prefer `created_by_user_id` and fall back only if something still calls it - otherwise delete it too. `X-Session-Id` /
to `created_by_id`. Writes fill both.~~ **Done 2026-09-06.** `events.service.ts` now reads `X-Session-Key` can then come out of the CORS `allowedHeaders` list in
both columns and prefers the admin one, resolving the name through a single `src/app.factory.ts`.
`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
**Settled 2026-09-06:** - **The shared calendar credentials.** Do `MEMBER_CREDENTIAL` and friends stay as a
separate mechanism (they serve people with no account at all, and iCal clients that
- **The shared calendar credentials keep working, but only for iCal.** The web app goes cannot send headers), or do read-only accounts replace them? This is a product decision,
cookie-only at step 4; `MEMBER_CREDENTIAL` and friends survive on not a technical one, and it decides how much of `credentials.service.ts` survives.
`GET /calendar/events/{calendar}/ical`, which is the one case where the client genuinely - **The iCal export.** `GET /calendar/events/{calendar}/ical` takes a password in the query
cannot send a cookie. Everything else in `credentials.service.ts` goes with step 5. string on purpose, because iCal clients cannot send headers. Cookie sessions do not help
`public` stays anonymous everywhere - see the note under step 4. here; this endpoint likely keeps its own scheme.
- **The iCal export keeps its own scheme.** Same reasoning; it is the reason the shared - **Which legacy accounts to keep.** Step 2 is the moment to not re-invite people who no
credentials survive at all rather than an exception to their removal. longer need access.
- **No account backfill.** See step 2 above. - **`event_versions.version_created_by_id`.** The same INT reference again, joined in
- **Pre-cutover authorship is archived, not discarded.** `events.created_by_name` and `events.service.ts` for the "last modified by" name. It has to move with `events`, and it
`event_versions.version_created_by_name`, backfilled once by migration 002 and never is the reason step 1's bridging column needs a sibling on `event_versions`.
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`.
@@ -1,38 +0,0 @@
-- 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`);
@@ -1,42 +0,0 @@
-- 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;
@@ -1,32 +0,0 @@
-- 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
@@ -1,60 +0,0 @@
-- 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`;
+3 -6
View File
@@ -63,12 +63,9 @@ 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({
// Content-Type alone. X-Session-Id / X-Session-Key were allowed here // X-Session-* stay allowed until the calendar module is migrated off the
// through the two cutovers so that a browser still holding a pre-cutover // legacy header sessions (see docs/calendar-auth-migration.md).
// bundle got a clean 401 rather than a confusing CORS preflight failure. allowedHeaders: ['Content-Type', 'X-Session-Id', 'X-Session-Key'],
// 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,
+1 -5
View File
@@ -33,11 +33,7 @@ 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
+2 -23
View File
@@ -73,29 +73,8 @@ 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.
* The apps whose frontends may talk to /admin/* with credentials. export const APP_ORIGINS = parseList(process.env.APP_ORIGINS, []).map(origin => origin.replace(/\/$/, ''));
*
* 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,30 +437,3 @@ 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]));
};
+2 -7
View File
@@ -5,6 +5,7 @@ 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
@@ -12,13 +13,7 @@ import {eventsRouter} from './events/events.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,55 +1,73 @@
import * as dotenv from 'dotenv'; import * as dotenv from 'dotenv';
import * as UserService from '../users/users.service.js';
dotenv.config(); dotenv.config();
/** /**
* The shared calendar passwords, and nothing else. * Checks if the password gives admin privileges (view / create / edit / delete)
* * @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) => {
const credentialFor = (calendarName: string): string | undefined => { if(sessionId) {
switch (calendarName) { let user = await UserService.checkSession(sessionId, sessionKey, ip);
case 'members': return user?.isActive ?? false;
return process.env.MEMBER_CREDENTIAL;
case 'choir':
case 'birthdays':
return process.env.CHOIR_CREDENTIAL;
case 'management':
return process.env.MANAGEMENT_CREDENTIAL;
default:
return undefined;
} }
}; return false;
}
/** /**
* Whether the given shared password opens the given calendar. Answers false * Checks if the password gives member view privileges
* for an unknown calendar, and - importantly - for a calendar whose credential * @param password
* 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> => { export const checkMemberPrivileges = async (sessionId: string, sessionKey: string, password: string, ip: string) => {
if (calendarName === 'public') { if(sessionId) {
return true; let user = await UserService.checkSession(sessionId, sessionKey, ip);
return user?.isActive ?? false;
} }
const expected = credentialFor(calendarName); return password == process.env.MEMBER_CREDENTIAL;
if (!expected) { }
/**
* 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) {
case 'public':
return true;
case 'members':
return await checkMemberPrivileges(sessionId, sessionKey, password, ip);
case 'choir':
return await checkChoirPrivileges(sessionId, sessionKey, password, ip);
case 'management':
return await checkManagementPrivileges(sessionId, sessionKey, password, ip);
case 'birthdays':
return await checkChoirPrivileges(sessionId, sessionKey, password, ip);
default:
return false; return false;
} }
}
return password === expected;
};
+11 -15
View File
@@ -14,6 +14,7 @@
* - endDateTime * - endDateTime
* - createdDate * - createdDate
* - location * - location
* - createdById
* - url * - url
* - wholeDay * - wholeDay
* properties: * properties:
@@ -64,20 +65,18 @@
* 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"
* createdByUserId: * createdById:
* type: string * type: integer
* nullable: true * description: The ID of the user who created the event
* description: The admin-module user id of the creator, once it has one * example: 456
* 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"
* lastModifiedByUserId: * lastModifiedById:
* type: string * type: integer
* nullable: true * description: The ID of the user who last modified the event
* description: The admin-module user id of the last editor, once it has one * example: 456
* 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
@@ -103,13 +102,10 @@ 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;
createdByUserId?: string | null; createdById: number;
lastModifiedBy?: string; lastModifiedBy?: string;
lastModifiedByUserId?: string | null; lastModifiedById?: number;
url: string; url: string;
wholeDay: boolean; wholeDay: boolean;
repeatFrequency: string; repeatFrequency: string;
+184 -195
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 {requireAppAccess, resolveAccess, AdminAccess} from '../../admin/admin.middleware.js'; import * as UserService from '../users/users.service.js';
import {Guid} from 'guid-typescript'; import {Guid} from 'guid-typescript';
import logger from '../../../middleware/logger.js'; import logger from '../../../middleware/logger.js';
@@ -29,44 +29,6 @@ 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
@@ -77,10 +39,7 @@ const signedInEditor = async (req: Request): Promise<AdminAccess | null> => {
* /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: > * description: Returns all events from the specified calendar in JSON format. Authentication required.
* 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:
@@ -89,13 +48,23 @@ const signedInEditor = async (req: Request): Promise<AdminAccess | null> => {
* required: true * required: true
* schema: * schema:
* type: string * type: string
* enum: [public, members, choir, management, birthdays] * enum: [public, members, choir, management]
* 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: The calendar's shared password, for callers with no account * description: Password for calendar access (if not using session authentication)
* responses: * responses:
* 200: * 200:
* description: Success * description: Success
@@ -140,7 +109,10 @@ 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.'});
@@ -154,19 +126,23 @@ eventsRouter.get('/:calendar/json', async (req: Request, res: Response) => {
let calendarId: number = calendarNames.get(calendarName)!.id; let calendarId: number = calendarNames.get(calendarName)!.id;
const editor = await signedInEditor(req); let user = await UserService.checkSession(sessionId, sessionKey, ip);
// Not signed in: fall back to the shared password for this calendar. // If no user was found, check if the password gives access to the calendar
if (!editor && ! await CredentialService.hasAccess(calendarName, password)) { if(user === null || !user.isActive) {
if (! await CredentialService.hasAccess(calendarName, sessionId, sessionKey, password, ip)) {
res.status(403).send({'message': 'You do not have access to the specified calendar.'}); res.status(403).send({'message': 'You do not have access to the specified calendar.'});
return; return;
} }
}
// Editors get the admin view (drafts included, calendar includes ignored); let events: Event[];
// everyone else gets published events only.
let events: Event[] = editor if(user?.isActive) {
? await EventService.getAllEventsAdmin(calendarId) events = await EventService.getAllEventsAdmin(calendarId);
: await EventService.getAllEvents(calendarId); } else {
events = await EventService.getAllEvents(calendarId);
}
// Send the events back // Send the events back
res.status(200).send(events); res.status(200).send(events);
@@ -182,10 +158,7 @@ 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: > * description: Returns the next upcoming event from the specified calendar. Authentication required.
* 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:
@@ -194,13 +167,23 @@ eventsRouter.get('/:calendar/json', async (req: Request, res: Response) => {
* required: true * required: true
* schema: * schema:
* type: string * type: string
* enum: [public, members, choir, management, birthdays] * enum: [public, members, choir, management]
* 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: The calendar's shared password, for callers with no account * description: Password for calendar access (if not using session authentication)
* responses: * responses:
* 200: * 200:
* description: Success * description: Success
@@ -259,7 +242,10 @@ 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.'});
@@ -273,19 +259,7 @@ eventsRouter.get('/:calendar/json/next', async (req: Request, res: Response) =>
let calendarId: number = calendarNames.get(calendarName)!.id; let calendarId: number = calendarNames.get(calendarName)!.id;
// Holding the calendar's shared password, or signed in. The password path if (! await CredentialService.hasAccess(calendarName, sessionId, sessionKey, password, ip)) {
// 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;
} }
@@ -316,10 +290,7 @@ 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: > * description: Returns all events from the specified calendar in iCal format for calendar applications. Authentication required.
* 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:
@@ -328,13 +299,23 @@ 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, birthdays] * enum: [public, members, choir, management]
* 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: The calendar's shared password, for callers with no account * description: Password for calendar access (if not using session authentication)
* responses: * responses:
* 200: * 200:
* description: Success - returns iCal file * description: Success - returns iCal file
@@ -384,7 +365,10 @@ 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.'});
@@ -398,19 +382,7 @@ eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => {
let calendarId: number = calendarNames.get(calendarName)!.id; let calendarId: number = calendarNames.get(calendarName)!.id;
// Holding the calendar's shared password, or signed in. The password path if (! await CredentialService.hasAccess(calendarName, sessionId, sessionKey, password, ip)) {
// 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;
} }
@@ -441,11 +413,22 @@ 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. Requires a signed-in account with the calendar permission. * description: Creates a new event in the specified calendar. Authentication required.
* tags: * tags:
* - calendar * - calendar
* security: * parameters:
* - AdminSessionCookie: [] * - 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:
@@ -512,32 +495,16 @@ eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => {
* message: * message:
* type: string * type: string
* example: Required parameters missing * example: Required parameters missing
* 401:
* description: Unauthorized - not signed in
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: UNAUTHORIZED
* message:
* type: string
* example: Anmeldung erforderlich.
* 403: * 403:
* description: Forbidden - the account lacks the calendar permission * description: Forbidden - no access to create events
* content: * content:
* application/json: * application/json:
* schema: * schema:
* type: object * type: object
* properties: * properties:
* status:
* type: string
* example: FORBIDDEN
* message: * message:
* type: string * type: string
* example: "Für diesen Bereich fehlt dir die Berechtigung." * example: You do not have access to the specified calendar.
* 500: * 500:
* description: Server error * description: Server error
* content: * content:
@@ -555,9 +522,19 @@ 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('/', requireCalendarAccess, async (req: Request, res: Response) => { eventsRouter.post('/', async (req: Request, res: Response) => {
try { try {
const admin = adminOf(res); // Get params
let sessionId: string = req.query.sessionId as string ?? '';
let sessionKey: string = req.query.sessionKey as string ?? '';
let ip: string = req.socket.remoteAddress ?? '';
let user = await UserService.checkSession(sessionId, sessionKey, ip);
if (!user?.isActive) {
res.status(403).send({'message': 'You do not have access to the specified calendar.'});
return;
}
if ( if (
req.body.calendarId === undefined || req.body.calendarId === undefined ||
@@ -579,9 +556,7 @@ eventsRouter.post('/', requireCalendarAccess, 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 ?? '',
// 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 ?? '',
@@ -610,11 +585,9 @@ eventsRouter.post('/', requireCalendarAccess, 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. Requires a signed-in account with the calendar permission. * description: Updates an existing event with the provided data. Authentication required.
* tags: * tags:
* - calendar * - calendar
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - in: path * - in: path
* name: eventId * name: eventId
@@ -622,6 +595,18 @@ eventsRouter.post('/', requireCalendarAccess, 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:
@@ -654,6 +639,9 @@ eventsRouter.post('/', requireCalendarAccess, 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"
@@ -685,32 +673,16 @@ eventsRouter.post('/', requireCalendarAccess, async (req: Request, res: Response
* message: * message:
* type: string * type: string
* example: Required parameters missing * example: Required parameters missing
* 401:
* description: Unauthorized - not signed in
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: UNAUTHORIZED
* message:
* type: string
* example: Anmeldung erforderlich.
* 403: * 403:
* description: Forbidden - the account lacks the calendar permission * description: Forbidden - no access to update events
* content: * content:
* application/json: * application/json:
* schema: * schema:
* type: object * type: object
* properties: * properties:
* status:
* type: string
* example: FORBIDDEN
* message: * message:
* type: string * type: string
* example: "Für diesen Bereich fehlt dir die Berechtigung." * example: You do not have access to the specified calendar.
* 500: * 500:
* description: Server error * description: Server error
* content: * content:
@@ -728,9 +700,19 @@ eventsRouter.post('/', requireCalendarAccess, async (req: Request, res: Response
* type: string * type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3 * example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/ */
eventsRouter.put('/:eventId', requireCalendarAccess, async (req: Request, res: Response) => { eventsRouter.put('/:eventId', async (req: Request, res: Response) => {
try { try {
const admin = adminOf(res); // Get params
let sessionId: string = req.query.sessionId as string ?? '';
let sessionKey: string = req.query.sessionKey as string ?? '';
let ip: string = req.socket.remoteAddress ?? '';
let user = await UserService.checkSession(sessionId, sessionKey, ip);
if (!user?.isActive) {
res.status(403).send({'message': 'You do not have access to the specified calendar.'});
return;
}
if ( if (
req.params.eventId === undefined || req.params.eventId === undefined ||
@@ -753,9 +735,8 @@ eventsRouter.put('/:eventId', requireCalendarAccess, async (req: Request, res: R
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 ?? '',
// LEGACY createdById is deliberately not set: there is no calendar createdBy: req.body.createdBy ?? '',
// user id any more, and migration 003 made the column nullable. createdById: user.userId ?? -1,
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 ?? '',
@@ -787,11 +768,9 @@ eventsRouter.put('/:eventId', requireCalendarAccess, async (req: Request, res: R
* /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 event to a different calendar. Requires a signed-in account with the calendar permission. * description: Moves an existing event to a different calendar. Authentication required.
* tags: * tags:
* - calendar * - calendar
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - in: path * - in: path
* name: eventId * name: eventId
@@ -799,6 +778,18 @@ eventsRouter.put('/:eventId', requireCalendarAccess, async (req: Request, res: R
* 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:
@@ -829,6 +820,9 @@ eventsRouter.put('/:eventId', requireCalendarAccess, async (req: Request, res: R
* 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"
@@ -860,32 +854,16 @@ eventsRouter.put('/:eventId', requireCalendarAccess, async (req: Request, res: R
* message: * message:
* type: string * type: string
* example: Required parameters missing * example: Required parameters missing
* 401:
* description: Unauthorized - not signed in
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: UNAUTHORIZED
* message:
* type: string
* example: Anmeldung erforderlich.
* 403: * 403:
* description: Forbidden - the account lacks the calendar permission * description: Forbidden - no access to move events
* content: * content:
* application/json: * application/json:
* schema: * schema:
* type: object * type: object
* properties: * properties:
* status:
* type: string
* example: FORBIDDEN
* message: * message:
* type: string * type: string
* example: "Für diesen Bereich fehlt dir die Berechtigung." * example: You do not have access to the specified calendar.
* 500: * 500:
* description: Server error * description: Server error
* content: * content:
@@ -903,9 +881,19 @@ eventsRouter.put('/:eventId', requireCalendarAccess, async (req: Request, res: R
* type: string * type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3 * example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/ */
eventsRouter.put('/move/:eventId', requireCalendarAccess, async (req: Request, res: Response) => { eventsRouter.put('/move/:eventId', async (req: Request, res: Response) => {
try { try {
const admin = adminOf(res); // Get params
let sessionId: string = req.query.sessionId as string ?? '';
let sessionKey: string = req.query.sessionKey as string ?? '';
let ip: string = req.socket.remoteAddress ?? '';
let user = await UserService.checkSession(sessionId, sessionKey, ip);
if (!user?.isActive) {
res.status(403).send({'message': 'You do not have access to the specified calendar.'});
return;
}
if ( if (
req.params.eventId === undefined || req.params.eventId === undefined ||
@@ -925,9 +913,8 @@ eventsRouter.put('/move/:eventId', requireCalendarAccess, async (req: Request, r
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 ?? '',
// LEGACY createdById is deliberately not set: there is no calendar createdBy: req.body.createdBy ?? '',
// user id any more, and migration 003 made the column nullable. createdById: user.userId ?? -1,
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 ?? '',
@@ -957,11 +944,9 @@ eventsRouter.put('/move/:eventId', requireCalendarAccess, async (req: Request, r
* /calendar/events/{eventId}: * /calendar/events/{eventId}:
* delete: * delete:
* summary: Delete an event * summary: Delete an event
* description: Deletes an event. Requires a signed-in account with the calendar permission. * description: Deletes an existing event. Authentication required.
* tags: * tags:
* - calendar * - calendar
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - in: path * - in: path
* name: eventId * name: eventId
@@ -969,6 +954,18 @@ eventsRouter.put('/move/:eventId', requireCalendarAccess, async (req: Request, r
* 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
@@ -990,32 +987,16 @@ eventsRouter.put('/move/:eventId', requireCalendarAccess, async (req: Request, r
* message: * message:
* type: string * type: string
* example: Required parameters missing * example: Required parameters missing
* 401:
* description: Unauthorized - not signed in
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: UNAUTHORIZED
* message:
* type: string
* example: Anmeldung erforderlich.
* 403: * 403:
* description: Forbidden - the account lacks the calendar permission * description: Forbidden - no access to delete events
* content: * content:
* application/json: * application/json:
* schema: * schema:
* type: object * type: object
* properties: * properties:
* status:
* type: string
* example: FORBIDDEN
* message: * message:
* type: string * type: string
* example: "Für diesen Bereich fehlt dir die Berechtigung." * example: You do not have access to the specified calendar.
* 500: * 500:
* description: Server error * description: Server error
* content: * content:
@@ -1033,9 +1014,19 @@ eventsRouter.put('/move/:eventId', requireCalendarAccess, async (req: Request, r
* type: string * type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3 * example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/ */
eventsRouter.delete('/:eventId', requireCalendarAccess, async (req: Request, res: Response) => { eventsRouter.delete('/:eventId', async (req: Request, res: Response) => {
try { try {
const admin = adminOf(res); // Get params
let sessionId: string = req.query.sessionId as string ?? '';
let sessionKey: string = req.query.sessionKey as string ?? '';
let ip: string = req.socket.remoteAddress ?? '';
let user = await UserService.checkSession(sessionId, sessionKey, ip);
if (!user?.isActive) {
res.status(403).send({'message': 'You do not have access to the specified calendar.'});
return;
}
if ( if (
req.params.eventId === undefined req.params.eventId === undefined
@@ -1055,9 +1046,7 @@ eventsRouter.delete('/:eventId', requireCalendarAccess, async (req: Request, res
createdDate: new Date(), createdDate: new Date(),
location: '', location: '',
createdBy: '', 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: '', url: '',
wholeDay: false, wholeDay: false,
repeatFrequency: '', repeatFrequency: '',
+145 -150
View File
@@ -2,42 +2,28 @@ 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();
/** /**
* How an event's creator is resolved, after step 5 of * Returns all events for the given calendar
* docs/calendar-auth-migration.md removed the legacy path. * @param calendarId The calendar Id
*
* 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 = `
* The one SELECT the four read paths share; callers append their own WHERE and 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
* 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
@@ -45,15 +31,15 @@ const EVENT_SELECT = `
) 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) {
* Maps a result row to an Event. `status` is included only where it always eventRows.push({
* was: the admin views and the by-id lookup return it, the two public listings
* do not.
*/
const toEvent = (row: any, includeStatus: boolean): Event => {
const event: Event = {
eventId: row.event_id, eventId: row.event_id,
calendarId: row.calendar_id, calendarId: row.calendar_id,
uuid: row.uuid, uuid: row.uuid,
@@ -64,101 +50,17 @@ const toEvent = (row: any, includeStatus: boolean): Event => {
createdDate: row.created_date, createdDate: row.created_date,
lastModifiedDate: row.version_created_at, lastModifiedDate: row.version_created_at,
location: row.location, location: row.location,
// The archived name. resolveAdminNames below overwrites it for rows that
// carry an admin id, which is the only source that follows a rename.
createdBy: row.created_by_name, createdBy: row.created_by_name,
createdByUserId: row.created_by_user_id ?? null, createdById: row.created_by_id,
lastModifiedBy: row.version_created_by_name, lastModifiedBy: row.last_modified_by_name,
lastModifiedByUserId: row.version_created_by_user_id ?? null, lastModifiedById: row.version_created_by_id,
url: row.url, url: row.url,
wholeDay: row.whole_day, wholeDay: row.whole_day,
repeatFrequency: row.repeat_frequency repeatFrequency: row.repeat_frequency
}; });
if (includeStatus) {
event.status = row.status;
} }
return event; return eventRows;
};
/**
* 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;
}
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 {
@@ -174,16 +76,48 @@ 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 = `${EVENT_SELECT} 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
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);
const events = eventsRes.map((row: any) => toEvent(row, true)); for (let row of eventsRes) {
await resolveAdminNames(events); eventRows.push({
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 events; return eventRows;
} catch (err) { } catch (err) {
throw err; throw err;
} finally { } finally {
@@ -202,7 +136,18 @@ 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 = `${EVENT_SELECT} 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
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);
@@ -210,10 +155,27 @@ export const getEventById = async (eventId: number): Promise<Event | null> => {
return null; return null;
} }
const event = toEvent(eventsRes[0], true); const row = eventsRes[0];
await resolveAdminNames([event]); return {
eventId: row.event_id,
return event; 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
} as Event;
} catch (err) { } catch (err) {
throw err; throw err;
} finally { } finally {
@@ -231,11 +193,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_user_id) VALUES (?,?,?) RETURNING event_id'; const eventsQuery = 'INSERT INTO events (calendar_id, uuid, created_by_id) VALUES (?,?,?) RETURNING event_id';
const eventsRes = await conn.execute(eventsQuery, [event.calendarId, eventUUID, event.createdByUserId ?? null]); const eventsRes = await conn.execute(eventsQuery, [event.calendarId, eventUUID, event.createdById]);
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 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 (?,?,?,?,?,?,?,?,?,?,?);'
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.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.commit(); await conn.commit();
@@ -256,8 +218,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_user_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_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.createdByUserId ?? null]); 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]);
await conn.commit(); await conn.commit();
@@ -278,8 +240,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_user_id) VALUES (?,?,?);' const versionQuery = 'INSERT INTO event_versions (event_id, status, version_created_by_id) VALUES (?,?,?);'
const versionRes = await conn.execute(versionQuery, [event.eventId, 'DELETED', event.createdByUserId ?? null]); const versionRes = await conn.execute(versionQuery, [event.eventId, 'DELETED', event.createdById]);
await conn.commit(); await conn.commit();
@@ -321,23 +283,56 @@ 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 calendars = await calendarsToFetch(conn, calendarId); 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 now = new Date(); const now = new Date();
const eventsQuery = `${EVENT_SELECT} 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
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, [calendars, now]); const eventsRes = await conn.query(eventsQuery, [calendarsToFetch, now]);
if (eventsRes.length === 0) { if (eventsRes.length === 0) {
return null; return null;
} }
const event = toEvent(eventsRes[0], false); const row = eventsRes[0];
await resolveAdminNames([event]); return {
eventId: row.event_id,
return event; 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
} as Event;
} catch (err) { } catch (err) {
throw err; throw err;
} finally { } finally {
@@ -0,0 +1,53 @@
/**
* @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;
}
@@ -0,0 +1,42 @@
/**
* @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
@@ -0,0 +1,671 @@
/**
* 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
@@ -0,0 +1,296 @@
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,6 +1,19 @@
/** /**
* @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
+5 -8
View File
@@ -26,8 +26,9 @@ 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]
* security: * parameters:
* - AdminSessionCookie: [] * - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* responses: * responses:
* 200: * 200:
* description: Success * description: Success
@@ -42,8 +43,6 @@ 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});
@@ -56,9 +55,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
@@ -71,8 +70,6 @@ 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,8 +18,9 @@ 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]
* security: * parameters:
* - AdminSessionCookie: [] * - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* responses: * responses:
* 200: * 200:
* description: Success * description: Success
@@ -31,14 +32,13 @@ 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]
* security: * parameters:
* - AdminSessionCookie: [] * - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* requestBody: * requestBody:
* required: true * required: true
* content: * content:
@@ -68,8 +68,6 @@ 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 {
@@ -103,9 +101,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
@@ -122,14 +120,12 @@ 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
@@ -142,15 +138,13 @@ 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
@@ -169,8 +163,6 @@ 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 {
@@ -222,9 +214,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
@@ -235,14 +227,12 @@ 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
@@ -267,8 +257,6 @@ 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 {
@@ -304,9 +292,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
@@ -329,8 +317,6 @@ 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 {
@@ -348,9 +334,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
@@ -361,15 +347,13 @@ 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
@@ -399,8 +383,6 @@ 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,13 +34,12 @@ 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]
* security: * parameters:
* - AdminSessionCookie: [] * - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* requestBody: * requestBody:
* required: true * required: true
* content: * content:
@@ -62,8 +61,6 @@ 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 {
@@ -97,9 +94,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
@@ -126,15 +123,13 @@ 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
@@ -147,8 +142,6 @@ 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,8 +34,6 @@ 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 {
@@ -57,9 +55,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
@@ -83,8 +81,6 @@ 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 {
@@ -105,9 +101,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
@@ -131,8 +127,6 @@ 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 {
@@ -153,9 +147,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
@@ -168,8 +162,6 @@ 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 {
@@ -195,9 +187,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
@@ -210,8 +202,6 @@ 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,15 +45,13 @@ 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
@@ -66,8 +64,6 @@ 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 {
+64 -24
View File
@@ -1,38 +1,78 @@
import {requireAppAccess} from '../admin/admin.middleware.js'; import express from 'express';
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. No route handler and no service outside this file may * authentication works today. No route handler and no service outside this
* read session headers or resolve a user itself. * file may import users.service, read session headers, or touch bcrypt.
* *
* Today: the shared admin identity in `src/models/admin/`. A session cookie * Today: reuses the existing Calendar users/sessions mechanism. Any
* set by /admin/auth on admin.nachklang.art, plus a `feedback` permission on * activated @nachklang.art account may administer feedback no roles.
* the account. Both are re-checked on every request, so disabling a user or * Migrating to Keycloak later means writing a keycloakJwtAuthenticator
* taking their feedback permission away takes effect immediately. * below and changing the one `activeAuthenticator` binding (plus the
* frontend's login route handler) nothing else in the feedback module
* needs to change.
* *
* Before 2026-09-06 this was a header session against the calendar users * Explicitly forbidden: accepting sessionId/sessionKey from query
* table, and any activated @nachklang.art account could administer feedback. * parameters, even "temporarily". That is the exact mistake documented in
* That is why the swap is a one-line binding: everything downstream only ever * DEFERRED_SECURITY.md item 1 for the Calendar domain, where credentials
* saw `requireAdminAuth` and `res.locals.admin`, and both still mean what * end up in access logs, browser history, proxy logs, and Referer headers.
* they meant. What changed is that access is now granted per user rather than * Headers only.
* 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 // The only thing the rest of the feedback module knows about an admin.
// 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 = AdminAccess (an AdminIdentity plus permissions), calls // res.locals.admin = AdminIdentity, calls next(). On failure: 401.
// next(). On failure: 401 when not signed in, 403 when signed in without the export const requireAdminAuth: express.RequestHandler = async (req, res, next) => {
// feedback permission. try {
export const requireAdminAuth = requireAppAccess('feedback'); 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);
}
};
+3 -4
View File
@@ -16,15 +16,14 @@ adminRouter.use(requireAdminAuth);
* get: * get:
* summary: Validate the current admin session * summary: Validate the current admin session
* tags: [tickets-admin] * tags: [tickets-admin]
* security: * parameters:
* - AdminSessionCookie: [] * - $ref: '#/components/parameters/SessionIdHeader'
* - $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});
+12 -20
View File
@@ -11,15 +11,14 @@ 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]
* security: * parameters:
* - AdminSessionCookie: [] * - $ref: '#/components/parameters/SessionIdHeader'
* - $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 {
@@ -36,15 +35,14 @@ 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]
* security: * parameters:
* - AdminSessionCookie: [] * - $ref: '#/components/parameters/SessionIdHeader'
* - $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 {
@@ -60,9 +58,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
@@ -77,8 +75,6 @@ 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 {
@@ -95,9 +91,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
@@ -127,8 +123,6 @@ 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 {
@@ -155,9 +149,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
@@ -170,8 +164,6 @@ 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,8 +33,6 @@ 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 {
@@ -52,9 +50,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
@@ -67,15 +65,13 @@ 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
@@ -109,8 +105,6 @@ 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 {
@@ -167,9 +161,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
@@ -192,8 +186,6 @@ 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 {
@@ -219,9 +211,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
@@ -238,8 +230,6 @@ 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 {
@@ -269,9 +259,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
@@ -288,8 +278,6 @@ 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,8 +33,6 @@ 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 {
@@ -53,8 +51,9 @@ 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]
* security: * parameters:
* - AdminSessionCookie: [] * - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* requestBody: * requestBody:
* required: true * required: true
* content: * content:
@@ -88,8 +87,6 @@ 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 {
@@ -115,8 +112,9 @@ 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]
* security: * parameters:
* - AdminSessionCookie: [] * - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* requestBody: * requestBody:
* required: true * required: true
* content: * content:
@@ -149,8 +147,6 @@ 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 {
@@ -173,9 +169,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
@@ -188,8 +184,6 @@ 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 {
@@ -211,9 +205,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
@@ -236,8 +230,6 @@ 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 {
+49 -17
View File
@@ -1,23 +1,20 @@
import {requireAppAccess} from '../admin/admin.middleware.js'; import express from 'express';
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 read session headers or * handler and no service outside this file may import users.service, read
* resolve a user itself. * session headers, or touch bcrypt.
* *
* Today: the shared admin identity in `src/models/admin/`. A session cookie * Today: reuses the existing Calendar users/sessions mechanism. Any
* set by /admin/auth on admin.nachklang.art, plus a `tickets` permission on * activated @nachklang.art account may administer vouchers - no roles, same
* the account. Both are re-checked on every request, so disabling a user or * policy as Feedback (see docs/plan-ticket-shop.md). A dedicated
* taking their tickets permission away takes effect immediately. * roles/permissions model is explicitly out of scope for v1.
* *
* Before 2026-09-06 this was a header session against the calendar users * Explicitly forbidden: accepting sessionId/sessionKey from query
* table, and any activated @nachklang.art account could administer vouchers * parameters - headers only (see DEFERRED_SECURITY.md item 1).
* (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 {
@@ -26,6 +23,41 @@ export interface AdminIdentity {
displayName: string; displayName: string;
} }
// On failure: 401 when not signed in, 403 when signed in without the tickets export type AdminAuthenticator = (req: express.Request) => Promise<AdminIdentity | null>;
// permission.
export const requireAdminAuth = requireAppAccess('tickets'); 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);
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,41 +78,6 @@ 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
@@ -1,107 +0,0 @@
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
@@ -1,49 +0,0 @@
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
@@ -1,224 +0,0 @@
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
@@ -1,169 +0,0 @@
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');
});
});
+84 -14
View File
@@ -1,18 +1,88 @@
import {vi, type Mock} from 'vitest'; import {vi, describe, it, expect, beforeEach, type Mock} from 'vitest';
import {Request, Response} from 'express';
vi.mock('../../src/models/admin/admin.auth.js', () => ({ vi.mock('../../src/models/calendar/users/users.service.js', () => ({
auth: {api: {getSession: vi.fn()}} checkSession: 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 UserService from '../../src/models/calendar/users/users.service.js';
import * as UsersService from '../../src/models/admin/users/users.admin.service.js'; import {requireAdminAuth, sessionHeaderAuthenticator} from '../../src/models/feedback/feedback.auth.js';
import {requireAdminAuth} from '../../src/models/feedback/feedback.auth.js';
import {describeAdminBinding} from '../admin/auth-binding.js';
describeAdminBinding('feedback', 'tickets', requireAdminAuth, () => ({ const mockCheckSession = UserService.checkSession as Mock;
getSession: auth.api.getSession as unknown as Mock,
loadAccess: UsersService.loadAccess as Mock const makeReq = (headers: Record<string, string>): Request => {
})); 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'});
});
});
+8 -24
View File
@@ -221,32 +221,16 @@ describe('requireAppAccess', () => {
expect(Array.isArray(res.body)).toBe(true); expect(Array.isArray(res.body)).toBe(true);
}); });
// The step 4 cutover (2026-09-06): the feedback and tickets admin areas now // Step 2 deliberately does NOT swap the feedback and tickets authenticators:
// sit behind this same gate, so one sign-in reaches every app the user has a // they still authenticate against the legacy calendar sessions, so an admin
// permission for - and reaches no further. Until step 4 these two returned // cookie means nothing to them yet. This asserts that boundary rather than
// 401 for an admin cookie, because each module still ran its own header // the end state - when step 4 lands, these two expectations become 200/403
// session against the calendar users table. // and this comment goes away.
it('lets an admin cookie into the feedback and tickets admin areas', async () => { it('leaves the feedback and tickets admin areas on their legacy authenticator', 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(200); expect((await user.agent.get('/feedback/admin/me')).status).toBe(401);
expect((await user.agent.get('/tickets/admin/me')).status).toBe(200); expect((await user.agent.get('/tickets/admin/me')).status).toBe(401);
});
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
@@ -1,18 +0,0 @@
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
}));