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