Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b4c8c91795 | |||
| d960ac8e24 | |||
| b848d6eab9 | |||
| 61d3883479 |
@@ -33,15 +33,15 @@ Express.js REST API in TypeScript with a service-oriented layering. Domains: `Ca
|
|||||||
|
|
||||||
| Layer | Location |
|
| Layer | Location |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Router | `src/models/calendar/Calendar.router.ts`, `…/events/events.router.ts` |
|
| Router | `src/models/calendar/Calendar.router.ts`, `…/events/events.router.ts`, `…/users/users.router.ts` |
|
||||||
| Services | `…/events/events.service.ts`, `…/events/credentials.service.ts`, `…/events/icalgenerator.service.ts` |
|
| Services | `…/events/events.service.ts`, `…/users/users.service.ts`, `…/events/credentials.service.ts`, `…/events/icalgenerator.service.ts` |
|
||||||
| DB pool | `src/models/calendar/Calendar.db.ts` (MariaDB, pool size 5) |
|
| DB pool | `src/models/calendar/Calendar.db.ts` (MariaDB, pool size 5) |
|
||||||
| Shared | `src/common/` (base route class, nodemailer wrapper), `src/middleware/logger.ts` (Winston) |
|
| Shared | `src/common/` (base route class, nodemailer wrapper), `src/middleware/logger.ts` (Winston) |
|
||||||
|
|
||||||
**Auth model:** One, since the calendar migration completed.
|
**Auth model:** Two of them, on purpose.
|
||||||
|
|
||||||
*Admin module (`src/models/admin/`)* — used by every app: calendar, feedback, tickets and the
|
*Admin module (`src/models/admin/`)* — the current one, used by feedback, tickets and the
|
||||||
admin app itself. better-auth 1.7 on its own `nachklang_admin` database (Kysely + mysql2; every
|
admin app. better-auth 1.7 on its own `nachklang_admin` database (Kysely + mysql2; every
|
||||||
other domain keeps the `mariadb` driver), mounted at `/admin/auth/*` for the auth handler
|
other domain keeps the `mariadb` driver), mounted at `/admin/auth/*` for the auth handler
|
||||||
and `/admin` for the JSON routes. Sessions are httpOnly cookies scoped to
|
and `/admin` for the JSON routes. Sessions are httpOnly cookies scoped to
|
||||||
`.nachklang.art`, so one sign-in covers every app. Accounts are **invite-only** — public
|
`.nachklang.art`, so one sign-in covers every app. Accounts are **invite-only** — public
|
||||||
@@ -59,24 +59,11 @@ two and the last-admin guard stops guarding; and both write endpoints accept
|
|||||||
same at the `access` role. `ADMIN_BOOTSTRAP_EMAIL`
|
same at the `access` role. `ADMIN_BOOTSTRAP_EMAIL`
|
||||||
makes sure someone can always get in on a fresh database.
|
makes sure someone can always get in on a fresh database.
|
||||||
|
|
||||||
*The calendar* used to be the exception - its own `users`/`sessions` tables, and a session
|
*Legacy calendar* — unchanged: users need a `@nachklang.art` email, and after activation
|
||||||
token passed in **query parameters**. That is gone: `docs/calendar-auth-migration.md` records
|
get a session token (30-day window, hash + IP stored in the DB), passed as query
|
||||||
the migration, finished 2026-09-06. Writes sit behind `requireAppAccess('calendar')`; reads
|
parameters. Migration is planned but not started: `docs/calendar-auth-migration.md`.
|
||||||
resolve the same cookie optionally, because one URL serves an anonymous visitor, an iCal
|
Credentials for non-user calendar access (`MEMBER_CREDENTIAL`, `CHOIR_CREDENTIAL`,
|
||||||
subscription and a signed-in editor.
|
`MANAGEMENT_CREDENTIAL`) come from `.env`.
|
||||||
|
|
||||||
Two calendar-specific things survive that migration and are easy to break:
|
|
||||||
|
|
||||||
- **The `public` calendar answers with no credential of any kind.** nachklang.art reads it to
|
|
||||||
show the next upcoming event. Pinned by `test/calendar/credentials.service.test.ts` and
|
|
||||||
`test/calendar/events.router.test.ts`.
|
|
||||||
- **The shared passwords (`MEMBER_CREDENTIAL`, `CHOIR_CREDENTIAL`, `MANAGEMENT_CREDENTIAL`,
|
|
||||||
from `.env`) still open the restricted calendars for reading**, because an iCal client
|
|
||||||
cannot send a cookie. They can never write.
|
|
||||||
|
|
||||||
An event's creator is a display name and nothing else - nothing authorises on it. It resolves
|
|
||||||
from the admin module's `user.name` when the row carries an admin id, and otherwise from
|
|
||||||
`created_by_name`, a snapshot taken before the legacy `users` table was renamed aside.
|
|
||||||
|
|
||||||
**Admin database driver:** the admin pool is the **callback-style** `mysql2`, never
|
**Admin database driver:** the admin pool is the **callback-style** `mysql2`, never
|
||||||
`mysql2/promise`. Kysely's `MysqlDialect` calls `pool.getConnection((err, conn) => ...)`;
|
`mysql2/promise`. Kysely's `MysqlDialect` calls `pool.getConnection((err, conn) => ...)`;
|
||||||
|
|||||||
+29
-15
@@ -24,9 +24,9 @@ Two things this did *not* change, both deliberate:
|
|||||||
this is the one caller that genuinely needs a credential in the URL. It grants read access
|
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
|
to one calendar and nothing else - `test/calendar/events.router.test.ts` pins that it can
|
||||||
never be used to write.
|
never be used to write.
|
||||||
- ~~The legacy `/calendar/users/*` routes still exist.~~ Removed by step 5 on 2026-09-06,
|
- The legacy `/calendar/users/*` routes still exist. Nothing calls them any more, and a
|
||||||
along with the `users` and `sessions` tables they used - renamed aside rather than dropped,
|
legacy session they mint no longer opens anything, but they are still live
|
||||||
so nothing was destroyed.
|
password-accepting endpoints. Step 5 removes them.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -43,23 +43,37 @@ Currently any account holding the `calendar` permission can edit, move, or delet
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Activation token has no expiry — CLOSED 2026-09-06
|
## 3. Activation token has no expiry
|
||||||
|
|
||||||
**File:** ~~`src/models/calendar/users/users.service.ts`~~ — deleted.
|
> **Superseded for new accounts (2026-09-05).** The admin module
|
||||||
|
> (`src/models/admin/`) replaced account creation for the feedback, tickets and admin
|
||||||
|
> apps: accounts now come from `invitations`, whose tokens expire after 7 days and are
|
||||||
|
> stored only as a SHA-256 hash. The item below still stands for the legacy calendar
|
||||||
|
> `users` table, which the admin module deliberately left alone - see
|
||||||
|
> `docs/calendar-auth-migration.md`.
|
||||||
|
|
||||||
The e-mail activation link was valid indefinitely. Closed not by adding an expiry but by
|
**File:** `src/models/calendar/users/users.service.ts` — `createUser` / `activateUser`
|
||||||
removing the thing that issued it: step 5 of `docs/calendar-auth-migration.md` deleted the
|
|
||||||
calendar's own account system. Accounts now come only from the admin module's `invitations`,
|
|
||||||
whose tokens expire after 7 days and are stored as a SHA-256 hash.
|
|
||||||
|
|
||||||
Any activation link still sitting in an inbox now 404s. It only ever activated a legacy
|
The email activation link is valid indefinitely. Acceptable for a small, trusted userbase.
|
||||||
account, which no longer opens anything.
|
|
||||||
|
**Fix:**
|
||||||
|
1. Add an `activation_expires` column to the `users` table (e.g. `DATETIME`).
|
||||||
|
2. Set it to `NOW() + INTERVAL 24 HOUR` in `createUser`.
|
||||||
|
3. Check `activation_expires > NOW()` in `activateUser` before accepting the token.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Password reset token has no expiry — CLOSED 2026-09-06
|
## 4. Password reset token has no expiry
|
||||||
|
|
||||||
**File:** ~~`src/models/calendar/users/users.service.ts`~~ — deleted.
|
> **Superseded for new accounts (2026-09-05).** Password resets for admin-module accounts
|
||||||
|
> go through better-auth, whose reset tokens expire after one hour. As with item 3, the
|
||||||
|
> text below still applies to the legacy calendar `users` table.
|
||||||
|
|
||||||
Same as item 3: `pw_reset_token_hash` never expired, and the code that set it no longer
|
**File:** `src/models/calendar/users/users.service.ts` — `initiatePasswordReset` / `finalizePasswordReset`
|
||||||
exists. Password resets go through better-auth, whose reset tokens expire after one hour.
|
|
||||||
|
The reset token stored in `pw_reset_token_hash` never expires. Acceptable for a small, trusted userbase.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
1. Add a `pw_reset_expires` column to the `users` table (e.g. `DATETIME`).
|
||||||
|
2. Set it to `NOW() + INTERVAL 15 MINUTE` in `initiatePasswordReset`.
|
||||||
|
3. Check `pw_reset_expires > NOW()` in `finalizePasswordReset` before accepting the token.
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
-- Local dev only. Derived from the real schema, which was provided directly by
|
-- Local dev only. Real schema, provided directly by the repo owner
|
||||||
-- the repo owner (calendars, events, event_versions, and the sessions/users
|
-- (calendars, events, event_versions, sessions, users) - not a guess.
|
||||||
-- tables that step 5 of docs/calendar-auth-migration.md renamed aside).
|
-- Columns added by this repo's own migrations under sql/calendar/ are folded
|
||||||
--
|
-- in here rather than appended, so a fresh dev container matches production
|
||||||
-- There is no users or sessions table here: a fresh dev database has no legacy
|
-- after every migration has been applied. Keep the two in step.
|
||||||
-- accounts to archive, so it starts where production ends up.
|
|
||||||
--
|
|
||||||
-- Changes made by this repo's own migrations under sql/calendar/ are folded in
|
|
||||||
-- here rather than appended, so a fresh dev container matches production once
|
|
||||||
-- every migration has been applied. Keep the two in step.
|
|
||||||
USE nachklang_calendar;
|
USE nachklang_calendar;
|
||||||
|
|
||||||
CREATE TABLE `calendars` (
|
CREATE TABLE `calendars` (
|
||||||
@@ -17,20 +12,47 @@ CREATE TABLE `calendars` (
|
|||||||
PRIMARY KEY (`calendar_id`)
|
PRIMARY KEY (`calendar_id`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
CREATE TABLE `users` (
|
||||||
|
`user_id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`full_name` text NOT NULL,
|
||||||
|
`password_hash` text DEFAULT NULL,
|
||||||
|
`email` text NOT NULL,
|
||||||
|
`is_active` tinyint(1) DEFAULT 0,
|
||||||
|
`pw_reset_token_hash` text DEFAULT NULL,
|
||||||
|
`activation_token` text DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`user_id`),
|
||||||
|
UNIQUE KEY `email` (`email`) USING HASH
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
CREATE TABLE `sessions` (
|
||||||
|
`session_id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`user_id` int(11) NOT NULL,
|
||||||
|
`session_key_hash` text DEFAULT NULL,
|
||||||
|
`created_date` datetime DEFAULT current_timestamp(),
|
||||||
|
`valid_until` datetime DEFAULT (current_timestamp() + interval 30 day),
|
||||||
|
`last_ip` text DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`session_id`),
|
||||||
|
KEY `sessions_users_user_id_fk` (`user_id`),
|
||||||
|
CONSTRAINT `sessions_users_user_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
CREATE TABLE `events` (
|
CREATE TABLE `events` (
|
||||||
`event_id` int(11) NOT NULL AUTO_INCREMENT,
|
`event_id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
`calendar_id` int(11) NOT NULL,
|
`calendar_id` int(11) NOT NULL,
|
||||||
`uuid` text NOT NULL,
|
`uuid` text NOT NULL,
|
||||||
`created_date` datetime DEFAULT current_timestamp(),
|
`created_date` datetime DEFAULT current_timestamp(),
|
||||||
|
-- Nullable since the cutover; see sql/calendar/003_allow_null_legacy_creator.sql.
|
||||||
|
`created_by_id` int(11) DEFAULT NULL,
|
||||||
-- Bridge to the admin module's user ids; see sql/calendar/001_add_admin_user_bridge.sql.
|
-- 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,
|
`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;
|
-- Archived creator name; see sql/calendar/002_snapshot_legacy_creator_names.sql.
|
||||||
-- see sql/calendar/002_snapshot_legacy_creator_names.sql.
|
|
||||||
`created_by_name` varchar(255) DEFAULT NULL,
|
`created_by_name` varchar(255) DEFAULT NULL,
|
||||||
PRIMARY KEY (`event_id`),
|
PRIMARY KEY (`event_id`),
|
||||||
KEY `events_calendars_calendar_id_fk` (`calendar_id`),
|
KEY `events_calendars_calendar_id_fk` (`calendar_id`),
|
||||||
|
KEY `events_users_user_id_fk` (`created_by_id`),
|
||||||
KEY `events_created_by_user_idx` (`created_by_user_id`),
|
KEY `events_created_by_user_idx` (`created_by_user_id`),
|
||||||
CONSTRAINT `events_calendars_calendar_id_fk` FOREIGN KEY (`calendar_id`) REFERENCES `calendars` (`calendar_id`)
|
CONSTRAINT `events_calendars_calendar_id_fk` FOREIGN KEY (`calendar_id`) REFERENCES `calendars` (`calendar_id`),
|
||||||
|
CONSTRAINT `events_users_user_id_fk` FOREIGN KEY (`created_by_id`) REFERENCES `users` (`user_id`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
CREATE TABLE `event_versions` (
|
CREATE TABLE `event_versions` (
|
||||||
@@ -44,6 +66,7 @@ CREATE TABLE `event_versions` (
|
|||||||
`repeat_frequency` text DEFAULT NULL,
|
`repeat_frequency` text DEFAULT NULL,
|
||||||
`location` text DEFAULT NULL,
|
`location` text DEFAULT NULL,
|
||||||
`url` text DEFAULT NULL,
|
`url` text DEFAULT NULL,
|
||||||
|
`version_created_by_id` int(11) DEFAULT NULL,
|
||||||
-- Bridge to the admin module's user ids; see sql/calendar/001_add_admin_user_bridge.sql.
|
-- 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,
|
`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.
|
-- Archived editor name; see sql/calendar/002_snapshot_legacy_creator_names.sql.
|
||||||
@@ -52,8 +75,10 @@ CREATE TABLE `event_versions` (
|
|||||||
`version_created_at` datetime DEFAULT current_timestamp(),
|
`version_created_at` datetime DEFAULT current_timestamp(),
|
||||||
PRIMARY KEY (`event_version_id`),
|
PRIMARY KEY (`event_version_id`),
|
||||||
KEY `event_versions_events_event_id_fk` (`event_id`),
|
KEY `event_versions_events_event_id_fk` (`event_id`),
|
||||||
|
KEY `event_versions_users_user_id_fk` (`version_created_by_id`),
|
||||||
KEY `event_versions_created_by_user_idx` (`version_created_by_user_id`),
|
KEY `event_versions_created_by_user_idx` (`version_created_by_user_id`),
|
||||||
CONSTRAINT `event_versions_events_event_id_fk` FOREIGN KEY (`event_id`) REFERENCES `events` (`event_id`) ON DELETE CASCADE ON UPDATE CASCADE
|
CONSTRAINT `event_versions_events_event_id_fk` FOREIGN KEY (`event_id`) REFERENCES `events` (`event_id`) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT `event_versions_users_user_id_fk` FOREIGN KEY (`version_created_by_id`) REFERENCES `users` (`user_id`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
INSERT INTO calendars (calendar_id, name, includes_calendars) VALUES
|
INSERT INTO calendars (calendar_id, name, includes_calendars) VALUES
|
||||||
@@ -63,17 +88,20 @@ INSERT INTO calendars (calendar_id, name, includes_calendars) VALUES
|
|||||||
(4, 'choir', '[]'),
|
(4, 'choir', '[]'),
|
||||||
(5, 'birthdays', '[]');
|
(5, 'birthdays', '[]');
|
||||||
|
|
||||||
-- Two events carry only an archived creator name, as every pre-cutover event
|
-- Dev admin, password: devpassword
|
||||||
-- does, and one carries an admin user id whose name is resolved live. Dev
|
INSERT INTO users (email, password_hash, full_name, is_active) VALUES
|
||||||
-- therefore exercises both name sources rather than only one. The one with an
|
('dev@nachklang.art', '$2b$10$vmj7POS/68SGE.eI7pGjMegrw0vNNZ2HVSUTra5NRsl8iOLwiMgZK', 'Dev Admin', 1);
|
||||||
-- id is deliberately a PUBLIC event, so the anonymous listing the website uses
|
|
||||||
-- covers both. The id is the dev admin from 04-admin-schema.sql.
|
|
||||||
INSERT INTO events (calendar_id, uuid, created_by_user_id, created_by_name) VALUES
|
|
||||||
(1, UUID(), NULL, 'Dev Admin'),
|
|
||||||
(1, UUID(), 'dev-user-0000-0000-0000-000000000001', NULL),
|
|
||||||
(1, UUID(), NULL, 'Dev Admin');
|
|
||||||
|
|
||||||
INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, location, url, status, version_created_by_user_id, version_created_by_name) VALUES
|
-- Two rows are left on the legacy path and one carries an admin user id, so
|
||||||
(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'),
|
-- dev exercises both branches of the step 3 dual-read rather than only the
|
||||||
(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),
|
-- happy one. It is deliberately a PUBLIC event, so the anonymous listing the
|
||||||
(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');
|
-- website uses covers both. The id is the dev admin from 04-admin-schema.sql.
|
||||||
|
INSERT INTO events (calendar_id, uuid, created_by_id, created_by_user_id, created_by_name) VALUES
|
||||||
|
(1, UUID(), 1, NULL, 'Dev Admin'),
|
||||||
|
(1, UUID(), 1, 'dev-user-0000-0000-0000-000000000001', NULL),
|
||||||
|
(1, UUID(), 1, NULL, 'Dev Admin');
|
||||||
|
|
||||||
|
INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, location, url, status, version_created_by_id, 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', 1, 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', 1, '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', 1, NULL, 'Dev Admin');
|
||||||
|
|||||||
@@ -1,22 +1,13 @@
|
|||||||
# Migrating the Calendar domain onto the admin identity module
|
# Migrating the Calendar domain onto the admin identity module
|
||||||
|
|
||||||
Status: **complete and deployed, 2026-09-06.** Every step is live. Step 2 was dropped by
|
Status: **steps 1-4 implemented 2026-09-06, not yet merged or deployed.** Step 2 dropped by
|
||||||
decision and part of step 5 brought forward; the rest went out as written.
|
decision, part of step 5 brought forward. Only step 5, the removal of the legacy path, is
|
||||||
|
left to write.
|
||||||
|
|
||||||
The calendar now shares one identity with the tickets, feedback and admin apps: writes sit
|
> Read the deploy checklist under step 4 before applying anything. "Done" below means the
|
||||||
behind `requireAppAccess('calendar')` against the shared session cookie, reads resolve that
|
> code exists on a branch, **not** that production has it - and in particular production has
|
||||||
cookie optionally, and the calendar's own `users`/`sessions` tables are renamed aside and
|
> none of the three migrations. Step 5 is scoped but deliberately unstarted: it must not be
|
||||||
referenced by nothing. `DEFERRED_SECURITY.md` items 1, 3 and 4 closed with it.
|
> built on top of a step 4 that has not been deployed and watched.
|
||||||
|
|
||||||
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
|
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
|
nachklang-admin repo), which deliberately left the calendar alone. Steps 1-4 of that plan
|
||||||
@@ -166,66 +157,62 @@ Each step is meant to leave production working on its own.
|
|||||||
|
|
||||||
**One-way door:** any iCal subscription whose URL carries `?sessionId=&sessionKey=` rather
|
**One-way door:** any iCal subscription whose URL carries `?sessionId=&sessionKey=` rather
|
||||||
than `?password=` stops working permanently. The shared-password URLs are unaffected.
|
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
|
5. **Drop the legacy path.** Not started - and deliberately not started until step 4 has been
|
||||||
was.
|
deployed and watched, because it removes the fallback step 4 still leans on. Scoped and
|
||||||
|
decided 2026-09-06; what follows is the agreed shape, not a suggestion.
|
||||||
|
|
||||||
What went:
|
**Prerequisite: step 4 live in production and behaving.** Until then the legacy join is
|
||||||
|
what renders the author of every pre-cutover event, and the legacy routes are what an old
|
||||||
|
cached bundle talks to. Doing this first turns a recoverable deploy into an unrecoverable
|
||||||
|
one.
|
||||||
|
|
||||||
- `src/models/calendar/users/` in its entirety - registration, login, activation, both
|
Code, in one branch:
|
||||||
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)
|
- **Delete `src/models/calendar/users/` entirely** - `users.router.ts`, `users.service.ts`,
|
||||||
close with it - not by adding expiries but by deleting the code that issued them.
|
`session.interface.ts`, `user.interface.ts` - and the `calendarRouter.use('/users', ...)`
|
||||||
|
line in `Calendar.router.ts`. *(Decided: delete outright rather than unmount.)* This
|
||||||
|
removes the last unauthenticated account-creation and mail-sending endpoint in the API.
|
||||||
|
A survey on 2026-09-06 confirmed nothing outside that directory imports it, and nothing
|
||||||
|
outside it touches the `users`/`sessions` tables except the two joins below.
|
||||||
|
- **Drop the legacy half of the read** in `events.service.ts`: the two
|
||||||
|
`LEFT OUTER JOIN users` clauses, the `legacy_*` aliases, and `created_by_id` /
|
||||||
|
`version_created_by_id` from the SELECT and the row mapper. The snapshot fallback stays -
|
||||||
|
it is what makes this safe. Remove `createdById` / `lastModifiedById` from
|
||||||
|
`event.interface.ts` and their (already deprecated) swagger properties.
|
||||||
|
- **Remove `X-Session-Id` / `X-Session-Key`** from the CORS `allowedHeaders` in
|
||||||
|
`src/app.factory.ts`. Nothing has sent them since the tickets and feedback frontends were
|
||||||
|
redeployed.
|
||||||
|
- **Drop the obsolete test mocks**: `test/feedback/feedback.auth.test.ts`,
|
||||||
|
`test/tickets/tickets.auth.test.ts` and `test/admin/auth-binding.ts` each mock
|
||||||
|
`calendar/users/users.service.js` and assert `checkSession` is never called. That
|
||||||
|
tripwire is meaningless once the module does not exist; remove the mock and the
|
||||||
|
assertion, keep the rest.
|
||||||
|
|
||||||
### Deploy checklist — kept for the record; the order was REVERSED from step 4
|
Database, as `sql/calendar/004_*.sql`:
|
||||||
|
|
||||||
Step 4's migration only added columns, so it went first. `004` *removes* columns and a
|
- Drop the foreign keys `events_users_user_id_fk` and `event_versions_users_user_id_fk`,
|
||||||
table that the currently running build still selects and joins, so running it first fails
|
then the `created_by_id` and `version_created_by_id` columns.
|
||||||
every calendar read including the public feed. The step 5 build references none of them and
|
- **`RENAME TABLE users TO users_legacy_archive`**, same for `sessions`. *(Decided: rename
|
||||||
runs happily against the old schema. Therefore:
|
rather than drop.)* The reasoning: the display names are already snapshotted so nothing
|
||||||
|
visible depends on these rows, but they still hold the old e-mail addresses and password
|
||||||
|
hashes, and a rename makes the tables unreachable without destroying anything. Dropping
|
||||||
|
them later is one statement, at a moment when nobody is mid-deploy.
|
||||||
|
- Mirror all of it in `docker/init/01-calendar-schema-dev.sql` (the archive tables need no
|
||||||
|
mirror - a fresh dev database has nothing to archive).
|
||||||
|
|
||||||
1. **Confirm the snapshot is complete.** Both must return 0:
|
Documentation: `DEFERRED_SECURITY.md` items **3** (activation token has no expiry) and
|
||||||
```sql
|
**4** (password reset token has no expiry) close outright - both describe code that ceases
|
||||||
SELECT SUM(created_by_id IS NOT NULL AND created_by_name IS NULL) FROM events;
|
to exist. Item 2 (no event ownership check) stays open.
|
||||||
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
|
Two consequences to accept explicitly rather than discover:
|
||||||
`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
|
- Any activation or password-reset e-mail already sent points at
|
||||||
anything reading `/calendar/events/*/json` that is not the calendar frontend.
|
`api.nachklang.art/calendar/users/activate` and becomes a 404. Those links were only ever
|
||||||
|
valid for legacy accounts, which no longer open anything.
|
||||||
**Observed during the deploy, worth keeping.** A browser holding a *cached pre-cutover*
|
- `Event.createdById` disappears from the API response. The Angular frontend never read it
|
||||||
Angular bundle looked signed in and showed every event's status as "Error". The old bundle
|
(its `Event` model has only `createdBy`, the name), so this is not a breaking change for
|
||||||
called `/calendar/users/checkSessionValid`, which still existed between step 4 and step 5,
|
the only known consumer - but it is a wire-format removal, so check anything else that
|
||||||
so it rendered as authenticated - then fetched events with no cookie, got the anonymous
|
reads `/calendar/events/*/json` first.
|
||||||
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)
|
## What the code actually looks like (surveyed 2026-09-06)
|
||||||
|
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
-- Nachklang e.V. Calendar module — step 5 of docs/calendar-auth-migration.md.
|
|
||||||
-- Apply manually against the CALENDAR_DB database, after 003:
|
|
||||||
-- mysql -h <DB_HOST> -u <DB_USER> -p <CALENDAR_DB> < 004_drop_legacy_auth.sql
|
|
||||||
--
|
|
||||||
-- *** APPLY THIS AFTER DEPLOYING THE API, NOT BEFORE. ***
|
|
||||||
--
|
|
||||||
-- This is the opposite order from the step 4 cutover, and getting it wrong by
|
|
||||||
-- analogy is the obvious mistake. Step 4's migration only *added* things, so it
|
|
||||||
-- was safe ahead of the deploy. This one removes columns and a table that the
|
|
||||||
-- currently running build still selects and joins - applying it first fails
|
|
||||||
-- every calendar read, including the anonymous public feed the website uses.
|
|
||||||
-- The step 5 build touches none of them, so it runs happily against the old
|
|
||||||
-- schema; deploy it, confirm the calendar works, then run this.
|
|
||||||
--
|
|
||||||
-- Nothing here loses information that is still reachable: the creators' display
|
|
||||||
-- names were snapshotted into events.created_by_name and
|
|
||||||
-- event_versions.version_created_by_name by migration 002, and the step 4
|
|
||||||
-- runbook re-ran that backfill after the deploy. Verify before running:
|
|
||||||
--
|
|
||||||
-- SELECT SUM(created_by_id IS NOT NULL AND created_by_name IS NULL) FROM events;
|
|
||||||
-- SELECT SUM(version_created_by_id IS NOT NULL AND version_created_by_name IS NULL) FROM event_versions;
|
|
||||||
--
|
|
||||||
-- Both must be 0. A non-zero count is an event whose author this migration
|
|
||||||
-- would erase; re-run 002's backfill first.
|
|
||||||
|
|
||||||
-- The foreign keys have to go before the columns they are declared on.
|
|
||||||
-- IF EXISTS so that a re-run after a partial failure gets past them.
|
|
||||||
ALTER TABLE `events`
|
|
||||||
DROP FOREIGN KEY IF EXISTS `events_users_user_id_fk`;
|
|
||||||
|
|
||||||
ALTER TABLE `event_versions`
|
|
||||||
DROP FOREIGN KEY IF EXISTS `event_versions_users_user_id_fk`;
|
|
||||||
|
|
||||||
ALTER TABLE `events`
|
|
||||||
DROP INDEX IF EXISTS `events_users_user_id_fk`,
|
|
||||||
DROP COLUMN IF EXISTS `created_by_id`;
|
|
||||||
|
|
||||||
ALTER TABLE `event_versions`
|
|
||||||
DROP INDEX IF EXISTS `event_versions_users_user_id_fk`,
|
|
||||||
DROP COLUMN IF EXISTS `version_created_by_id`;
|
|
||||||
|
|
||||||
-- The accounts themselves are renamed aside rather than dropped.
|
|
||||||
--
|
|
||||||
-- Nothing visible depends on them any more - the names are snapshotted, and no
|
|
||||||
-- code has referenced these tables since the step 4 cutover. But they still
|
|
||||||
-- hold e-mail addresses and password hashes, and a rename makes them
|
|
||||||
-- unreachable without destroying anything.
|
|
||||||
--
|
|
||||||
-- `sessions` has a foreign key into `users`; InnoDB rewires it to the new name
|
|
||||||
-- on rename, so after this it reads REFERENCES `users_legacy_archive` and the
|
|
||||||
-- pair stays internally consistent whichever order they are renamed in.
|
|
||||||
-- Verified on MariaDB 11.
|
|
||||||
--
|
|
||||||
-- Unlike the statements above this is not re-runnable, and that is the safe
|
|
||||||
-- behaviour: a second run fails on a missing `sessions` rather than doing
|
|
||||||
-- anything. Drop them for real whenever you like, at a moment when nobody is
|
|
||||||
-- mid-deploy:
|
|
||||||
-- DROP TABLE `sessions_legacy_archive`, `users_legacy_archive`;
|
|
||||||
RENAME TABLE `sessions` TO `sessions_legacy_archive`;
|
|
||||||
RENAME TABLE `users` TO `users_legacy_archive`;
|
|
||||||
+9
-6
@@ -63,12 +63,15 @@ export const createApp = (): express.Application => {
|
|||||||
// the dev machine's LAN IP, never "localhost"). Dev-only, same as above.
|
// the dev machine's LAN IP, never "localhost"). Dev-only, same as above.
|
||||||
const lanIpRegex = /^http:\/\/(192\.168\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}):\d+$/;
|
const lanIpRegex = /^http:\/\/(192\.168\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}):\d+$/;
|
||||||
app.use(cors({
|
app.use(cors({
|
||||||
// Content-Type alone. X-Session-Id / X-Session-Key were allowed here
|
// X-Session-* are no longer read by anything on this side, and no longer
|
||||||
// through the two cutovers so that a browser still holding a pre-cutover
|
// sent by anything either: the tickets and feedback cutover took the last
|
||||||
// bundle got a clean 401 rather than a confusing CORS preflight failure.
|
// two readers off them, and the calendar cutover removed the last legacy
|
||||||
// Nothing has read them since the first cutover and nothing has sent them
|
// credential path in the API (its session used to travel in query
|
||||||
// since the second, so they came out with the rest of the legacy path.
|
// parameters - DEFERRED_SECURITY.md item 1, now closed). They stay allowed
|
||||||
allowedHeaders: ['Content-Type'],
|
// only so a browser still running a pre-cutover tickets or feedback bundle
|
||||||
|
// gets a clean 401 rather than a CORS preflight failure. Drop them once
|
||||||
|
// those have aged out - see docs/calendar-auth-migration.md step 5.
|
||||||
|
allowedHeaders: ['Content-Type', 'X-Session-Id', 'X-Session-Key'],
|
||||||
// The admin session lives in a cookie, so browsers must be allowed to send
|
// The admin session lives in a cookie, so browsers must be allowed to send
|
||||||
// it cross-origin - this is what makes credentials: 'include' work.
|
// it cross-origin - this is what makes credentials: 'include' work.
|
||||||
credentials: true,
|
credentials: true,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import express, {Request, Response} from 'express';
|
|||||||
import {Guid} from 'guid-typescript';
|
import {Guid} from 'guid-typescript';
|
||||||
import logger from '../../middleware/logger.js';
|
import logger from '../../middleware/logger.js';
|
||||||
import {eventsRouter} from './events/events.router.js';
|
import {eventsRouter} from './events/events.router.js';
|
||||||
|
import {usersRouter} from './users/users.router.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Router Definition
|
* Router Definition
|
||||||
@@ -12,13 +13,7 @@ import {eventsRouter} from './events/events.router.js';
|
|||||||
export const calendarRouter = express.Router();
|
export const calendarRouter = express.Router();
|
||||||
|
|
||||||
calendarRouter.use('/events', eventsRouter);
|
calendarRouter.use('/events', eventsRouter);
|
||||||
|
calendarRouter.use('/users', usersRouter);
|
||||||
/*
|
|
||||||
* There is no /calendar/users any more. It held this module's own accounts -
|
|
||||||
* registration, login, activation, password reset, and the session table the
|
|
||||||
* feedback and tickets admin areas used to authenticate against - and every one
|
|
||||||
* of those moved to the admin module. See docs/calendar-auth-migration.md.
|
|
||||||
*/
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
* - endDateTime
|
* - endDateTime
|
||||||
* - createdDate
|
* - createdDate
|
||||||
* - location
|
* - location
|
||||||
|
* - createdById
|
||||||
* - url
|
* - url
|
||||||
* - wholeDay
|
* - wholeDay
|
||||||
* properties:
|
* properties:
|
||||||
@@ -64,6 +65,15 @@
|
|||||||
* type: string
|
* type: string
|
||||||
* description: The name of the user who created the event
|
* description: The name of the user who created the event
|
||||||
* example: "John Doe"
|
* example: "John Doe"
|
||||||
|
* createdById:
|
||||||
|
* type: integer
|
||||||
|
* deprecated: true
|
||||||
|
* description: >
|
||||||
|
* The legacy calendar user id of the creator. Being replaced by
|
||||||
|
* createdByUserId; see docs/calendar-auth-migration.md. Null on
|
||||||
|
* events created after the cutover.
|
||||||
|
* nullable: true
|
||||||
|
* example: 456
|
||||||
* createdByUserId:
|
* createdByUserId:
|
||||||
* type: string
|
* type: string
|
||||||
* nullable: true
|
* nullable: true
|
||||||
@@ -73,6 +83,14 @@
|
|||||||
* type: string
|
* type: string
|
||||||
* description: The name of the user who last modified the event
|
* description: The name of the user who last modified the event
|
||||||
* example: "John Doe"
|
* example: "John Doe"
|
||||||
|
* lastModifiedById:
|
||||||
|
* type: integer
|
||||||
|
* deprecated: true
|
||||||
|
* nullable: true
|
||||||
|
* description: >
|
||||||
|
* The legacy calendar user id of the last editor. Being replaced
|
||||||
|
* by lastModifiedByUserId.
|
||||||
|
* example: 456
|
||||||
* lastModifiedByUserId:
|
* lastModifiedByUserId:
|
||||||
* type: string
|
* type: string
|
||||||
* nullable: true
|
* nullable: true
|
||||||
@@ -103,12 +121,14 @@ export interface Event {
|
|||||||
createdDate: Date;
|
createdDate: Date;
|
||||||
lastModifiedDate?: Date;
|
lastModifiedDate?: Date;
|
||||||
location: string;
|
location: string;
|
||||||
/** Display name of the creator: the live admin name when the id below
|
/** Display name of the creator, from whichever id below resolved. */
|
||||||
* resolves, otherwise the name archived before the legacy users table was
|
|
||||||
* removed. See docs/calendar-auth-migration.md. */
|
|
||||||
createdBy?: string;
|
createdBy?: string;
|
||||||
|
createdById?: number | null;
|
||||||
|
/** Set once the event's creator exists in the admin module. Preferred over
|
||||||
|
* createdById when both are present; see docs/calendar-auth-migration.md. */
|
||||||
createdByUserId?: string | null;
|
createdByUserId?: string | null;
|
||||||
lastModifiedBy?: string;
|
lastModifiedBy?: string;
|
||||||
|
lastModifiedById?: number | null;
|
||||||
lastModifiedByUserId?: string | null;
|
lastModifiedByUserId?: string | null;
|
||||||
url: string;
|
url: string;
|
||||||
wholeDay: boolean;
|
wholeDay: boolean;
|
||||||
|
|||||||
@@ -9,35 +9,49 @@ import logger from '../../../middleware/logger.js';
|
|||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How an event's creator is resolved, after step 5 of
|
* Step 3 of docs/calendar-auth-migration.md: the dual read.
|
||||||
* docs/calendar-auth-migration.md removed the legacy path.
|
|
||||||
*
|
*
|
||||||
* The creator is only ever rendered as a name - nothing authorises on it, there
|
* An event records its creator twice - `created_by_id`, the legacy INT into
|
||||||
* is no "only the creator may edit" rule anywhere - which is why an unresolvable
|
* the calendar database's own `users` table, and `created_by_user_id`, the
|
||||||
* one degrades to blank rather than to an error.
|
* admin module's VARCHAR(36) id. Old rows have only the first, rows written
|
||||||
|
* after the step 4 cutover will have only the second, and the two live in
|
||||||
|
* different databases, so this file has to read both and prefer the new one.
|
||||||
*
|
*
|
||||||
* Two sources remain, weaker first:
|
* The one thing the creator is used for is a display name. Nothing authorises
|
||||||
|
* on it - there is no "only the creator may edit" rule anywhere - which is why
|
||||||
|
* a name that cannot be resolved degrades to blank instead of to an error.
|
||||||
*
|
*
|
||||||
* 1. `created_by_name`, a snapshot of the name as it stood when the calendar
|
* That name has three possible sources, and they are tried weakest first:
|
||||||
* 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
|
* 1. LEGACY - joining the calendar's own `users` table on `created_by_id`.
|
||||||
* `created_by_id` - is gone with the table and the column.
|
* 2. `created_by_name`, the snapshot migration 002 took of exactly that join,
|
||||||
|
* so the authorship of pre-cutover events survives step 5 dropping the
|
||||||
|
* table. An archive: nothing writes it after the backfill.
|
||||||
|
* 3. The admin module's `user.name`, looked up live for rows that carry an
|
||||||
|
* admin id. It wins because it is the only one that follows a rename.
|
||||||
|
*
|
||||||
|
* Writes only ever set the admin id: since the step 4 cutover there is no
|
||||||
|
* calendar user id to write, which is why migration 003 made `created_by_id`
|
||||||
|
* nullable. The reads below still handle rows that predate that.
|
||||||
|
*
|
||||||
|
* Removal note: everything marked LEGACY below comes out in step 5, together
|
||||||
|
* with the `users`/`sessions` tables and the `created_by_id` columns. The
|
||||||
|
* snapshot stays - it is the reason step 5 can drop them.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The one SELECT the four read paths share; callers append their own WHERE and
|
* The one SELECT the four read paths share. It was copied out four times
|
||||||
* ORDER BY. `v.*` carries the version row's own creator columns, so only the
|
* before, which is precisely why the dual read had to be added in four
|
||||||
* `events` columns need naming.
|
* places; callers append their own WHERE and ORDER BY.
|
||||||
*
|
*
|
||||||
* There are no joins to a users table any more. There is no users table.
|
* `v.*` carries `version_created_by_user_id` and `version_created_by_name`
|
||||||
|
* along with the rest of the version row, so only the `events` columns need
|
||||||
|
* naming. The two joined names are aliased `legacy_*` because the unprefixed
|
||||||
|
* names are now real columns.
|
||||||
*/
|
*/
|
||||||
const EVENT_SELECT = `
|
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
|
SELECT e.calendar_id, e.uuid, e.created_date, e.created_by_id, e.created_by_user_id, e.created_by_name,
|
||||||
|
u.full_name as legacy_created_by_name, u2.full_name as legacy_last_modified_by_name, v.* FROM events e
|
||||||
INNER JOIN (
|
INNER JOIN (
|
||||||
SELECT event_id, MAX(event_version_id) AS latest_version
|
SELECT event_id, MAX(event_version_id) AS latest_version
|
||||||
FROM event_versions
|
FROM event_versions
|
||||||
@@ -45,7 +59,9 @@ const EVENT_SELECT = `
|
|||||||
) latest_versions
|
) latest_versions
|
||||||
ON e.event_id = latest_versions.event_id
|
ON e.event_id = latest_versions.event_id
|
||||||
INNER JOIN event_versions v
|
INNER JOIN event_versions v
|
||||||
ON v.event_id = latest_versions.event_id AND v.event_version_id = latest_versions.latest_version`;
|
ON v.event_id = latest_versions.event_id AND v.event_version_id = latest_versions.latest_version
|
||||||
|
LEFT OUTER JOIN users u ON u.user_id = e.created_by_id
|
||||||
|
LEFT OUTER JOIN users u2 ON u2.user_id = v.version_created_by_id`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maps a result row to an Event. `status` is included only where it always
|
* Maps a result row to an Event. `status` is included only where it always
|
||||||
@@ -64,11 +80,15 @@ const toEvent = (row: any, includeStatus: boolean): Event => {
|
|||||||
createdDate: row.created_date,
|
createdDate: row.created_date,
|
||||||
lastModifiedDate: row.version_created_at,
|
lastModifiedDate: row.version_created_at,
|
||||||
location: row.location,
|
location: row.location,
|
||||||
// The archived name. resolveAdminNames below overwrites it for rows that
|
// Name resolution, weakest first: the LEGACY join against the calendar
|
||||||
// carry an admin id, which is the only source that follows a rename.
|
// users table, then the snapshot taken in migration 002, then - in
|
||||||
createdBy: row.created_by_name,
|
// resolveAdminNames below - the live admin name, which wins because it
|
||||||
|
// is the only one that follows an account being renamed.
|
||||||
|
createdBy: row.created_by_name ?? row.legacy_created_by_name,
|
||||||
|
createdById: row.created_by_id,
|
||||||
createdByUserId: row.created_by_user_id ?? null,
|
createdByUserId: row.created_by_user_id ?? null,
|
||||||
lastModifiedBy: row.version_created_by_name,
|
lastModifiedBy: row.version_created_by_name ?? row.legacy_last_modified_by_name,
|
||||||
|
lastModifiedById: row.version_created_by_id,
|
||||||
lastModifiedByUserId: row.version_created_by_user_id ?? null,
|
lastModifiedByUserId: row.version_created_by_user_id ?? null,
|
||||||
url: row.url,
|
url: row.url,
|
||||||
wholeDay: row.whole_day,
|
wholeDay: row.whole_day,
|
||||||
@@ -92,7 +112,7 @@ const toEvent = (row: any, includeStatus: boolean): Event => {
|
|||||||
* A failure here is swallowed on purpose. These endpoints include the public
|
* A failure here is swallowed on purpose. These endpoints include the public
|
||||||
* calendar the website reads anonymously, and a name is decoration: if the
|
* calendar the website reads anonymously, and a name is decoration: if the
|
||||||
* admin database is unreachable, an event should still render with whatever
|
* admin database is unreachable, an event should still render with whatever
|
||||||
* the snapshot holds rather than 500 the whole listing. The alternative
|
* the legacy join produced rather than 500 the whole listing. The alternative
|
||||||
* would widen the public calendar's blast radius to include the admin
|
* would widen the public calendar's blast radius to include the admin
|
||||||
* database, which it has never depended on before.
|
* database, which it has never depended on before.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* components:
|
||||||
|
* schemas:
|
||||||
|
* Session:
|
||||||
|
* type: object
|
||||||
|
* required:
|
||||||
|
* - sessionId
|
||||||
|
* - userId
|
||||||
|
* - sessionKey
|
||||||
|
* - sessionKeyHash
|
||||||
|
* - lastIP
|
||||||
|
* properties:
|
||||||
|
* sessionId:
|
||||||
|
* type: integer
|
||||||
|
* description: The unique identifier for the session
|
||||||
|
* example: 789
|
||||||
|
* userId:
|
||||||
|
* type: integer
|
||||||
|
* description: The ID of the user this session belongs to
|
||||||
|
* example: 456
|
||||||
|
* sessionKey:
|
||||||
|
* type: string
|
||||||
|
* description: The session key used for authentication
|
||||||
|
* example: "abc123def456"
|
||||||
|
* sessionKeyHash:
|
||||||
|
* type: string
|
||||||
|
* description: The hashed session key (not returned in API responses)
|
||||||
|
* example: "$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG"
|
||||||
|
* createdDate:
|
||||||
|
* type: string
|
||||||
|
* format: date-time
|
||||||
|
* description: The date and time when the session was created
|
||||||
|
* example: "2023-05-01T10:00:00.000Z"
|
||||||
|
* validUntil:
|
||||||
|
* type: string
|
||||||
|
* format: date-time
|
||||||
|
* description: The date and time until when the session is valid
|
||||||
|
* example: "2023-05-08T10:00:00.000Z"
|
||||||
|
* lastIP:
|
||||||
|
* type: string
|
||||||
|
* description: The last IP address used with this session
|
||||||
|
* example: "192.168.1.1"
|
||||||
|
*/
|
||||||
|
export interface Session {
|
||||||
|
sessionId: number;
|
||||||
|
userId: number;
|
||||||
|
sessionKey: string;
|
||||||
|
sessionKeyHash: string;
|
||||||
|
createdDate?: Date;
|
||||||
|
validUntil?: Date;
|
||||||
|
lastIP: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* components:
|
||||||
|
* schemas:
|
||||||
|
* User:
|
||||||
|
* type: object
|
||||||
|
* required:
|
||||||
|
* - userId
|
||||||
|
* - fullName
|
||||||
|
* - passwordHash
|
||||||
|
* - email
|
||||||
|
* - isActive
|
||||||
|
* properties:
|
||||||
|
* userId:
|
||||||
|
* type: integer
|
||||||
|
* description: The unique identifier for the user
|
||||||
|
* example: 456
|
||||||
|
* fullName:
|
||||||
|
* type: string
|
||||||
|
* description: The full name of the user
|
||||||
|
* example: "John Doe"
|
||||||
|
* passwordHash:
|
||||||
|
* type: string
|
||||||
|
* description: The hashed password of the user (not returned in API responses)
|
||||||
|
* example: "$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG"
|
||||||
|
* email:
|
||||||
|
* type: string
|
||||||
|
* format: email
|
||||||
|
* description: The email address of the user
|
||||||
|
* example: "john.doe@nachklang.art"
|
||||||
|
* isActive:
|
||||||
|
* type: boolean
|
||||||
|
* description: Whether the user account is active
|
||||||
|
* example: true
|
||||||
|
*/
|
||||||
|
export interface User {
|
||||||
|
userId: number;
|
||||||
|
fullName: string;
|
||||||
|
passwordHash: string;
|
||||||
|
email: string;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,671 @@
|
|||||||
|
/**
|
||||||
|
* Required External Modules and Interfaces
|
||||||
|
*/
|
||||||
|
|
||||||
|
import express, {Request, Response} from 'express';
|
||||||
|
import * as UserService from './users.service.js';
|
||||||
|
import {Session} from './session.interface.js';
|
||||||
|
import {User} from './user.interface.js';
|
||||||
|
import {Guid} from 'guid-typescript';
|
||||||
|
import logger from '../../../middleware/logger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Router Definition
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const usersRouter = express.Router();
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controller Definitions
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /calendar/users/register:
|
||||||
|
* post:
|
||||||
|
* summary: Register a new user
|
||||||
|
* description: Creates a new user account with the provided email, password, and full name. Only accepts official Nachklang email addresses.
|
||||||
|
* tags:
|
||||||
|
* - calendar
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* required:
|
||||||
|
* - email
|
||||||
|
* - password
|
||||||
|
* - fullName
|
||||||
|
* properties:
|
||||||
|
* email:
|
||||||
|
* type: string
|
||||||
|
* format: email
|
||||||
|
* example: john.doe@nachklang.art
|
||||||
|
* description: Must be an official Nachklang email address
|
||||||
|
* password:
|
||||||
|
* type: string
|
||||||
|
* format: password
|
||||||
|
* example: securePassword123
|
||||||
|
* fullName:
|
||||||
|
* type: string
|
||||||
|
* example: John Doe
|
||||||
|
* responses:
|
||||||
|
* 201:
|
||||||
|
* description: User registered successfully
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* sessionId:
|
||||||
|
* type: integer
|
||||||
|
* example: 123
|
||||||
|
* sessionKey:
|
||||||
|
* type: string
|
||||||
|
* example: abc123def456
|
||||||
|
* 400:
|
||||||
|
* description: Bad request - missing or invalid parameters
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* message:
|
||||||
|
* type: string
|
||||||
|
* example: Missing parameters
|
||||||
|
* 500:
|
||||||
|
* description: Server error
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* status:
|
||||||
|
* type: string
|
||||||
|
* example: PROCESSING_ERROR
|
||||||
|
* message:
|
||||||
|
* type: string
|
||||||
|
* example: Internal Server Error. Try again later.
|
||||||
|
* reference:
|
||||||
|
* type: string
|
||||||
|
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
|
||||||
|
*/
|
||||||
|
// POST users/register
|
||||||
|
usersRouter.post('/register', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const password: string = req.body.password;
|
||||||
|
const email: string = req.body.email;
|
||||||
|
const fullName: string = req.body.fullName;
|
||||||
|
const ip: string = req.socket.remoteAddress ?? '';
|
||||||
|
|
||||||
|
if (!password || !email || !fullName) {
|
||||||
|
// Missing
|
||||||
|
res.status(400).send(JSON.stringify({message: 'Missing parameters'}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailRegex = /^[a-zA-Z0-9\_\-\.]+@nachklang\.art$/;
|
||||||
|
|
||||||
|
if(!emailRegex.test(email)) {
|
||||||
|
res.status(400).send(JSON.stringify({message: 'Must use an official Nachklang email address'}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the user and a session
|
||||||
|
const session: Session = await UserService.createUser(email, password, fullName, ip);
|
||||||
|
|
||||||
|
// Send the session details back to the user
|
||||||
|
res.status(201).send({
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
sessionKey: session.sessionKey
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
let errorGuid = Guid.create().toString();
|
||||||
|
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
|
||||||
|
res.status(500).send({
|
||||||
|
'status': 'PROCESSING_ERROR',
|
||||||
|
'message': 'Internal Server Error. Try again later.',
|
||||||
|
'reference': errorGuid
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /calendar/users/activate:
|
||||||
|
* get:
|
||||||
|
* summary: Activate a user account
|
||||||
|
* description: Activates a user account using the provided user ID and activation token.
|
||||||
|
* tags:
|
||||||
|
* - calendar
|
||||||
|
* parameters:
|
||||||
|
* - in: query
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* description: The ID of the user to activate
|
||||||
|
* - in: query
|
||||||
|
* name: token
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: The activation token sent to the user's email
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: User activated successfully
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* status:
|
||||||
|
* type: string
|
||||||
|
* example: OK
|
||||||
|
* message:
|
||||||
|
* type: string
|
||||||
|
* example: User activated
|
||||||
|
* 400:
|
||||||
|
* description: Bad request - missing parameters or activation failed
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* status:
|
||||||
|
* type: string
|
||||||
|
* example: PROCESSING_ERROR
|
||||||
|
* message:
|
||||||
|
* type: string
|
||||||
|
* example: Error activating user. Please contact your administrator.
|
||||||
|
* 500:
|
||||||
|
* description: Server error
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* status:
|
||||||
|
* type: string
|
||||||
|
* example: PROCESSING_ERROR
|
||||||
|
* message:
|
||||||
|
* type: string
|
||||||
|
* example: Internal Server Error. Try again later.
|
||||||
|
* reference:
|
||||||
|
* type: string
|
||||||
|
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
|
||||||
|
*/
|
||||||
|
// GET /users/activate
|
||||||
|
usersRouter.get('/activate', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const userId: number = parseInt(req.query.id as string ?? '-1', 10);
|
||||||
|
const token: string = req.query.token as string ?? '';
|
||||||
|
|
||||||
|
if (!userId || !token) {
|
||||||
|
// Missing
|
||||||
|
res.status(400).send(JSON.stringify({message: 'Missing parameters'}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the user and a session
|
||||||
|
const success: boolean = await UserService.activateUser(userId, token);
|
||||||
|
|
||||||
|
// Send the session details back to the user
|
||||||
|
if(success) {
|
||||||
|
res.status(200).send({
|
||||||
|
'status': 'OK',
|
||||||
|
'message': 'User activated'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(400).send({'status': 'PROCESSING_ERROR','message': 'Error activating user. Please contact your administrator.'});
|
||||||
|
} catch (e: any) {
|
||||||
|
let errorGuid = Guid.create().toString();
|
||||||
|
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
|
||||||
|
res.status(500).send({
|
||||||
|
'status': 'PROCESSING_ERROR',
|
||||||
|
'message': 'Internal Server Error. Try again later.',
|
||||||
|
'reference': errorGuid
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /calendar/users/login:
|
||||||
|
* post:
|
||||||
|
* summary: Login a user
|
||||||
|
* description: Authenticates a user with the provided email and password and returns a session.
|
||||||
|
* tags:
|
||||||
|
* - calendar
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* required:
|
||||||
|
* - email
|
||||||
|
* - password
|
||||||
|
* properties:
|
||||||
|
* email:
|
||||||
|
* type: string
|
||||||
|
* format: email
|
||||||
|
* example: john.doe@nachklang.art
|
||||||
|
* password:
|
||||||
|
* type: string
|
||||||
|
* format: password
|
||||||
|
* example: securePassword123
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Login successful
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* sessionId:
|
||||||
|
* type: integer
|
||||||
|
* example: 123
|
||||||
|
* sessionKey:
|
||||||
|
* type: string
|
||||||
|
* example: abc123def456
|
||||||
|
* 400:
|
||||||
|
* description: Bad request - missing parameters
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* message:
|
||||||
|
* type: string
|
||||||
|
* example: Missing parameters
|
||||||
|
* 401:
|
||||||
|
* description: Unauthorized - invalid credentials
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* message:
|
||||||
|
* type: string
|
||||||
|
* example: Wrong username and / or password
|
||||||
|
* sessionId:
|
||||||
|
* type: integer
|
||||||
|
* example: -1
|
||||||
|
* sessionKey:
|
||||||
|
* type: string
|
||||||
|
* example: ""
|
||||||
|
* 500:
|
||||||
|
* description: Server error
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* status:
|
||||||
|
* type: string
|
||||||
|
* example: PROCESSING_ERROR
|
||||||
|
* message:
|
||||||
|
* type: string
|
||||||
|
* example: Internal Server Error. Try again later.
|
||||||
|
* reference:
|
||||||
|
* type: string
|
||||||
|
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
|
||||||
|
*/
|
||||||
|
// POST users/login
|
||||||
|
usersRouter.post('/login', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const password: string = req.body.password;
|
||||||
|
const email: string = req.body.email;
|
||||||
|
const ip: string = req.socket.remoteAddress ?? '';
|
||||||
|
|
||||||
|
if (!password || !email) {
|
||||||
|
// Missing
|
||||||
|
res.status(400).send(JSON.stringify({message: 'Missing parameters'}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a session
|
||||||
|
const session: Session | null = await UserService.login(email, password, ip);
|
||||||
|
|
||||||
|
if (!session || !session.sessionId) {
|
||||||
|
// Error logging in, probably wrong username / password
|
||||||
|
res.status(401).send(JSON.stringify({message: 'Wrong username and / or password', sessionId: -1, sessionKey: ''}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the session details back to the user
|
||||||
|
res.status(200).send({
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
sessionKey: session.sessionKey
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
let errorGuid = Guid.create().toString();
|
||||||
|
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
|
||||||
|
res.status(500).send({
|
||||||
|
'status': 'PROCESSING_ERROR',
|
||||||
|
'message': 'Internal Server Error. Try again later.',
|
||||||
|
'reference': errorGuid
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /calendar/users/checkSessionValid:
|
||||||
|
* post:
|
||||||
|
* summary: Check if a session is valid
|
||||||
|
* description: Checks if the provided session is valid and returns the user information if it is.
|
||||||
|
* tags:
|
||||||
|
* - calendar
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* required:
|
||||||
|
* - sessionId
|
||||||
|
* - sessionKey
|
||||||
|
* properties:
|
||||||
|
* sessionId:
|
||||||
|
* type: integer
|
||||||
|
* example: 123
|
||||||
|
* sessionKey:
|
||||||
|
* type: string
|
||||||
|
* example: abc123def456
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Session is valid
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/User'
|
||||||
|
* 401:
|
||||||
|
* description: Unauthorized - invalid session
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* messages:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
* example: ["Invalid session"]
|
||||||
|
* 500:
|
||||||
|
* description: Server error
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* status:
|
||||||
|
* type: string
|
||||||
|
* example: PROCESSING_ERROR
|
||||||
|
* message:
|
||||||
|
* type: string
|
||||||
|
* example: Internal Server Error. Try again later.
|
||||||
|
* reference:
|
||||||
|
* type: string
|
||||||
|
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
|
||||||
|
*/
|
||||||
|
// POST users/checkSessionValid
|
||||||
|
usersRouter.post('/checkSessionValid', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const ip: string = req.socket.remoteAddress ?? '';
|
||||||
|
const session_id = req.body.sessionId;
|
||||||
|
const session_key = req.body.sessionKey;
|
||||||
|
|
||||||
|
if (!session_id || !session_key) {
|
||||||
|
// Error logging in, probably wrong username / password
|
||||||
|
res.status(401).send(JSON.stringify({messages: ['No session detected']}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user: User | null = await UserService.checkSession(session_id, session_key, ip);
|
||||||
|
|
||||||
|
if (!user || !user.userId) {
|
||||||
|
// Error logging in, probably wrong username / password
|
||||||
|
res.status(401).send(JSON.stringify({messages: ['Invalid session']}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).send(user);
|
||||||
|
} catch (e: any) {
|
||||||
|
let errorGuid = Guid.create().toString();
|
||||||
|
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
|
||||||
|
res.status(500).send({
|
||||||
|
'status': 'PROCESSING_ERROR',
|
||||||
|
'message': 'Internal Server Error. Try again later.',
|
||||||
|
'reference': errorGuid
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /calendar/users/initiatePasswordReset:
|
||||||
|
* post:
|
||||||
|
* summary: Initiates a password reset
|
||||||
|
* description: Checks if the user exists and if so, initiates a password reset by sending an email to the user.
|
||||||
|
* tags:
|
||||||
|
* - calendar
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Success
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* messages:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
* example: Success
|
||||||
|
* description: A list of status messages
|
||||||
|
* 400:
|
||||||
|
* description: Problem with the request. Please consider the returned detailed error.
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* messages:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
* example: Missing parameters
|
||||||
|
* description: A list of error messages
|
||||||
|
* 401:
|
||||||
|
* description: Problem with authorizing the user. Please check the provided credentials.
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* messages:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
* example: Invalid session
|
||||||
|
* description: A list of error messages
|
||||||
|
* 500:
|
||||||
|
* description: A server error occurred. Please try again. If this issue persists, contact the admin.
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* status:
|
||||||
|
* type: string
|
||||||
|
* description: The response status
|
||||||
|
* example: PROCESSING_ERROR
|
||||||
|
* message:
|
||||||
|
* type: string
|
||||||
|
* description: The detailed error message
|
||||||
|
* example: Internal Server Error. Try again later.
|
||||||
|
* reference:
|
||||||
|
* type: string
|
||||||
|
* description: An error reference for getting support concerning this error.
|
||||||
|
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* email:
|
||||||
|
* type: string
|
||||||
|
* example: patrick@nachklang.art
|
||||||
|
*/
|
||||||
|
usersRouter.post('/initiatePasswordReset', async(req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const username = req.body.username;
|
||||||
|
|
||||||
|
if (!username) {
|
||||||
|
// Error logging in, probably wrong username / password
|
||||||
|
res.status(400).send(JSON.stringify({messages: ['No username given']}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const success: boolean = await UserService.initiatePasswordReset(username);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
// Error logging in, probably wrong username / password
|
||||||
|
res.status(401).send(JSON.stringify({messages: ['Error']}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).send(JSON.stringify({messages: ['Success']}));
|
||||||
|
} catch (e: any) {
|
||||||
|
let errorGuid = Guid.create().toString();
|
||||||
|
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
|
||||||
|
res.status(500).send({
|
||||||
|
'status': 'PROCESSING_ERROR',
|
||||||
|
'message': 'Internal Server Error. Try again later.',
|
||||||
|
'reference': errorGuid
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /calendar/users/finalizePasswordReset:
|
||||||
|
* post:
|
||||||
|
* summary: Finalizes the password reset
|
||||||
|
* description: Checks if the given token is valid and if so, finalizes the password reset by setting the new password.
|
||||||
|
* tags:
|
||||||
|
* - calendar
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Success
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* messages:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
* example: Success
|
||||||
|
* description: A list of status messages
|
||||||
|
* 400:
|
||||||
|
* description: Problem with the request. Please consider the returned detailed error.
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* messages:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
* example: Missing parameters
|
||||||
|
* description: A list of error messages
|
||||||
|
* 401:
|
||||||
|
* description: Problem with authorizing the user. Please check the provided credentials.
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* messages:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
* example: Invalid session
|
||||||
|
* description: A list of error messages
|
||||||
|
* 500:
|
||||||
|
* description: A server error occurred. Please try again. If this issue persists, contact the admin.
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* status:
|
||||||
|
* type: string
|
||||||
|
* description: The response status
|
||||||
|
* example: PROCESSING_ERROR
|
||||||
|
* message:
|
||||||
|
* type: string
|
||||||
|
* description: The detailed error message
|
||||||
|
* example: Internal Server Error. Try again later.
|
||||||
|
* reference:
|
||||||
|
* type: string
|
||||||
|
* description: An error reference for getting support concerning this error.
|
||||||
|
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* email:
|
||||||
|
* type: string
|
||||||
|
* example: patrick@nachklang.art
|
||||||
|
* token:
|
||||||
|
* type: string
|
||||||
|
* example: 3ccd147f-720b-4e29-a8b7-46b63de31555
|
||||||
|
* password:
|
||||||
|
* type: string
|
||||||
|
* example: ExtremelyBadPassword
|
||||||
|
*/
|
||||||
|
usersRouter.post('/finalizePasswordReset', async(req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const username = req.body.username;
|
||||||
|
const token = req.body.token;
|
||||||
|
const newPassword = req.body.password;
|
||||||
|
|
||||||
|
if (!username) {
|
||||||
|
// Error logging in, probably wrong username / password
|
||||||
|
res.status(400).send(JSON.stringify({messages: ['No username, token or password given']}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const success: boolean = await UserService.finalizePasswordReset(username, token, newPassword);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
// Error logging in, probably wrong username / password
|
||||||
|
res.status(401).send(JSON.stringify({messages: ['Error']}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).send(JSON.stringify({messages: ['Success']}));
|
||||||
|
} catch (e: any) {
|
||||||
|
let errorGuid = Guid.create().toString();
|
||||||
|
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
|
||||||
|
res.status(500).send({
|
||||||
|
'status': 'PROCESSING_ERROR',
|
||||||
|
'message': 'Internal Server Error. Try again later.',
|
||||||
|
'reference': errorGuid
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
import * as dotenv from 'dotenv';
|
||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
import {Guid} from 'guid-typescript';
|
||||||
|
import {User} from './user.interface.js';
|
||||||
|
import {Session} from './session.interface.js';
|
||||||
|
import {NachklangCalendarDB} from '../Calendar.db.js';
|
||||||
|
import {MailService} from '../../../common/common.mail.js';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data Model Interfaces
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service Methods
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a user record in the database, also creates a session. Returns the session if successful.
|
||||||
|
*/
|
||||||
|
export const createUser = async (email: string, password: string, fullName: string, ip: string): Promise<Session> => {
|
||||||
|
let conn = await NachklangCalendarDB.getConnection();
|
||||||
|
try {
|
||||||
|
await conn.beginTransaction();
|
||||||
|
|
||||||
|
// Hash password and generate + hash session key
|
||||||
|
const pwHash = bcrypt.hashSync(password, 10);
|
||||||
|
const sessionKey = Guid.create().toString();
|
||||||
|
const sessionKeyHash = bcrypt.hashSync(sessionKey, 10);
|
||||||
|
|
||||||
|
const activationToken = Guid.create().toString();
|
||||||
|
const activationTokenHash = bcrypt.hashSync(activationToken, 10);
|
||||||
|
|
||||||
|
// Create user entry in SQL
|
||||||
|
const userQuery = 'INSERT INTO users (email, password_hash, full_name, activation_token) VALUES (?, ?, ?, ?) RETURNING user_id';
|
||||||
|
const userIdRes = await conn.query(userQuery, [email, pwHash, fullName, activationTokenHash]);
|
||||||
|
|
||||||
|
// Get user id of the created user
|
||||||
|
let userId: number = -1;
|
||||||
|
for (const row of userIdRes) {
|
||||||
|
userId = row.user_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
const sessionQuery = 'INSERT INTO sessions (user_id, session_key_hash, created_date, valid_until, last_ip) VALUES (?,?,NOW(),DATE_ADD(NOW(), INTERVAL 30 DAY),?) RETURNING session_id';
|
||||||
|
const sessionIdRes = await conn.query(sessionQuery, [userId, sessionKeyHash, ip]);
|
||||||
|
await conn.commit();
|
||||||
|
|
||||||
|
// Get session id of the created session
|
||||||
|
let sessionId: number = -1;
|
||||||
|
for (const row of sessionIdRes) {
|
||||||
|
sessionId = row.session_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send email with activation link (after commit so we don't block on email
|
||||||
|
// delivery). sendMail never throws on a delivery failure - it logs and
|
||||||
|
// returns false - so a mail-server problem here can't roll back the
|
||||||
|
// already-committed user and leave registration reporting a false error.
|
||||||
|
await MailService.sendMail(email, 'Activate your Nachklang account', `Hi ${fullName},\n\nPlease click on the following link to activate your account:\n\nhttps://api.nachklang.art/calendar/users/activate?id=${userId}&token=${activationToken}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId: sessionId,
|
||||||
|
userId: userId,
|
||||||
|
sessionKey: sessionKey,
|
||||||
|
sessionKeyHash: 'HIDDEN',
|
||||||
|
lastIP: ip
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
await conn.rollback();
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
await conn.end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const activateUser = async (userId: number, token: string): Promise<boolean> => {
|
||||||
|
let conn = await NachklangCalendarDB.getConnection();
|
||||||
|
try {
|
||||||
|
await conn.beginTransaction();
|
||||||
|
|
||||||
|
const checkTokenQuery = 'SELECT user_id, activation_token FROM users WHERE user_id = ? AND is_active = 0';
|
||||||
|
const userNameRes = await conn.query(checkTokenQuery, [userId]);
|
||||||
|
let storedTokenHash = '';
|
||||||
|
for (const row of userNameRes) {
|
||||||
|
storedTokenHash = row.activation_token;
|
||||||
|
}
|
||||||
|
if (!storedTokenHash || !bcrypt.compareSync(token, storedTokenHash)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const activateQuery = 'UPDATE users SET is_active = 1, activation_token = null WHERE user_id = ?';
|
||||||
|
const activateRes = await conn.execute(activateQuery, [userId]);
|
||||||
|
await conn.commit();
|
||||||
|
return activateRes.affectedRows !== 0;
|
||||||
|
} catch (err) {
|
||||||
|
await conn.rollback();
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
await conn.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the given credentials are valid and creates a new session if they are.
|
||||||
|
* Returns the session information in case of a successful login
|
||||||
|
*/
|
||||||
|
export const login = async (email: string, password: string, ip: string): Promise<Session | null> => {
|
||||||
|
let conn = await NachklangCalendarDB.getConnection();
|
||||||
|
try {
|
||||||
|
await conn.beginTransaction();
|
||||||
|
|
||||||
|
// Get saved password hash
|
||||||
|
const query = 'SELECT user_id, password_hash FROM users WHERE email = ?';
|
||||||
|
const userRows = await conn.query(query, email);
|
||||||
|
let savedHash = '';
|
||||||
|
let userId = -1;
|
||||||
|
for (const row of userRows) {
|
||||||
|
savedHash = row.password_hash;
|
||||||
|
userId = row.user_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for correct password
|
||||||
|
if (!bcrypt.compareSync(password, savedHash)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate + hash session key
|
||||||
|
const sessionKey = Guid.create().toString();
|
||||||
|
const sessionKeyHash = bcrypt.hashSync(sessionKey, 10);
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
const sessionQuery = 'INSERT INTO sessions (user_id, session_key_hash, created_date, valid_until, last_ip) VALUES (?,?,NOW(),DATE_ADD(NOW(), INTERVAL 30 DAY),?) RETURNING session_id';
|
||||||
|
const sessionIdRes = await conn.query(sessionQuery, [userId, sessionKeyHash, ip]);
|
||||||
|
await conn.commit();
|
||||||
|
|
||||||
|
// Get session id of the created session
|
||||||
|
let sessionId: number = -1;
|
||||||
|
for (const row of sessionIdRes) {
|
||||||
|
sessionId = row.session_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId: sessionId,
|
||||||
|
userId: userId,
|
||||||
|
sessionKey: sessionKey,
|
||||||
|
sessionKeyHash: 'HIDDEN',
|
||||||
|
lastIP: ip
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
await conn.rollback();
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
await conn.end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the given session information are valid and returns the user information if they are
|
||||||
|
*/
|
||||||
|
export const checkSession = async (sessionId: string, sessionKey: string, ip: string): Promise<User | null> => {
|
||||||
|
let conn = await NachklangCalendarDB.getConnection();
|
||||||
|
try {
|
||||||
|
await conn.beginTransaction();
|
||||||
|
|
||||||
|
// Get saved session key hash
|
||||||
|
const query = 'SELECT user_id, session_key_hash, valid_until FROM sessions WHERE session_id = ?';
|
||||||
|
const sessionRows = await conn.query(query, sessionId);
|
||||||
|
let savedHash = '';
|
||||||
|
let userId = -1;
|
||||||
|
let validUntil = new Date();
|
||||||
|
for (const row of sessionRows) {
|
||||||
|
savedHash = row.session_key_hash;
|
||||||
|
userId = row.user_id;
|
||||||
|
validUntil = row.valid_until;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for correct key
|
||||||
|
if (!bcrypt.compareSync(sessionKey, savedHash)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the session is still valid
|
||||||
|
if (validUntil <= new Date()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update session entry in SQL
|
||||||
|
const updateSessionsQuery = 'UPDATE sessions SET last_IP = ? WHERE session_id = ?';
|
||||||
|
await conn.query(updateSessionsQuery, [ip, sessionId]);
|
||||||
|
await conn.commit();
|
||||||
|
|
||||||
|
// Get the other required user information
|
||||||
|
const userQuery = 'SELECT user_id, email, full_name, is_active FROM users WHERE user_id = ?';
|
||||||
|
const userRows = await conn.query(userQuery, userId);
|
||||||
|
let email = '';
|
||||||
|
let fullName = '';
|
||||||
|
let is_active = false;
|
||||||
|
for (const row of userRows) {
|
||||||
|
email = row.email;
|
||||||
|
fullName = row.full_name;
|
||||||
|
is_active = row.is_active;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything is fine, return user information
|
||||||
|
return {
|
||||||
|
userId: userId,
|
||||||
|
email: email,
|
||||||
|
passwordHash: 'HIDDEN',
|
||||||
|
fullName: fullName,
|
||||||
|
isActive: is_active
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
await conn.rollback();
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
await conn.end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const initiatePasswordReset = async (email: string): Promise<boolean> => {
|
||||||
|
let conn = await NachklangCalendarDB.getConnection();
|
||||||
|
try {
|
||||||
|
await conn.beginTransaction();
|
||||||
|
|
||||||
|
const checkUsernameQuery = 'SELECT user_id, full_Name FROM users WHERE email = ?';
|
||||||
|
const userNameRes = await conn.query(checkUsernameQuery, [email]);
|
||||||
|
if (userNameRes.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let userId: number = -1;
|
||||||
|
let fullName: string = '';
|
||||||
|
for(let row of userNameRes) {
|
||||||
|
userId = row.user_id;
|
||||||
|
fullName = row.full_Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
let resetToken = Guid.create().toString();
|
||||||
|
let resetTokenHash = bcrypt.hashSync(resetToken, 10);
|
||||||
|
|
||||||
|
const updateQuery = 'UPDATE users SET pw_reset_token_hash = ? WHERE user_id = ?';
|
||||||
|
const updateRes = await conn.execute(updateQuery, [resetTokenHash, userId]);
|
||||||
|
|
||||||
|
if(updateRes.affectedRows === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await conn.commit();
|
||||||
|
|
||||||
|
await MailService.sendMail(email, 'Password Reset', `Hello ${fullName},\n\nYou requested a password reset for your BonkApp account. If you did not request this, please ignore this email.\n\nTo reset your password, please use the following reset token:\n\n${resetToken}`);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
await conn.rollback();
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
await conn.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const finalizePasswordReset = async (email: string, token: string, newPassword: string): Promise<boolean> => {
|
||||||
|
let conn = await NachklangCalendarDB.getConnection();
|
||||||
|
try {
|
||||||
|
await conn.beginTransaction();
|
||||||
|
|
||||||
|
const checkTokenQuery = 'SELECT user_id, pw_reset_token_hash FROM users WHERE email = ?';
|
||||||
|
const userNameRes = await conn.query(checkTokenQuery, [email]);
|
||||||
|
if (userNameRes.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let userId: string = '';
|
||||||
|
let tokenHash: string = '';
|
||||||
|
for(let row of userNameRes) {
|
||||||
|
userId = row.user_id;
|
||||||
|
tokenHash = row.pw_reset_token_hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!bcrypt.compareSync(token, tokenHash)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pwHash = bcrypt.hashSync(newPassword, 10);
|
||||||
|
const updatePasswordQuery = 'UPDATE users SET password_hash = ?, pw_reset_token_hash = NULL WHERE user_id = ?';
|
||||||
|
const updateRes = await conn.execute(updatePasswordQuery, [pwHash, userId]);
|
||||||
|
|
||||||
|
if(updateRes.affectedRows > 0) {
|
||||||
|
await conn.commit();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (err) {
|
||||||
|
await conn.rollback();
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
await conn.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,14 +7,9 @@ import express, {Request, Response} from 'express';
|
|||||||
* the calendar users table; both are now one binding to the shared admin gate.
|
* 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
|
* 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
|
* owns that - but that each module is bound to *its own* app, and that neither
|
||||||
* the calling file because vi.mock is per-module-graph; only the assertions are
|
* consults the calendar users service any more. The mocks live in the calling
|
||||||
* shared.
|
* 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 {
|
export interface BindingMocks {
|
||||||
@@ -22,6 +17,8 @@ export interface BindingMocks {
|
|||||||
getSession: Mock;
|
getSession: Mock;
|
||||||
/** loadAccess from the mocked users.admin.service.js */
|
/** loadAccess from the mocked users.admin.service.js */
|
||||||
loadAccess: Mock;
|
loadAccess: Mock;
|
||||||
|
/** checkSession from the mocked calendar users.service.js */
|
||||||
|
checkSession: Mock;
|
||||||
}
|
}
|
||||||
|
|
||||||
const makeReq = (): Request => ({headers: {cookie: 'nachklang.session_token=abc'}} as unknown as Request);
|
const makeReq = (): Request => ({headers: {cookie: 'nachklang.session_token=abc'}} as unknown as Request);
|
||||||
@@ -63,6 +60,7 @@ export const describeAdminBinding = (
|
|||||||
m = mocks();
|
m = mocks();
|
||||||
m.getSession.mockReset();
|
m.getSession.mockReset();
|
||||||
m.loadAccess.mockReset();
|
m.loadAccess.mockReset();
|
||||||
|
m.checkSession.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('responds 401 and does not call next() without a session', async () => {
|
it('responds 401 and does not call next() without a session', async () => {
|
||||||
@@ -103,5 +101,18 @@ export const describeAdminBinding = (
|
|||||||
expect(next).toHaveBeenCalled();
|
expect(next).toHaveBeenCalled();
|
||||||
expect(res.locals.admin).toMatchObject({id: 'u1', email: 'a@nachklang.art', displayName: 'Anna Admin'});
|
expect(res.locals.admin).toMatchObject({id: 'u1', email: 'a@nachklang.art', displayName: 'Anna Admin'});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Weaker than it looks and kept deliberately: neither module imports
|
||||||
|
// checkSession any more, so this cannot fail today. It is a tripwire for
|
||||||
|
// the change that would matter - someone reintroducing a header-session
|
||||||
|
// fallback "just for the calendar users who have not been invited yet",
|
||||||
|
// which is exactly the shortcut the cutover exists to close.
|
||||||
|
it('never falls back to a calendar header session', async () => {
|
||||||
|
m.getSession.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await run();
|
||||||
|
|
||||||
|
expect(m.checkSession).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,11 +23,11 @@ import * as EventService from '../../src/models/calendar/events/events.service.j
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Step 3 of docs/calendar-auth-migration.md. The property under test is that
|
* 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 -
|
* an event's creator resolves from whichever of its three possible sources is
|
||||||
* the live admin name, else the name archived before the legacy users table was
|
* strongest - the live admin name, then the snapshot from migration 002, then
|
||||||
* removed - and that a failure to reach the admin database costs a name rather
|
* the legacy join - and that a failure to reach the admin database costs a name
|
||||||
* than the whole response: the public calendar is read anonymously by the
|
* rather than the whole response: the public calendar is read anonymously by
|
||||||
* website and has never depended on the admin database being up.
|
* the website and has never depended on the admin database being up.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// One row of the shape the shared SELECT produces.
|
// One row of the shape the shared SELECT produces.
|
||||||
@@ -42,10 +42,14 @@ const row = (over: Record<string, unknown> = {}) => ({
|
|||||||
created_date: new Date('2026-01-01T00:00:00Z'),
|
created_date: new Date('2026-01-01T00:00:00Z'),
|
||||||
version_created_at: new Date('2026-01-02T00:00:00Z'),
|
version_created_at: new Date('2026-01-02T00:00:00Z'),
|
||||||
location: '',
|
location: '',
|
||||||
|
created_by_id: 7,
|
||||||
created_by_user_id: null,
|
created_by_user_id: null,
|
||||||
created_by_name: 'Archived Person',
|
created_by_name: null,
|
||||||
|
legacy_created_by_name: 'Legacy Person',
|
||||||
|
version_created_by_id: 7,
|
||||||
version_created_by_user_id: null,
|
version_created_by_user_id: null,
|
||||||
version_created_by_name: 'Archived Person',
|
version_created_by_name: null,
|
||||||
|
legacy_last_modified_by_name: 'Legacy Person',
|
||||||
url: '',
|
url: '',
|
||||||
whole_day: 0,
|
whole_day: 0,
|
||||||
repeat_frequency: '',
|
repeat_frequency: '',
|
||||||
@@ -68,12 +72,13 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('creator names', () => {
|
describe('creator names', () => {
|
||||||
it('uses the archived name when the row has no admin id', async () => {
|
it('uses the legacy join when the row has no admin id', async () => {
|
||||||
givenEvents(row());
|
givenEvents(row());
|
||||||
|
|
||||||
const events = await EventService.getAllEvents(1);
|
const events = await EventService.getAllEvents(1);
|
||||||
|
|
||||||
expect(events[0].createdBy).toBe('Archived Person');
|
expect(events[0].createdBy).toBe('Legacy Person');
|
||||||
|
expect(events[0].createdById).toBe(7);
|
||||||
expect(events[0].createdByUserId).toBeNull();
|
expect(events[0].createdByUserId).toBeNull();
|
||||||
// Nothing to resolve, so the admin database is not touched at all.
|
// Nothing to resolve, so the admin database is not touched at all.
|
||||||
expect(AdminUsersService.findDisplayNames).not.toHaveBeenCalled();
|
expect(AdminUsersService.findDisplayNames).not.toHaveBeenCalled();
|
||||||
@@ -92,9 +97,23 @@ describe('creator names', () => {
|
|||||||
|
|
||||||
expect(events[0].createdBy).toBe('Neue Person');
|
expect(events[0].createdBy).toBe('Neue Person');
|
||||||
expect(events[0].lastModifiedBy).toBe('Andere Person');
|
expect(events[0].lastModifiedBy).toBe('Andere Person');
|
||||||
|
// The legacy id is still reported during the transition.
|
||||||
|
expect(events[0].createdById).toBe(7);
|
||||||
expect(events[0].createdByUserId).toBe('admin-1');
|
expect(events[0].createdByUserId).toBe('admin-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('prefers the snapshot over the legacy join', async () => {
|
||||||
|
givenEvents(row({
|
||||||
|
created_by_name: 'Archived Person',
|
||||||
|
version_created_by_name: 'Archived Person'
|
||||||
|
}));
|
||||||
|
|
||||||
|
const events = await EventService.getAllEvents(1);
|
||||||
|
|
||||||
|
expect(events[0].createdBy).toBe('Archived Person');
|
||||||
|
expect(events[0].lastModifiedBy).toBe('Archived Person');
|
||||||
|
});
|
||||||
|
|
||||||
it('prefers the live admin name over the snapshot', async () => {
|
it('prefers the live admin name over the snapshot', async () => {
|
||||||
// A renamed account has to win over an archive that was correct when it
|
// A renamed account has to win over an archive that was correct when it
|
||||||
// was taken - otherwise renaming someone would leave stale names behind.
|
// was taken - otherwise renaming someone would leave stale names behind.
|
||||||
@@ -106,26 +125,29 @@ describe('creator names', () => {
|
|||||||
expect(events[0].createdBy).toBe('Neue Person');
|
expect(events[0].createdBy).toBe('Neue Person');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to the archived name when the admin account is gone', async () => {
|
it('keeps the snapshot when step 5 has removed the legacy join', async () => {
|
||||||
|
// What a post-step-5 row looks like: no legacy id, no join, snapshot only.
|
||||||
|
givenEvents(row({
|
||||||
|
created_by_id: null,
|
||||||
|
legacy_created_by_name: undefined,
|
||||||
|
legacy_last_modified_by_name: undefined,
|
||||||
|
created_by_name: 'Archived Person',
|
||||||
|
version_created_by_name: 'Archived Person'
|
||||||
|
}));
|
||||||
|
|
||||||
|
const events = await EventService.getAllEvents(1);
|
||||||
|
|
||||||
|
expect(events[0].createdBy).toBe('Archived Person');
|
||||||
|
expect(events[0].lastModifiedBy).toBe('Archived Person');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the legacy name when the admin account is gone', async () => {
|
||||||
givenEvents(row({created_by_user_id: 'deleted'}));
|
givenEvents(row({created_by_user_id: 'deleted'}));
|
||||||
(AdminUsersService.findDisplayNames as any).mockResolvedValue(new Map());
|
(AdminUsersService.findDisplayNames as any).mockResolvedValue(new Map());
|
||||||
|
|
||||||
const events = await EventService.getAllEvents(1);
|
const events = await EventService.getAllEvents(1);
|
||||||
|
|
||||||
expect(events[0].createdBy).toBe('Archived Person');
|
expect(events[0].createdBy).toBe('Legacy 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 () => {
|
it('resolves a mixed result set in a single lookup', async () => {
|
||||||
@@ -138,7 +160,7 @@ describe('creator names', () => {
|
|||||||
|
|
||||||
const events = await EventService.getAllEvents(1);
|
const events = await EventService.getAllEvents(1);
|
||||||
|
|
||||||
expect(events.map(e => e.createdBy)).toEqual(['Archived Person', 'Neue Person', 'Neue Person']);
|
expect(events.map(e => e.createdBy)).toEqual(['Legacy Person', 'Neue Person', 'Neue Person']);
|
||||||
expect(AdminUsersService.findDisplayNames).toHaveBeenCalledTimes(1);
|
expect(AdminUsersService.findDisplayNames).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -150,8 +172,8 @@ describe('creator names', () => {
|
|||||||
|
|
||||||
expect(events).toHaveLength(1);
|
expect(events).toHaveLength(1);
|
||||||
expect(events[0].name).toBe('Konzert');
|
expect(events[0].name).toBe('Konzert');
|
||||||
// Degrades to the archived name rather than failing the request.
|
// Degrades to the legacy name rather than failing the request.
|
||||||
expect(events[0].createdBy).toBe('Archived Person');
|
expect(events[0].createdBy).toBe('Legacy Person');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import {vi, type Mock} from 'vitest';
|
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', () => ({
|
vi.mock('../../src/models/admin/admin.auth.js', () => ({
|
||||||
auth: {api: {getSession: vi.fn()}}
|
auth: {api: {getSession: vi.fn()}}
|
||||||
}));
|
}));
|
||||||
@@ -7,6 +10,7 @@ vi.mock('../../src/models/admin/users/users.admin.service.js', () => ({
|
|||||||
loadAccess: vi.fn()
|
loadAccess: vi.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
import * as UserService from '../../src/models/calendar/users/users.service.js';
|
||||||
import {auth} from '../../src/models/admin/admin.auth.js';
|
import {auth} from '../../src/models/admin/admin.auth.js';
|
||||||
import * as UsersService from '../../src/models/admin/users/users.admin.service.js';
|
import * as UsersService from '../../src/models/admin/users/users.admin.service.js';
|
||||||
import {requireAdminAuth} from '../../src/models/feedback/feedback.auth.js';
|
import {requireAdminAuth} from '../../src/models/feedback/feedback.auth.js';
|
||||||
@@ -14,5 +18,6 @@ import {describeAdminBinding} from '../admin/auth-binding.js';
|
|||||||
|
|
||||||
describeAdminBinding('feedback', 'tickets', requireAdminAuth, () => ({
|
describeAdminBinding('feedback', 'tickets', requireAdminAuth, () => ({
|
||||||
getSession: auth.api.getSession as unknown as Mock,
|
getSession: auth.api.getSession as unknown as Mock,
|
||||||
loadAccess: UsersService.loadAccess as Mock
|
loadAccess: UsersService.loadAccess as Mock,
|
||||||
|
checkSession: UserService.checkSession as Mock
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import {vi, type Mock} from 'vitest';
|
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', () => ({
|
vi.mock('../../src/models/admin/admin.auth.js', () => ({
|
||||||
auth: {api: {getSession: vi.fn()}}
|
auth: {api: {getSession: vi.fn()}}
|
||||||
}));
|
}));
|
||||||
@@ -7,6 +10,7 @@ vi.mock('../../src/models/admin/users/users.admin.service.js', () => ({
|
|||||||
loadAccess: vi.fn()
|
loadAccess: vi.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
import * as UserService from '../../src/models/calendar/users/users.service.js';
|
||||||
import {auth} from '../../src/models/admin/admin.auth.js';
|
import {auth} from '../../src/models/admin/admin.auth.js';
|
||||||
import * as UsersService from '../../src/models/admin/users/users.admin.service.js';
|
import * as UsersService from '../../src/models/admin/users/users.admin.service.js';
|
||||||
import {requireAdminAuth} from '../../src/models/tickets/tickets.auth.js';
|
import {requireAdminAuth} from '../../src/models/tickets/tickets.auth.js';
|
||||||
@@ -14,5 +18,6 @@ import {describeAdminBinding} from '../admin/auth-binding.js';
|
|||||||
|
|
||||||
describeAdminBinding('tickets', 'feedback', requireAdminAuth, () => ({
|
describeAdminBinding('tickets', 'feedback', requireAdminAuth, () => ({
|
||||||
getSession: auth.api.getSession as unknown as Mock,
|
getSession: auth.api.getSession as unknown as Mock,
|
||||||
loadAccess: UsersService.loadAccess as Mock
|
loadAccess: UsersService.loadAccess as Mock,
|
||||||
|
checkSession: UserService.checkSession as Mock
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user