Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3fc6894070 | |||
| 69c1e4926c | |||
| a49b939fdc | |||
| aa95ab2745 | |||
| 13a0c07d1b | |||
| 3c892d02ed | |||
| bf7be65b03 | |||
| ce9b173c71 | |||
| bf7f45acce | |||
| 10c459db0f | |||
| 3ea9e630ed | |||
| 449edd6c68 |
@@ -1,3 +1,11 @@
|
|||||||
|
# Values containing #, ", \ or surrounding spaces must be single-quoted
|
||||||
|
# (dotenv 16 treats an unquoted # as a comment): DB_PASSWORD='abc#def'
|
||||||
|
# REQUIRED. The admin module treats anything other than "development" or "test"
|
||||||
|
# as production: strict secrets, cross-subdomain cookies, no relaxed CORS.
|
||||||
|
# Leaving it unset is therefore safe-by-default but will refuse to boot without
|
||||||
|
# the admin secrets below. Set it to development for local work.
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
DB_HOST=
|
DB_HOST=
|
||||||
@@ -23,6 +31,41 @@ TICKETS_DB=
|
|||||||
TICKETS_RATE_LIMIT_MAX=10
|
TICKETS_RATE_LIMIT_MAX=10
|
||||||
TICKETS_RATE_LIMIT_WINDOW_MIN=10
|
TICKETS_RATE_LIMIT_WINDOW_MIN=10
|
||||||
|
|
||||||
|
ADMIN_DB=
|
||||||
|
# 32+ random bytes, e.g. `openssl rand -base64 48`. Mandatory outside
|
||||||
|
# development/test - there is deliberately no fallback, since a hardcoded one
|
||||||
|
# would be a published signing key. Rotating it signs everyone out and
|
||||||
|
# invalidates outstanding password-reset links.
|
||||||
|
BETTER_AUTH_SECRET=
|
||||||
|
API_BASE_URL=http://localhost:3000
|
||||||
|
ADMIN_APP_URL=http://localhost:3002
|
||||||
|
# Comma-separated origins of the apps that may call /admin/* with credentials.
|
||||||
|
APP_ORIGINS=http://localhost:3001
|
||||||
|
# nachklang.art in production; passkeys are bound to this value.
|
||||||
|
PASSKEY_RP_ID=localhost
|
||||||
|
# On start-up, makes sure this address can get in (invite, or grant admin if the
|
||||||
|
# user already exists). Idempotent, safe to leave set.
|
||||||
|
ADMIN_BOOTSTRAP_EMAIL=
|
||||||
|
|
||||||
|
# The header the reverse proxy puts the real client IP in, and the proxy hops to
|
||||||
|
# trust. Get these right or better-auth cannot resolve a client IP and every
|
||||||
|
# request shares ONE rate-limit bucket (/sign-in/* allows 3 per 10 seconds, so
|
||||||
|
# one noisy client locks everyone out). Check with:
|
||||||
|
# SELECT `key` FROM rateLimit; -- a "no-trusted-ip" row means it is happening.
|
||||||
|
# The header the reverse proxy puts the real client IP in. Must be one the proxy
|
||||||
|
# actually overwrites - trusting a header it does not set lets any client send its
|
||||||
|
# own value and bypass the sign-in rate limit entirely.
|
||||||
|
# Set to "none" to trust no header at all: every request then shares one rate-limit
|
||||||
|
# bucket, which is the safe fallback if the check below fails. Verify after deploy
|
||||||
|
# with: SELECT ipAddress FROM session ORDER BY createdAt DESC LIMIT 3;
|
||||||
|
CLIENT_IP_HEADERS=x-real-ip
|
||||||
|
TRUSTED_PROXY_IPS=
|
||||||
|
|
||||||
|
# Writes invitation links to the log. That link is a live account-creation
|
||||||
|
# credential, so this is refused outside development. Needed locally, where the
|
||||||
|
# mail relay is off and only the token's hash is stored.
|
||||||
|
ADMIN_LOG_INVITE_LINKS=true
|
||||||
|
|
||||||
MEMBER_CREDENTIAL=123
|
MEMBER_CREDENTIAL=123
|
||||||
CHOIR_CREDENTIAL=123
|
CHOIR_CREDENTIAL=123
|
||||||
MANAGEMENT_CREDENTIAL=123
|
MANAGEMENT_CREDENTIAL=123
|
||||||
|
|||||||
@@ -8,20 +8,24 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
npm run build # Compile TypeScript → dist/
|
npm run build # Compile TypeScript → dist/
|
||||||
npm run start # Build and start (tsc && node ./dist/app.js)
|
npm run start # Build and start (tsc && node ./dist/app.js)
|
||||||
npm run debug # Start with DEBUG=* environment variable
|
npm run debug # Start with DEBUG=* environment variable
|
||||||
npm run test # Run Jest tests with coverage (outputs sonar-report.xml)
|
npm run test # Run the vitest suite once with coverage (lcov + testResults/sonar-report.xml)
|
||||||
|
npm run test:watch # vitest in watch mode
|
||||||
|
npm run test:integration # Admin-module tests against a throwaway MariaDB (needs docker or podman)
|
||||||
```
|
```
|
||||||
|
|
||||||
Run a single test file:
|
Run a single test file:
|
||||||
```bash
|
```bash
|
||||||
npx jest test/some.test.ts
|
npx vitest run test/some.test.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
Express.js REST API in TypeScript with a service-oriented layering. Domains: `Calendar` (events, users) and `Feedback` (concert feedback forms, mounted at `/feedback`, backed by its own `FEEDBACK_DB` — see `src/models/feedback/`: public submission flow, admin CRUD, reporting, and a Salesforce newsletter-sync integration).
|
Express.js REST API in TypeScript with a service-oriented layering. Domains: `Calendar` (events, users), `Feedback` (concert feedback forms, mounted at `/feedback`, backed by its own `FEEDBACK_DB` — see `src/models/feedback/`: public submission flow, admin CRUD, reporting, and a Salesforce newsletter-sync integration), `Tickets` (mounted at `/tickets`), and `Admin` (identity and permissions, mounted at `/admin`, backed by `ADMIN_DB` — see below).
|
||||||
|
|
||||||
|
`src/app.factory.ts` builds the Express app; `app.ts` only starts it. The split exists so the integration tests drive the real wiring.
|
||||||
|
|
||||||
**Request path:**
|
**Request path:**
|
||||||
1. `app.ts` mounts `Calendar.router.ts` at `/calendar`
|
1. `src/app.factory.ts` mounts `Calendar.router.ts` at `/calendar`
|
||||||
2. `Calendar.router.ts` delegates to `events.router.ts` and `users.router.ts`
|
2. `Calendar.router.ts` delegates to `events.router.ts` and `users.router.ts`
|
||||||
3. Routers call services; services call the MariaDB pool in `Calendar.db.ts`
|
3. Routers call services; services call the MariaDB pool in `Calendar.db.ts`
|
||||||
|
|
||||||
@@ -29,12 +33,61 @@ Express.js REST API in TypeScript with a service-oriented layering. Domains: `Ca
|
|||||||
|
|
||||||
| Layer | Location |
|
| Layer | Location |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Router | `src/models/calendar/Calendar.router.ts`, `…/events/events.router.ts`, `…/users/users.router.ts` |
|
| Router | `src/models/calendar/Calendar.router.ts`, `…/events/events.router.ts` |
|
||||||
| Services | `…/events/events.service.ts`, `…/users/users.service.ts`, `…/events/credentials.service.ts`, `…/events/icalgenerator.service.ts` |
|
| Services | `…/events/events.service.ts`, `…/events/credentials.service.ts`, `…/events/icalgenerator.service.ts` |
|
||||||
| DB pool | `src/models/calendar/Calendar.db.ts` (MariaDB, pool size 5) |
|
| DB pool | `src/models/calendar/Calendar.db.ts` (MariaDB, pool size 5) |
|
||||||
| Shared | `src/common/` (base route class, nodemailer wrapper), `src/middleware/logger.ts` (Winston) |
|
| Shared | `src/common/` (base route class, nodemailer wrapper), `src/middleware/logger.ts` (Winston) |
|
||||||
|
|
||||||
**Auth model:** Users must have a `@nachklang.art` email. After activation they receive a session token (30-day window); the token hash + IP are stored in the DB. Credentials for non-user calendar access (`MEMBER_CREDENTIAL`, `CHOIR_CREDENTIAL`, `MANAGEMENT_CREDENTIAL`) come from `.env`.
|
**Auth model:** One, since the calendar migration completed.
|
||||||
|
|
||||||
|
*Admin module (`src/models/admin/`)* — used by every app: calendar, feedback, tickets and the
|
||||||
|
admin app itself. better-auth 1.7 on its own `nachklang_admin` database (Kysely + mysql2; every
|
||||||
|
other domain keeps the `mariadb` driver), mounted at `/admin/auth/*` for the auth handler
|
||||||
|
and `/admin` for the JSON routes. Sessions are httpOnly cookies scoped to
|
||||||
|
`.nachklang.art`, so one sign-in covers every app. Accounts are **invite-only** — public
|
||||||
|
sign-up is disabled, and `invitations.plugin.ts` is the only code that creates users.
|
||||||
|
A permission is **(app, role)** in `user_app_permissions`, keyed on
|
||||||
|
`(user_id, app, role)` so one user can hold several roles per app. `access` is the only role
|
||||||
|
today and means "may use this app at all"; `APP_ROLES` in `admin.schema.ts` is the contract,
|
||||||
|
and a role not listed there is rejected rather than written. `requireAppAccess(app)` in
|
||||||
|
`admin.middleware.ts` is the single authenticator - it takes an optional second argument to
|
||||||
|
narrow to one role, and queries the database on every request (no cookie cache) so disabling
|
||||||
|
a user takes effect at once. Two things to know before touching this: any count of admins
|
||||||
|
must count **distinct users**, not permission rows, or a single admin with two roles reads as
|
||||||
|
two and the last-admin guard stops guarding; and both write endpoints accept
|
||||||
|
`{permissions: [{app, role}]}` as well as the older `{apps: ['tickets']}`, which means the
|
||||||
|
same at the `access` role. `ADMIN_BOOTSTRAP_EMAIL`
|
||||||
|
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
|
||||||
|
token passed in **query parameters**. That is gone: `docs/calendar-auth-migration.md` records
|
||||||
|
the migration, finished 2026-09-06. Writes sit behind `requireAppAccess('calendar')`; reads
|
||||||
|
resolve the same cookie optionally, because one URL serves an anonymous visitor, an iCal
|
||||||
|
subscription and a signed-in editor.
|
||||||
|
|
||||||
|
Two calendar-specific things survive that migration and are easy to break:
|
||||||
|
|
||||||
|
- **The `public` calendar answers with no credential of any kind.** nachklang.art reads it to
|
||||||
|
show the next upcoming event. Pinned by `test/calendar/credentials.service.test.ts` and
|
||||||
|
`test/calendar/events.router.test.ts`.
|
||||||
|
- **The shared passwords (`MEMBER_CREDENTIAL`, `CHOIR_CREDENTIAL`, `MANAGEMENT_CREDENTIAL`,
|
||||||
|
from `.env`) still open the restricted calendars for reading**, because an iCal client
|
||||||
|
cannot send a cookie. They can never write.
|
||||||
|
|
||||||
|
An event's creator is a display name and nothing else - nothing authorises on it. It resolves
|
||||||
|
from the admin module's `user.name` when the row carries an admin id, and otherwise from
|
||||||
|
`created_by_name`, a snapshot taken before the legacy `users` table was renamed aside.
|
||||||
|
|
||||||
|
**Admin database driver:** the admin pool is the **callback-style** `mysql2`, never
|
||||||
|
`mysql2/promise`. Kysely's `MysqlDialect` calls `pool.getConnection((err, conn) => ...)`;
|
||||||
|
the promise wrapper ignores that callback, so every Kysely query hangs forever with no
|
||||||
|
error. Only the integration tests catch this.
|
||||||
|
|
||||||
|
**Admin schema changes:** `sql/admin/NNN_*.sql`, hand-maintained and mirrored in
|
||||||
|
`docker/init/`. The better-auth tables must match what the configured version derives from
|
||||||
|
`admin.auth.ts` — on every better-auth upgrade, re-derive them (`getAuthTables` from
|
||||||
|
`better-auth/db`, called with `auth.options`), diff, and add a numbered migration. Do not
|
||||||
|
use the published `@better-auth/cli`; it lags the library.
|
||||||
|
|
||||||
**Event versioning:** Events have a companion `event_versions` table. `events.service.ts` manages writes to both.
|
**Event versioning:** Events have a companion `event_versions` table. `events.service.ts` manages writes to both.
|
||||||
|
|
||||||
@@ -46,13 +99,36 @@ Express.js REST API in TypeScript with a service-oriented layering. Domains: `Ca
|
|||||||
|
|
||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
|
dotenv 16 parses `.env` stricter than the old dotenv 8: an unquoted `#` starts a comment and
|
||||||
|
backslash escapes inside double quotes are expanded. Wrap any value containing `#`, `"`, `\` or
|
||||||
|
surrounding spaces in single quotes (`DB_PASSWORD='abc#def'`), which are taken literally.
|
||||||
|
A truncated password shows up as MariaDB "Access denied ... (using password: YES)".
|
||||||
|
|
||||||
|
**`NODE_ENV` is load-bearing for the admin module.** Only the explicit values
|
||||||
|
`development` and `test` relax anything; everything else, *including unset*, is treated as
|
||||||
|
production (strict secrets, cross-subdomain cookies, no localhost CORS). That direction is
|
||||||
|
deliberate: a Plesk vhost does not set `NODE_ENV`, and the inverse arrangement would
|
||||||
|
silently degrade the signing key, the cookie domain and the CORS list at once. Local work
|
||||||
|
needs `NODE_ENV=development`.
|
||||||
|
|
||||||
Copy `.env.example` (or create `.env`) with:
|
Copy `.env.example` (or create `.env`) with:
|
||||||
```
|
```
|
||||||
|
NODE_ENV=
|
||||||
PORT=
|
PORT=
|
||||||
DB_HOST=
|
DB_HOST=
|
||||||
DB_USER=
|
DB_USER=
|
||||||
DB_PASSWORD=
|
DB_PASSWORD=
|
||||||
CALENDAR_DB=
|
CALENDAR_DB=
|
||||||
|
ADMIN_DB=
|
||||||
|
BETTER_AUTH_SECRET=
|
||||||
|
API_BASE_URL=
|
||||||
|
ADMIN_APP_URL=
|
||||||
|
APP_ORIGINS=
|
||||||
|
PASSKEY_RP_ID=
|
||||||
|
ADMIN_BOOTSTRAP_EMAIL=
|
||||||
|
CLIENT_IP_HEADERS=
|
||||||
|
TRUSTED_PROXY_IPS=
|
||||||
|
ADMIN_LOG_INVITE_LINKS=
|
||||||
FEEDBACK_DB=
|
FEEDBACK_DB=
|
||||||
FEEDBACK_IP_SALT=
|
FEEDBACK_IP_SALT=
|
||||||
FEEDBACK_RATE_LIMIT_MAX=
|
FEEDBACK_RATE_LIMIT_MAX=
|
||||||
@@ -69,6 +145,10 @@ CHOIR_CREDENTIAL=
|
|||||||
MANAGEMENT_CREDENTIAL=
|
MANAGEMENT_CREDENTIAL=
|
||||||
```
|
```
|
||||||
|
|
||||||
## TypeScript config
|
## TypeScript / module system
|
||||||
|
|
||||||
Strict mode enabled, target ES2016, compiled output in `./dist`, inline source maps. Tests run through `ts-jest` directly against `.ts` sources.
|
The API runs on Node 26 (`engines` in package.json, `.nvmrc`; Plesk runs 26 too) and is native ESM (`"type": "module"`, `module: nodenext`, target ES2024, strict mode, compiled output in `./dist`, inline source maps). Consequences:
|
||||||
|
|
||||||
|
- Relative imports carry the `.js` suffix (`import {x} from "./x.js"`) even though the source file is `.ts`.
|
||||||
|
- CommonJS dependencies are consumed via default imports (`import mariadb from "mariadb"`, `import cors from "cors"`, `import winston from "winston"`), never `require()`.
|
||||||
|
- Tests run with vitest directly against `.ts` sources; import `describe`/`it`/`expect`/`vi` from `vitest` explicitly (no globals). Module mocks use `vi.mock(...)` with the same `.js`-suffixed paths as the imports.
|
||||||
|
|||||||
+31
-21
@@ -5,15 +5,28 @@ These items were identified during a security review on 2026-05-02 and conscious
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Session credentials in URL query parameters (logged-in users)
|
## 1. Session credentials in URL query parameters (logged-in users) — CLOSED 2026-09-06
|
||||||
|
|
||||||
**Files:** `src/models/calendar/events/events.router.ts` — all GET/PUT/DELETE handlers
|
**Files:** `src/models/calendar/events/events.router.ts` — all GET/PUT/DELETE handlers
|
||||||
|
|
||||||
`sessionId` and `sessionKey` are currently read from query parameters, which means they appear in server access logs, browser history, proxy logs, and `Referer` headers.
|
`sessionId` and `sessionKey` were read from query parameters, which meant they appeared in
|
||||||
|
server access logs, browser history, proxy logs, and `Referer` headers.
|
||||||
|
|
||||||
**Fix:** Move to request headers (`X-Session-Id` / `X-Session-Key`) or the request body. Requires a corresponding frontend update.
|
**Fixed** by step 4 of `docs/calendar-auth-migration.md`: the calendar's write routes now sit
|
||||||
|
behind `requireAppAccess('calendar')` against the better-auth session cookie, and the read
|
||||||
|
routes resolve the same cookie optionally. No route reads `sessionId`/`sessionKey` any more,
|
||||||
|
and the Angular frontend sends `withCredentials` instead of appending them to every URL. That
|
||||||
|
closed the item outright rather than moving the credential somewhere safer.
|
||||||
|
|
||||||
> Note: the shared calendar `password` parameter in query params is intentional (iCal clients don't support headers) and is acceptable for the current setup.
|
Two things this did *not* change, both deliberate:
|
||||||
|
|
||||||
|
- The shared calendar `password` parameter stays. An iCal client cannot send a cookie, so
|
||||||
|
this is the one caller that genuinely needs a credential in the URL. It grants read access
|
||||||
|
to one calendar and nothing else - `test/calendar/events.router.test.ts` pins that it can
|
||||||
|
never be used to write.
|
||||||
|
- ~~The legacy `/calendar/users/*` routes still exist.~~ Removed by step 5 on 2026-09-06,
|
||||||
|
along with the `users` and `sessions` tables they used - renamed aside rather than dropped,
|
||||||
|
so nothing was destroyed.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -24,32 +37,29 @@ These items were identified during a security review on 2026-05-02 and conscious
|
|||||||
- `PUT /move/:eventId` (move)
|
- `PUT /move/:eventId` (move)
|
||||||
- `DELETE /:eventId` (delete)
|
- `DELETE /:eventId` (delete)
|
||||||
|
|
||||||
Currently any active user can edit, move, or delete any event regardless of who created it. This is acceptable while all users are trusted admins.
|
Currently any account holding the `calendar` permission can edit, move, or delete any event regardless of who created it. This is acceptable while everyone holding it is a trusted admin.
|
||||||
|
|
||||||
**Fix:** When non-admin users are introduced, fetch the event first and verify `event.createdById === user.userId` before allowing the mutation. Add an `isAdmin` flag to the user model to let admins bypass the check.
|
**Fix (updated 2026-09-06):** fetch the event first and verify `event.createdByUserId === res.locals.admin.id` before allowing the mutation — `createdById`, the legacy INT, is no longer written and is gone at step 5. Rather than an `isAdmin` flag, the bypass belongs in the permission model that already exists: `requireAppAccess('calendar', 'manage')` alongside the current `access` role, which needs a row in `APP_ROLES` on both sides and nothing else.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Activation token has no expiry
|
## 3. Activation token has no expiry — CLOSED 2026-09-06
|
||||||
|
|
||||||
**File:** `src/models/calendar/users/users.service.ts` — `createUser` / `activateUser`
|
**File:** ~~`src/models/calendar/users/users.service.ts`~~ — deleted.
|
||||||
|
|
||||||
The email activation link is valid indefinitely. Acceptable for a small, trusted userbase.
|
The e-mail activation link was valid indefinitely. Closed not by adding an expiry but by
|
||||||
|
removing the thing that issued it: step 5 of `docs/calendar-auth-migration.md` deleted the
|
||||||
|
calendar's own account system. Accounts now come only from the admin module's `invitations`,
|
||||||
|
whose tokens expire after 7 days and are stored as a SHA-256 hash.
|
||||||
|
|
||||||
**Fix:**
|
Any activation link still sitting in an inbox now 404s. It only ever activated a legacy
|
||||||
1. Add an `activation_expires` column to the `users` table (e.g. `DATETIME`).
|
account, which no longer opens anything.
|
||||||
2. Set it to `NOW() + INTERVAL 24 HOUR` in `createUser`.
|
|
||||||
3. Check `activation_expires > NOW()` in `activateUser` before accepting the token.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Password reset token has no expiry
|
## 4. Password reset token has no expiry — CLOSED 2026-09-06
|
||||||
|
|
||||||
**File:** `src/models/calendar/users/users.service.ts` — `initiatePasswordReset` / `finalizePasswordReset`
|
**File:** ~~`src/models/calendar/users/users.service.ts`~~ — deleted.
|
||||||
|
|
||||||
The reset token stored in `pw_reset_token_hash` never expires. Acceptable for a small, trusted userbase.
|
Same as item 3: `pw_reset_token_hash` never expired, and the code that set it no longer
|
||||||
|
exists. Password resets go through better-auth, whose reset tokens expire after one hour.
|
||||||
**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,17 +1,8 @@
|
|||||||
import express from 'express';
|
|
||||||
import * as http from 'http';
|
import * as http from 'http';
|
||||||
import * as dotenv from 'dotenv';
|
import * as dotenv from 'dotenv';
|
||||||
import swaggerUi from 'swagger-ui-express';
|
import logger from './src/middleware/logger.js';
|
||||||
import swaggerJSDoc from 'swagger-jsdoc';
|
import {createApp} from './src/app.factory.js';
|
||||||
import logger from './src/middleware/logger';
|
import {bootstrapAdmin} from './src/models/admin/admin.bootstrap.js';
|
||||||
|
|
||||||
// Router imports
|
|
||||||
import {calendarRouter} from './src/models/calendar/Calendar.router';
|
|
||||||
import {feedbackRouter} from './src/models/feedback/Feedback.router';
|
|
||||||
import {ticketsRouter} from './src/models/tickets/Tickets.router';
|
|
||||||
|
|
||||||
|
|
||||||
let cors = require('cors');
|
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
@@ -22,97 +13,11 @@ if (!process.env.PORT) {
|
|||||||
|
|
||||||
const port: number = parseInt(process.env.PORT, 10);
|
const port: number = parseInt(process.env.PORT, 10);
|
||||||
|
|
||||||
const app: express.Application = express();
|
const app = createApp();
|
||||||
const server: http.Server = http.createServer(app);
|
const server: http.Server = http.createServer(app);
|
||||||
|
|
||||||
// Behind Plesk's nginx, req.ip is the proxy unless we trust the forwarded header.
|
|
||||||
// Verify the resolved client IP is correct in staging before relying on it
|
|
||||||
// (used by the feedback rate limiter).
|
|
||||||
app.set('trust proxy', 1);
|
|
||||||
|
|
||||||
// here we are adding middleware to parse all incoming requests as JSON
|
|
||||||
app.use(express.json());
|
|
||||||
|
|
||||||
// Configure CORS
|
|
||||||
let allowedHosts = [
|
|
||||||
'https://www.nachklang.art',
|
|
||||||
'https://calendar.nachklang.art',
|
|
||||||
'https://feedback.nachklang.art',
|
|
||||||
'https://tickets.nachklang.art'
|
|
||||||
];
|
|
||||||
const isDev = process.env.NODE_ENV !== 'production';
|
|
||||||
const localhostRegex = /^http:\/\/localhost:\d+$/;
|
|
||||||
// Matches http://<private-LAN-IPv4>:<port> - needed so the feedback form can
|
|
||||||
// be reached from a real phone over WiFi during dev (the phone's Origin is
|
|
||||||
// the dev machine's LAN IP, never "localhost"). Dev-only, same as above.
|
|
||||||
const lanIpRegex = /^http:\/\/(192\.168\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}):\d+$/;
|
|
||||||
app.use(cors({
|
|
||||||
allowedHeaders: ['Content-Type', 'X-Session-Id', 'X-Session-Key'],
|
|
||||||
origin: function (origin: any, callback: any) {
|
|
||||||
// Allow requests with no origin
|
|
||||||
if (!origin) return callback(null, true);
|
|
||||||
|
|
||||||
// Any localhost port, or a private-LAN IP, is fine outside production -
|
|
||||||
// dev servers pick whatever port is free (Next.js falls back from 3000
|
|
||||||
// if it's taken), and real-device testing hits the dev machine by IP.
|
|
||||||
if (isDev && (localhostRegex.test(origin) || lanIpRegex.test(origin))) {
|
|
||||||
return callback(null, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Block requests with wrong origin
|
|
||||||
if (allowedHosts.indexOf(origin) === -1) {
|
|
||||||
return callback(new Error('The CORS policy doesn\'t allow access for your origin.'), false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allow all other requests
|
|
||||||
return callback(null, true);
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Swagger documentation
|
|
||||||
const swaggerDefinition = {
|
|
||||||
openapi: '3.0.0',
|
|
||||||
info: {
|
|
||||||
title: 'Nachklang e.V. REST API',
|
|
||||||
version: '0.1.0',
|
|
||||||
license: {
|
|
||||||
name: 'Licensed Under MIT',
|
|
||||||
url: 'https://spdx.org/licenses/MIT.html'
|
|
||||||
},
|
|
||||||
contact: {
|
|
||||||
name: 'Nachklang e.V.',
|
|
||||||
url: 'https://www.nachklang.art'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const options = {
|
|
||||||
swaggerDefinition,
|
|
||||||
// Paths to files containing OpenAPI definitions
|
|
||||||
apis: [
|
|
||||||
'./src/models/**/*.interface.ts',
|
|
||||||
'./src/models/**/*.router.ts'
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
const swaggerSpec = swaggerJSDoc(options);
|
|
||||||
|
|
||||||
app.use(
|
|
||||||
'/docs',
|
|
||||||
swaggerUi.serve,
|
|
||||||
swaggerUi.setup(swaggerSpec)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add routers
|
|
||||||
app.use('/calendar', calendarRouter);
|
|
||||||
app.use('/feedback', feedbackRouter);
|
|
||||||
app.use('/tickets', ticketsRouter);
|
|
||||||
|
|
||||||
// this is a simple route to make sure everything is working properly
|
|
||||||
app.get('/', (req: express.Request, res: express.Response) => {
|
|
||||||
res.status(200).send('Welcome to the Nachklang e.V. REST API!');
|
|
||||||
});
|
|
||||||
|
|
||||||
server.listen(port, () => {
|
server.listen(port, () => {
|
||||||
logger.info('Server listening on Port ' + port);
|
logger.info('Server listening on Port ' + port);
|
||||||
|
// Makes sure ADMIN_BOOTSTRAP_EMAIL can always get in. Never throws.
|
||||||
|
void bootstrapAdmin();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Manual alternative for bringing up the admin tests' database by hand.
|
||||||
|
#
|
||||||
|
# `npm run test:integration` does NOT use this file: test/integration/setup.ts
|
||||||
|
# starts the container directly, because `podman compose` needs a separate
|
||||||
|
# compose provider that neither podman nor docker ships, and one container needs
|
||||||
|
# no orchestration. Keep the two in step, or delete this file if nobody uses it.
|
||||||
|
#
|
||||||
|
# Port 3307 and a throwaway data directory on purpose: it must never collide
|
||||||
|
# with, or outlive, the dev database from docker-compose.dev.yml.
|
||||||
|
services:
|
||||||
|
mariadb-test:
|
||||||
|
image: mariadb:11
|
||||||
|
environment:
|
||||||
|
MARIADB_ROOT_PASSWORD: roottestpassword
|
||||||
|
MARIADB_DATABASE: nachklang_admin
|
||||||
|
MARIADB_USER: nachklang
|
||||||
|
MARIADB_PASSWORD: testpassword
|
||||||
|
ports:
|
||||||
|
- "3307:3306"
|
||||||
|
tmpfs:
|
||||||
|
- /var/lib/mysql
|
||||||
|
volumes:
|
||||||
|
# Applied by the entrypoint on first boot, against MARIADB_DATABASE.
|
||||||
|
# This is the very migration production runs, so a mistake in it fails
|
||||||
|
# the test run rather than the deploy.
|
||||||
|
- ./sql/admin/001_init.sql:/docker-entrypoint-initdb.d/001_init.sql:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||||
|
interval: 2s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 30
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
-- Local dev only. Creates the three databases + a dev user with full access.
|
-- Local dev only. Creates the four databases + a dev user with full access.
|
||||||
CREATE DATABASE IF NOT EXISTS nachklang_calendar CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
CREATE DATABASE IF NOT EXISTS nachklang_calendar CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
CREATE DATABASE IF NOT EXISTS nachklang_feedback CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
CREATE DATABASE IF NOT EXISTS nachklang_feedback CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
CREATE DATABASE IF NOT EXISTS nachklang_tickets CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
CREATE DATABASE IF NOT EXISTS nachklang_tickets CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
CREATE DATABASE IF NOT EXISTS nachklang_admin CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
CREATE USER IF NOT EXISTS 'nachklang'@'%' IDENTIFIED BY 'devpassword';
|
CREATE USER IF NOT EXISTS 'nachklang'@'%' IDENTIFIED BY 'devpassword';
|
||||||
GRANT ALL PRIVILEGES ON nachklang_calendar.* TO 'nachklang'@'%';
|
GRANT ALL PRIVILEGES ON nachklang_calendar.* TO 'nachklang'@'%';
|
||||||
GRANT ALL PRIVILEGES ON nachklang_feedback.* TO 'nachklang'@'%';
|
GRANT ALL PRIVILEGES ON nachklang_feedback.* TO 'nachklang'@'%';
|
||||||
GRANT ALL PRIVILEGES ON nachklang_tickets.* TO 'nachklang'@'%';
|
GRANT ALL PRIVILEGES ON nachklang_tickets.* TO 'nachklang'@'%';
|
||||||
|
GRANT ALL PRIVILEGES ON nachklang_admin.* TO 'nachklang'@'%';
|
||||||
FLUSH PRIVILEGES;
|
FLUSH PRIVILEGES;
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
-- Local dev only. Real schema, provided directly by the repo owner
|
-- Local dev only. Derived from the real schema, which was provided directly by
|
||||||
-- (calendars, events, event_versions, sessions, users) - not a guess.
|
-- the repo owner (calendars, events, event_versions, and the sessions/users
|
||||||
|
-- tables that step 5 of docs/calendar-auth-migration.md renamed aside).
|
||||||
|
--
|
||||||
|
-- There is no users or sessions table here: a fresh dev database has no legacy
|
||||||
|
-- accounts to archive, so it starts where production ends up.
|
||||||
|
--
|
||||||
|
-- Changes made by this repo's own migrations under sql/calendar/ are folded in
|
||||||
|
-- here rather than appended, so a fresh dev container matches production once
|
||||||
|
-- every migration has been applied. Keep the two in step.
|
||||||
USE nachklang_calendar;
|
USE nachklang_calendar;
|
||||||
|
|
||||||
CREATE TABLE `calendars` (
|
CREATE TABLE `calendars` (
|
||||||
@@ -9,41 +17,20 @@ CREATE TABLE `calendars` (
|
|||||||
PRIMARY KEY (`calendar_id`)
|
PRIMARY KEY (`calendar_id`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
CREATE TABLE `users` (
|
|
||||||
`user_id` int(11) NOT NULL AUTO_INCREMENT,
|
|
||||||
`full_name` text NOT NULL,
|
|
||||||
`password_hash` text DEFAULT NULL,
|
|
||||||
`email` text NOT NULL,
|
|
||||||
`is_active` tinyint(1) DEFAULT 0,
|
|
||||||
`pw_reset_token_hash` text DEFAULT NULL,
|
|
||||||
`activation_token` text DEFAULT NULL,
|
|
||||||
PRIMARY KEY (`user_id`),
|
|
||||||
UNIQUE KEY `email` (`email`) USING HASH
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
|
||||||
|
|
||||||
CREATE TABLE `sessions` (
|
|
||||||
`session_id` int(11) NOT NULL AUTO_INCREMENT,
|
|
||||||
`user_id` int(11) NOT NULL,
|
|
||||||
`session_key_hash` text DEFAULT NULL,
|
|
||||||
`created_date` datetime DEFAULT current_timestamp(),
|
|
||||||
`valid_until` datetime DEFAULT (current_timestamp() + interval 30 day),
|
|
||||||
`last_ip` text DEFAULT NULL,
|
|
||||||
PRIMARY KEY (`session_id`),
|
|
||||||
KEY `sessions_users_user_id_fk` (`user_id`),
|
|
||||||
CONSTRAINT `sessions_users_user_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
|
||||||
|
|
||||||
CREATE TABLE `events` (
|
CREATE TABLE `events` (
|
||||||
`event_id` int(11) NOT NULL AUTO_INCREMENT,
|
`event_id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
`calendar_id` int(11) NOT NULL,
|
`calendar_id` int(11) NOT NULL,
|
||||||
`uuid` text NOT NULL,
|
`uuid` text NOT NULL,
|
||||||
`created_date` datetime DEFAULT current_timestamp(),
|
`created_date` datetime DEFAULT current_timestamp(),
|
||||||
`created_by_id` int(11) NOT NULL,
|
-- Bridge to the admin module's user ids; see sql/calendar/001_add_admin_user_bridge.sql.
|
||||||
|
`created_by_user_id` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL,
|
||||||
|
-- Creator name, archived before the legacy users table went away;
|
||||||
|
-- see sql/calendar/002_snapshot_legacy_creator_names.sql.
|
||||||
|
`created_by_name` varchar(255) DEFAULT NULL,
|
||||||
PRIMARY KEY (`event_id`),
|
PRIMARY KEY (`event_id`),
|
||||||
KEY `events_calendars_calendar_id_fk` (`calendar_id`),
|
KEY `events_calendars_calendar_id_fk` (`calendar_id`),
|
||||||
KEY `events_users_user_id_fk` (`created_by_id`),
|
KEY `events_created_by_user_idx` (`created_by_user_id`),
|
||||||
CONSTRAINT `events_calendars_calendar_id_fk` FOREIGN KEY (`calendar_id`) REFERENCES `calendars` (`calendar_id`),
|
CONSTRAINT `events_calendars_calendar_id_fk` FOREIGN KEY (`calendar_id`) REFERENCES `calendars` (`calendar_id`)
|
||||||
CONSTRAINT `events_users_user_id_fk` FOREIGN KEY (`created_by_id`) REFERENCES `users` (`user_id`)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
CREATE TABLE `event_versions` (
|
CREATE TABLE `event_versions` (
|
||||||
@@ -57,14 +44,16 @@ CREATE TABLE `event_versions` (
|
|||||||
`repeat_frequency` text DEFAULT NULL,
|
`repeat_frequency` text DEFAULT NULL,
|
||||||
`location` text DEFAULT NULL,
|
`location` text DEFAULT NULL,
|
||||||
`url` text DEFAULT NULL,
|
`url` text DEFAULT NULL,
|
||||||
`version_created_by_id` int(11) DEFAULT NULL,
|
-- Bridge to the admin module's user ids; see sql/calendar/001_add_admin_user_bridge.sql.
|
||||||
|
`version_created_by_user_id` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL,
|
||||||
|
-- Archived editor name; see sql/calendar/002_snapshot_legacy_creator_names.sql.
|
||||||
|
`version_created_by_name` varchar(255) DEFAULT NULL,
|
||||||
`status` text DEFAULT NULL,
|
`status` text DEFAULT NULL,
|
||||||
`version_created_at` datetime DEFAULT current_timestamp(),
|
`version_created_at` datetime DEFAULT current_timestamp(),
|
||||||
PRIMARY KEY (`event_version_id`),
|
PRIMARY KEY (`event_version_id`),
|
||||||
KEY `event_versions_events_event_id_fk` (`event_id`),
|
KEY `event_versions_events_event_id_fk` (`event_id`),
|
||||||
KEY `event_versions_users_user_id_fk` (`version_created_by_id`),
|
KEY `event_versions_created_by_user_idx` (`version_created_by_user_id`),
|
||||||
CONSTRAINT `event_versions_events_event_id_fk` FOREIGN KEY (`event_id`) REFERENCES `events` (`event_id`) ON DELETE CASCADE ON UPDATE CASCADE,
|
CONSTRAINT `event_versions_events_event_id_fk` FOREIGN KEY (`event_id`) REFERENCES `events` (`event_id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
CONSTRAINT `event_versions_users_user_id_fk` FOREIGN KEY (`version_created_by_id`) REFERENCES `users` (`user_id`)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
INSERT INTO calendars (calendar_id, name, includes_calendars) VALUES
|
INSERT INTO calendars (calendar_id, name, includes_calendars) VALUES
|
||||||
@@ -74,16 +63,17 @@ INSERT INTO calendars (calendar_id, name, includes_calendars) VALUES
|
|||||||
(4, 'choir', '[]'),
|
(4, 'choir', '[]'),
|
||||||
(5, 'birthdays', '[]');
|
(5, 'birthdays', '[]');
|
||||||
|
|
||||||
-- Dev admin, password: devpassword
|
-- Two events carry only an archived creator name, as every pre-cutover event
|
||||||
INSERT INTO users (email, password_hash, full_name, is_active) VALUES
|
-- does, and one carries an admin user id whose name is resolved live. Dev
|
||||||
('dev@nachklang.art', '$2b$10$vmj7POS/68SGE.eI7pGjMegrw0vNNZ2HVSUTra5NRsl8iOLwiMgZK', 'Dev Admin', 1);
|
-- therefore exercises both name sources rather than only one. The one with an
|
||||||
|
-- id is deliberately a PUBLIC event, so the anonymous listing the website uses
|
||||||
|
-- covers both. The id is the dev admin from 04-admin-schema.sql.
|
||||||
|
INSERT INTO events (calendar_id, uuid, created_by_user_id, created_by_name) VALUES
|
||||||
|
(1, UUID(), NULL, 'Dev Admin'),
|
||||||
|
(1, UUID(), 'dev-user-0000-0000-0000-000000000001', NULL),
|
||||||
|
(1, UUID(), NULL, 'Dev Admin');
|
||||||
|
|
||||||
INSERT INTO events (calendar_id, uuid, created_by_id) VALUES
|
INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, location, url, status, version_created_by_user_id, version_created_by_name) VALUES
|
||||||
(1, UUID(), 1),
|
(1, 'Frühlingskonzert 2026', 'Erstes Konzert der Reihe', '2026-04-18 19:00:00', '2026-04-18 21:00:00', 0, 'Musikhochschule, Karlsruhe', 'https://www.nachklang.art/events/fruehlingskonzert-2026', 'PUBLIC', NULL, 'Dev Admin'),
|
||||||
(1, UUID(), 1),
|
(2, 'Sommerkonzert 2026', 'Zweites Konzert der Reihe', '2026-07-11 19:00:00', '2026-07-11 21:00:00', 0, 'Christuskirche, Karlsruhe', 'https://www.nachklang.art/events/sommerkonzert-2026', 'PUBLIC', 'dev-user-0000-0000-0000-000000000001', NULL),
|
||||||
(1, UUID(), 1);
|
(3, 'Adventskonzert 2026', 'Drittes Konzert der Reihe', '2026-12-05 19:00:00', '2026-12-05 21:00:00', 0, 'Stadtkirche, Karlsruhe', 'https://www.nachklang.art/events/adventskonzert-2026', 'DRAFT', NULL, 'Dev Admin');
|
||||||
|
|
||||||
INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, location, url, status, version_created_by_id) VALUES
|
|
||||||
(1, 'Frühlingskonzert 2026', 'Erstes Konzert der Reihe', '2026-04-18 19:00:00', '2026-04-18 21:00:00', 0, 'Musikhochschule, Karlsruhe', 'https://www.nachklang.art/events/fruehlingskonzert-2026', 'PUBLIC', 1),
|
|
||||||
(2, 'Sommerkonzert 2026', 'Zweites Konzert der Reihe', '2026-07-11 19:00:00', '2026-07-11 21:00:00', 0, 'Christuskirche, Karlsruhe', 'https://www.nachklang.art/events/sommerkonzert-2026', 'PUBLIC', 1),
|
|
||||||
(3, 'Adventskonzert 2026', 'Drittes Konzert der Reihe', '2026-12-05 19:00:00', '2026-12-05 21:00:00', 0, 'Stadtkirche, Karlsruhe', 'https://www.nachklang.art/events/adventskonzert-2026', 'DRAFT', 1);
|
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
-- Local dev only. Mirrors the table definitions in sql/admin/001_init.sql -
|
||||||
|
-- keep the two in step - and seeds a ready-to-use dev account on top.
|
||||||
|
USE nachklang_admin;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `user` (
|
||||||
|
`id` VARCHAR(36) NOT NULL,
|
||||||
|
`name` VARCHAR(255) NOT NULL,
|
||||||
|
`email` VARCHAR(255) NOT NULL,
|
||||||
|
`emailVerified` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
`image` TEXT DEFAULT NULL,
|
||||||
|
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updatedAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
-- Nachklang addition, declared through better-auth's additionalFields so
|
||||||
|
-- the adapter knows about it. Disabling also revokes the user's sessions.
|
||||||
|
`disabled` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `user_email` (`email`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `session` (
|
||||||
|
`id` VARCHAR(36) NOT NULL,
|
||||||
|
`expiresAt` DATETIME NOT NULL,
|
||||||
|
`token` VARCHAR(255) NOT NULL,
|
||||||
|
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updatedAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`ipAddress` VARCHAR(255) DEFAULT NULL,
|
||||||
|
`userAgent` TEXT DEFAULT NULL,
|
||||||
|
`userId` VARCHAR(36) NOT NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `session_token` (`token`),
|
||||||
|
KEY `session_user` (`userId`),
|
||||||
|
CONSTRAINT `session_user_fk` FOREIGN KEY (`userId`) REFERENCES `user` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `account` (
|
||||||
|
`id` VARCHAR(36) NOT NULL,
|
||||||
|
-- 1.7 addition: distinguishes a local credential account
|
||||||
|
-- ("local:credential") from an OAuth issuer. Written by better-auth.
|
||||||
|
`issuer` VARCHAR(255) NOT NULL,
|
||||||
|
`accountId` VARCHAR(255) NOT NULL,
|
||||||
|
`providerId` VARCHAR(255) NOT NULL,
|
||||||
|
`userId` VARCHAR(36) NOT NULL,
|
||||||
|
`accessToken` TEXT DEFAULT NULL,
|
||||||
|
`refreshToken` TEXT DEFAULT NULL,
|
||||||
|
`idToken` TEXT DEFAULT NULL,
|
||||||
|
`accessTokenExpiresAt` DATETIME DEFAULT NULL,
|
||||||
|
`refreshTokenExpiresAt` DATETIME DEFAULT NULL,
|
||||||
|
`scope` TEXT DEFAULT NULL,
|
||||||
|
`password` TEXT DEFAULT NULL,
|
||||||
|
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updatedAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `account_user` (`userId`),
|
||||||
|
KEY `account_provider` (`providerId`, `accountId`),
|
||||||
|
CONSTRAINT `account_user_fk` FOREIGN KEY (`userId`) REFERENCES `user` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Password-reset and e-mail-verification tokens.
|
||||||
|
CREATE TABLE IF NOT EXISTS `verification` (
|
||||||
|
`id` VARCHAR(36) NOT NULL,
|
||||||
|
`identifier` VARCHAR(255) NOT NULL,
|
||||||
|
`value` TEXT NOT NULL,
|
||||||
|
`expiresAt` DATETIME NOT NULL,
|
||||||
|
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updatedAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `verification_identifier` (`identifier`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `passkey` (
|
||||||
|
`id` VARCHAR(36) NOT NULL,
|
||||||
|
`name` VARCHAR(255) DEFAULT NULL,
|
||||||
|
`publicKey` TEXT NOT NULL,
|
||||||
|
`userId` VARCHAR(36) NOT NULL,
|
||||||
|
`credentialID` VARCHAR(255) NOT NULL,
|
||||||
|
`counter` INT NOT NULL DEFAULT 0,
|
||||||
|
`deviceType` VARCHAR(255) NOT NULL,
|
||||||
|
`backedUp` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
`transports` VARCHAR(255) DEFAULT NULL,
|
||||||
|
`createdAt` DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`aaguid` VARCHAR(255) DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `passkey_user` (`userId`),
|
||||||
|
KEY `passkey_credential` (`credentialID`),
|
||||||
|
CONSTRAINT `passkey_user_fk` FOREIGN KEY (`userId`) REFERENCES `user` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Required by rateLimit.storage = 'database' in admin.auth.ts. Passenger may
|
||||||
|
-- run several API instances, and an in-memory limiter would give each of them
|
||||||
|
-- its own budget.
|
||||||
|
CREATE TABLE IF NOT EXISTS `rateLimit` (
|
||||||
|
`id` VARCHAR(36) NOT NULL,
|
||||||
|
`key` VARCHAR(255) NOT NULL,
|
||||||
|
`count` INT NOT NULL DEFAULT 0,
|
||||||
|
-- Epoch milliseconds, not a DATETIME: better-auth stores a number here.
|
||||||
|
`lastRequest` BIGINT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `rate_limit_key` (`key`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Nachklang-owned tables
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Which apps a user may administer. `admin` is just another app: holding it is
|
||||||
|
-- what lets someone manage users and invitations. A permission is (app, role);
|
||||||
|
-- `access` is the only role today, and the key admits several per app so finer
|
||||||
|
-- ones can be added by inserting rows rather than by migrating this table.
|
||||||
|
CREATE TABLE IF NOT EXISTS `user_app_permissions` (
|
||||||
|
`user_id` VARCHAR(36) NOT NULL,
|
||||||
|
`app` ENUM('calendar','feedback','tickets','admin') NOT NULL,
|
||||||
|
-- One row per (user, app, role). `access` means "may use this app at all"
|
||||||
|
-- and is the only role today; the key allows several per app so a finer
|
||||||
|
-- permission can be added later by inserting rows, not by migrating.
|
||||||
|
`role` VARCHAR(32) NOT NULL DEFAULT 'access',
|
||||||
|
`granted_by` VARCHAR(36) DEFAULT NULL,
|
||||||
|
`granted_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
-- (user_id, app) is the leftmost prefix of this key, so the per-request
|
||||||
|
-- permission lookup needs no separate index.
|
||||||
|
PRIMARY KEY (`user_id`, `app`, `role`),
|
||||||
|
CONSTRAINT `uap_user_fk` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- The only route to a new account: there is no public sign-up. Only the
|
||||||
|
-- SHA-256 of the token is stored, so a dump of this table hands out no access.
|
||||||
|
CREATE TABLE IF NOT EXISTS `invitations` (
|
||||||
|
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
`email` VARCHAR(255) NOT NULL,
|
||||||
|
`name` VARCHAR(255) NOT NULL,
|
||||||
|
`token_hash` CHAR(64) NOT NULL,
|
||||||
|
`permissions` JSON NOT NULL,
|
||||||
|
`invited_by` VARCHAR(36) DEFAULT NULL,
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`expires_at` DATETIME NOT NULL,
|
||||||
|
`accepted_at` DATETIME DEFAULT NULL,
|
||||||
|
`revoked_at` DATETIME DEFAULT NULL,
|
||||||
|
UNIQUE KEY `inv_token_hash` (`token_hash`),
|
||||||
|
KEY `inv_email` (`email`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Dev seed: dev@nachklang.art / devpassword, with every app permission.
|
||||||
|
-- Local only - this account exists nowhere but in this container.
|
||||||
|
--
|
||||||
|
-- The password hash is better-auth's own scrypt format (salt:hash), produced
|
||||||
|
-- with better-auth 1.7.2's hashPassword(). Regenerate it if better-auth ever
|
||||||
|
-- changes that format; a hash it cannot parse shows up as "invalid password"
|
||||||
|
-- on an otherwise correct sign-in.
|
||||||
|
--
|
||||||
|
-- `issuer` must be exactly 'local:credential' - it is how better-auth 1.7
|
||||||
|
-- recognises a local password account when signing in.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
INSERT INTO `user` (`id`, `name`, `email`, `emailVerified`, `disabled`)
|
||||||
|
VALUES ('dev-user-0000-0000-0000-000000000001', 'Dev Admin', 'dev@nachklang.art', 1, 0);
|
||||||
|
|
||||||
|
INSERT INTO `account` (`id`, `issuer`, `accountId`, `providerId`, `userId`, `password`)
|
||||||
|
VALUES (
|
||||||
|
'dev-acct-0000-0000-0000-000000000001',
|
||||||
|
'local:credential',
|
||||||
|
'dev-user-0000-0000-0000-000000000001',
|
||||||
|
'credential',
|
||||||
|
'dev-user-0000-0000-0000-000000000001',
|
||||||
|
'e6a0485feb04b8fa64453db87badd8e1:85aaffd845e1e44fabc5be97d684c0f533845ed11088bd3f4d533ef277ead71da8372b5c6a72c7eae2d48e3270c50e2d13cffa4fcfb0b5cec456100b8f007ed2'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO `user_app_permissions` (`user_id`, `app`, `role`) VALUES
|
||||||
|
('dev-user-0000-0000-0000-000000000001', 'calendar', 'access'),
|
||||||
|
('dev-user-0000-0000-0000-000000000001', 'feedback', 'access'),
|
||||||
|
('dev-user-0000-0000-0000-000000000001', 'tickets', 'access'),
|
||||||
|
('dev-user-0000-0000-0000-000000000001', 'admin', 'access');
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
# 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
|
||||||
|
decision and part of step 5 brought forward; the rest went out as written.
|
||||||
|
|
||||||
|
The calendar now shares one identity with the tickets, feedback and admin apps: writes sit
|
||||||
|
behind `requireAppAccess('calendar')` against the shared session cookie, reads resolve that
|
||||||
|
cookie optionally, and the calendar's own `users`/`sessions` tables are renamed aside and
|
||||||
|
referenced by nothing. `DEFERRED_SECURITY.md` items 1, 3 and 4 closed with it.
|
||||||
|
|
||||||
|
Verified in production after the final deploy: the public calendar answers anonymously on all
|
||||||
|
three endpoints (23 events, 23 VEVENTs in the iCal feed, the next-event teaser intact),
|
||||||
|
restricted calendars still refuse without a credential, unauthenticated writes answer 401,
|
||||||
|
all six `/calendar/users/*` routes answer 404, CORS grants only `Content-Type`, sign-out from
|
||||||
|
`calendar.nachklang.art` succeeds, and **every event kept its author** - rendered from the
|
||||||
|
`created_by_name` snapshot, since the table it was copied from no longer exists under that
|
||||||
|
name. That last one is the whole reason part of step 5 was brought forward.
|
||||||
|
|
||||||
|
Remaining, at your leisure: `DROP TABLE sessions_legacy_archive, users_legacy_archive;`
|
||||||
|
|
||||||
|
Written 2026-09-05 alongside the admin module (step 2 of `docs/plan-admin-auth.md` in the
|
||||||
|
nachklang-admin repo), which deliberately left the calendar alone. Steps 1-4 of that plan
|
||||||
|
are now live, so the calendar is the last module still on the legacy query-parameter
|
||||||
|
sessions.
|
||||||
|
|
||||||
|
## Why the calendar was left out
|
||||||
|
|
||||||
|
The admin module replaced authentication for feedback and tickets by swapping one
|
||||||
|
middleware. The calendar cannot be done that way, because its user identity is woven into
|
||||||
|
its data:
|
||||||
|
|
||||||
|
- `users`/`sessions` live in the **calendar** database and are the same tables the
|
||||||
|
feedback and tickets admin areas used to authenticate against.
|
||||||
|
- `events.created_by_id` is an **INT** foreign key into `users.user_id`. The admin module's
|
||||||
|
user ids are **VARCHAR(36)** strings. Migrating identity means migrating that column and
|
||||||
|
every query that joins it.
|
||||||
|
- The Angular frontend passes `sessionId`/`sessionKey` as **query parameters**
|
||||||
|
(`DEFERRED_SECURITY.md` item 1). Cookie sessions remove the parameters entirely, so
|
||||||
|
every calendar route signature and the frontend's HTTP layer change together.
|
||||||
|
- `credentials.service.ts` implements a second, parallel authorisation model: the
|
||||||
|
`MEMBER_CREDENTIAL` / `CHOIR_CREDENTIAL` / `MANAGEMENT_CREDENTIAL` shared secrets that
|
||||||
|
let non-users read specific calendars. That has no equivalent in the admin module and is
|
||||||
|
not a per-user permission at all.
|
||||||
|
|
||||||
|
What already exists today: `calendar` is a value in the `user_app_permissions.app` enum, so
|
||||||
|
permissions can be granted before anything else moves.
|
||||||
|
|
||||||
|
## What is in place to build on
|
||||||
|
|
||||||
|
- Cookie sessions across `*.nachklang.art`, and `requireAppAccess('calendar')` in
|
||||||
|
`src/models/admin/admin.middleware.ts` - usable the moment a calendar route wants it.
|
||||||
|
- `res.locals.admin` is `{id, email, displayName, apps}`; `id` is the string user id.
|
||||||
|
- Invitations, disable/enable and session revocation already cover calendar users, because
|
||||||
|
they are properties of the account rather than of an app.
|
||||||
|
|
||||||
|
## Suggested sequence
|
||||||
|
|
||||||
|
Each step is meant to leave production working on its own.
|
||||||
|
|
||||||
|
1. **Add a bridging column.** ~~`ALTER TABLE events ADD COLUMN created_by_user_id
|
||||||
|
VARCHAR(36) NULL`, indexed. Nothing reads it yet.~~ **Done 2026-09-06**, as
|
||||||
|
`sql/calendar/001_add_admin_user_bridge.sql` - the first migration this repo owns for the
|
||||||
|
calendar schema, mirrored into `docker/init/01-calendar-schema-dev.sql`. It covers both
|
||||||
|
`events.created_by_user_id` and `event_versions.version_created_by_user_id`, and carries
|
||||||
|
no foreign key (see "What the code actually looks like" below). The dev seed leaves two
|
||||||
|
events on the legacy path and gives one an admin id, so step 3's dual-read has both cases
|
||||||
|
to exercise. Verified by applying the pre-migration schema and then the migration to a
|
||||||
|
throwaway MariaDB 11 container, and diffing `SHOW CREATE TABLE` against a fresh dev
|
||||||
|
schema: identical. Applied to the running dev database on the same day; a dev container
|
||||||
|
created before then needs it applied, or recreating.
|
||||||
|
2. ~~**Map the accounts.**~~ **Dropped 2026-09-06.** There is no backfill: since the
|
||||||
|
creator is only ever a display name (see below), old events keep resolving through the
|
||||||
|
legacy join until step 5 and then simply lose the name. Re-inviting the people who
|
||||||
|
actually still need calendar access remains an operational task, but it is no longer a
|
||||||
|
migration step and nothing is blocked on it.
|
||||||
|
3. **Dual-read.** ~~Change `events.service.ts` to prefer `created_by_user_id` and fall back
|
||||||
|
to `created_by_id`. Writes fill both.~~ **Done 2026-09-06.** `events.service.ts` now reads
|
||||||
|
both columns and prefers the admin one, resolving the name through a single
|
||||||
|
`findDisplayNames` lookup against the admin database per result set (added to
|
||||||
|
`users.admin.service.ts` for this). Four copies of the same SELECT and four copies of the
|
||||||
|
row mapper were collapsed into one of each first - the dual read would otherwise have had
|
||||||
|
to be written four times.
|
||||||
|
|
||||||
|
A name now has three possible sources, tried weakest first: the legacy join, then the
|
||||||
|
`created_by_name` snapshot from migration 002, then the live admin lookup - which wins
|
||||||
|
because it is the only one that follows an account being renamed. An admin id that no
|
||||||
|
longer resolves falls back rather than blanking, and a failure to reach the admin database
|
||||||
|
is caught and logged rather than propagated, so an anonymous read of the public calendar
|
||||||
|
never depends on the admin database being up. Covered by
|
||||||
|
`test/calendar/events.service.test.ts`.
|
||||||
|
|
||||||
|
**Writes are not dual-written**, contrary to the original plan: before the cutover the
|
||||||
|
request only ever carries a legacy session, so there is no admin id available to write.
|
||||||
|
Writes start filling `created_by_user_id` (and stop filling `created_by_id`) in step 4.
|
||||||
|
4. **Switch the routes.** ~~Replace the query-parameter session checks in `events.router.ts`
|
||||||
|
and `users.router.ts` with `requireAppAccess('calendar')`, and change the Angular frontend
|
||||||
|
to `withCredentials: true`.~~ **Done 2026-09-06.** `DEFERRED_SECURITY.md` item 1 is closed:
|
||||||
|
no route reads `sessionId`/`sessionKey` any more.
|
||||||
|
|
||||||
|
How it came out, route by route:
|
||||||
|
|
||||||
|
- The four write routes sit behind `requireAppAccess('calendar')` as middleware. They
|
||||||
|
answer 401 when signed out and 403 without the permission, where they used to answer 403
|
||||||
|
for both.
|
||||||
|
- The three read routes cannot use middleware - the same URL serves an anonymous visitor,
|
||||||
|
an iCal subscription holding a shared password, and a signed-in editor who should see
|
||||||
|
drafts. They call `resolveAccess` optionally instead (`signedInEditor` in the router),
|
||||||
|
and a signed-in user *without* the calendar permission is treated as anonymous rather
|
||||||
|
than refused, so they keep their access to the public calendar.
|
||||||
|
- `credentials.service.ts` lost its session half entirely and is now just the password
|
||||||
|
table. `hasAccess(calendar, password)`.
|
||||||
|
- `/calendar/users/*` was left alone. Nothing calls it and a session it mints opens
|
||||||
|
nothing, but they are live password-accepting endpoints - step 5 removes them.
|
||||||
|
|
||||||
|
Also: `calendar.nachklang.art` joined `DEFAULT_APP_ORIGINS` (better-auth `trustedOrigins`,
|
||||||
|
without which sign-out from the calendar fails while everything else works), and
|
||||||
|
`localhost:4200` joined the dev origins for the same reason.
|
||||||
|
|
||||||
|
Two things this step had to carry that the original sequence put in step 5:
|
||||||
|
|
||||||
|
- **`sql/calendar/003_allow_null_legacy_creator.sql` makes `events.created_by_id` nullable**
|
||||||
|
(`MODIFY created_by_id INT NULL`). It is `NOT NULL` today, so the first event created after the
|
||||||
|
cutover would otherwise fail to insert - there is no legacy int id to write any more.
|
||||||
|
`event_versions.version_created_by_id` is already nullable. The foreign key can stay
|
||||||
|
until step 5; it permits NULL. It also re-runs 002's idempotent name backfill, to catch
|
||||||
|
anything created between the two migrations. Applying it early is safe - widening a
|
||||||
|
column to accept NULL cannot break the running pre-cutover build.
|
||||||
|
- **The public calendar stays anonymous.** `hasAccess('public')` returns true before any
|
||||||
|
credential check, and nachklang.art reads `/calendar/events/public/json` and
|
||||||
|
`/public/json/next` with no session at all. Pinned at both levels - the password table in
|
||||||
|
`test/calendar/credentials.service.test.ts`, the routes themselves in
|
||||||
|
`test/calendar/events.router.test.ts` - so this cannot regress quietly.
|
||||||
|
|
||||||
|
### Deploy checklist
|
||||||
|
|
||||||
|
Production has **none** of the three migrations: 001 and 002 were only ever applied to the
|
||||||
|
dev database. The API build below selects `created_by_user_id` and `created_by_name` on
|
||||||
|
every read, so deploying it against a database missing them fails every calendar request
|
||||||
|
including the anonymous public feed the website uses. In order:
|
||||||
|
|
||||||
|
1. **Apply `sql/calendar/001`, `002`, `003`, in that order**, against `CALENDAR_DB`. All
|
||||||
|
three are re-runnable, so applying one that is already applied is a no-op. Verify
|
||||||
|
before continuing:
|
||||||
|
`SHOW COLUMNS FROM events LIKE '%by_user%'; SHOW COLUMNS FROM events LIKE '%by_name%';`
|
||||||
|
- four rows across the two tables, and `created_by_id` nullable.
|
||||||
|
2. **Check `APP_ORIGINS` on the API vhost.** `calendar.nachklang.art` is in the code's
|
||||||
|
default list, but the environment variable *replaces* that list rather than adding to
|
||||||
|
it - so if it is set at all (the tickets/feedback cutover may have set it), append
|
||||||
|
`https://calendar.nachklang.art` or the calendar's sign-out will 403 while everything
|
||||||
|
else works. That is the failure mode the comment in `admin.config.ts` warns about.
|
||||||
|
3. **Deploy the API.**
|
||||||
|
4. **Deploy the calendar frontend immediately after.** Do not leave a gap - see below.
|
||||||
|
5. **Re-run 002's two `UPDATE` statements.** Between step 1 and step 3 the old API was
|
||||||
|
still writing `created_by_id` with no snapshot; those few rows would otherwise lose
|
||||||
|
their author at step 5.
|
||||||
|
6. **Rebuild the admin app** if `NEXT_PUBLIC_ALLOWED_REDIRECT_ORIGINS` does not already
|
||||||
|
contain `https://calendar.nachklang.art`. It is a **build-time** value, so a restart
|
||||||
|
does nothing.
|
||||||
|
|
||||||
|
**The window between steps 3 and 4 does not look broken, which is the danger.** The old
|
||||||
|
Angular bundle starts by calling `POST /calendar/users/checkSessionValid`, and those
|
||||||
|
legacy routes are untouched - so it still succeeds and the page renders as signed in. What
|
||||||
|
the user then sees is an empty event table and saves that silently do nothing. It looks
|
||||||
|
like the calendar lost its data, not like a deploy in progress. Keep the gap to minutes,
|
||||||
|
or take the frontend offline for it.
|
||||||
|
|
||||||
|
**One-way door:** any iCal subscription whose URL carries `?sessionId=&sessionKey=` rather
|
||||||
|
than `?password=` stops working permanently. The shared-password URLs are unaffected.
|
||||||
|
5. **Drop the legacy path.** **Deployed 2026-09-06.** Gated on step 4 being live, which it
|
||||||
|
was.
|
||||||
|
|
||||||
|
What went:
|
||||||
|
|
||||||
|
- `src/models/calendar/users/` in its entirety - registration, login, activation, both
|
||||||
|
password-reset routes, and the session checking the feedback and tickets admin areas
|
||||||
|
used to authenticate against - plus its mount in `Calendar.router.ts`. That was the
|
||||||
|
API's last unauthenticated account-creation and mail-sending endpoint.
|
||||||
|
- The two `LEFT OUTER JOIN users` clauses in `events.service.ts` and the `legacy_*`
|
||||||
|
aliases they fed, along with `created_by_id` / `version_created_by_id` in the SELECT, the
|
||||||
|
row mapper and the `Event` interface. One name source remains besides the live admin
|
||||||
|
lookup: the snapshot, which is what made this safe.
|
||||||
|
- `X-Session-Id` / `X-Session-Key` from the CORS `allowedHeaders`. Nothing had read them
|
||||||
|
since the tickets and feedback cutover, or sent them since this one.
|
||||||
|
- The tripwire in `test/admin/auth-binding.ts` asserting neither module fell back to a
|
||||||
|
calendar header session. There is nothing left to fall back to.
|
||||||
|
|
||||||
|
`DEFERRED_SECURITY.md` items **3** and **4** (activation and reset tokens never expiring)
|
||||||
|
close with it - not by adding expiries but by deleting the code that issued them.
|
||||||
|
|
||||||
|
### Deploy checklist — kept for the record; the order was REVERSED from step 4
|
||||||
|
|
||||||
|
Step 4's migration only added columns, so it went first. `004` *removes* columns and a
|
||||||
|
table that the currently running build still selects and joins, so running it first fails
|
||||||
|
every calendar read including the public feed. The step 5 build references none of them and
|
||||||
|
runs happily against the old schema. Therefore:
|
||||||
|
|
||||||
|
1. **Confirm the snapshot is complete.** Both must return 0:
|
||||||
|
```sql
|
||||||
|
SELECT SUM(created_by_id IS NOT NULL AND created_by_name IS NULL) FROM events;
|
||||||
|
SELECT SUM(version_created_by_id IS NOT NULL AND version_created_by_name IS NULL) FROM event_versions;
|
||||||
|
```
|
||||||
|
A non-zero count is an event whose author `004` would erase. Re-run 002's backfill first.
|
||||||
|
2. **Deploy the API.** No frontend deploy is needed: the calendar frontend never read
|
||||||
|
`createdById` (its `Event` model has only the name), and nothing else is known to.
|
||||||
|
3. **Confirm the calendar still works** - the public feed, a signed-in read, and one save.
|
||||||
|
At this point the old columns and tables still exist, unused, so this step is fully
|
||||||
|
reversible by redeploying the previous build.
|
||||||
|
|
||||||
|
Note that `tsc` does not remove output for deleted sources, so a build over an existing
|
||||||
|
`dist/` leaves `dist/src/models/calendar/users/*.js` behind. Nothing imports it and the
|
||||||
|
routes 404, but the deployed artifact still contains the code - clear `dist/` in the
|
||||||
|
pipeline if you want the artifact to match the source.
|
||||||
|
4. **Apply `sql/calendar/004_drop_legacy_auth.sql`.** This is the point of no return for
|
||||||
|
the columns; the accounts themselves are only renamed aside.
|
||||||
|
5. Optionally, later and at a quiet moment:
|
||||||
|
`DROP TABLE sessions_legacy_archive, users_legacy_archive;`
|
||||||
|
|
||||||
|
**One-way door:** `Event.createdById` and `lastModifiedById` leave the API response. Check
|
||||||
|
anything reading `/calendar/events/*/json` that is not the calendar frontend.
|
||||||
|
|
||||||
|
**Observed during the deploy, worth keeping.** A browser holding a *cached pre-cutover*
|
||||||
|
Angular bundle looked signed in and showed every event's status as "Error". The old bundle
|
||||||
|
called `/calendar/users/checkSessionValid`, which still existed between step 4 and step 5,
|
||||||
|
so it rendered as authenticated - then fetched events with no cookie, got the anonymous
|
||||||
|
listing, which omits `status`, and the UI's status switch fell through to its error label.
|
||||||
|
Signing out and back in fixed it. After this step that route 404s, so a stale bundle now
|
||||||
|
fails honestly instead of faking a session. This is the same "does not look broken" window
|
||||||
|
the step 4 checklist warns about, seen from the other side.
|
||||||
|
|
||||||
|
## What the code actually looks like (surveyed 2026-09-06)
|
||||||
|
|
||||||
|
Four things found while doing step 1 that change how the later steps should be built:
|
||||||
|
|
||||||
|
- **`created_by_id` is display-only.** Nothing authorises on it. `events.router.ts` gates
|
||||||
|
PUT, POST, DELETE and `/move` on `user?.isActive` alone - there is no "only the creator may
|
||||||
|
edit" rule anywhere - and the column is read back solely to render `created_by_name` and
|
||||||
|
`last_modified_by_name`. That de-risks steps 2, 3 and 5 considerably: an event whose
|
||||||
|
creator never gets re-invited loses a name in the UI, it does not become uneditable or
|
||||||
|
invisible. It also means the step 2 backfill is best-effort, not a precondition.
|
||||||
|
- **The two schemas are separate databases.** `nachklang_calendar` and `nachklang_admin`
|
||||||
|
have their own connection pools (`Calendar.db.ts` vs the admin module's Kysely instance).
|
||||||
|
So the bridging columns get no foreign key, and - the part the original sequence missed -
|
||||||
|
**the `LEFT OUTER JOIN users` that produces the creator's name cannot simply be repointed**.
|
||||||
|
It would have to become a cross-schema join, which hardcodes the admin database name into
|
||||||
|
calendar SQL and ties the two schemas together exactly as an FK would. Recommendation for
|
||||||
|
step 3: drop the join for the new path and resolve names in the service layer instead -
|
||||||
|
collect the distinct ids from the result set and do one lookup against the admin users
|
||||||
|
service. One extra query per listing, no coupling, and it keeps working if the admin
|
||||||
|
database ever moves.
|
||||||
|
- **`events.created_by_id` is `NOT NULL`.** Step 5 cannot simply stop writing it; that step
|
||||||
|
has to drop the column (and its FK to `users`) in the same migration that stops the writes,
|
||||||
|
or make it nullable first.
|
||||||
|
- **Every calendar read already hits the session table.** `/:calendar/json` calls
|
||||||
|
`UserService.checkSession` before falling back to `credentials.service.ts`, so the shared
|
||||||
|
credentials are the *fallback*, not the primary path. Step 4 replaces the first half of
|
||||||
|
that with `requireAppAccess('calendar')` and has to decide what happens to the second half
|
||||||
|
- which is the first open question below.
|
||||||
|
|
||||||
|
## Open questions to settle before starting
|
||||||
|
|
||||||
|
**Settled 2026-09-06:**
|
||||||
|
|
||||||
|
- **The shared calendar credentials keep working, but only for iCal.** The web app goes
|
||||||
|
cookie-only at step 4; `MEMBER_CREDENTIAL` and friends survive on
|
||||||
|
`GET /calendar/events/{calendar}/ical`, which is the one case where the client genuinely
|
||||||
|
cannot send a cookie. Everything else in `credentials.service.ts` goes with step 5.
|
||||||
|
`public` stays anonymous everywhere - see the note under step 4.
|
||||||
|
- **The iCal export keeps its own scheme.** Same reasoning; it is the reason the shared
|
||||||
|
credentials survive at all rather than an exception to their removal.
|
||||||
|
- **No account backfill.** See step 2 above.
|
||||||
|
- **Pre-cutover authorship is archived, not discarded.** `events.created_by_name` and
|
||||||
|
`event_versions.version_created_by_name`, backfilled once by migration 002 and never
|
||||||
|
written again. This was originally listed as a step 5 question; it was brought forward so
|
||||||
|
the data is safe well before the table that holds it is dropped.
|
||||||
|
|
||||||
|
**Nothing is open.** The last one - ~~`event_versions.version_created_by_id`~~, the same INT
|
||||||
|
reference on the version rows - was handled in passing: step 1 gave it a sibling bridging
|
||||||
|
column, step 2 a sibling snapshot, and step 3 reads it exactly like `events`.
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
|
|
||||||
module.exports = {
|
|
||||||
preset: 'ts-jest',
|
|
||||||
testEnvironment: 'node',
|
|
||||||
roots: [
|
|
||||||
'test'
|
|
||||||
]
|
|
||||||
};
|
|
||||||
Generated
+3837
-5927
File diff suppressed because it is too large
Load Diff
+28
-16
@@ -3,53 +3,65 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=26"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "tsc && node ./dist/app.js",
|
"start": "tsc && node ./dist/app.js",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"debug": "export DEBUG=* && npm run start",
|
"debug": "export DEBUG=* && npm run start",
|
||||||
"test": "jest --coverage --testResultsProcessor ./node_modules/jest-sonar-reporter/index.js"
|
"test": "vitest run --coverage",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:integration": "vitest run --config vitest.integration.config.ts"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@better-auth/core": "^1.7.2",
|
||||||
|
"@better-auth/passkey": "^1.7.2",
|
||||||
"app-root-path": "^3.0.0",
|
"app-root-path": "^3.0.0",
|
||||||
"axios": "^0.24.0",
|
"axios": "^1.20.0",
|
||||||
"bcrypt": "^5.0.1",
|
"bcrypt": "^5.0.1",
|
||||||
|
"better-auth": "^1.7.2",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"debug": "^4.3.1",
|
"debug": "^4.3.1",
|
||||||
"dotenv": "^8.2.0",
|
"dotenv": "^16.6.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"guid-typescript": "^1.0.9",
|
"guid-typescript": "^1.0.9",
|
||||||
|
"kysely": "^0.29.5",
|
||||||
"mariadb": "^3.0.2",
|
"mariadb": "^3.0.2",
|
||||||
|
"mysql2": "^3.24.3",
|
||||||
"random-words": "^1.1.1",
|
"random-words": "^1.1.1",
|
||||||
"swagger-jsdoc": "^6.1.0",
|
"swagger-jsdoc": "^6.1.0",
|
||||||
"swagger-ui-express": "^4.3.0",
|
"swagger-ui-express": "^4.3.0",
|
||||||
"winston": "^3.3.3"
|
"winston": "^3.3.3",
|
||||||
|
"zod": "^4.5.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/app-root-path": "^1.2.4",
|
"@types/app-root-path": "^1.2.4",
|
||||||
"@types/bcrypt": "^3.0.1",
|
"@types/bcrypt": "^3.0.1",
|
||||||
|
"@types/cors": "^2.8.19",
|
||||||
"@types/debug": "^4.1.5",
|
"@types/debug": "^4.1.5",
|
||||||
"@types/express": "^4.17.15",
|
"@types/express": "^4.17.15",
|
||||||
"@types/jest": "^28.1.3",
|
"@types/node": "^26.4.1",
|
||||||
"@types/node": "^18.11.17",
|
|
||||||
"@types/random-words": "^1.1.2",
|
"@types/random-words": "^1.1.2",
|
||||||
|
"@types/supertest": "^7.2.1",
|
||||||
"@types/swagger-jsdoc": "^6.0.1",
|
"@types/swagger-jsdoc": "^6.0.1",
|
||||||
"@types/swagger-ui-express": "^4.1.3",
|
"@types/swagger-ui-express": "^4.1.3",
|
||||||
"@types/winston": "^2.4.4",
|
"@types/winston": "^2.4.4",
|
||||||
|
"@vitest/coverage-v8": "^5.0.0",
|
||||||
"is-number": "^7.0.0",
|
"is-number": "^7.0.0",
|
||||||
"jest": "^28.1.1",
|
|
||||||
"jest-sonar-reporter": "^2.0.0",
|
|
||||||
"source-map-support": "^0.5.19",
|
"source-map-support": "^0.5.19",
|
||||||
"ts-jest": "^28.0.5",
|
"supertest": "^7.2.2",
|
||||||
"tslint": "^6.1.3",
|
"typescript": "^5.9.3",
|
||||||
"typescript": "^4.9.4"
|
"vitest": "^5.0.0",
|
||||||
|
"vitest-sonar-reporter": "^3.0.0"
|
||||||
},
|
},
|
||||||
"jestSonar": {
|
"overrides": {
|
||||||
"sonar56x": true,
|
"better-auth": {
|
||||||
"reportPath": "testResults",
|
"vitest": "$vitest"
|
||||||
"reportFile": "sonar-report.xml",
|
}
|
||||||
"indent": 4
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
-- nachklang_admin: identity, sessions and per-app permissions for every
|
||||||
|
-- *.nachklang.art app.
|
||||||
|
--
|
||||||
|
-- The better-auth tables below (user, session, account, verification, passkey,
|
||||||
|
-- rateLimit) mirror what better-auth 1.7.2 derives from the configuration in
|
||||||
|
-- src/models/admin/admin.auth.ts, including the `disabled` additionalField on
|
||||||
|
-- `user` and the `rateLimit` table that rateLimit.storage='database' requires.
|
||||||
|
-- On every better-auth upgrade: re-derive the table list, diff it against this
|
||||||
|
-- file, and add a numbered migration - never edit this one in place.
|
||||||
|
--
|
||||||
|
-- Table and column names are better-auth's own ("camel" casing, so `rateLimit`
|
||||||
|
-- and `userId`). MariaDB on Linux compares table names case-sensitively, so the
|
||||||
|
-- casing here is load-bearing. The two Nachklang-owned tables at the bottom use
|
||||||
|
-- the snake_case convention of the rest of this repo's SQL.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `user` (
|
||||||
|
`id` VARCHAR(36) NOT NULL,
|
||||||
|
`name` VARCHAR(255) NOT NULL,
|
||||||
|
`email` VARCHAR(255) NOT NULL,
|
||||||
|
`emailVerified` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
`image` TEXT DEFAULT NULL,
|
||||||
|
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updatedAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
-- Nachklang addition, declared through better-auth's additionalFields so
|
||||||
|
-- the adapter knows about it. Disabling also revokes the user's sessions.
|
||||||
|
`disabled` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `user_email` (`email`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `session` (
|
||||||
|
`id` VARCHAR(36) NOT NULL,
|
||||||
|
`expiresAt` DATETIME NOT NULL,
|
||||||
|
`token` VARCHAR(255) NOT NULL,
|
||||||
|
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updatedAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`ipAddress` VARCHAR(255) DEFAULT NULL,
|
||||||
|
`userAgent` TEXT DEFAULT NULL,
|
||||||
|
`userId` VARCHAR(36) NOT NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `session_token` (`token`),
|
||||||
|
KEY `session_user` (`userId`),
|
||||||
|
CONSTRAINT `session_user_fk` FOREIGN KEY (`userId`) REFERENCES `user` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `account` (
|
||||||
|
`id` VARCHAR(36) NOT NULL,
|
||||||
|
-- 1.7 addition: distinguishes a local credential account
|
||||||
|
-- ("local:credential") from an OAuth issuer. Written by better-auth.
|
||||||
|
`issuer` VARCHAR(255) NOT NULL,
|
||||||
|
`accountId` VARCHAR(255) NOT NULL,
|
||||||
|
`providerId` VARCHAR(255) NOT NULL,
|
||||||
|
`userId` VARCHAR(36) NOT NULL,
|
||||||
|
`accessToken` TEXT DEFAULT NULL,
|
||||||
|
`refreshToken` TEXT DEFAULT NULL,
|
||||||
|
`idToken` TEXT DEFAULT NULL,
|
||||||
|
`accessTokenExpiresAt` DATETIME DEFAULT NULL,
|
||||||
|
`refreshTokenExpiresAt` DATETIME DEFAULT NULL,
|
||||||
|
`scope` TEXT DEFAULT NULL,
|
||||||
|
`password` TEXT DEFAULT NULL,
|
||||||
|
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updatedAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `account_user` (`userId`),
|
||||||
|
KEY `account_provider` (`providerId`, `accountId`),
|
||||||
|
CONSTRAINT `account_user_fk` FOREIGN KEY (`userId`) REFERENCES `user` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Password-reset and e-mail-verification tokens.
|
||||||
|
CREATE TABLE IF NOT EXISTS `verification` (
|
||||||
|
`id` VARCHAR(36) NOT NULL,
|
||||||
|
`identifier` VARCHAR(255) NOT NULL,
|
||||||
|
`value` TEXT NOT NULL,
|
||||||
|
`expiresAt` DATETIME NOT NULL,
|
||||||
|
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updatedAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `verification_identifier` (`identifier`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `passkey` (
|
||||||
|
`id` VARCHAR(36) NOT NULL,
|
||||||
|
`name` VARCHAR(255) DEFAULT NULL,
|
||||||
|
`publicKey` TEXT NOT NULL,
|
||||||
|
`userId` VARCHAR(36) NOT NULL,
|
||||||
|
`credentialID` VARCHAR(255) NOT NULL,
|
||||||
|
`counter` INT NOT NULL DEFAULT 0,
|
||||||
|
`deviceType` VARCHAR(255) NOT NULL,
|
||||||
|
`backedUp` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
`transports` VARCHAR(255) DEFAULT NULL,
|
||||||
|
`createdAt` DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`aaguid` VARCHAR(255) DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `passkey_user` (`userId`),
|
||||||
|
KEY `passkey_credential` (`credentialID`),
|
||||||
|
CONSTRAINT `passkey_user_fk` FOREIGN KEY (`userId`) REFERENCES `user` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Required by rateLimit.storage = 'database' in admin.auth.ts. Passenger may
|
||||||
|
-- run several API instances, and an in-memory limiter would give each of them
|
||||||
|
-- its own budget.
|
||||||
|
CREATE TABLE IF NOT EXISTS `rateLimit` (
|
||||||
|
`id` VARCHAR(36) NOT NULL,
|
||||||
|
`key` VARCHAR(255) NOT NULL,
|
||||||
|
`count` INT NOT NULL DEFAULT 0,
|
||||||
|
-- Epoch milliseconds, not a DATETIME: better-auth stores a number here.
|
||||||
|
`lastRequest` BIGINT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `rate_limit_key` (`key`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Nachklang-owned tables
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Which apps a user may administer. `admin` is just another app: holding it is
|
||||||
|
-- what lets someone manage users and invitations. A permission is (app, role);
|
||||||
|
-- `access` is the only role today, and the key admits several per app so finer
|
||||||
|
-- ones can be added by inserting rows rather than by migrating this table.
|
||||||
|
CREATE TABLE IF NOT EXISTS `user_app_permissions` (
|
||||||
|
`user_id` VARCHAR(36) NOT NULL,
|
||||||
|
`app` ENUM('calendar','feedback','tickets','admin') NOT NULL,
|
||||||
|
-- One row per (user, app, role). `access` means "may use this app at all"
|
||||||
|
-- and is the only role today; the key allows several per app so a finer
|
||||||
|
-- permission can be added later by inserting rows, not by migrating.
|
||||||
|
`role` VARCHAR(32) NOT NULL DEFAULT 'access',
|
||||||
|
`granted_by` VARCHAR(36) DEFAULT NULL,
|
||||||
|
`granted_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
-- (user_id, app) is the leftmost prefix of this key, so the per-request
|
||||||
|
-- permission lookup needs no separate index.
|
||||||
|
PRIMARY KEY (`user_id`, `app`, `role`),
|
||||||
|
CONSTRAINT `uap_user_fk` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- The only route to a new account: there is no public sign-up. Only the
|
||||||
|
-- SHA-256 of the token is stored, so a dump of this table hands out no access.
|
||||||
|
CREATE TABLE IF NOT EXISTS `invitations` (
|
||||||
|
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
`email` VARCHAR(255) NOT NULL,
|
||||||
|
`name` VARCHAR(255) NOT NULL,
|
||||||
|
`token_hash` CHAR(64) NOT NULL,
|
||||||
|
`permissions` JSON NOT NULL,
|
||||||
|
`invited_by` VARCHAR(36) DEFAULT NULL,
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`expires_at` DATETIME NOT NULL,
|
||||||
|
`accepted_at` DATETIME DEFAULT NULL,
|
||||||
|
`revoked_at` DATETIME DEFAULT NULL,
|
||||||
|
UNIQUE KEY `inv_token_hash` (`token_hash`),
|
||||||
|
KEY `inv_email` (`email`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
-- Nachklang e.V. Calendar module — step 1 of docs/calendar-auth-migration.md.
|
||||||
|
-- Adds the bridging columns that let an event record who created it as an
|
||||||
|
-- *admin* user id (VARCHAR(36)) alongside the legacy calendar users.user_id
|
||||||
|
-- (INT). Apply manually against the CALENDAR_DB database:
|
||||||
|
-- mysql -h <DB_HOST> -u <DB_USER> -p <CALENDAR_DB> < 001_add_admin_user_bridge.sql
|
||||||
|
--
|
||||||
|
-- Numbered 001 because this is the first migration this repo owns for the
|
||||||
|
-- calendar schema: the tables themselves predate it and were provided by the
|
||||||
|
-- repo owner (mirrored for dev in docker/init/01-calendar-schema-dev.sql).
|
||||||
|
--
|
||||||
|
-- Nothing reads these columns yet — step 3 introduces the dual-read. Adding
|
||||||
|
-- them first means the backfill in step 2 has somewhere to write, and this
|
||||||
|
-- migration can be applied to production on its own without any code change.
|
||||||
|
--
|
||||||
|
-- No foreign key, on purpose. The admin `user` table lives in a *different*
|
||||||
|
-- database (nachklang_admin) behind a different connection pool, and a
|
||||||
|
-- cross-schema FK would tie the two schemas' lifecycles together: you could no
|
||||||
|
-- longer dump, restore or move one without the other. The reference is
|
||||||
|
-- enforced in application code, which is also where the legacy/new fallback
|
||||||
|
-- lives.
|
||||||
|
--
|
||||||
|
-- The collation is pinned to the admin database's (utf8mb4_unicode_ci) rather
|
||||||
|
-- than inherited from the calendar tables' utf8mb4_general_ci. These columns
|
||||||
|
-- hold ids that only ever compare against nachklang_admin.user.id, and a
|
||||||
|
-- mismatched collation makes any such comparison fail at runtime with
|
||||||
|
-- "Illegal mix of collations" instead of at review time.
|
||||||
|
|
||||||
|
ALTER TABLE `events`
|
||||||
|
ADD COLUMN IF NOT EXISTS `created_by_user_id` VARCHAR(36)
|
||||||
|
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||||
|
NULL DEFAULT NULL AFTER `created_by_id`,
|
||||||
|
ADD KEY IF NOT EXISTS `events_created_by_user_idx` (`created_by_user_id`);
|
||||||
|
|
||||||
|
ALTER TABLE `event_versions`
|
||||||
|
ADD COLUMN IF NOT EXISTS `version_created_by_user_id` VARCHAR(36)
|
||||||
|
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||||
|
NULL DEFAULT NULL AFTER `version_created_by_id`,
|
||||||
|
ADD KEY IF NOT EXISTS `event_versions_created_by_user_idx` (`version_created_by_user_id`);
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
-- Nachklang e.V. Calendar module — step 5 preparation, brought forward.
|
||||||
|
-- Apply manually against the CALENDAR_DB database, after 001:
|
||||||
|
-- mysql -h <DB_HOST> -u <DB_USER> -p <CALENDAR_DB> < 002_snapshot_legacy_creator_names.sql
|
||||||
|
--
|
||||||
|
-- Snapshots the creator's and last editor's *name* onto the event itself.
|
||||||
|
--
|
||||||
|
-- Why: the creator is only ever rendered as a name (nothing authorises on it),
|
||||||
|
-- and today that name comes from joining the calendar's own `users` table.
|
||||||
|
-- Step 5 drops that table, which would silently erase the authorship of every
|
||||||
|
-- event created before the cutover. There is no account backfill to save them
|
||||||
|
-- either - that was dropped deliberately, see docs/calendar-auth-migration.md.
|
||||||
|
-- One text column per reference keeps the history at no ongoing cost.
|
||||||
|
--
|
||||||
|
-- These columns are an archive, not a source of truth. Nothing writes them
|
||||||
|
-- after this backfill: events created from the cutover onwards carry an admin
|
||||||
|
-- user id, whose name is resolved live so that renaming an account updates
|
||||||
|
-- everywhere. The read path prefers the live admin name, falls back to this
|
||||||
|
-- snapshot, and falls back again to the join until step 5 removes it.
|
||||||
|
--
|
||||||
|
-- The whole file is re-runnable: IF NOT EXISTS on the columns, and the backfill
|
||||||
|
-- only touches rows with no snapshot yet. Step 4's migration re-runs the
|
||||||
|
-- backfill, to catch anything created between this migration and the cutover.
|
||||||
|
--
|
||||||
|
-- No charset clause: unlike 001's id columns these hold display text that is
|
||||||
|
-- only ever compared against other calendar data, so they inherit the tables'
|
||||||
|
-- utf8mb4_general_ci like the columns they are copied from.
|
||||||
|
|
||||||
|
ALTER TABLE `events`
|
||||||
|
ADD COLUMN IF NOT EXISTS `created_by_name` VARCHAR(255) NULL DEFAULT NULL AFTER `created_by_user_id`;
|
||||||
|
|
||||||
|
ALTER TABLE `event_versions`
|
||||||
|
ADD COLUMN IF NOT EXISTS `version_created_by_name` VARCHAR(255) NULL DEFAULT NULL AFTER `version_created_by_user_id`;
|
||||||
|
|
||||||
|
UPDATE `events` e
|
||||||
|
JOIN `users` u ON u.user_id = e.created_by_id
|
||||||
|
SET e.created_by_name = u.full_name
|
||||||
|
WHERE e.created_by_name IS NULL;
|
||||||
|
|
||||||
|
UPDATE `event_versions` v
|
||||||
|
JOIN `users` u ON u.user_id = v.version_created_by_id
|
||||||
|
SET v.version_created_by_name = u.full_name
|
||||||
|
WHERE v.version_created_by_name IS NULL;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- Nachklang e.V. Calendar module — step 4 of docs/calendar-auth-migration.md,
|
||||||
|
-- the cutover. Apply manually against the CALENDAR_DB database, after 002,
|
||||||
|
-- and BEFORE deploying the API build that goes with it:
|
||||||
|
-- mysql -h <DB_HOST> -u <DB_USER> -p <CALENDAR_DB> < 003_allow_null_legacy_creator.sql
|
||||||
|
--
|
||||||
|
-- From the cutover on, an event's creator is an admin-module user id. There is
|
||||||
|
-- no legacy calendar user id to write any more, and `events.created_by_id` is
|
||||||
|
-- NOT NULL - so without this the very first event created after the deploy
|
||||||
|
-- fails to insert. `event_versions.version_created_by_id` is already nullable.
|
||||||
|
--
|
||||||
|
-- The foreign key to `users` is kept: it permits NULL, so it costs nothing
|
||||||
|
-- until step 5 drops the column and the table together.
|
||||||
|
--
|
||||||
|
-- Applying this early is harmless. Widening a column to accept NULL cannot
|
||||||
|
-- break the running pre-cutover build, which always supplies a value, so this
|
||||||
|
-- can go out ahead of the deploy rather than during it.
|
||||||
|
|
||||||
|
ALTER TABLE `events`
|
||||||
|
MODIFY COLUMN `created_by_id` INT(11) NULL DEFAULT NULL;
|
||||||
|
|
||||||
|
-- Re-run of 002's backfill, to catch anything created between the two
|
||||||
|
-- migrations while the legacy path was still writing events. Idempotent by
|
||||||
|
-- construction: it only touches rows that have no snapshot yet.
|
||||||
|
UPDATE `events` e
|
||||||
|
JOIN `users` u ON u.user_id = e.created_by_id
|
||||||
|
SET e.created_by_name = u.full_name
|
||||||
|
WHERE e.created_by_name IS NULL;
|
||||||
|
|
||||||
|
UPDATE `event_versions` v
|
||||||
|
JOIN `users` u ON u.user_id = v.version_created_by_id
|
||||||
|
SET v.version_created_by_name = u.full_name
|
||||||
|
WHERE v.version_created_by_name IS NULL;
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
-- Nachklang e.V. Calendar module — step 5 of docs/calendar-auth-migration.md.
|
||||||
|
-- Apply manually against the CALENDAR_DB database, after 003:
|
||||||
|
-- mysql -h <DB_HOST> -u <DB_USER> -p <CALENDAR_DB> < 004_drop_legacy_auth.sql
|
||||||
|
--
|
||||||
|
-- *** APPLY THIS AFTER DEPLOYING THE API, NOT BEFORE. ***
|
||||||
|
--
|
||||||
|
-- This is the opposite order from the step 4 cutover, and getting it wrong by
|
||||||
|
-- analogy is the obvious mistake. Step 4's migration only *added* things, so it
|
||||||
|
-- was safe ahead of the deploy. This one removes columns and a table that the
|
||||||
|
-- currently running build still selects and joins - applying it first fails
|
||||||
|
-- every calendar read, including the anonymous public feed the website uses.
|
||||||
|
-- The step 5 build touches none of them, so it runs happily against the old
|
||||||
|
-- schema; deploy it, confirm the calendar works, then run this.
|
||||||
|
--
|
||||||
|
-- Nothing here loses information that is still reachable: the creators' display
|
||||||
|
-- names were snapshotted into events.created_by_name and
|
||||||
|
-- event_versions.version_created_by_name by migration 002, and the step 4
|
||||||
|
-- runbook re-ran that backfill after the deploy. Verify before running:
|
||||||
|
--
|
||||||
|
-- SELECT SUM(created_by_id IS NOT NULL AND created_by_name IS NULL) FROM events;
|
||||||
|
-- SELECT SUM(version_created_by_id IS NOT NULL AND version_created_by_name IS NULL) FROM event_versions;
|
||||||
|
--
|
||||||
|
-- Both must be 0. A non-zero count is an event whose author this migration
|
||||||
|
-- would erase; re-run 002's backfill first.
|
||||||
|
|
||||||
|
-- The foreign keys have to go before the columns they are declared on.
|
||||||
|
-- IF EXISTS so that a re-run after a partial failure gets past them.
|
||||||
|
ALTER TABLE `events`
|
||||||
|
DROP FOREIGN KEY IF EXISTS `events_users_user_id_fk`;
|
||||||
|
|
||||||
|
ALTER TABLE `event_versions`
|
||||||
|
DROP FOREIGN KEY IF EXISTS `event_versions_users_user_id_fk`;
|
||||||
|
|
||||||
|
ALTER TABLE `events`
|
||||||
|
DROP INDEX IF EXISTS `events_users_user_id_fk`,
|
||||||
|
DROP COLUMN IF EXISTS `created_by_id`;
|
||||||
|
|
||||||
|
ALTER TABLE `event_versions`
|
||||||
|
DROP INDEX IF EXISTS `event_versions_users_user_id_fk`,
|
||||||
|
DROP COLUMN IF EXISTS `version_created_by_id`;
|
||||||
|
|
||||||
|
-- The accounts themselves are renamed aside rather than dropped.
|
||||||
|
--
|
||||||
|
-- Nothing visible depends on them any more - the names are snapshotted, and no
|
||||||
|
-- code has referenced these tables since the step 4 cutover. But they still
|
||||||
|
-- hold e-mail addresses and password hashes, and a rename makes them
|
||||||
|
-- unreachable without destroying anything.
|
||||||
|
--
|
||||||
|
-- `sessions` has a foreign key into `users`; InnoDB rewires it to the new name
|
||||||
|
-- on rename, so after this it reads REFERENCES `users_legacy_archive` and the
|
||||||
|
-- pair stays internally consistent whichever order they are renamed in.
|
||||||
|
-- Verified on MariaDB 11.
|
||||||
|
--
|
||||||
|
-- Unlike the statements above this is not re-runnable, and that is the safe
|
||||||
|
-- behaviour: a second run fails on a missing `sessions` rather than doing
|
||||||
|
-- anything. Drop them for real whenever you like, at a moment when nobody is
|
||||||
|
-- mid-deploy:
|
||||||
|
-- DROP TABLE `sessions_legacy_archive`, `users_legacy_archive`;
|
||||||
|
RENAME TABLE `sessions` TO `sessions_legacy_archive`;
|
||||||
|
RENAME TABLE `users` TO `users_legacy_archive`;
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import * as dotenv from 'dotenv';
|
||||||
|
import swaggerUi from 'swagger-ui-express';
|
||||||
|
import swaggerJSDoc from 'swagger-jsdoc';
|
||||||
|
import cors from 'cors';
|
||||||
|
import {toNodeHandler} from 'better-auth/node';
|
||||||
|
import logger from './middleware/logger.js';
|
||||||
|
|
||||||
|
// Router imports
|
||||||
|
import {calendarRouter} from './models/calendar/Calendar.router.js';
|
||||||
|
import {feedbackRouter} from './models/feedback/Feedback.router.js';
|
||||||
|
import {ticketsRouter} from './models/tickets/Tickets.router.js';
|
||||||
|
import {adminRouter} from './models/admin/Admin.router.js';
|
||||||
|
import {auth} from './models/admin/admin.auth.js';
|
||||||
|
import {ADMIN_ALLOWED_ORIGINS, isProd} from './models/admin/admin.config.js';
|
||||||
|
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the Express app with every router and middleware in place.
|
||||||
|
*
|
||||||
|
* Separate from app.ts so the integration tests can drive the *real* wiring
|
||||||
|
* with supertest instead of a hand-rolled copy of it. The order below is not
|
||||||
|
* cosmetic - CORS has to precede the better-auth handler so preflights get
|
||||||
|
* their headers, and the better-auth handler has to precede express.json()
|
||||||
|
* because it reads the raw body stream itself.
|
||||||
|
*/
|
||||||
|
export const createApp = (): express.Application => {
|
||||||
|
const app: express.Application = express();
|
||||||
|
|
||||||
|
// Behind Plesk's nginx, req.ip is the proxy unless we trust the forwarded header.
|
||||||
|
// Verify the resolved client IP is correct in staging before relying on it
|
||||||
|
// (used by the feedback rate limiter).
|
||||||
|
app.set('trust proxy', 1);
|
||||||
|
|
||||||
|
// Configure CORS. This has to run before the better-auth handler below, so
|
||||||
|
// that preflights for /admin/auth/* get their headers, which is why it now
|
||||||
|
// sits above express.json() instead of after it.
|
||||||
|
let allowedHosts = [
|
||||||
|
'https://www.nachklang.art',
|
||||||
|
'https://calendar.nachklang.art',
|
||||||
|
'https://feedback.nachklang.art',
|
||||||
|
'https://tickets.nachklang.art',
|
||||||
|
'https://admin.nachklang.art',
|
||||||
|
// The admin app's origin comes from ADMIN_APP_URL, so a rename or a
|
||||||
|
// staging host does not need a code change here.
|
||||||
|
...ADMIN_ALLOWED_ORIGINS
|
||||||
|
];
|
||||||
|
// `isProd` from admin.config, NOT `NODE_ENV !== 'production'`. The two are not
|
||||||
|
// the same when NODE_ENV is unset, which is exactly what a fresh Plesk vhost
|
||||||
|
// gives you: the old test called that "dev" and opened the loopback and
|
||||||
|
// private-LAN exceptions below. With `credentials: true` on this CORS config
|
||||||
|
// and a session cookie scoped to .nachklang.art, that let any page served
|
||||||
|
// from localhost read a signed-in admin's data cross-origin. admin.config
|
||||||
|
// treats anything but an explicit 'development'/'test' as production, so an
|
||||||
|
// unset value now fails closed.
|
||||||
|
const isDev = !isProd;
|
||||||
|
const localhostRegex = /^http:\/\/localhost:\d+$/;
|
||||||
|
// Matches http://<private-LAN-IPv4>:<port> - needed so the feedback form can
|
||||||
|
// be reached from a real phone over WiFi during dev (the phone's Origin is
|
||||||
|
// the dev machine's LAN IP, never "localhost"). Dev-only, same as above.
|
||||||
|
const lanIpRegex = /^http:\/\/(192\.168\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}):\d+$/;
|
||||||
|
app.use(cors({
|
||||||
|
// Content-Type alone. X-Session-Id / X-Session-Key were allowed here
|
||||||
|
// through the two cutovers so that a browser still holding a pre-cutover
|
||||||
|
// bundle got a clean 401 rather than a confusing CORS preflight failure.
|
||||||
|
// Nothing has read them since the first cutover and nothing has sent them
|
||||||
|
// since the second, so they came out with the rest of the legacy path.
|
||||||
|
allowedHeaders: ['Content-Type'],
|
||||||
|
// The admin session lives in a cookie, so browsers must be allowed to send
|
||||||
|
// it cross-origin - this is what makes credentials: 'include' work.
|
||||||
|
credentials: true,
|
||||||
|
origin: function (origin: any, callback: any) {
|
||||||
|
// Allow requests with no origin
|
||||||
|
if (!origin) return callback(null, true);
|
||||||
|
|
||||||
|
// Any localhost port, or a private-LAN IP, is fine outside production -
|
||||||
|
// dev servers pick whatever port is free (Next.js falls back from 3000
|
||||||
|
// if it's taken), and real-device testing hits the dev machine by IP.
|
||||||
|
if (isDev && (localhostRegex.test(origin) || lanIpRegex.test(origin))) {
|
||||||
|
return callback(null, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block requests with wrong origin
|
||||||
|
if (allowedHosts.indexOf(origin) === -1) {
|
||||||
|
return callback(new Error('The CORS policy doesn\'t allow access for your origin.'), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow all other requests
|
||||||
|
return callback(null, true);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// better-auth's own handler, mounted before express.json(): it reads the raw
|
||||||
|
// request body stream itself and a parsed body would leave it hanging.
|
||||||
|
//
|
||||||
|
// Wrapped, because Express 4 does not await an async handler: a rejected
|
||||||
|
// promise escapes as an unhandled rejection instead of becoming a response.
|
||||||
|
// Nearly every better-auth route touches the admin database, so a database
|
||||||
|
// blip would leave the request hanging with no answer at all while the
|
||||||
|
// process logged an uncaughtException - observed by pointing ADMIN_DB at a
|
||||||
|
// database the user cannot open. Answer 503 instead: the caller learns, and
|
||||||
|
// the other domains keep serving.
|
||||||
|
const authHandler = toNodeHandler(auth);
|
||||||
|
app.all('/admin/auth/*', (req, res) => {
|
||||||
|
Promise.resolve(authHandler(req, res)).catch((e: any) => {
|
||||||
|
logger.error('Admin auth handler failed', {path: req.path, detail: e?.message});
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(503).send({
|
||||||
|
status: 'SERVICE_UNAVAILABLE',
|
||||||
|
message: 'Die Anmeldung ist derzeit nicht verfügbar. Bitte versuche es später erneut.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// here we are adding middleware to parse all incoming requests as JSON
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Swagger documentation
|
||||||
|
const swaggerDefinition = {
|
||||||
|
openapi: '3.0.0',
|
||||||
|
info: {
|
||||||
|
title: 'Nachklang e.V. REST API',
|
||||||
|
version: '1.0.0',
|
||||||
|
license: {
|
||||||
|
name: 'Licensed Under MIT',
|
||||||
|
url: 'https://spdx.org/licenses/MIT.html'
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
name: 'Nachklang e.V.',
|
||||||
|
url: 'https://www.nachklang.art'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
swaggerDefinition,
|
||||||
|
// Paths to files containing OpenAPI definitions
|
||||||
|
apis: [
|
||||||
|
'./src/models/**/*.interface.ts',
|
||||||
|
'./src/models/**/*.router.ts'
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const swaggerSpec = swaggerJSDoc(options);
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
'/docs',
|
||||||
|
swaggerUi.serve,
|
||||||
|
swaggerUi.setup(swaggerSpec)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add routers
|
||||||
|
app.use('/calendar', calendarRouter);
|
||||||
|
app.use('/feedback', feedbackRouter);
|
||||||
|
app.use('/tickets', ticketsRouter);
|
||||||
|
// JSON routes only; the auth handler above is mounted separately.
|
||||||
|
app.use('/admin', adminRouter);
|
||||||
|
|
||||||
|
// this is a simple route to make sure everything is working properly
|
||||||
|
app.get('/', (req: express.Request, res: express.Response) => {
|
||||||
|
res.status(200).send('Welcome to the Nachklang e.V. REST API!');
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import logger from '../middleware/logger';
|
import logger from '../middleware/logger.js';
|
||||||
import {salesforceApexRestPost, salesforceEnabled} from './salesforce.client';
|
import {salesforceApexRestPost, salesforceEnabled} from './salesforce.client.js';
|
||||||
|
|
||||||
// Transactional email for the ticketing/calendar flows (voucher redemption
|
// Transactional email for the ticketing/calendar flows (voucher redemption
|
||||||
// confirmations, account activation links, password-reset tokens) is relayed
|
// confirmations, account activation links, password-reset tokens) is relayed
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import * as appRoot from 'app-root-path';
|
import appRoot from 'app-root-path';
|
||||||
import * as winston from 'winston';
|
import winston from 'winston';
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
file_info: {
|
file_info: {
|
||||||
level: 'info',
|
level: 'info',
|
||||||
filename: `${appRoot}/logs/app.log`,
|
filename: `${appRoot.path}/logs/app.log`,
|
||||||
handleExceptions: true,
|
handleExceptions: true,
|
||||||
json: true,
|
json: true,
|
||||||
maxsize: 5242880, // 5MB
|
maxsize: 5242880, // 5MB
|
||||||
@@ -13,7 +13,7 @@ const options = {
|
|||||||
},
|
},
|
||||||
file_error: {
|
file_error: {
|
||||||
level: 'error',
|
level: 'error',
|
||||||
filename: `${appRoot}/logs/error.log`,
|
filename: `${appRoot.path}/logs/error.log`,
|
||||||
handleExceptions: true,
|
handleExceptions: true,
|
||||||
json: true,
|
json: true,
|
||||||
maxsize: 5242880, // 5MB
|
maxsize: 5242880, // 5MB
|
||||||
@@ -22,7 +22,7 @@ const options = {
|
|||||||
},
|
},
|
||||||
file_debug: {
|
file_debug: {
|
||||||
level: 'debug',
|
level: 'debug',
|
||||||
filename: `${appRoot}/logs/debug.log`,
|
filename: `${appRoot.path}/logs/debug.log`,
|
||||||
handleExceptions: true,
|
handleExceptions: true,
|
||||||
json: true,
|
json: true,
|
||||||
maxsize: 5242880, // 5MB
|
maxsize: 5242880, // 5MB
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import * as dotenv from 'dotenv';
|
||||||
|
import mysql from 'mysql2';
|
||||||
|
import {Kysely, MysqlDialect} from 'kysely';
|
||||||
|
import {AdminDatabase} from './admin.schema.js';
|
||||||
|
import logger from '../../middleware/logger.js';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The admin module is the one place in this API that does not use the
|
||||||
|
* `mariadb` driver: better-auth talks to the database through Kysely, whose
|
||||||
|
* MySQL dialect expects a mysql2 pool. The other domains keep their own
|
||||||
|
* `mariadb` pools (see Feedback.db.ts) - this is an addition, not a migration.
|
||||||
|
*
|
||||||
|
* The pool is the callback-style `mysql2` one, NOT `mysql2/promise`: Kysely's
|
||||||
|
* MysqlDialect calls `pool.getConnection((err, conn) => ...)`. The promise
|
||||||
|
* wrapper ignores that callback and returns a Promise instead, so every query
|
||||||
|
* through Kysely would hang forever with no error - which is exactly what it
|
||||||
|
* did until the integration tests caught it.
|
||||||
|
*
|
||||||
|
* timezone 'Z' matters: better-auth computes session and token expiry in UTC.
|
||||||
|
* Without it mysql2 would write and read those DATETIMEs in the process's local
|
||||||
|
* zone, so sessions would expire an hour early or late depending on DST.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export namespace NachklangAdminDB {
|
||||||
|
export const pool = mysql.createPool({
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
database: process.env.ADMIN_DB,
|
||||||
|
// The other modules' pools default to 3306. This one is configurable so
|
||||||
|
// the integration tests can point at a throwaway container on another
|
||||||
|
// port without touching a developer's real .env.
|
||||||
|
port: parseInt(process.env.DB_PORT || '3306', 10),
|
||||||
|
connectionLimit: 5,
|
||||||
|
timezone: 'Z'
|
||||||
|
});
|
||||||
|
|
||||||
|
// mysql2 emits connection trouble as an event on the pool, not only as a
|
||||||
|
// rejected query. Without a listener Node turns that into an
|
||||||
|
// uncaughtException, so a database restart would take the whole API - and
|
||||||
|
// with it the calendar, feedback and tickets domains - down with it.
|
||||||
|
// Individual queries still reject, and their callers still answer 500.
|
||||||
|
pool.on('error', (err: unknown) => {
|
||||||
|
logger.error('Admin database pool error', {detail: (err as any)?.message});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handed to better-auth as `database: {dialect, type: 'mysql'}`.
|
||||||
|
export const dialect = new MysqlDialect({pool});
|
||||||
|
|
||||||
|
// Used by this module's own services for the two custom tables and for
|
||||||
|
// permission lookups that join better-auth's `user`.
|
||||||
|
export const db = new Kysely<AdminDatabase>({dialect});
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import express, {Request, Response} from 'express';
|
||||||
|
import {requireAppAccess, requireSignedIn} from './admin.middleware.js';
|
||||||
|
import {usersAdminRouter} from './users/users.admin.router.js';
|
||||||
|
import {invitationsRouter} from './invitations/invitations.router.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The admin module's JSON routes. Deliberately *not* the better-auth handler:
|
||||||
|
* that one is mounted separately in app.ts, ahead of express.json(), because it
|
||||||
|
* needs the raw request body stream.
|
||||||
|
*
|
||||||
|
* Mounted at /admin, so the tree is:
|
||||||
|
* /admin/me any signed-in account
|
||||||
|
* /admin/users/* admin permission
|
||||||
|
* /admin/invitations/* admin permission
|
||||||
|
*/
|
||||||
|
export const adminRouter = express.Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /admin/me:
|
||||||
|
* get:
|
||||||
|
* summary: The current user's identity and app permissions
|
||||||
|
* description: Used by every frontend to decide what to show. The API remains the real gate.
|
||||||
|
* tags: [admin]
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Success
|
||||||
|
* 401:
|
||||||
|
* description: Not signed in
|
||||||
|
* 403:
|
||||||
|
* description: Account disabled
|
||||||
|
*/
|
||||||
|
adminRouter.get('/me', requireSignedIn, (req: Request, res: Response) => {
|
||||||
|
res.status(200).send({
|
||||||
|
id: res.locals.admin.id,
|
||||||
|
email: res.locals.admin.email,
|
||||||
|
fullName: res.locals.admin.displayName,
|
||||||
|
// `permissions` is the full (app, role) truth; `apps` is the distinct
|
||||||
|
// apps within it. Both are sent because the three frontends only ever ask
|
||||||
|
// "may I show this app?", and keeping `apps` means a finer permission can
|
||||||
|
// land here without a coordinated deploy of all of them.
|
||||||
|
permissions: res.locals.admin.permissions,
|
||||||
|
apps: res.locals.admin.apps
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRouter.use('/users', requireAppAccess('admin'), usersAdminRouter);
|
||||||
|
adminRouter.use('/invitations', requireAppAccess('admin'), invitationsRouter);
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import {betterAuth} from 'better-auth';
|
||||||
|
import {APIError} from 'better-auth/api';
|
||||||
|
import {passkey, getAuthenticatorName} from '@better-auth/passkey';
|
||||||
|
import {NachklangAdminDB} from './Admin.db.js';
|
||||||
|
import {invitationsPlugin} from './invitations/invitations.plugin.js';
|
||||||
|
import {sendPasswordResetMail} from './admin.mail.js';
|
||||||
|
import * as UsersService from './users/users.admin.service.js';
|
||||||
|
import logger from '../../middleware/logger.js';
|
||||||
|
import {
|
||||||
|
ADMIN_ALLOWED_ORIGINS,
|
||||||
|
API_BASE_URL,
|
||||||
|
BETTER_AUTH_SECRET,
|
||||||
|
CLIENT_IP_HEADERS,
|
||||||
|
PASSKEY_RP_ID,
|
||||||
|
TRUSTED_PROXY_IPS,
|
||||||
|
isProd
|
||||||
|
} from './admin.config.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single better-auth instance for all *.nachklang.art apps. Mounted in
|
||||||
|
* app.ts at /admin/auth/* with better-auth's own node handler, ahead of
|
||||||
|
* express.json() (it needs the raw body stream).
|
||||||
|
*
|
||||||
|
* The session cookie is what every app trusts. Everything else in this module -
|
||||||
|
* permissions, invitations, the admin UI - hangs off it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DAY = 60 * 60 * 24;
|
||||||
|
|
||||||
|
// Dev runs the apps on plain localhost ports; cookies ignore the port, so
|
||||||
|
// single-sign-on across them works without fake subdomains or mkcert.
|
||||||
|
const localhostOrigins = [
|
||||||
|
'http://localhost:3000',
|
||||||
|
'http://localhost:3001',
|
||||||
|
'http://localhost:3002',
|
||||||
|
'http://localhost:3003',
|
||||||
|
// The Angular calendar frontend; `ng serve` defaults to 4200. Missing from
|
||||||
|
// this list, sign-out from the calendar answers 403 in dev only, which is a
|
||||||
|
// confusing thing to debug against a production config that is fine.
|
||||||
|
'http://localhost:4200'
|
||||||
|
];
|
||||||
|
|
||||||
|
const trustedOrigins = isProd
|
||||||
|
? ADMIN_ALLOWED_ORIGINS
|
||||||
|
: Array.from(new Set([...ADMIN_ALLOWED_ORIGINS, ...localhostOrigins]));
|
||||||
|
|
||||||
|
export const auth = betterAuth({
|
||||||
|
appName: 'Nachklang',
|
||||||
|
database: {
|
||||||
|
dialect: NachklangAdminDB.dialect,
|
||||||
|
type: 'mysql'
|
||||||
|
},
|
||||||
|
basePath: '/admin/auth',
|
||||||
|
// Mandatory once crossSubDomainCookies is on: better-auth derives the
|
||||||
|
// cookie domain and its own absolute URLs from this.
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
secret: BETTER_AUTH_SECRET,
|
||||||
|
trustedOrigins,
|
||||||
|
|
||||||
|
emailAndPassword: {
|
||||||
|
enabled: true,
|
||||||
|
// There is no public sign-up: accounts exist only through an
|
||||||
|
// invitation (see invitations.plugin.ts). This also makes
|
||||||
|
// auth.api.signUpEmail throw, which is intended.
|
||||||
|
disableSignUp: true,
|
||||||
|
sendResetPassword: async ({user, url}) => {
|
||||||
|
await sendPasswordResetMail(user.email, user.name, url);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
user: {
|
||||||
|
additionalFields: {
|
||||||
|
// Not `returned`, and not settable through the API: disabling is an
|
||||||
|
// admin action on /admin/users/:id/disable, never something a
|
||||||
|
// session owner can flip on themselves.
|
||||||
|
disabled: {
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
|
input: false,
|
||||||
|
returned: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
session: {
|
||||||
|
expiresIn: 30 * DAY,
|
||||||
|
updateAge: DAY
|
||||||
|
// Deliberately no cookieCache: requireAppAccess hits the database on
|
||||||
|
// every request anyway, and a cached session would keep a disabled
|
||||||
|
// user or a revoked session alive for the cache's lifetime.
|
||||||
|
},
|
||||||
|
|
||||||
|
advanced: {
|
||||||
|
// Fixes the cookie name across releases so the frontends' middleware can
|
||||||
|
// check for it: "nachklang.session_token", or
|
||||||
|
// "__Secure-nachklang.session_token" over https.
|
||||||
|
cookiePrefix: 'nachklang',
|
||||||
|
crossSubDomainCookies: isProd
|
||||||
|
? {enabled: true, domain: '.nachklang.art'}
|
||||||
|
: {enabled: false},
|
||||||
|
ipAddress: {
|
||||||
|
// better-auth reads the request itself and does not know about
|
||||||
|
// Express's `trust proxy`, so both the header and the trusted hops
|
||||||
|
// have to be named here. Getting this wrong does not fail loudly -
|
||||||
|
// it collapses every client into one rate-limit bucket. See the
|
||||||
|
// commentary on CLIENT_IP_HEADERS in admin.config.ts.
|
||||||
|
ipAddressHeaders: CLIENT_IP_HEADERS,
|
||||||
|
...(TRUSTED_PROXY_IPS.length > 0 ? {trustedProxies: TRUSTED_PROXY_IPS} : {})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
rateLimit: {
|
||||||
|
enabled: true,
|
||||||
|
// Passenger may run more than one instance; an in-memory limiter would
|
||||||
|
// then give each of them its own budget.
|
||||||
|
storage: 'database'
|
||||||
|
},
|
||||||
|
|
||||||
|
plugins: [
|
||||||
|
passkey({
|
||||||
|
rpID: PASSKEY_RP_ID,
|
||||||
|
rpName: 'Nachklang',
|
||||||
|
origin: ADMIN_ALLOWED_ORIGINS,
|
||||||
|
|
||||||
|
registration: {
|
||||||
|
// Without this, every passkey is stored with name = NULL and the
|
||||||
|
// account page can only label them all "Passkey" - useless at the
|
||||||
|
// one moment that list matters, when someone has to remove the
|
||||||
|
// passkey on the device they just lost.
|
||||||
|
//
|
||||||
|
// The AAGUID identifies the authenticator *model* (not a device
|
||||||
|
// and not a person), and better-auth ships the lookup table, so
|
||||||
|
// this yields "1Password", "iCloud Keychain", "Windows Hello".
|
||||||
|
// It only fills a blank: a name the client sent always wins, and
|
||||||
|
// an unknown AAGUID leaves the column NULL as before.
|
||||||
|
afterVerification: async ({verification}) => {
|
||||||
|
const name = getAuthenticatorName(verification.registrationInfo?.aaguid);
|
||||||
|
return name ? {name} : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
invitationsPlugin()
|
||||||
|
],
|
||||||
|
|
||||||
|
databaseHooks: {
|
||||||
|
session: {
|
||||||
|
create: {
|
||||||
|
before: async session => {
|
||||||
|
const access = await UsersService.loadAccess(session.userId);
|
||||||
|
if (access?.disabled) {
|
||||||
|
logger.warn('Admin: sign-in attempt by a disabled account', {userId: session.userId});
|
||||||
|
// Throwing rather than returning false: `false` aborts
|
||||||
|
// the session write silently and the caller sees a
|
||||||
|
// confusing success-shaped response with no cookie.
|
||||||
|
throw new APIError('FORBIDDEN', {
|
||||||
|
code: 'ACCOUNT_DISABLED',
|
||||||
|
message: 'Dieses Konto ist deaktiviert.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AdminAuth = typeof auth;
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import * as UsersService from './users/users.admin.service.js';
|
||||||
|
import * as InvitationsService from './invitations/invitations.service.js';
|
||||||
|
import {sendInvitationMail} from './admin.mail.js';
|
||||||
|
import {ACCESS_ROLE} from './admin.schema.js';
|
||||||
|
import {ADMIN_APP_URL, ADMIN_BOOTSTRAP_EMAIL, LOG_INVITE_LINKS} from './admin.config.js';
|
||||||
|
import logger from '../../middleware/logger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Solves the empty-database problem: with invite-only accounts and no public
|
||||||
|
* sign-up, a fresh nachklang_admin has nobody who can invite anybody. Rather
|
||||||
|
* than a CLI script somebody has to remember to run against production, the API
|
||||||
|
* makes sure on every start that ADMIN_BOOTSTRAP_EMAIL can get in.
|
||||||
|
*
|
||||||
|
* Idempotent by design - it is safe on every restart:
|
||||||
|
* - an active admin already exists -> do nothing
|
||||||
|
* - the address exists as a user -> grant it `admin`
|
||||||
|
* - an open invitation exists -> do nothing (do not re-mail on restart)
|
||||||
|
* - otherwise -> invite, and mail the link
|
||||||
|
*
|
||||||
|
* Never throws: a database blip at boot must not stop the API from serving the
|
||||||
|
* calendar, feedback and tickets domains.
|
||||||
|
*/
|
||||||
|
export const bootstrapAdmin = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (!ADMIN_BOOTSTRAP_EMAIL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = ADMIN_BOOTSTRAP_EMAIL.trim().toLowerCase();
|
||||||
|
|
||||||
|
if ((await UsersService.countActiveAdmins()) > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await UsersService.findUserByEmail(email);
|
||||||
|
if (existing) {
|
||||||
|
await UsersService.grantPermission(existing.id, 'admin', null);
|
||||||
|
logger.info('Admin bootstrap: granted the admin permission to the existing bootstrap user', {email});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// An expired invitation is not "open", so the next restart re-issues
|
||||||
|
// one - which is the recovery path if the first mail never arrived.
|
||||||
|
if (await InvitationsService.hasOpenInvitationFor(email)) {
|
||||||
|
logger.info('Admin bootstrap: an open invitation already exists', {email});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const invitation = await InvitationsService.createInvitation(
|
||||||
|
email,
|
||||||
|
'Nachklang Admin',
|
||||||
|
[{app: 'admin', role: ACCESS_ROLE}],
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
const mailed = await sendInvitationMail(email, 'Nachklang Admin', invitation.token, invitation.expiresAt);
|
||||||
|
logger.info('Admin bootstrap: invitation created', {email, mailed});
|
||||||
|
|
||||||
|
// With the mail relay off, the logged link is how a local setup gets its
|
||||||
|
// first admin. Explicit opt-in (see LOG_INVITE_LINKS): the link is a
|
||||||
|
// live credential, so this must never depend on NODE_ENV alone.
|
||||||
|
if (LOG_INVITE_LINKS) {
|
||||||
|
logger.info(`Admin bootstrap: ${ADMIN_APP_URL}/accept-invite?token=${invitation.token}`);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
logger.error('Admin bootstrap failed', {detail: e?.message});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import * as crypto from 'crypto';
|
||||||
|
import * as dotenv from 'dotenv';
|
||||||
|
import logger from '../../middleware/logger.js';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One place that reads the admin module's environment. Both admin.auth.ts
|
||||||
|
* (better-auth trustedOrigins, passkey origins) and app.ts (CORS) need the
|
||||||
|
* same origin list, and a second parser would drift from this one.
|
||||||
|
*
|
||||||
|
* Read this before changing the environment handling below: several security
|
||||||
|
* properties depend on it, and they are deliberately arranged to fail *safe*.
|
||||||
|
*
|
||||||
|
* `NODE_ENV` is opt-in to relaxed behaviour, not opt-in to strict behaviour.
|
||||||
|
* Only the explicit values 'development' and 'test' relax anything; anything
|
||||||
|
* else - including NODE_ENV being unset, which is exactly what a fresh Plesk
|
||||||
|
* vhost gives you - is treated as production. The inverse arrangement is a
|
||||||
|
* trap: it degrades the cookie domain, the CORS origin list and the signing
|
||||||
|
* key all at once, and every one of those failures is silent.
|
||||||
|
*
|
||||||
|
* The signing key is never allowed to be a known constant. In dev, an unset
|
||||||
|
* BETTER_AUTH_SECRET becomes a random per-process value: sessions do not
|
||||||
|
* survive a restart, which is mildly annoying and much better than a default
|
||||||
|
* secret that can be copied out of this file and used against production.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const nodeEnv = process.env.NODE_ENV;
|
||||||
|
|
||||||
|
// Explicitly relaxed environments. Everything else, unset included, is strict.
|
||||||
|
const isRelaxedEnv = nodeEnv === 'development' || nodeEnv === 'test';
|
||||||
|
|
||||||
|
export const isProd = !isRelaxedEnv;
|
||||||
|
|
||||||
|
const required = (name: string, devDefault: string): string => {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (value) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (isProd) {
|
||||||
|
logger.error(
|
||||||
|
`Admin module: ${name} is not set (NODE_ENV=${nodeEnv ?? 'unset'}, so strict mode applies; ` +
|
||||||
|
'set NODE_ENV=development for local work)'
|
||||||
|
);
|
||||||
|
throw new Error(`${name} must be set unless NODE_ENV is development or test`);
|
||||||
|
}
|
||||||
|
return devDefault;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const API_BASE_URL = required('API_BASE_URL', 'http://localhost:3000');
|
||||||
|
export const ADMIN_APP_URL = required('ADMIN_APP_URL', 'http://localhost:3002');
|
||||||
|
|
||||||
|
// 32+ random bytes; better-auth signs cookies and reset tokens with it.
|
||||||
|
// Rotating it invalidates every session, which is why it is not derived.
|
||||||
|
// There is no hardcoded fallback on purpose: a constant committed here would
|
||||||
|
// be a published signing key the moment someone deploys without setting it.
|
||||||
|
export const BETTER_AUTH_SECRET = required(
|
||||||
|
'BETTER_AUTH_SECRET',
|
||||||
|
crypto.randomBytes(48).toString('base64')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Passkeys are bound to this: a credential registered for "nachklang.art"
|
||||||
|
// works on every *.nachklang.art host, one registered for "localhost" only
|
||||||
|
// works in dev. Changing it invalidates every registered passkey.
|
||||||
|
export const PASSKEY_RP_ID = process.env.PASSKEY_RP_ID || (isProd ? 'nachklang.art' : 'localhost');
|
||||||
|
|
||||||
|
const parseList = (value: string | undefined, fallback: string[]): string[] => {
|
||||||
|
const parsed = (value || '')
|
||||||
|
.split(',')
|
||||||
|
.map(entry => entry.trim())
|
||||||
|
.filter(entry => entry.length > 0);
|
||||||
|
|
||||||
|
return parsed.length > 0 ? parsed : fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The apps whose frontends may talk to /admin/* with credentials.
|
||||||
|
*
|
||||||
|
* They feed better-auth's `trustedOrigins`, which is what lets the tickets and
|
||||||
|
* feedback admin areas call /admin/auth/sign-out from their own origin. That
|
||||||
|
* became load-bearing with the step 4 cutover: before it, the only browser
|
||||||
|
* origin that ever reached /admin/auth was the admin app itself. The calendar
|
||||||
|
* joined them with its own cutover (docs/calendar-auth-migration.md step 4).
|
||||||
|
*
|
||||||
|
* Hence the production default rather than an empty list. An origin missing
|
||||||
|
* here fails in a way that is easy to misread - sign-in works, the app works,
|
||||||
|
* and only sign-out returns an origin error - so the two frontends we know
|
||||||
|
* about are named here and APP_ORIGINS overrides them for a staging host.
|
||||||
|
* Dev adds the localhost ports separately (see admin.auth.ts).
|
||||||
|
*/
|
||||||
|
const DEFAULT_APP_ORIGINS = [
|
||||||
|
'https://tickets.nachklang.art',
|
||||||
|
'https://feedback.nachklang.art',
|
||||||
|
'https://calendar.nachklang.art'
|
||||||
|
];
|
||||||
|
|
||||||
|
export const APP_ORIGINS = parseList(process.env.APP_ORIGINS, DEFAULT_APP_ORIGINS)
|
||||||
|
.map(origin => origin.replace(/\/$/, ''));
|
||||||
|
|
||||||
|
// Kept in sync by construction rather than by three separate lists: the admin
|
||||||
|
// app itself always counts, and dev adds the local ports.
|
||||||
|
export const ADMIN_ALLOWED_ORIGINS = Array.from(new Set([
|
||||||
|
ADMIN_APP_URL.replace(/\/$/, ''),
|
||||||
|
...APP_ORIGINS
|
||||||
|
]));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The header the reverse proxy puts the real client IP in, and the proxy hops
|
||||||
|
* to trust when reading it.
|
||||||
|
*
|
||||||
|
* This matters more than it looks. better-auth does not know about Express's
|
||||||
|
* `trust proxy`; it reads the request itself. If it cannot resolve a client IP
|
||||||
|
* it falls back to a single shared bucket ("no-trusted-ip") for the whole
|
||||||
|
* process - and /sign-in/* carries a default of 3 requests per 10 seconds, so
|
||||||
|
* one noisy client would lock every user out of every app.
|
||||||
|
*
|
||||||
|
* Without TRUSTED_PROXY_IPS, better-auth rejects a multi-value
|
||||||
|
* x-forwarded-for outright (it cannot tell which hop is the client), which is
|
||||||
|
* exactly the case that produces that shared bucket. Set it to the address or
|
||||||
|
* CIDR of Plesk's nginx. Conversely, listing a header the proxy does not
|
||||||
|
* overwrite lets a client set its own IP and mint itself an unlimited
|
||||||
|
* brute-force budget - so the default is the single header nginx sets, not a
|
||||||
|
* permissive list.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `CLIENT_IP_HEADERS=none` trusts no header at all.
|
||||||
|
*
|
||||||
|
* This is the escape hatch for the one case where the wrong setting is worse
|
||||||
|
* than no setting: if the proxy turns out NOT to overwrite the header we are
|
||||||
|
* trusting, any client can send it and mint itself an unlimited brute-force
|
||||||
|
* budget against /sign-in. Falling back to the shared bucket is bad (one noisy
|
||||||
|
* client can lock the organisation out for ten seconds at a time) but it is
|
||||||
|
* bad in a way that fails closed, and it can be reverted from the environment
|
||||||
|
* without a deploy.
|
||||||
|
*
|
||||||
|
* Reach for it only after a check has actually failed - `SELECT ipAddress FROM
|
||||||
|
* session ORDER BY createdAt DESC` showing 127.0.0.1 or NULL for a real remote
|
||||||
|
* sign-in - and take it back out once the header is configured.
|
||||||
|
*
|
||||||
|
* An empty or unset value still means "use the default", not "trust nothing":
|
||||||
|
* a stray blank line in a .env must not silently change how requests are
|
||||||
|
* bucketed. Only the explicit word does that.
|
||||||
|
*/
|
||||||
|
const TRUST_NO_HEADER = 'none';
|
||||||
|
|
||||||
|
export const TRUST_NO_CLIENT_IP_HEADER =
|
||||||
|
(process.env.CLIENT_IP_HEADERS || '').trim().toLowerCase() === TRUST_NO_HEADER;
|
||||||
|
|
||||||
|
// An empty array is what better-auth reads as "no headers": it only falls back
|
||||||
|
// to its own default when the option is absent, and `[]` is truthy.
|
||||||
|
export const CLIENT_IP_HEADERS = TRUST_NO_CLIENT_IP_HEADER
|
||||||
|
? []
|
||||||
|
: parseList(process.env.CLIENT_IP_HEADERS, ['x-real-ip']);
|
||||||
|
|
||||||
|
export const TRUSTED_PROXY_IPS = parseList(process.env.TRUSTED_PROXY_IPS, []);
|
||||||
|
|
||||||
|
if (isProd && TRUST_NO_CLIENT_IP_HEADER) {
|
||||||
|
logger.warn(
|
||||||
|
'Admin module: CLIENT_IP_HEADERS=none - no client-IP header is trusted, so every ' +
|
||||||
|
'request shares one rate-limit bucket and /sign-in allows 3 attempts per 10 seconds ' +
|
||||||
|
'for everyone combined. This is the safe fallback, not a destination: configure the ' +
|
||||||
|
'header the proxy actually sets and remove it.'
|
||||||
|
);
|
||||||
|
} else if (isProd && TRUSTED_PROXY_IPS.length === 0) {
|
||||||
|
logger.warn(
|
||||||
|
'Admin module: TRUSTED_PROXY_IPS is not set. If the proxy sends a multi-value ' +
|
||||||
|
`${CLIENT_IP_HEADERS.join('/')}, better-auth cannot resolve a client IP and every ` +
|
||||||
|
'request shares one rate-limit bucket. Verify with: SELECT `key` FROM rateLimit - ' +
|
||||||
|
'a "no-trusted-ip" row means this is happening. A single-value header needs no ' +
|
||||||
|
'trusted proxies, so this warning is expected on a plain single-proxy setup.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ADMIN_BOOTSTRAP_EMAIL = process.env.ADMIN_BOOTSTRAP_EMAIL || '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to write invitation links to the log. An invitation link is a live
|
||||||
|
* account-creation credential, so this is an explicit opt-in rather than
|
||||||
|
* something inferred from NODE_ENV: local work needs it (the mail relay is
|
||||||
|
* usually off, and only the token's hash is stored, so there is otherwise no
|
||||||
|
* way to walk the accept flow), and production must never have it.
|
||||||
|
*
|
||||||
|
* Refused outright in strict mode, so setting it in a production .env by
|
||||||
|
* accident fails at boot instead of quietly filling the log with credentials.
|
||||||
|
*/
|
||||||
|
export const LOG_INVITE_LINKS = process.env.ADMIN_LOG_INVITE_LINKS === 'true' && !isProd;
|
||||||
|
|
||||||
|
if (process.env.ADMIN_LOG_INVITE_LINKS === 'true' && isProd) {
|
||||||
|
logger.error('Admin module: ADMIN_LOG_INVITE_LINKS is set outside development - refusing to log invitation tokens');
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import {Response} from 'express';
|
||||||
|
import {Guid} from 'guid-typescript';
|
||||||
|
import logger from '../../middleware/logger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same catch-block convention as the feedback and tickets modules: log with a
|
||||||
|
* reference guid, never hand the real error message to the client.
|
||||||
|
*/
|
||||||
|
export const sendServerError = (res: Response, e: any): void => {
|
||||||
|
const 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,120 @@
|
|||||||
|
/**
|
||||||
|
* Swagger component definitions for the admin module. Picked up by
|
||||||
|
* swagger-jsdoc through the `src/models/**\/*.interface.ts` glob in
|
||||||
|
* app.factory.ts.
|
||||||
|
*
|
||||||
|
* Note what is *not* documented here: the better-auth routes under
|
||||||
|
* /admin/auth/* (sign-in, sign-out, reset-password, passkey ceremonies, and
|
||||||
|
* the invitation preview/accept endpoints). better-auth owns those paths and
|
||||||
|
* their shapes; duplicating them by hand would only drift on the next upgrade.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* components:
|
||||||
|
* securitySchemes:
|
||||||
|
* AdminSessionCookie:
|
||||||
|
* type: apiKey
|
||||||
|
* in: cookie
|
||||||
|
* name: nachklang.session_token
|
||||||
|
* description: >
|
||||||
|
* Set by /admin/auth/sign-in/email. Over https the name is
|
||||||
|
* __Secure-nachklang.session_token and the cookie is scoped to
|
||||||
|
* .nachklang.art, so one sign-in covers every *.nachklang.art app.
|
||||||
|
* schemas:
|
||||||
|
* AdminApp:
|
||||||
|
* type: string
|
||||||
|
* enum: [calendar, feedback, tickets, admin]
|
||||||
|
* description: Holding "admin" is what allows managing users and invitations.
|
||||||
|
* AdminMe:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* id:
|
||||||
|
* type: string
|
||||||
|
* email:
|
||||||
|
* type: string
|
||||||
|
* fullName:
|
||||||
|
* type: string
|
||||||
|
* apps:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* $ref: '#/components/schemas/AdminApp'
|
||||||
|
* AdminUserSession:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* id:
|
||||||
|
* type: string
|
||||||
|
* createdAt:
|
||||||
|
* type: string
|
||||||
|
* format: date-time
|
||||||
|
* expiresAt:
|
||||||
|
* type: string
|
||||||
|
* format: date-time
|
||||||
|
* ipAddress:
|
||||||
|
* type: string
|
||||||
|
* nullable: true
|
||||||
|
* userAgent:
|
||||||
|
* type: string
|
||||||
|
* nullable: true
|
||||||
|
* AdminUser:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* id:
|
||||||
|
* type: string
|
||||||
|
* email:
|
||||||
|
* type: string
|
||||||
|
* name:
|
||||||
|
* type: string
|
||||||
|
* apps:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* $ref: '#/components/schemas/AdminApp'
|
||||||
|
* status:
|
||||||
|
* type: string
|
||||||
|
* enum: [aktiv, deaktiviert]
|
||||||
|
* description: Derived - there is no status column.
|
||||||
|
* createdAt:
|
||||||
|
* type: string
|
||||||
|
* format: date-time
|
||||||
|
* lastSignInAt:
|
||||||
|
* type: string
|
||||||
|
* format: date-time
|
||||||
|
* nullable: true
|
||||||
|
* description: Newest session's creation time; null once every session has expired.
|
||||||
|
* AdminUserDetail:
|
||||||
|
* allOf:
|
||||||
|
* - $ref: '#/components/schemas/AdminUser'
|
||||||
|
* - type: object
|
||||||
|
* properties:
|
||||||
|
* sessions:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* $ref: '#/components/schemas/AdminUserSession'
|
||||||
|
* passkeyCount:
|
||||||
|
* type: integer
|
||||||
|
* AdminInvitation:
|
||||||
|
* type: object
|
||||||
|
* description: An open invitation. The token itself is never returned by any endpoint.
|
||||||
|
* properties:
|
||||||
|
* id:
|
||||||
|
* type: integer
|
||||||
|
* email:
|
||||||
|
* type: string
|
||||||
|
* name:
|
||||||
|
* type: string
|
||||||
|
* apps:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* $ref: '#/components/schemas/AdminApp'
|
||||||
|
* invitedBy:
|
||||||
|
* type: string
|
||||||
|
* nullable: true
|
||||||
|
* description: Null for the invitation created by the ADMIN_BOOTSTRAP_EMAIL bootstrap.
|
||||||
|
* createdAt:
|
||||||
|
* type: string
|
||||||
|
* format: date-time
|
||||||
|
* expiresAt:
|
||||||
|
* type: string
|
||||||
|
* format: date-time
|
||||||
|
*/
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import {MailService} from '../../common/common.mail.js';
|
||||||
|
import {ADMIN_APP_URL} from './admin.config.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two transactional mails the admin module sends. Both go out through the
|
||||||
|
* shared MailService (Salesforce relay, see common.mail.ts), which never throws
|
||||||
|
* on a delivery failure - the invitation row and the reset token are already
|
||||||
|
* committed by the time we get here.
|
||||||
|
*
|
||||||
|
* HTML plus a plain-text body: the text part is not a fallback afterthought,
|
||||||
|
* it is what allowlist-based receivers and text-only clients actually show.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const escapeHtml = (value: string): string => {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
};
|
||||||
|
|
||||||
|
// `heading` is escaped here; `paragraphs` are not, because callers pass markup
|
||||||
|
// (a <strong> around the expiry date) and escape their own interpolations.
|
||||||
|
const layout = (heading: string, paragraphs: string[], buttonLabel: string, buttonUrl: string): string => {
|
||||||
|
const body = paragraphs.map(p => `<p style="margin:0 0 16px;">${p}</p>`).join('');
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<body style="margin:0;padding:24px;background:#f5f5f4;font-family:Helvetica,Arial,sans-serif;color:#1c1917;">
|
||||||
|
<div style="max-width:520px;margin:0 auto;background:#ffffff;border-radius:8px;padding:32px;">
|
||||||
|
<h1 style="margin:0 0 24px;font-size:20px;">${escapeHtml(heading)}</h1>
|
||||||
|
${body}
|
||||||
|
<p style="margin:24px 0;">
|
||||||
|
<a href="${escapeHtml(buttonUrl)}" style="display:inline-block;background:#1c1917;color:#ffffff;text-decoration:none;padding:12px 20px;border-radius:6px;">${escapeHtml(buttonLabel)}</a>
|
||||||
|
</p>
|
||||||
|
<p style="margin:0;font-size:13px;color:#57534e;">Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:<br>
|
||||||
|
<span style="word-break:break-all;">${escapeHtml(buttonUrl)}</span></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invitation mail. The link carries the raw token in the query string; the
|
||||||
|
* admin app strips it from the URL as soon as it has read it (see the plan's
|
||||||
|
* §3b - the token must never reach an API access log or a Referer header).
|
||||||
|
*/
|
||||||
|
export const sendInvitationMail = async (
|
||||||
|
recipientAddress: string,
|
||||||
|
name: string,
|
||||||
|
token: string,
|
||||||
|
expiresAt: Date
|
||||||
|
): Promise<boolean> => {
|
||||||
|
const url = `${ADMIN_APP_URL}/accept-invite?token=${encodeURIComponent(token)}`;
|
||||||
|
const expiry = expiresAt.toLocaleDateString('de-DE', {day: '2-digit', month: '2-digit', year: 'numeric'});
|
||||||
|
const subject = 'Dein Zugang zu Nachklang';
|
||||||
|
|
||||||
|
const text = [
|
||||||
|
`Hallo ${name},`,
|
||||||
|
'',
|
||||||
|
'du wurdest eingeladen, ein Nachklang-Konto anzulegen. Über diesen Link vergibst du dein Passwort:',
|
||||||
|
'',
|
||||||
|
url,
|
||||||
|
'',
|
||||||
|
`Der Link ist bis zum ${expiry} gültig.`,
|
||||||
|
'',
|
||||||
|
'Wenn du damit nichts anfangen kannst, ignoriere diese E-Mail einfach.',
|
||||||
|
'',
|
||||||
|
'Viele Grüße',
|
||||||
|
'Nachklang e.V.'
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const html = layout(
|
||||||
|
`Hallo ${name},`,
|
||||||
|
[
|
||||||
|
'du wurdest eingeladen, ein Nachklang-Konto anzulegen. Über den Button vergibst du dein Passwort.',
|
||||||
|
`Der Link ist bis zum <strong>${escapeHtml(expiry)}</strong> gültig.`,
|
||||||
|
'Wenn du damit nichts anfangen kannst, ignoriere diese E-Mail einfach.'
|
||||||
|
],
|
||||||
|
'Konto einrichten',
|
||||||
|
url
|
||||||
|
);
|
||||||
|
|
||||||
|
return MailService.sendMail(recipientAddress, subject, text, {html});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Password reset. better-auth builds the URL (it embeds its own token and the
|
||||||
|
* redirectTo the admin app passed), so this only wraps it in our templates.
|
||||||
|
*/
|
||||||
|
export const sendPasswordResetMail = async (
|
||||||
|
recipientAddress: string,
|
||||||
|
name: string,
|
||||||
|
url: string
|
||||||
|
): Promise<boolean> => {
|
||||||
|
const subject = 'Passwort zurücksetzen';
|
||||||
|
|
||||||
|
const text = [
|
||||||
|
`Hallo ${name},`,
|
||||||
|
'',
|
||||||
|
'über diesen Link kannst du ein neues Passwort vergeben:',
|
||||||
|
'',
|
||||||
|
url,
|
||||||
|
'',
|
||||||
|
'Der Link ist eine Stunde gültig.',
|
||||||
|
'',
|
||||||
|
'Wenn du kein neues Passwort angefordert hast, ist nichts passiert - ignoriere diese E-Mail.',
|
||||||
|
'',
|
||||||
|
'Viele Grüße',
|
||||||
|
'Nachklang e.V.'
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const html = layout(
|
||||||
|
`Hallo ${name},`,
|
||||||
|
[
|
||||||
|
'über den Button kannst du ein neues Passwort vergeben.',
|
||||||
|
'Der Link ist eine Stunde gültig.',
|
||||||
|
'Wenn du kein neues Passwort angefordert hast, ist nichts passiert - ignoriere diese E-Mail.'
|
||||||
|
],
|
||||||
|
'Neues Passwort vergeben',
|
||||||
|
url
|
||||||
|
);
|
||||||
|
|
||||||
|
return MailService.sendMail(recipientAddress, subject, text, {html});
|
||||||
|
};
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import {fromNodeHeaders} from 'better-auth/node';
|
||||||
|
import {auth} from './admin.auth.js';
|
||||||
|
import * as UsersService from './users/users.admin.service.js';
|
||||||
|
import {AppName, AppPermission, AppRole} from './admin.schema.js';
|
||||||
|
import {sendServerError} from './admin.errors.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one authenticator for every admin area in this API. It replaces
|
||||||
|
* feedback.auth.ts and tickets.auth.ts, which each re-implemented the same
|
||||||
|
* header-session check against the calendar users table.
|
||||||
|
*
|
||||||
|
* Two things are checked on every request, deliberately without any caching:
|
||||||
|
* that the session cookie is valid (better-auth), and that the user is still
|
||||||
|
* enabled and still holds the permission for this app (one database query).
|
||||||
|
* That is what makes "disable a user" and "revoke a session" take effect
|
||||||
|
* immediately rather than whenever a cached session happens to expire.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// The shape the feedback and tickets services already expect - unchanged, so
|
||||||
|
// nothing downstream of the authenticator needs to know this file replaced
|
||||||
|
// their own.
|
||||||
|
export interface AdminIdentity {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminAccess extends AdminIdentity {
|
||||||
|
disabled: boolean;
|
||||||
|
/** Every (app, role) grant. */
|
||||||
|
permissions: AppPermission[];
|
||||||
|
/** The distinct apps those grants cover. */
|
||||||
|
apps: AppName[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const unauthorized = (res: express.Response): void => {
|
||||||
|
res.status(401).send({status: 'UNAUTHORIZED', message: 'Anmeldung erforderlich.'});
|
||||||
|
};
|
||||||
|
|
||||||
|
const forbidden = (res: express.Response, message: string): void => {
|
||||||
|
res.status(403).send({status: 'FORBIDDEN', message});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the session cookie to a user with their permissions, or null.
|
||||||
|
* One database query, no cache. Throws only on infrastructure errors.
|
||||||
|
*/
|
||||||
|
export const resolveAccess = async (req: express.Request): Promise<AdminAccess | null> => {
|
||||||
|
const session = await auth.api.getSession({headers: fromNodeHeaders(req.headers)});
|
||||||
|
if (!session?.user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = await UsersService.loadAccess(session.user.id);
|
||||||
|
if (!access) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: access.id,
|
||||||
|
email: access.email,
|
||||||
|
displayName: access.displayName,
|
||||||
|
disabled: access.disabled,
|
||||||
|
permissions: access.permissions,
|
||||||
|
apps: access.apps
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Any signed-in account, no permission required. Used by /admin/me and the
|
||||||
|
* account-management routes: a user with no app permissions at all still has
|
||||||
|
* to be able to see that, and to manage their own password and passkeys.
|
||||||
|
*
|
||||||
|
* The disabled check is not redundant with the session-create hook: that hook
|
||||||
|
* stops a disabled user from signing in, this stops one who was disabled while
|
||||||
|
* holding a live cookie. Disabling revokes sessions, so the window is small -
|
||||||
|
* but "small" is not "closed".
|
||||||
|
*/
|
||||||
|
export const requireSignedIn: express.RequestHandler = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const access = await resolveAccess(req);
|
||||||
|
if (!access) {
|
||||||
|
unauthorized(res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (access.disabled) {
|
||||||
|
forbidden(res, 'Dieses Konto ist deaktiviert.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.locals.admin = access;
|
||||||
|
next();
|
||||||
|
} catch (e: any) {
|
||||||
|
sendServerError(res, e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The gate every admin area sits behind. `requireAppAccess('feedback')` is
|
||||||
|
* what feedback.auth.ts's requireAdminAuth used to be, except that it now
|
||||||
|
* answers 403 for a signed-in user without that app's permission instead of
|
||||||
|
* letting any activated @nachklang.art account in.
|
||||||
|
*
|
||||||
|
* The optional second argument narrows it to one role within the app. Nothing
|
||||||
|
* passes it today - every app has exactly the `access` role - but it is the
|
||||||
|
* seam a finer permission arrives through.
|
||||||
|
*/
|
||||||
|
export const requireAppAccess = (app: AppName, role?: AppRole): express.RequestHandler => {
|
||||||
|
return async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const access = await resolveAccess(req);
|
||||||
|
if (!access) {
|
||||||
|
unauthorized(res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (access.disabled) {
|
||||||
|
forbidden(res, 'Dieses Konto ist deaktiviert.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without a role this asks "may they open this app at all?", which is
|
||||||
|
// any grant on it. With one it asks for that specific grant - the hook
|
||||||
|
// a finer permission plugs into, without touching existing call sites.
|
||||||
|
const allowed = role === undefined
|
||||||
|
? access.apps.includes(app)
|
||||||
|
: access.permissions.some(permission => permission.app === app && permission.role === role);
|
||||||
|
|
||||||
|
if (!allowed) {
|
||||||
|
forbidden(res, 'Für diesen Bereich fehlt dir die Berechtigung.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.locals.admin = access;
|
||||||
|
next();
|
||||||
|
} catch (e: any) {
|
||||||
|
sendServerError(res, e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import {Generated} from 'kysely';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kysely table types for `nachklang_admin`. Only the columns this module
|
||||||
|
* actually reads or writes are declared - better-auth owns the full shape of
|
||||||
|
* its own tables and does not use this interface, it is here so the users and
|
||||||
|
* invitations services get compile-time checking instead of `any`.
|
||||||
|
*
|
||||||
|
* Column names follow better-auth's default "camel" casing for its tables
|
||||||
|
* (`emailVerified`, `userId`, `createdAt`); our own two tables use the
|
||||||
|
* snake_case convention of the rest of the repo's SQL.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type AppName = 'calendar' | 'feedback' | 'tickets' | 'admin';
|
||||||
|
|
||||||
|
export const APP_NAMES: AppName[] = ['calendar', 'feedback', 'tickets', 'admin'];
|
||||||
|
|
||||||
|
export const isAppName = (value: unknown): value is AppName => {
|
||||||
|
return typeof value === 'string' && (APP_NAMES as string[]).includes(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A permission is (app, role), not just an app. Today every app has exactly one
|
||||||
|
* role - `access`, "may use this app at all" - so the model looks like a plain
|
||||||
|
* list of apps and the UI renders one checkbox each. It is written this way
|
||||||
|
* anyway because the alternative gets expensive fast: `user_app_permissions`
|
||||||
|
* has primary key (user_id, app, role), so a user can hold several roles for
|
||||||
|
* the same app, and adding one later is a string in APP_ROLES plus rows - never
|
||||||
|
* a schema migration and never a change to the shape on the wire.
|
||||||
|
*
|
||||||
|
* Note the role is deliberately NOT called `admin`, which is what the column
|
||||||
|
* defaulted to before: on a `tickets` row that reads as "tickets administrator"
|
||||||
|
* when it only ever meant "has access", and once real roles exist there would
|
||||||
|
* be no way to tell the two apart.
|
||||||
|
*/
|
||||||
|
export const ACCESS_ROLE = 'access';
|
||||||
|
|
||||||
|
export type AppRole = string;
|
||||||
|
|
||||||
|
/** Every role that exists, per app, in display order. Extend to add one. */
|
||||||
|
export const APP_ROLES: Record<AppName, readonly AppRole[]> = {
|
||||||
|
calendar: [ACCESS_ROLE],
|
||||||
|
feedback: [ACCESS_ROLE],
|
||||||
|
tickets: [ACCESS_ROLE],
|
||||||
|
admin: [ACCESS_ROLE]
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface AppPermission {
|
||||||
|
app: AppName;
|
||||||
|
role: AppRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isAppRole = (app: AppName, role: unknown): role is AppRole => {
|
||||||
|
return typeof role === 'string' && APP_ROLES[app].includes(role);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isAppPermission = (value: unknown): value is AppPermission => {
|
||||||
|
if (typeof value !== 'object' || value === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const candidate = value as {app?: unknown; role?: unknown};
|
||||||
|
return isAppName(candidate.app) && isAppRole(candidate.app, candidate.role);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalises whatever a caller sent into a valid, duplicate-free permission
|
||||||
|
* list. Accepts the richer `{app, role}` form and the plain `AppName` form,
|
||||||
|
* because `{apps: ['tickets']}` is still what the older callers send and it
|
||||||
|
* means exactly "tickets at the access role".
|
||||||
|
*/
|
||||||
|
export const toPermissions = (value: unknown): AppPermission[] | null => {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissions: AppPermission[] = [];
|
||||||
|
for (const entry of value) {
|
||||||
|
if (isAppName(entry)) {
|
||||||
|
permissions.push({app: entry, role: ACCESS_ROLE});
|
||||||
|
} else if (isAppPermission(entry)) {
|
||||||
|
permissions.push({app: entry.app, role: entry.role});
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return permissions.filter(permission => {
|
||||||
|
const key = `${permission.app}:${permission.role}`;
|
||||||
|
if (seen.has(key)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The distinct apps a permission list grants any access to. */
|
||||||
|
export const appsOf = (permissions: AppPermission[]): AppName[] => {
|
||||||
|
return APP_NAMES.filter(app => permissions.some(permission => permission.app === app));
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface UserTable {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
emailVerified: boolean;
|
||||||
|
image: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
// Added via better-auth `additionalFields` (see admin.auth.ts).
|
||||||
|
disabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionTable {
|
||||||
|
id: string;
|
||||||
|
token: string;
|
||||||
|
userId: string;
|
||||||
|
expiresAt: Date;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
ipAddress: string | null;
|
||||||
|
userAgent: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PasskeyTable {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
userId: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserAppPermissionTable {
|
||||||
|
user_id: string;
|
||||||
|
app: AppName;
|
||||||
|
role: AppRole;
|
||||||
|
granted_by: string | null;
|
||||||
|
granted_at: Generated<Date>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvitationTable {
|
||||||
|
// AUTO_INCREMENT: present on select, never supplied on insert.
|
||||||
|
id: Generated<number>;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
token_hash: string;
|
||||||
|
// JSON column holding an AppPermission[]. Older rows may hold a plain
|
||||||
|
// AppName[]; `parsePermissions` reads both.
|
||||||
|
permissions: string;
|
||||||
|
invited_by: string | null;
|
||||||
|
created_at: Generated<Date>;
|
||||||
|
expires_at: Date;
|
||||||
|
accepted_at: Date | null;
|
||||||
|
revoked_at: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerificationTable {
|
||||||
|
id: string;
|
||||||
|
identifier: string;
|
||||||
|
value: string;
|
||||||
|
expiresAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RateLimitTable {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
count: number;
|
||||||
|
lastRequest: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminDatabase {
|
||||||
|
user: UserTable;
|
||||||
|
session: SessionTable;
|
||||||
|
passkey: PasskeyTable;
|
||||||
|
verification: VerificationTable;
|
||||||
|
rateLimit: RateLimitTable;
|
||||||
|
user_app_permissions: UserAppPermissionTable;
|
||||||
|
invitations: InvitationTable;
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import * as z from 'zod';
|
||||||
|
import {APIError, createAuthEndpoint} from 'better-auth/api';
|
||||||
|
import {setSessionCookie} from 'better-auth/cookies';
|
||||||
|
import {createLocalAccountIssuer} from 'better-auth/db';
|
||||||
|
import {runWithTransaction} from '@better-auth/core/context';
|
||||||
|
import type {BetterAuthPlugin} from 'better-auth';
|
||||||
|
import * as InvitationsService from './invitations.service.js';
|
||||||
|
import * as UsersService from '../users/users.admin.service.js';
|
||||||
|
import logger from '../../../middleware/logger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two public endpoints of the invitation flow, implemented as a better-auth
|
||||||
|
* plugin rather than as plain Express routes on the admin router.
|
||||||
|
*
|
||||||
|
* Why a plugin: `emailAndPassword.disableSignUp` is on, which makes
|
||||||
|
* `auth.api.signUpEmail` refuse - deliberately, there is no public sign-up.
|
||||||
|
* Accepting an invitation still has to create a user, hash a password, write a
|
||||||
|
* credential account and sign the person in. All four are better-auth
|
||||||
|
* internals reachable only from inside an endpoint's context, so this is where
|
||||||
|
* account creation lives. Nothing outside this file may create users.
|
||||||
|
*
|
||||||
|
* Because they are plugin endpoints they sit under better-auth's basePath:
|
||||||
|
* POST /admin/auth/invitations/preview
|
||||||
|
* POST /admin/auth/invitations/accept
|
||||||
|
*
|
||||||
|
* The token travels in the request *body*, never in the path or query, so it
|
||||||
|
* cannot end up in an access log or a Referer header.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Unknown, expired, revoked and already-accepted tokens must be
|
||||||
|
// indistinguishable to the caller: one shared error, one shared message.
|
||||||
|
const invalidToken = (): APIError => {
|
||||||
|
return new APIError('BAD_REQUEST', {
|
||||||
|
code: 'INVALID_INVITATION',
|
||||||
|
message: 'Diese Einladung ist nicht mehr gültig.'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const invitationsPlugin = () => {
|
||||||
|
return {
|
||||||
|
id: 'nachklang-invitations',
|
||||||
|
endpoints: {
|
||||||
|
/**
|
||||||
|
* Lets the accept-invite page show who the invitation is for before
|
||||||
|
* asking for a password. Returns only name and email - never the
|
||||||
|
* granted apps, which is information the invitee has no need for
|
||||||
|
* and an attacker with a stolen link should not get either.
|
||||||
|
*/
|
||||||
|
previewInvitation: createAuthEndpoint(
|
||||||
|
'/invitations/preview',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: z.object({
|
||||||
|
token: z.string().min(1)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async ctx => {
|
||||||
|
const invitation = await InvitationsService.findByToken(ctx.body.token);
|
||||||
|
if (!invitation) {
|
||||||
|
throw invalidToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.json({email: invitation.email, name: invitation.name});
|
||||||
|
}
|
||||||
|
),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redeems the invitation: creates the user, its credential account
|
||||||
|
* and its permissions, then signs the person straight in so they
|
||||||
|
* land in the app instead of on a login form.
|
||||||
|
*/
|
||||||
|
acceptInvitation: createAuthEndpoint(
|
||||||
|
'/invitations/accept',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: z.object({
|
||||||
|
token: z.string().min(1),
|
||||||
|
password: z.string().min(8).max(128)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async ctx => {
|
||||||
|
const invitation = await InvitationsService.findByToken(ctx.body.token);
|
||||||
|
if (!invitation) {
|
||||||
|
throw invalidToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
// An account for this address already exists: the right fix
|
||||||
|
// is for an admin to grant permissions on the existing user,
|
||||||
|
// not to create a second one. Reported distinctly because
|
||||||
|
// the person holds a valid token - this leaks nothing they
|
||||||
|
// do not already know about their own mailbox.
|
||||||
|
const existing = await UsersService.findUserByEmail(invitation.email);
|
||||||
|
if (existing) {
|
||||||
|
throw new APIError('CONFLICT', {
|
||||||
|
code: 'USER_ALREADY_EXISTS',
|
||||||
|
message: 'Für diese E-Mail-Adresse gibt es bereits ein Konto. Melde dich stattdessen an.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claim the invitation before creating anything. The update
|
||||||
|
// is conditional on it still being open, so two concurrent
|
||||||
|
// submissions of the same link cannot both end up creating a
|
||||||
|
// user.
|
||||||
|
const claimed = await InvitationsService.markAccepted(invitation.id);
|
||||||
|
if (!claimed) {
|
||||||
|
throw invalidToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The user and its credential account go in one better-auth
|
||||||
|
// transaction, the way better-auth's own sign-up route does
|
||||||
|
// it: a half-created account with no password is not
|
||||||
|
// recoverable through any route this API exposes.
|
||||||
|
//
|
||||||
|
// It cannot cover everything, though. `user_app_permissions`
|
||||||
|
// and `invitations` are written through this module's own
|
||||||
|
// Kysely pool, which is a different connection, so no single
|
||||||
|
// transaction spans both. What follows is therefore
|
||||||
|
// compensated by hand rather than rolled back.
|
||||||
|
let user: Awaited<ReturnType<typeof ctx.context.internalAdapter.createUser>> | null = null;
|
||||||
|
try {
|
||||||
|
user = await runWithTransaction(ctx.context.adapter, async () => {
|
||||||
|
const created = await ctx.context.internalAdapter.createUser(
|
||||||
|
{
|
||||||
|
email: invitation.email,
|
||||||
|
name: invitation.name,
|
||||||
|
// Accepting a link sent to that mailbox *is*
|
||||||
|
// the proof of address ownership, so there is
|
||||||
|
// no separate verification mail (plan
|
||||||
|
// decision 15).
|
||||||
|
emailVerified: true,
|
||||||
|
disabled: false
|
||||||
|
},
|
||||||
|
{method: 'email-password'}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Same call better-auth's own sign-up route makes,
|
||||||
|
// down to the synthetic issuer - a credential account
|
||||||
|
// written any other way is not found on sign-in.
|
||||||
|
await ctx.context.internalAdapter.linkAccount({
|
||||||
|
userId: created.id,
|
||||||
|
providerId: 'credential',
|
||||||
|
issuer: createLocalAccountIssuer('credential'),
|
||||||
|
accountId: created.id,
|
||||||
|
password: await ctx.context.password.hash(ctx.body.password)
|
||||||
|
});
|
||||||
|
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
|
||||||
|
await UsersService.setPermissions(user.id, invitation.permissions, null);
|
||||||
|
|
||||||
|
const session = await ctx.context.internalAdapter.createSession(user.id);
|
||||||
|
await setSessionCookie(ctx, {session, user});
|
||||||
|
|
||||||
|
return ctx.json({
|
||||||
|
user: {id: user.id, email: user.email, name: user.name}
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
// Undo what committed, so the invitee can use their link
|
||||||
|
// again instead of being stranded with a burnt token, an
|
||||||
|
// account they cannot sign into, and an admin who cannot
|
||||||
|
// re-invite them (the create route 409s on an existing
|
||||||
|
// user, and there is no delete route by design).
|
||||||
|
//
|
||||||
|
// Deleting the user is safe here: it was created moments
|
||||||
|
// ago in this request, and acceptance already established
|
||||||
|
// that no account for this address existed before.
|
||||||
|
try {
|
||||||
|
if (user) {
|
||||||
|
await ctx.context.internalAdapter.deleteUser(user.id);
|
||||||
|
}
|
||||||
|
await InvitationsService.unmarkAccepted(invitation.id);
|
||||||
|
} catch (compensationError: any) {
|
||||||
|
// Now the state really is inconsistent, and only a
|
||||||
|
// human can sort it out. Say so loudly and precisely.
|
||||||
|
logger.error('Admin: invitation acceptance failed AND its rollback failed', {
|
||||||
|
invitationId: invitation.id,
|
||||||
|
email: invitation.email,
|
||||||
|
userId: user?.id,
|
||||||
|
detail: e?.message,
|
||||||
|
compensationDetail: compensationError?.message
|
||||||
|
});
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error('Admin: invitation acceptance failed and was rolled back', {
|
||||||
|
invitationId: invitation.id,
|
||||||
|
detail: e?.message
|
||||||
|
});
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} satisfies BetterAuthPlugin;
|
||||||
|
};
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import express, {Request, Response} from 'express';
|
||||||
|
import * as InvitationsService from './invitations.service.js';
|
||||||
|
import * as UsersService from '../users/users.admin.service.js';
|
||||||
|
import {toPermissions} from '../admin.schema.js';
|
||||||
|
import {sendInvitationMail} from '../admin.mail.js';
|
||||||
|
import {ADMIN_APP_URL, LOG_INVITE_LINKS} from '../admin.config.js';
|
||||||
|
import {sendServerError} from '../admin.errors.js';
|
||||||
|
import logger from '../../../middleware/logger.js';
|
||||||
|
|
||||||
|
export const invitationsRouter = express.Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The admin-facing half of invitations (create, resend, revoke). The public
|
||||||
|
* half - preview and accept - lives in invitations.plugin.ts, because
|
||||||
|
* redeeming an invitation has to create a user through better-auth internals.
|
||||||
|
*
|
||||||
|
* Mounted behind requireAppAccess('admin').
|
||||||
|
*/
|
||||||
|
|
||||||
|
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* With the mail relay off (the normal local setup) the invitation mail never
|
||||||
|
* arrives, and only the token's hash is stored, so there would be no way to
|
||||||
|
* walk through the accept flow. Logging the link closes that.
|
||||||
|
*
|
||||||
|
* Gated on an explicit opt-in rather than on NODE_ENV: the link is a live
|
||||||
|
* account-creation credential, and "not production" is too weak a condition to
|
||||||
|
* hang that on. See LOG_INVITE_LINKS in admin.config.ts.
|
||||||
|
*/
|
||||||
|
const logInviteLinkInDev = (token: string): void => {
|
||||||
|
if (LOG_INVITE_LINKS) {
|
||||||
|
logger.info(`Admin: invitation link ${ADMIN_APP_URL}/accept-invite?token=${encodeURIComponent(token)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /admin/invitations:
|
||||||
|
* get:
|
||||||
|
* summary: List open (unaccepted, unrevoked, unexpired) invitations
|
||||||
|
* tags: [admin]
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Success
|
||||||
|
*/
|
||||||
|
invitationsRouter.get('/', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
res.status(200).send(await InvitationsService.listOpenInvitations());
|
||||||
|
} catch (e: any) {
|
||||||
|
sendServerError(res, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /admin/invitations:
|
||||||
|
* post:
|
||||||
|
* summary: Invite someone and mail them an acceptance link
|
||||||
|
* tags: [admin]
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* required: [email, name, permissions]
|
||||||
|
* properties:
|
||||||
|
* email:
|
||||||
|
* type: string
|
||||||
|
* name:
|
||||||
|
* type: string
|
||||||
|
* permissions:
|
||||||
|
* type: array
|
||||||
|
* description: >
|
||||||
|
* One entry per (app, role). A plain array of app names is
|
||||||
|
* accepted too and means the same at the `access` role.
|
||||||
|
* items:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* app:
|
||||||
|
* type: string
|
||||||
|
* role:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 201:
|
||||||
|
* description: Invitation created and mailed
|
||||||
|
* 400:
|
||||||
|
* description: Invalid input
|
||||||
|
* 409:
|
||||||
|
* description: A user with this address already exists
|
||||||
|
*/
|
||||||
|
invitationsRouter.post('/', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const email = String(req.body?.email || '').trim().toLowerCase();
|
||||||
|
const name = String(req.body?.name || '').trim();
|
||||||
|
|
||||||
|
// Same two accepted shapes as PUT /admin/users/:id/permissions.
|
||||||
|
const permissions = toPermissions(req.body?.permissions ?? req.body?.apps);
|
||||||
|
|
||||||
|
if (!EMAIL_PATTERN.test(email) || name.length === 0 || !permissions) {
|
||||||
|
res.status(400).send({
|
||||||
|
status: 'BAD_REQUEST',
|
||||||
|
message: 'E-Mail, Name und Berechtigungen sind erforderlich.'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inviting someone who already has an account would strand them on an
|
||||||
|
// accept page that can only fail. Granting permissions on the existing
|
||||||
|
// user is the operation they actually want.
|
||||||
|
if (await UsersService.findUserByEmail(email)) {
|
||||||
|
res.status(409).send({
|
||||||
|
status: 'CONFLICT',
|
||||||
|
message: 'Für diese E-Mail-Adresse gibt es bereits ein Konto. Vergib dort die Berechtigungen.'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const invitation = await InvitationsService.createInvitation(
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
permissions,
|
||||||
|
res.locals.admin.id
|
||||||
|
);
|
||||||
|
|
||||||
|
const mailed = await sendInvitationMail(email, name, invitation.token, invitation.expiresAt);
|
||||||
|
if (!mailed) {
|
||||||
|
logger.warn('Admin: invitation created but the mail was not accepted', {email});
|
||||||
|
}
|
||||||
|
logInviteLinkInDev(invitation.token);
|
||||||
|
|
||||||
|
res.status(201).send({id: invitation.id, email, name, expiresAt: invitation.expiresAt, mailed});
|
||||||
|
} catch (e: any) {
|
||||||
|
sendServerError(res, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /admin/invitations/{invitationId}/resend:
|
||||||
|
* post:
|
||||||
|
* summary: Issue a new token for an open invitation and mail it again
|
||||||
|
* description: The previous link stops working.
|
||||||
|
* tags: [admin]
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Resent
|
||||||
|
* 404:
|
||||||
|
* description: No open invitation with this id
|
||||||
|
*/
|
||||||
|
invitationsRouter.post('/:invitationId/resend', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const invitationId = parseInt(req.params.invitationId, 10);
|
||||||
|
if (Number.isNaN(invitationId)) {
|
||||||
|
res.status(400).send({status: 'BAD_REQUEST', message: 'Ungültige Einladungs-ID.'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resent = await InvitationsService.resendInvitation(invitationId);
|
||||||
|
if (!resent) {
|
||||||
|
res.status(404).send({status: 'NOT_FOUND', message: 'Einladung nicht gefunden.'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mailed = await sendInvitationMail(resent.email, resent.name, resent.token, resent.expiresAt);
|
||||||
|
if (!mailed) {
|
||||||
|
logger.warn('Admin: invitation resent but the mail was not accepted', {email: resent.email});
|
||||||
|
}
|
||||||
|
logInviteLinkInDev(resent.token);
|
||||||
|
|
||||||
|
res.status(200).send({id: invitationId, expiresAt: resent.expiresAt, mailed});
|
||||||
|
} catch (e: any) {
|
||||||
|
sendServerError(res, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /admin/invitations/{invitationId}:
|
||||||
|
* delete:
|
||||||
|
* summary: Revoke an open invitation
|
||||||
|
* tags: [admin]
|
||||||
|
* responses:
|
||||||
|
* 204:
|
||||||
|
* description: Revoked
|
||||||
|
* 404:
|
||||||
|
* description: No open invitation with this id
|
||||||
|
*/
|
||||||
|
invitationsRouter.delete('/:invitationId', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const invitationId = parseInt(req.params.invitationId, 10);
|
||||||
|
if (Number.isNaN(invitationId)) {
|
||||||
|
res.status(400).send({status: 'BAD_REQUEST', message: 'Ungültige Einladungs-ID.'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const revoked = await InvitationsService.revokeInvitation(invitationId);
|
||||||
|
if (!revoked) {
|
||||||
|
res.status(404).send({status: 'NOT_FOUND', message: 'Einladung nicht gefunden.'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (e: any) {
|
||||||
|
sendServerError(res, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import * as crypto from 'crypto';
|
||||||
|
import {NachklangAdminDB} from '../Admin.db.js';
|
||||||
|
import {AppPermission, isAppName, isAppPermission, ACCESS_ROLE} from '../admin.schema.js';
|
||||||
|
|
||||||
|
const db = NachklangAdminDB.db;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invitations are this API's only path to a new account (there is no public
|
||||||
|
* sign-up). The raw token exists exactly twice: in the mail we send and in the
|
||||||
|
* request body when it comes back. What we store is its SHA-256 hash, so a
|
||||||
|
* database dump does not hand out account access - the same reasoning as the
|
||||||
|
* calendar module's session key hashing, and the reason lookups go through
|
||||||
|
* `findByToken` rather than any query on a plaintext column.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const INVITATION_TTL_DAYS = 7;
|
||||||
|
|
||||||
|
export interface OpenInvitation {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
permissions: AppPermission[];
|
||||||
|
invitedBy: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
expiresAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AcceptableInvitation {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
permissions: AppPermission[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashToken = (token: string): string => {
|
||||||
|
return crypto.createHash('sha256').update(token).digest('hex');
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateToken = (): string => {
|
||||||
|
// 32 bytes, url-safe: it travels in a mail link's query string.
|
||||||
|
return crypto.randomBytes(32).toString('base64url');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the stored permission list. Two shapes are accepted: the current
|
||||||
|
* `[{app, role}]`, and a bare `['tickets', ...]` from before roles existed,
|
||||||
|
* which means the same thing at the `access` role. Invitations live for seven
|
||||||
|
* days, so a deploy that changes the shape has in-flight rows in the old one -
|
||||||
|
* tolerating both is what stops those invitees from being stranded.
|
||||||
|
*
|
||||||
|
* mysql2 hands back a JSON column already parsed; a driver or column-type
|
||||||
|
* change that turns it into a string must not break the read path either.
|
||||||
|
*/
|
||||||
|
const parsePermissions = (value: unknown): AppPermission[] => {
|
||||||
|
const raw = typeof value === 'string' ? JSON.parse(value) : value;
|
||||||
|
if (!Array.isArray(raw)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw.flatMap((entry): AppPermission[] => {
|
||||||
|
if (isAppName(entry)) {
|
||||||
|
return [{app: entry, role: ACCESS_ROLE}];
|
||||||
|
}
|
||||||
|
return isAppPermission(entry) ? [{app: entry.app, role: entry.role}] : [];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const expiryFromNow = (): Date => {
|
||||||
|
return new Date(Date.now() + INVITATION_TTL_DAYS * 24 * 60 * 60 * 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an invitation and returns the raw token for the mail. Any earlier
|
||||||
|
* open invitation for the same address is revoked first: two valid links for
|
||||||
|
* one mailbox is a needless second live credential, and "resend" would
|
||||||
|
* otherwise quietly accumulate them.
|
||||||
|
*/
|
||||||
|
export const createInvitation = async (
|
||||||
|
email: string,
|
||||||
|
name: string,
|
||||||
|
permissions: AppPermission[],
|
||||||
|
invitedBy: string | null
|
||||||
|
): Promise<{id: number; token: string; expiresAt: Date}> => {
|
||||||
|
const token = generateToken();
|
||||||
|
const expiresAt = expiryFromNow();
|
||||||
|
const valid = permissions.filter(isAppPermission);
|
||||||
|
|
||||||
|
const id = await db.transaction().execute(async trx => {
|
||||||
|
await trx
|
||||||
|
.updateTable('invitations')
|
||||||
|
.set({revoked_at: new Date()})
|
||||||
|
.where('email', '=', email)
|
||||||
|
.where('accepted_at', 'is', null)
|
||||||
|
.where('revoked_at', 'is', null)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
const result = await trx
|
||||||
|
.insertInto('invitations')
|
||||||
|
.values({
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
token_hash: hashToken(token),
|
||||||
|
permissions: JSON.stringify(valid),
|
||||||
|
invited_by: invitedBy,
|
||||||
|
created_at: new Date(),
|
||||||
|
expires_at: expiresAt
|
||||||
|
})
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
return Number(result.insertId);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {id, token, expiresAt};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Looks up a still-usable invitation by raw token. Callers must not
|
||||||
|
* distinguish "unknown", "expired", "revoked" and "already accepted" to the
|
||||||
|
* client: all four answer with the same shape, so a stranger cannot probe which
|
||||||
|
* tokens ever existed.
|
||||||
|
*/
|
||||||
|
export const findByToken = async (token: string): Promise<AcceptableInvitation | null> => {
|
||||||
|
const row = await db
|
||||||
|
.selectFrom('invitations')
|
||||||
|
.select(['id', 'email', 'name', 'permissions'])
|
||||||
|
.where('token_hash', '=', hashToken(token))
|
||||||
|
.where('accepted_at', 'is', null)
|
||||||
|
.where('revoked_at', 'is', null)
|
||||||
|
.where('expires_at', '>', new Date())
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {id: row.id, email: row.email, name: row.name, permissions: parsePermissions(row.permissions)};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Marks the invitation accepted. Conditional on it still being open so two
|
||||||
|
* concurrent accepts of the same link cannot both create an account. */
|
||||||
|
export const markAccepted = async (invitationId: number): Promise<boolean> => {
|
||||||
|
const result = await db
|
||||||
|
.updateTable('invitations')
|
||||||
|
.set({accepted_at: new Date()})
|
||||||
|
.where('id', '=', invitationId)
|
||||||
|
.where('accepted_at', 'is', null)
|
||||||
|
.where('revoked_at', 'is', null)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
return Number(result.numUpdatedRows) > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverses markAccepted. Used only to compensate a failed acceptance: the user
|
||||||
|
* could not be created, so the link must become usable again rather than
|
||||||
|
* stranding the invitee with a burnt token and no account.
|
||||||
|
*/
|
||||||
|
export const unmarkAccepted = async (invitationId: number): Promise<void> => {
|
||||||
|
await db
|
||||||
|
.updateTable('invitations')
|
||||||
|
.set({accepted_at: null})
|
||||||
|
.where('id', '=', invitationId)
|
||||||
|
.execute();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listOpenInvitations = async (): Promise<OpenInvitation[]> => {
|
||||||
|
const rows = await db
|
||||||
|
.selectFrom('invitations')
|
||||||
|
.select(['id', 'email', 'name', 'permissions', 'invited_by', 'created_at', 'expires_at'])
|
||||||
|
.where('accepted_at', 'is', null)
|
||||||
|
.where('revoked_at', 'is', null)
|
||||||
|
.where('expires_at', '>', new Date())
|
||||||
|
.orderBy('created_at', 'desc')
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return rows.map(row => ({
|
||||||
|
id: row.id,
|
||||||
|
email: row.email,
|
||||||
|
name: row.name,
|
||||||
|
permissions: parsePermissions(row.permissions),
|
||||||
|
invitedBy: row.invited_by,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
expiresAt: row.expires_at
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getOpenInvitation = async (invitationId: number): Promise<OpenInvitation | null> => {
|
||||||
|
const all = await listOpenInvitations();
|
||||||
|
return all.find(invitation => invitation.id === invitationId) ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Resend issues a *new* token and expiry and invalidates the old one, rather
|
||||||
|
* than re-mailing the existing link: if the first mail leaked, resending it
|
||||||
|
* would extend the leak's lifetime. */
|
||||||
|
export const resendInvitation = async (
|
||||||
|
invitationId: number
|
||||||
|
): Promise<{token: string; email: string; name: string; expiresAt: Date} | null> => {
|
||||||
|
const invitation = await getOpenInvitation(invitationId);
|
||||||
|
if (!invitation) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = generateToken();
|
||||||
|
const expiresAt = expiryFromNow();
|
||||||
|
|
||||||
|
await db
|
||||||
|
.updateTable('invitations')
|
||||||
|
.set({token_hash: hashToken(token), expires_at: expiresAt, created_at: new Date()})
|
||||||
|
.where('id', '=', invitationId)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return {token, email: invitation.email, name: invitation.name, expiresAt};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const revokeInvitation = async (invitationId: number): Promise<boolean> => {
|
||||||
|
const result = await db
|
||||||
|
.updateTable('invitations')
|
||||||
|
.set({revoked_at: new Date()})
|
||||||
|
.where('id', '=', invitationId)
|
||||||
|
.where('accepted_at', 'is', null)
|
||||||
|
.where('revoked_at', 'is', null)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
return Number(result.numUpdatedRows) > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Used by the bootstrap to stay idempotent across restarts. */
|
||||||
|
export const hasOpenInvitationFor = async (email: string): Promise<boolean> => {
|
||||||
|
const row = await db
|
||||||
|
.selectFrom('invitations')
|
||||||
|
.select('id')
|
||||||
|
.where('email', '=', email)
|
||||||
|
.where('accepted_at', 'is', null)
|
||||||
|
.where('revoked_at', 'is', null)
|
||||||
|
.where('expires_at', '>', new Date())
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
return Boolean(row);
|
||||||
|
};
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import express, {Request, Response} from 'express';
|
||||||
|
import * as UsersService from './users.admin.service.js';
|
||||||
|
import {toPermissions} from '../admin.schema.js';
|
||||||
|
import {sendServerError} from '../admin.errors.js';
|
||||||
|
|
||||||
|
export const usersAdminRouter = express.Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User administration. Mounted behind requireAppAccess('admin'), so every
|
||||||
|
* handler here can assume res.locals.admin is an admin.
|
||||||
|
*
|
||||||
|
* The guards below exist because this API can lock its own operators out: the
|
||||||
|
* only way to grant a permission is through these routes, so an admin who
|
||||||
|
* removes the last `admin` permission leaves nobody who can put it back short
|
||||||
|
* of a manual SQL statement in production.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const conflict = (res: Response, message: string): void => {
|
||||||
|
res.status(409).send({status: 'CONFLICT', message});
|
||||||
|
};
|
||||||
|
|
||||||
|
const notFound = (res: Response): void => {
|
||||||
|
res.status(404).send({status: 'NOT_FOUND', message: 'Benutzer nicht gefunden.'});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /admin/users:
|
||||||
|
* get:
|
||||||
|
* summary: List all users with their app permissions and status
|
||||||
|
* tags: [admin]
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Success
|
||||||
|
* 401:
|
||||||
|
* description: Not signed in
|
||||||
|
* 403:
|
||||||
|
* description: Missing the admin permission
|
||||||
|
*/
|
||||||
|
usersAdminRouter.get('/', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
res.status(200).send(await UsersService.listUsers());
|
||||||
|
} catch (e: any) {
|
||||||
|
sendServerError(res, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /admin/users/{userId}:
|
||||||
|
* get:
|
||||||
|
* summary: One user with their active sessions and passkey count
|
||||||
|
* tags: [admin]
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: userId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Success
|
||||||
|
* 404:
|
||||||
|
* description: Unknown user
|
||||||
|
*/
|
||||||
|
usersAdminRouter.get('/:userId', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const detail = await UsersService.getUserDetail(req.params.userId);
|
||||||
|
if (!detail) {
|
||||||
|
notFound(res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(200).send(detail);
|
||||||
|
} catch (e: any) {
|
||||||
|
sendServerError(res, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /admin/users/{userId}/permissions:
|
||||||
|
* put:
|
||||||
|
* summary: Replace a user's app permissions
|
||||||
|
* description: Refuses to remove the caller's own admin permission or the last remaining active admin.
|
||||||
|
* tags: [admin]
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* permissions:
|
||||||
|
* type: array
|
||||||
|
* description: >
|
||||||
|
* One entry per (app, role). `access` is the only role today.
|
||||||
|
* A plain array of app names is also accepted and means the
|
||||||
|
* same at the `access` role.
|
||||||
|
* items:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* app:
|
||||||
|
* type: string
|
||||||
|
* enum: [calendar, feedback, tickets, admin]
|
||||||
|
* role:
|
||||||
|
* type: string
|
||||||
|
* enum: [access]
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Success
|
||||||
|
* 400:
|
||||||
|
* description: Invalid app or role
|
||||||
|
* 409:
|
||||||
|
* description: Would lock the last admin out
|
||||||
|
*/
|
||||||
|
usersAdminRouter.put('/:userId/permissions', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const userId = req.params.userId;
|
||||||
|
|
||||||
|
// `permissions: [{app, role}]` is the real shape; `apps: ['tickets']` is
|
||||||
|
// accepted as shorthand for the same thing at the `access` role, so a
|
||||||
|
// caller that predates roles keeps working.
|
||||||
|
const permissions = toPermissions(req.body?.permissions ?? req.body?.apps);
|
||||||
|
|
||||||
|
if (!permissions) {
|
||||||
|
res.status(400).send({status: 'BAD_REQUEST', message: 'Ungültige Berechtigungsliste.'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await UsersService.userExists(userId))) {
|
||||||
|
notFound(res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = await UsersService.loadAccess(userId);
|
||||||
|
const keepsAdmin = permissions.some(permission => permission.app === 'admin');
|
||||||
|
const losesAdmin = Boolean(target?.apps.includes('admin')) && !keepsAdmin;
|
||||||
|
|
||||||
|
// Self-lockout is checked here because it needs the caller's identity,
|
||||||
|
// which the service has no business knowing. The last-admin check is
|
||||||
|
// NOT done here: it has to be inside the write transaction to survive
|
||||||
|
// two admins acting at the same time (see setPermissionsGuarded).
|
||||||
|
if (losesAdmin && userId === res.locals.admin.id) {
|
||||||
|
conflict(res, 'Du kannst dir die Admin-Berechtigung nicht selbst entziehen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await UsersService.setPermissionsGuarded(userId, permissions, res.locals.admin.id);
|
||||||
|
if (result === 'last-admin') {
|
||||||
|
conflict(res, 'Die letzte Admin-Berechtigung kann nicht entzogen werden.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).send(await UsersService.getUserDetail(userId));
|
||||||
|
} catch (e: any) {
|
||||||
|
sendServerError(res, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /admin/users/{userId}/disable:
|
||||||
|
* post:
|
||||||
|
* summary: Disable a user and revoke all of their sessions
|
||||||
|
* tags: [admin]
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Success
|
||||||
|
* 409:
|
||||||
|
* description: Would disable the caller or the last admin
|
||||||
|
*/
|
||||||
|
usersAdminRouter.post('/:userId/disable', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const userId = req.params.userId;
|
||||||
|
|
||||||
|
if (userId === res.locals.admin.id) {
|
||||||
|
conflict(res, 'Du kannst dich nicht selbst deaktivieren.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = await UsersService.loadAccess(userId);
|
||||||
|
if (!target) {
|
||||||
|
notFound(res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await UsersService.disableUserGuarded(userId);
|
||||||
|
if (result === 'last-admin') {
|
||||||
|
conflict(res, 'Der letzte aktive Admin kann nicht deaktiviert werden.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).send(await UsersService.getUserDetail(userId));
|
||||||
|
} catch (e: any) {
|
||||||
|
sendServerError(res, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /admin/users/{userId}/enable:
|
||||||
|
* post:
|
||||||
|
* summary: Re-enable a disabled user
|
||||||
|
* description: Does not restore sessions - the user signs in again.
|
||||||
|
* tags: [admin]
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Success
|
||||||
|
*/
|
||||||
|
usersAdminRouter.post('/:userId/enable', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
if (!(await UsersService.userExists(req.params.userId))) {
|
||||||
|
notFound(res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await UsersService.enableUser(req.params.userId);
|
||||||
|
res.status(200).send(await UsersService.getUserDetail(req.params.userId));
|
||||||
|
} catch (e: any) {
|
||||||
|
sendServerError(res, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /admin/users/{userId}/sessions/{sessionId}:
|
||||||
|
* delete:
|
||||||
|
* summary: Revoke one session of a user
|
||||||
|
* tags: [admin]
|
||||||
|
* responses:
|
||||||
|
* 204:
|
||||||
|
* description: Revoked
|
||||||
|
* 404:
|
||||||
|
* description: Unknown session for this user
|
||||||
|
*/
|
||||||
|
usersAdminRouter.delete('/:userId/sessions/:sessionId', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const revoked = await UsersService.revokeSession(req.params.userId, req.params.sessionId);
|
||||||
|
if (!revoked) {
|
||||||
|
res.status(404).send({status: 'NOT_FOUND', message: 'Sitzung nicht gefunden.'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (e: any) {
|
||||||
|
sendServerError(res, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
import {Transaction} from 'kysely';
|
||||||
|
import {NachklangAdminDB} from '../Admin.db.js';
|
||||||
|
import {
|
||||||
|
AdminDatabase,
|
||||||
|
AppName,
|
||||||
|
AppPermission,
|
||||||
|
AppRole,
|
||||||
|
ACCESS_ROLE,
|
||||||
|
appsOf,
|
||||||
|
isAppName,
|
||||||
|
isAppRole
|
||||||
|
} from '../admin.schema.js';
|
||||||
|
|
||||||
|
const db = NachklangAdminDB.db;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything that reads or writes permissions. Two callers with very different
|
||||||
|
* hot-path requirements share this file: admin.middleware.ts runs
|
||||||
|
* `loadAccess` on *every* admin-authenticated request (which is why it is one
|
||||||
|
* query joining `user.disabled` and the permission rows - see the plan's
|
||||||
|
* decision to run without better-auth's cookieCache), and the /admin/users
|
||||||
|
* routes run the rest.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface UserAccess {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
displayName: string;
|
||||||
|
disabled: boolean;
|
||||||
|
/** Every (app, role) grant this user holds. */
|
||||||
|
permissions: AppPermission[];
|
||||||
|
/** The distinct apps the above grants any access to. Derived, kept because
|
||||||
|
* most callers only ever ask "may they open this app at all?". */
|
||||||
|
apps: AppName[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserStatus = 'aktiv' | 'deaktiviert';
|
||||||
|
|
||||||
|
export interface UserListEntry {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
permissions: AppPermission[];
|
||||||
|
apps: AppName[];
|
||||||
|
status: UserStatus;
|
||||||
|
createdAt: Date;
|
||||||
|
lastSignInAt: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserSessionEntry {
|
||||||
|
id: string;
|
||||||
|
createdAt: Date;
|
||||||
|
expiresAt: Date;
|
||||||
|
ipAddress: string | null;
|
||||||
|
userAgent: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserDetail extends UserListEntry {
|
||||||
|
sessions: UserSessionEntry[];
|
||||||
|
passkeyCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single per-request lookup behind requireAppAccess. Returns null when the
|
||||||
|
* user row is gone; `disabled` is returned rather than filtered so the
|
||||||
|
* middleware can answer 403 (account deactivated) instead of a misleading 401.
|
||||||
|
*/
|
||||||
|
export const loadAccess = async (userId: string): Promise<UserAccess | null> => {
|
||||||
|
const rows = await db
|
||||||
|
.selectFrom('user')
|
||||||
|
.leftJoin('user_app_permissions', 'user_app_permissions.user_id', 'user.id')
|
||||||
|
.where('user.id', '=', userId)
|
||||||
|
.select([
|
||||||
|
'user.id as id',
|
||||||
|
'user.email as email',
|
||||||
|
'user.name as name',
|
||||||
|
'user.disabled as disabled',
|
||||||
|
'user_app_permissions.app as app',
|
||||||
|
'user_app_permissions.role as role'
|
||||||
|
])
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissions = toPermissionRows(rows);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: rows[0].id,
|
||||||
|
email: rows[0].email,
|
||||||
|
displayName: rows[0].name,
|
||||||
|
// MySQL TINYINT(1) comes back as 0/1 through mysql2.
|
||||||
|
disabled: Boolean(rows[0].disabled),
|
||||||
|
permissions,
|
||||||
|
apps: appsOf(permissions)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns joined permission rows into AppPermission[]. The left join produces one
|
||||||
|
* row with a null app for a user who holds nothing, and a role written directly
|
||||||
|
* into the database that no longer appears in APP_ROLES is dropped rather than
|
||||||
|
* trusted - the table is the store, APP_ROLES is the contract.
|
||||||
|
*/
|
||||||
|
const toPermissionRows = (rows: {app: AppName | null; role: string | null}[]): AppPermission[] => {
|
||||||
|
return rows
|
||||||
|
.filter((row): row is {app: AppName; role: string} =>
|
||||||
|
isAppName(row.app) && isAppRole(row.app, row.role))
|
||||||
|
.map(row => ({app: row.app, role: row.role}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listUsers = async (): Promise<UserListEntry[]> => {
|
||||||
|
const users = await db
|
||||||
|
.selectFrom('user')
|
||||||
|
.select(['id', 'email', 'name', 'disabled', 'createdAt'])
|
||||||
|
.orderBy('name', 'asc')
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
const permissions = await db
|
||||||
|
.selectFrom('user_app_permissions')
|
||||||
|
.select(['user_id', 'app', 'role'])
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
// Last sign-in is derived from the newest session rather than stored: a
|
||||||
|
// session row is created on every sign-in and we never update its
|
||||||
|
// createdAt, so max(createdAt) is exactly that, with no extra column to
|
||||||
|
// keep in sync.
|
||||||
|
//
|
||||||
|
// The expiry filter must match getUserDetail's. better-auth only deletes an
|
||||||
|
// expired session when someone actually presents it, so expired rows linger
|
||||||
|
// - without this, the list would report a last sign-in for someone the
|
||||||
|
// detail view shows as never having signed in.
|
||||||
|
const lastSessions = await db
|
||||||
|
.selectFrom('session')
|
||||||
|
.where('expiresAt', '>', new Date())
|
||||||
|
.select(({fn}) => ['userId', fn.max('createdAt').as('lastSignInAt')])
|
||||||
|
.groupBy('userId')
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
const permissionsByUser = new Map<string, AppPermission[]>();
|
||||||
|
for (const row of permissions) {
|
||||||
|
if (!isAppRole(row.app, row.role)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const held = permissionsByUser.get(row.user_id) || [];
|
||||||
|
held.push({app: row.app, role: row.role});
|
||||||
|
permissionsByUser.set(row.user_id, held);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastSignInByUser = new Map<string, Date | null>(
|
||||||
|
lastSessions.map(row => [row.userId, row.lastSignInAt as Date | null])
|
||||||
|
);
|
||||||
|
|
||||||
|
return users.map(user => {
|
||||||
|
const held = permissionsByUser.get(user.id) || [];
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
permissions: held,
|
||||||
|
apps: appsOf(held),
|
||||||
|
status: user.disabled ? ('deaktiviert' as const) : ('aktiv' as const),
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
lastSignInAt: lastSignInByUser.get(user.id) ?? null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUserDetail = async (userId: string): Promise<UserDetail | null> => {
|
||||||
|
const user = await db
|
||||||
|
.selectFrom('user')
|
||||||
|
.select(['id', 'email', 'name', 'disabled', 'createdAt'])
|
||||||
|
.where('id', '=', userId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [permissions, sessions, passkeys] = await Promise.all([
|
||||||
|
db
|
||||||
|
.selectFrom('user_app_permissions')
|
||||||
|
.select(['app', 'role'])
|
||||||
|
.where('user_id', '=', userId)
|
||||||
|
.execute(),
|
||||||
|
db
|
||||||
|
.selectFrom('session')
|
||||||
|
.select(['id', 'createdAt', 'expiresAt', 'ipAddress', 'userAgent'])
|
||||||
|
.where('userId', '=', userId)
|
||||||
|
.where('expiresAt', '>', new Date())
|
||||||
|
.orderBy('createdAt', 'desc')
|
||||||
|
.execute(),
|
||||||
|
db
|
||||||
|
.selectFrom('passkey')
|
||||||
|
.select(({fn}) => fn.countAll<number>().as('count'))
|
||||||
|
.where('userId', '=', userId)
|
||||||
|
.executeTakeFirst()
|
||||||
|
]);
|
||||||
|
|
||||||
|
const held = toPermissionRows(permissions);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
permissions: held,
|
||||||
|
apps: appsOf(held),
|
||||||
|
status: user.disabled ? 'deaktiviert' : 'aktiv',
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
lastSignInAt: sessions.length > 0 ? sessions[0].createdAt : null,
|
||||||
|
sessions,
|
||||||
|
passkeyCount: Number(passkeys?.count ?? 0)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces a user's permission set. Written as delete-then-insert inside one
|
||||||
|
* transaction rather than a diff: the set is at most four rows, and a diff
|
||||||
|
* would only add branches for no measurable gain.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The rows a permission list becomes. One row per (app, role). */
|
||||||
|
const permissionRows = (
|
||||||
|
userId: string,
|
||||||
|
permissions: AppPermission[],
|
||||||
|
grantedBy: string | null
|
||||||
|
) => {
|
||||||
|
return permissions.map(permission => ({
|
||||||
|
user_id: userId,
|
||||||
|
app: permission.app,
|
||||||
|
role: permission.role,
|
||||||
|
granted_by: grantedBy,
|
||||||
|
granted_at: new Date()
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Drops anything not in APP_ROLES and de-duplicates on (app, role). */
|
||||||
|
const validPermissions = (permissions: AppPermission[]): AppPermission[] => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return permissions.filter(permission => {
|
||||||
|
if (!isAppName(permission.app) || !isAppRole(permission.app, permission.role)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const key = `${permission.app}:${permission.role}`;
|
||||||
|
if (seen.has(key)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setPermissions = async (
|
||||||
|
userId: string,
|
||||||
|
permissions: AppPermission[],
|
||||||
|
grantedBy: string | null
|
||||||
|
): Promise<void> => {
|
||||||
|
const valid = validPermissions(permissions);
|
||||||
|
|
||||||
|
await db.transaction().execute(async trx => {
|
||||||
|
await trx.deleteFrom('user_app_permissions').where('user_id', '=', userId).execute();
|
||||||
|
if (valid.length > 0) {
|
||||||
|
await trx
|
||||||
|
.insertInto('user_app_permissions')
|
||||||
|
.values(permissionRows(userId, valid, grantedBy))
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why the guards live down here rather than in the router: they are
|
||||||
|
* check-then-act, and the check has to happen inside the same transaction as
|
||||||
|
* the write, over locked rows. Two admins each removing the other's `admin`
|
||||||
|
* permission at the same moment would otherwise both read a count of 2, both
|
||||||
|
* pass, and both commit - leaving nobody who can administer anything, with
|
||||||
|
* ADMIN_BOOTSTRAP_EMAIL at the next restart as the only way back in.
|
||||||
|
*
|
||||||
|
* `SELECT ... FOR UPDATE` makes the second transaction wait and re-read the
|
||||||
|
* count the first one just changed.
|
||||||
|
*/
|
||||||
|
export type LastAdminGuardResult = 'ok' | 'last-admin';
|
||||||
|
|
||||||
|
const countActiveAdminsForUpdate = async (trx: Transaction<AdminDatabase>): Promise<number> => {
|
||||||
|
const row = await trx
|
||||||
|
.selectFrom('user_app_permissions')
|
||||||
|
.innerJoin('user', 'user.id', 'user_app_permissions.user_id')
|
||||||
|
.where('user_app_permissions.app', '=', 'admin')
|
||||||
|
.where('user.disabled', '=', false)
|
||||||
|
// countDistinct, not countAll: with (user_id, app, role) as the key one
|
||||||
|
// user can hold several roles on `admin`, and counting rows would make a
|
||||||
|
// single admin with two roles look like two admins - defeating the guard
|
||||||
|
// at exactly the moment it matters.
|
||||||
|
.select(({fn}) => fn.count<number>('user_app_permissions.user_id').distinct().as('count'))
|
||||||
|
.forUpdate()
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
return Number(row?.count ?? 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces a user's permissions, refusing to remove the last active admin.
|
||||||
|
* Returns 'last-admin' instead of throwing so the router can answer 409.
|
||||||
|
*/
|
||||||
|
export const setPermissionsGuarded = async (
|
||||||
|
userId: string,
|
||||||
|
permissions: AppPermission[],
|
||||||
|
grantedBy: string | null
|
||||||
|
): Promise<LastAdminGuardResult> => {
|
||||||
|
const valid = validPermissions(permissions);
|
||||||
|
const keepsAdmin = valid.some(permission => permission.app === 'admin');
|
||||||
|
|
||||||
|
return db.transaction().execute(async trx => {
|
||||||
|
const target = await trx
|
||||||
|
.selectFrom('user_app_permissions')
|
||||||
|
.innerJoin('user', 'user.id', 'user_app_permissions.user_id')
|
||||||
|
.where('user_app_permissions.user_id', '=', userId)
|
||||||
|
.where('user_app_permissions.app', '=', 'admin')
|
||||||
|
.select(['user.disabled as disabled'])
|
||||||
|
.limit(1)
|
||||||
|
.forUpdate()
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
const losesAdmin = Boolean(target) && !keepsAdmin;
|
||||||
|
if (losesAdmin && !target?.disabled && (await countActiveAdminsForUpdate(trx)) <= 1) {
|
||||||
|
return 'last-admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
await trx.deleteFrom('user_app_permissions').where('user_id', '=', userId).execute();
|
||||||
|
if (valid.length > 0) {
|
||||||
|
await trx
|
||||||
|
.insertInto('user_app_permissions')
|
||||||
|
.values(permissionRows(userId, valid, grantedBy))
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'ok';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disables a user and revokes every session, refusing to disable the last
|
||||||
|
* active admin. Same locking rationale as setPermissionsGuarded.
|
||||||
|
*/
|
||||||
|
export const disableUserGuarded = async (userId: string): Promise<LastAdminGuardResult> => {
|
||||||
|
return db.transaction().execute(async trx => {
|
||||||
|
const isAdmin = await trx
|
||||||
|
.selectFrom('user_app_permissions')
|
||||||
|
.innerJoin('user', 'user.id', 'user_app_permissions.user_id')
|
||||||
|
.where('user_app_permissions.user_id', '=', userId)
|
||||||
|
.where('user_app_permissions.app', '=', 'admin')
|
||||||
|
.where('user.disabled', '=', false)
|
||||||
|
.select('user_app_permissions.user_id')
|
||||||
|
.limit(1)
|
||||||
|
.forUpdate()
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (isAdmin && (await countActiveAdminsForUpdate(trx)) <= 1) {
|
||||||
|
return 'last-admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
await trx.updateTable('user').set({disabled: true}).where('id', '=', userId).execute();
|
||||||
|
await trx.deleteFrom('session').where('userId', '=', userId).execute();
|
||||||
|
|
||||||
|
return 'ok';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const grantPermission = async (
|
||||||
|
userId: string,
|
||||||
|
app: AppName,
|
||||||
|
grantedBy: string | null,
|
||||||
|
role: AppRole = ACCESS_ROLE
|
||||||
|
): Promise<void> => {
|
||||||
|
await db
|
||||||
|
.insertInto('user_app_permissions')
|
||||||
|
.values({user_id: userId, app, role, granted_by: grantedBy, granted_at: new Date()})
|
||||||
|
// The row already existing is the success case - this is "make sure they
|
||||||
|
// hold it", not "re-grant it" - so nothing is overwritten and granted_by
|
||||||
|
// keeps naming whoever granted it first.
|
||||||
|
.onDuplicateKeyUpdate({role})
|
||||||
|
.execute();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Disabling revokes every session: a disabled user must lose access now, not
|
||||||
|
* when their 30-day cookie happens to expire. */
|
||||||
|
export const disableUser = async (userId: string): Promise<void> => {
|
||||||
|
await db.transaction().execute(async trx => {
|
||||||
|
await trx.updateTable('user').set({disabled: true}).where('id', '=', userId).execute();
|
||||||
|
await trx.deleteFrom('session').where('userId', '=', userId).execute();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const enableUser = async (userId: string): Promise<void> => {
|
||||||
|
await db.updateTable('user').set({disabled: false}).where('id', '=', userId).execute();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const revokeSession = async (userId: string, sessionId: string): Promise<boolean> => {
|
||||||
|
const result = await db
|
||||||
|
.deleteFrom('session')
|
||||||
|
.where('id', '=', sessionId)
|
||||||
|
.where('userId', '=', userId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
return Number(result.numDeletedRows) > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guard input for the self-lockout rules: how many enabled users still hold the
|
||||||
|
* `admin` permission. Disabled admins do not count - they cannot sign in, so
|
||||||
|
* leaving only disabled admins is the same lockout as leaving none.
|
||||||
|
*/
|
||||||
|
export const countActiveAdmins = async (): Promise<number> => {
|
||||||
|
const row = await db
|
||||||
|
.selectFrom('user_app_permissions')
|
||||||
|
.innerJoin('user', 'user.id', 'user_app_permissions.user_id')
|
||||||
|
.where('user_app_permissions.app', '=', 'admin')
|
||||||
|
.where('user.disabled', '=', false)
|
||||||
|
.select(({fn}) => fn.countAll<number>().as('count'))
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
return Number(row?.count ?? 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const userExists = async (userId: string): Promise<boolean> => {
|
||||||
|
const row = await db.selectFrom('user').select('id').where('id', '=', userId).executeTakeFirst();
|
||||||
|
return Boolean(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findUserByEmail = async (email: string): Promise<{id: string; email: string} | null> => {
|
||||||
|
const row = await db
|
||||||
|
.selectFrom('user')
|
||||||
|
.select(['id', 'email'])
|
||||||
|
.where('email', '=', email)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
return row ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display names for a set of user ids, as an id -> name map. Ids that no
|
||||||
|
* longer exist are simply absent from the map rather than mapping to a
|
||||||
|
* placeholder, so callers can distinguish "deleted account" from "never had
|
||||||
|
* one" and choose their own fallback.
|
||||||
|
*
|
||||||
|
* This exists for the calendar migration (docs/calendar-auth-migration.md
|
||||||
|
* step 3): the calendar lives in a different database, so it cannot join
|
||||||
|
* against `user` to render "created by". One lookup per result set keeps that
|
||||||
|
* cheap without coupling the two schemas.
|
||||||
|
*/
|
||||||
|
export const findDisplayNames = async (ids: readonly string[]): Promise<Map<string, string>> => {
|
||||||
|
const distinct = Array.from(new Set(ids.filter(id => id)));
|
||||||
|
if (distinct.length === 0) {
|
||||||
|
// Kysely renders `in ()` for an empty list, which MariaDB rejects.
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.selectFrom('user')
|
||||||
|
.select(['id', 'name'])
|
||||||
|
.where('id', 'in', distinct)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return new Map(rows.map(row => [row.id, row.name]));
|
||||||
|
};
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import * as dotenv from 'dotenv';
|
import * as dotenv from 'dotenv';
|
||||||
|
import mariadb from 'mariadb';
|
||||||
const mariadb = require('mariadb');
|
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
@@ -10,8 +9,7 @@ export namespace NachklangCalendarDB {
|
|||||||
user: process.env.DB_USER,
|
user: process.env.DB_USER,
|
||||||
password: process.env.DB_PASSWORD,
|
password: process.env.DB_PASSWORD,
|
||||||
database: process.env.CALENDAR_DB,
|
database: process.env.CALENDAR_DB,
|
||||||
connectionLimit: 5,
|
connectionLimit: 5
|
||||||
autoCommit: false
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getConnection = async () => {
|
export const getConnection = async () => {
|
||||||
|
|||||||
@@ -3,9 +3,8 @@
|
|||||||
*/
|
*/
|
||||||
import express, {Request, Response} from 'express';
|
import express, {Request, Response} from 'express';
|
||||||
import {Guid} from 'guid-typescript';
|
import {Guid} from 'guid-typescript';
|
||||||
import logger from '../../middleware/logger';
|
import logger from '../../middleware/logger.js';
|
||||||
import {eventsRouter} from './events/events.router';
|
import {eventsRouter} from './events/events.router.js';
|
||||||
import {usersRouter} from './users/users.router';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Router Definition
|
* Router Definition
|
||||||
@@ -13,7 +12,13 @@ import {usersRouter} from './users/users.router';
|
|||||||
export const calendarRouter = express.Router();
|
export const calendarRouter = express.Router();
|
||||||
|
|
||||||
calendarRouter.use('/events', eventsRouter);
|
calendarRouter.use('/events', eventsRouter);
|
||||||
calendarRouter.use('/users', usersRouter);
|
|
||||||
|
/*
|
||||||
|
* There is no /calendar/users any more. It held this module's own accounts -
|
||||||
|
* registration, login, activation, password reset, and the session table the
|
||||||
|
* feedback and tickets admin areas used to authenticate against - and every one
|
||||||
|
* of those moved to the admin module. See docs/calendar-auth-migration.md.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,73 +1,55 @@
|
|||||||
import * as dotenv from 'dotenv';
|
import * as dotenv from 'dotenv';
|
||||||
import * as UserService from '../users/users.service';
|
|
||||||
|
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if the password gives admin privileges (view / create / edit / delete)
|
* The shared calendar passwords, and nothing else.
|
||||||
* @param password
|
*
|
||||||
|
* Before the step 4 cutover each function here also took a sessionId/sessionKey
|
||||||
|
* pair and checked it against the calendar's own sessions table, so "is this a
|
||||||
|
* signed-in user?" and "did they send the right shared password?" were tangled
|
||||||
|
* together in five places. Signed-in access is now decided by
|
||||||
|
* requireAppAccess('calendar') before the handler runs; what is left is the
|
||||||
|
* fallback for people who have no account at all.
|
||||||
|
*
|
||||||
|
* That fallback survives on purpose, for one reason: an iCal client subscribing
|
||||||
|
* to a calendar URL cannot send a cookie. Everything the Angular app does goes
|
||||||
|
* through the session cookie instead. See docs/calendar-auth-migration.md.
|
||||||
|
*
|
||||||
|
* `public` is deliberately open to everyone with no credential of any kind -
|
||||||
|
* nachklang.art reads it anonymously to show the next upcoming event. Pinned by
|
||||||
|
* test/calendar/credentials.service.test.ts.
|
||||||
*/
|
*/
|
||||||
export const checkAdminPrivileges = async (sessionId: string, sessionKey: string, ip: string) => {
|
|
||||||
if(sessionId) {
|
|
||||||
let user = await UserService.checkSession(sessionId, sessionKey, ip);
|
|
||||||
return user?.isActive ?? false;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
const credentialFor = (calendarName: string): string | undefined => {
|
||||||
* Checks if the password gives member view privileges
|
|
||||||
* @param password
|
|
||||||
*/
|
|
||||||
export const checkMemberPrivileges = async (sessionId: string, sessionKey: string, password: string, ip: string) => {
|
|
||||||
if(sessionId) {
|
|
||||||
let user = await UserService.checkSession(sessionId, sessionKey, ip);
|
|
||||||
return user?.isActive ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return password == process.env.MEMBER_CREDENTIAL;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if the password gives choir view privileges
|
|
||||||
* @param password
|
|
||||||
*/
|
|
||||||
export const checkChoirPrivileges = async (sessionId: string, sessionKey: string, password: string, ip: string) => {
|
|
||||||
if(sessionId) {
|
|
||||||
let user = await UserService.checkSession(sessionId, sessionKey, ip);
|
|
||||||
return user?.isActive ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return password == process.env.CHOIR_CREDENTIAL;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if the password gives management view privileges
|
|
||||||
* @param password
|
|
||||||
*/
|
|
||||||
export const checkManagementPrivileges = async (sessionId: string, sessionKey: string, password: string, ip: string) => {
|
|
||||||
if(sessionId) {
|
|
||||||
let user = await UserService.checkSession(sessionId, sessionKey, ip);
|
|
||||||
return user?.isActive ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return password == process.env.MANAGEMENT_CREDENTIAL;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const hasAccess = async (calendarName: string, sessionId: string, sessionKey: string, password: string, ip: string) => {
|
|
||||||
switch (calendarName) {
|
switch (calendarName) {
|
||||||
case 'public':
|
|
||||||
return true;
|
|
||||||
case 'members':
|
case 'members':
|
||||||
return await checkMemberPrivileges(sessionId, sessionKey, password, ip);
|
return process.env.MEMBER_CREDENTIAL;
|
||||||
case 'choir':
|
case 'choir':
|
||||||
return await checkChoirPrivileges(sessionId, sessionKey, password, ip);
|
|
||||||
case 'management':
|
|
||||||
return await checkManagementPrivileges(sessionId, sessionKey, password, ip);
|
|
||||||
case 'birthdays':
|
case 'birthdays':
|
||||||
return await checkChoirPrivileges(sessionId, sessionKey, password, ip);
|
return process.env.CHOIR_CREDENTIAL;
|
||||||
|
case 'management':
|
||||||
|
return process.env.MANAGEMENT_CREDENTIAL;
|
||||||
default:
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the given shared password opens the given calendar. Answers false
|
||||||
|
* for an unknown calendar, and - importantly - for a calendar whose credential
|
||||||
|
* is not configured at all: an unset MEMBER_CREDENTIAL must not turn into
|
||||||
|
* "everyone with an empty password gets in".
|
||||||
|
*/
|
||||||
|
export const hasAccess = async (calendarName: string, password: string): Promise<boolean> => {
|
||||||
|
if (calendarName === 'public') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expected = credentialFor(calendarName);
|
||||||
|
if (!expected) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
return password === expected;
|
||||||
|
};
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
* - endDateTime
|
* - endDateTime
|
||||||
* - createdDate
|
* - createdDate
|
||||||
* - location
|
* - location
|
||||||
* - createdById
|
|
||||||
* - url
|
* - url
|
||||||
* - wholeDay
|
* - wholeDay
|
||||||
* properties:
|
* properties:
|
||||||
@@ -65,18 +64,20 @@
|
|||||||
* type: string
|
* type: string
|
||||||
* description: The name of the user who created the event
|
* description: The name of the user who created the event
|
||||||
* example: "John Doe"
|
* example: "John Doe"
|
||||||
* createdById:
|
* createdByUserId:
|
||||||
* type: integer
|
* type: string
|
||||||
* description: The ID of the user who created the event
|
* nullable: true
|
||||||
* example: 456
|
* description: The admin-module user id of the creator, once it has one
|
||||||
|
* example: "8f1c0f2e-0f1a-4b9e-9a7c-2d5f1b3c4d5e"
|
||||||
* lastModifiedBy:
|
* lastModifiedBy:
|
||||||
* type: string
|
* type: string
|
||||||
* description: The name of the user who last modified the event
|
* description: The name of the user who last modified the event
|
||||||
* example: "John Doe"
|
* example: "John Doe"
|
||||||
* lastModifiedById:
|
* lastModifiedByUserId:
|
||||||
* type: integer
|
* type: string
|
||||||
* description: The ID of the user who last modified the event
|
* nullable: true
|
||||||
* example: 456
|
* description: The admin-module user id of the last editor, once it has one
|
||||||
|
* example: "8f1c0f2e-0f1a-4b9e-9a7c-2d5f1b3c4d5e"
|
||||||
* url:
|
* url:
|
||||||
* type: string
|
* type: string
|
||||||
* description: A URL with more information about the event
|
* description: A URL with more information about the event
|
||||||
@@ -102,10 +103,13 @@ export interface Event {
|
|||||||
createdDate: Date;
|
createdDate: Date;
|
||||||
lastModifiedDate?: Date;
|
lastModifiedDate?: Date;
|
||||||
location: string;
|
location: string;
|
||||||
|
/** Display name of the creator: the live admin name when the id below
|
||||||
|
* resolves, otherwise the name archived before the legacy users table was
|
||||||
|
* removed. See docs/calendar-auth-migration.md. */
|
||||||
createdBy?: string;
|
createdBy?: string;
|
||||||
createdById: number;
|
createdByUserId?: string | null;
|
||||||
lastModifiedBy?: string;
|
lastModifiedBy?: string;
|
||||||
lastModifiedById?: number;
|
lastModifiedByUserId?: string | null;
|
||||||
url: string;
|
url: string;
|
||||||
wholeDay: boolean;
|
wholeDay: boolean;
|
||||||
repeatFrequency: string;
|
repeatFrequency: string;
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import express, {Request, Response} from 'express';
|
import express, {Request, Response} from 'express';
|
||||||
import {Event} from './event.interface';
|
import {Event} from './event.interface.js';
|
||||||
import * as EventService from './events.service';
|
import * as EventService from './events.service.js';
|
||||||
import * as iCalService from './icalgenerator.service';
|
import * as iCalService from './icalgenerator.service.js';
|
||||||
import * as CredentialService from './credentials.service';
|
import * as CredentialService from './credentials.service.js';
|
||||||
import * as UserService from '../users/users.service';
|
import {requireAppAccess, resolveAccess, AdminAccess} from '../../admin/admin.middleware.js';
|
||||||
import {Guid} from 'guid-typescript';
|
import {Guid} from 'guid-typescript';
|
||||||
import logger from '../../../middleware/logger';
|
import logger from '../../../middleware/logger.js';
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,6 +29,44 @@ export const calendarNames = new Map<string, any>([
|
|||||||
['birthdays', {id: 5, name: 'Nachklang_birthday_calendar'}]
|
['birthdays', {id: 5, name: 'Nachklang_birthday_calendar'}]
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The gate on everything that writes. Step 4 of
|
||||||
|
* docs/calendar-auth-migration.md replaced a sessionId/sessionKey pair in the
|
||||||
|
* query string (DEFERRED_SECURITY.md item 1) with the same session cookie the
|
||||||
|
* other three apps use, and "any activated @nachklang.art account" with an
|
||||||
|
* explicit per-user calendar permission.
|
||||||
|
*/
|
||||||
|
const requireCalendarAccess = requireAppAccess('calendar');
|
||||||
|
|
||||||
|
/** Set by requireCalendarAccess; the writer's admin identity. */
|
||||||
|
const adminOf = (res: Response): AdminAccess => res.locals.admin as AdminAccess;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a signed-in calendar user for the *read* routes, or null.
|
||||||
|
*
|
||||||
|
* Reads cannot use the middleware: the same URL serves an anonymous visitor
|
||||||
|
* (the public calendar the website polls), someone holding a shared password
|
||||||
|
* (an iCal subscription), and a signed-in editor who should see drafts. So it
|
||||||
|
* answers "who is this, if anyone?" instead of refusing the request, and each
|
||||||
|
* handler decides what that means.
|
||||||
|
*
|
||||||
|
* A failure to reach the admin database is swallowed for the same reason the
|
||||||
|
* name lookup in events.service.ts swallows one: it must not be able to take
|
||||||
|
* the anonymous public calendar down.
|
||||||
|
*/
|
||||||
|
const signedInEditor = async (req: Request): Promise<AdminAccess | null> => {
|
||||||
|
try {
|
||||||
|
const access = await resolveAccess(req);
|
||||||
|
if (!access || access.disabled || !access.apps.includes('calendar')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return access;
|
||||||
|
} catch (e: any) {
|
||||||
|
logger.warn('Calendar: could not resolve the session, continuing as anonymous: ' + e.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Controller Definitions
|
* Controller Definitions
|
||||||
@@ -39,7 +77,10 @@ export const calendarNames = new Map<string, any>([
|
|||||||
* /calendar/events/{calendar}/json:
|
* /calendar/events/{calendar}/json:
|
||||||
* get:
|
* get:
|
||||||
* summary: Get all events from a specific calendar in JSON format
|
* summary: Get all events from a specific calendar in JSON format
|
||||||
* description: Returns all events from the specified calendar in JSON format. Authentication required.
|
* description: >
|
||||||
|
* Returns the calendar's events. The public calendar is open to everyone; the
|
||||||
|
* others need either a signed-in account with the calendar permission - which
|
||||||
|
* also unlocks drafts - or the calendar's shared password.
|
||||||
* tags:
|
* tags:
|
||||||
* - calendar
|
* - calendar
|
||||||
* parameters:
|
* parameters:
|
||||||
@@ -48,23 +89,13 @@ export const calendarNames = new Map<string, any>([
|
|||||||
* required: true
|
* required: true
|
||||||
* schema:
|
* schema:
|
||||||
* type: string
|
* type: string
|
||||||
* enum: [public, members, choir, management]
|
* enum: [public, members, choir, management, birthdays]
|
||||||
* description: The name of the calendar to get events from
|
* description: The name of the calendar to get events from
|
||||||
* - in: query
|
* - in: query
|
||||||
* name: sessionId
|
|
||||||
* schema:
|
|
||||||
* type: string
|
|
||||||
* description: Session ID for authentication
|
|
||||||
* - in: query
|
|
||||||
* name: sessionKey
|
|
||||||
* schema:
|
|
||||||
* type: string
|
|
||||||
* description: Session key for authentication
|
|
||||||
* - in: query
|
|
||||||
* name: password
|
* name: password
|
||||||
* schema:
|
* schema:
|
||||||
* type: string
|
* type: string
|
||||||
* description: Password for calendar access (if not using session authentication)
|
* description: The calendar's shared password, for callers with no account
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: Success
|
* description: Success
|
||||||
@@ -109,10 +140,7 @@ eventsRouter.get('/:calendar/json', async (req: Request, res: Response) => {
|
|||||||
try {
|
try {
|
||||||
// Get request params
|
// Get request params
|
||||||
let calendarName: string = req.params.calendar as string ?? '';
|
let calendarName: string = req.params.calendar as string ?? '';
|
||||||
let sessionId: string = req.query.sessionId as string ?? '';
|
|
||||||
let sessionKey: string = req.query.sessionKey as string ?? '';
|
|
||||||
let password: string = req.query.password as string ?? '';
|
let password: string = req.query.password as string ?? '';
|
||||||
let ip: string = req.socket.remoteAddress ?? '';
|
|
||||||
|
|
||||||
if (calendarName.length < 1) {
|
if (calendarName.length < 1) {
|
||||||
res.status(400).send({'message': 'Please state the name of the calendar you want events from.'});
|
res.status(400).send({'message': 'Please state the name of the calendar you want events from.'});
|
||||||
@@ -126,23 +154,19 @@ eventsRouter.get('/:calendar/json', async (req: Request, res: Response) => {
|
|||||||
|
|
||||||
let calendarId: number = calendarNames.get(calendarName)!.id;
|
let calendarId: number = calendarNames.get(calendarName)!.id;
|
||||||
|
|
||||||
let user = await UserService.checkSession(sessionId, sessionKey, ip);
|
const editor = await signedInEditor(req);
|
||||||
|
|
||||||
// If no user was found, check if the password gives access to the calendar
|
// Not signed in: fall back to the shared password for this calendar.
|
||||||
if(user === null || !user.isActive) {
|
if (!editor && ! await CredentialService.hasAccess(calendarName, password)) {
|
||||||
if (! await CredentialService.hasAccess(calendarName, sessionId, sessionKey, password, ip)) {
|
|
||||||
res.status(403).send({'message': 'You do not have access to the specified calendar.'});
|
res.status(403).send({'message': 'You do not have access to the specified calendar.'});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let events: Event[];
|
// Editors get the admin view (drafts included, calendar includes ignored);
|
||||||
|
// everyone else gets published events only.
|
||||||
if(user?.isActive) {
|
let events: Event[] = editor
|
||||||
events = await EventService.getAllEventsAdmin(calendarId);
|
? await EventService.getAllEventsAdmin(calendarId)
|
||||||
} else {
|
: await EventService.getAllEvents(calendarId);
|
||||||
events = await EventService.getAllEvents(calendarId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send the events back
|
// Send the events back
|
||||||
res.status(200).send(events);
|
res.status(200).send(events);
|
||||||
@@ -158,7 +182,10 @@ eventsRouter.get('/:calendar/json', async (req: Request, res: Response) => {
|
|||||||
* /calendar/events/{calendar}/json/next:
|
* /calendar/events/{calendar}/json/next:
|
||||||
* get:
|
* get:
|
||||||
* summary: Get the next upcoming event from a calendar
|
* summary: Get the next upcoming event from a calendar
|
||||||
* description: Returns the next upcoming event from the specified calendar. Authentication required.
|
* description: >
|
||||||
|
* The next upcoming event. The public calendar is open to everyone; the
|
||||||
|
* others need either a signed-in account with the calendar permission or the
|
||||||
|
* calendar's shared password.
|
||||||
* tags:
|
* tags:
|
||||||
* - calendar
|
* - calendar
|
||||||
* parameters:
|
* parameters:
|
||||||
@@ -167,23 +194,13 @@ eventsRouter.get('/:calendar/json', async (req: Request, res: Response) => {
|
|||||||
* required: true
|
* required: true
|
||||||
* schema:
|
* schema:
|
||||||
* type: string
|
* type: string
|
||||||
* enum: [public, members, choir, management]
|
* enum: [public, members, choir, management, birthdays]
|
||||||
* description: The name of the calendar to get the next event from
|
* description: The name of the calendar to get the next event from
|
||||||
* - in: query
|
* - in: query
|
||||||
* name: sessionId
|
|
||||||
* schema:
|
|
||||||
* type: string
|
|
||||||
* description: Session ID for authentication
|
|
||||||
* - in: query
|
|
||||||
* name: sessionKey
|
|
||||||
* schema:
|
|
||||||
* type: string
|
|
||||||
* description: Session key for authentication
|
|
||||||
* - in: query
|
|
||||||
* name: password
|
* name: password
|
||||||
* schema:
|
* schema:
|
||||||
* type: string
|
* type: string
|
||||||
* description: Password for calendar access (if not using session authentication)
|
* description: The calendar's shared password, for callers with no account
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: Success
|
* description: Success
|
||||||
@@ -242,10 +259,7 @@ eventsRouter.get('/:calendar/json/next', async (req: Request, res: Response) =>
|
|||||||
try {
|
try {
|
||||||
// Get request params
|
// Get request params
|
||||||
let calendarName: string = req.params.calendar as string ?? '';
|
let calendarName: string = req.params.calendar as string ?? '';
|
||||||
let sessionId: string = req.query.sessionId as string ?? '';
|
|
||||||
let sessionKey: string = req.query.sessionKey as string ?? '';
|
|
||||||
let password: string = req.query.password as string ?? '';
|
let password: string = req.query.password as string ?? '';
|
||||||
let ip: string = req.socket.remoteAddress ?? '';
|
|
||||||
|
|
||||||
if (calendarName.length < 1) {
|
if (calendarName.length < 1) {
|
||||||
res.status(400).send({'message': 'Please state the name of the calendar you want events from.'});
|
res.status(400).send({'message': 'Please state the name of the calendar you want events from.'});
|
||||||
@@ -259,7 +273,19 @@ eventsRouter.get('/:calendar/json/next', async (req: Request, res: Response) =>
|
|||||||
|
|
||||||
let calendarId: number = calendarNames.get(calendarName)!.id;
|
let calendarId: number = calendarNames.get(calendarName)!.id;
|
||||||
|
|
||||||
if (! await CredentialService.hasAccess(calendarName, sessionId, sessionKey, password, ip)) {
|
// Holding the calendar's shared password, or signed in. The password path
|
||||||
|
// is what keeps iCal subscriptions working - a calendar client cannot
|
||||||
|
// send a cookie.
|
||||||
|
//
|
||||||
|
// The password is checked FIRST so that `public`, which needs no
|
||||||
|
// credential at all, short-circuits before signedInEditor runs. Otherwise
|
||||||
|
// every request from a browser that happens to hold a .nachklang.art
|
||||||
|
// cookie - which is any signed-in user on any of the four apps - would put
|
||||||
|
// an admin-database query in front of the anonymous public feed, with no
|
||||||
|
// timeout. Both operands are side-effect free, so the order is free to
|
||||||
|
// choose; this order is the one that keeps the public calendar
|
||||||
|
// independent of the admin database.
|
||||||
|
if (! await CredentialService.hasAccess(calendarName, password) && !await signedInEditor(req)) {
|
||||||
res.status(403).send({'message': 'You do not have access to the specified calendar.'});
|
res.status(403).send({'message': 'You do not have access to the specified calendar.'});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -290,7 +316,10 @@ eventsRouter.get('/:calendar/json/next', async (req: Request, res: Response) =>
|
|||||||
* /calendar/events/{calendar}/ical:
|
* /calendar/events/{calendar}/ical:
|
||||||
* get:
|
* get:
|
||||||
* summary: Get all events from a specific calendar in iCal format
|
* summary: Get all events from a specific calendar in iCal format
|
||||||
* description: Returns all events from the specified calendar in iCal format for calendar applications. Authentication required.
|
* description: >
|
||||||
|
* The calendar in iCal format. The public calendar is open to everyone; the
|
||||||
|
* others take the calendar's shared password in the query string, which is
|
||||||
|
* why that mechanism survives - an iCal client cannot send a cookie.
|
||||||
* tags:
|
* tags:
|
||||||
* - calendar
|
* - calendar
|
||||||
* parameters:
|
* parameters:
|
||||||
@@ -299,23 +328,13 @@ eventsRouter.get('/:calendar/json/next', async (req: Request, res: Response) =>
|
|||||||
* required: true
|
* required: true
|
||||||
* schema:
|
* schema:
|
||||||
* type: string
|
* type: string
|
||||||
* enum: [public, members, choir, management]
|
* enum: [public, members, choir, management, birthdays]
|
||||||
* description: The name of the calendar to get events from
|
* description: The name of the calendar to get events from
|
||||||
* - in: query
|
* - in: query
|
||||||
* name: sessionId
|
|
||||||
* schema:
|
|
||||||
* type: string
|
|
||||||
* description: Session ID for authentication
|
|
||||||
* - in: query
|
|
||||||
* name: sessionKey
|
|
||||||
* schema:
|
|
||||||
* type: string
|
|
||||||
* description: Session key for authentication
|
|
||||||
* - in: query
|
|
||||||
* name: password
|
* name: password
|
||||||
* schema:
|
* schema:
|
||||||
* type: string
|
* type: string
|
||||||
* description: Password for calendar access (if not using session authentication)
|
* description: The calendar's shared password, for callers with no account
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: Success - returns iCal file
|
* description: Success - returns iCal file
|
||||||
@@ -365,10 +384,7 @@ eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => {
|
|||||||
try {
|
try {
|
||||||
// Get request params
|
// Get request params
|
||||||
let calendarName: string = req.params.calendar as string ?? '';
|
let calendarName: string = req.params.calendar as string ?? '';
|
||||||
let sessionId: string = req.query.sessionId as string ?? '';
|
|
||||||
let sessionKey: string = req.query.sessionKey as string ?? '';
|
|
||||||
let password: string = req.query.password as string ?? '';
|
let password: string = req.query.password as string ?? '';
|
||||||
let ip: string = req.socket.remoteAddress ?? '';
|
|
||||||
|
|
||||||
if (calendarName.length < 1) {
|
if (calendarName.length < 1) {
|
||||||
res.status(400).send({'message': 'Please state the name of the calendar you want events from.'});
|
res.status(400).send({'message': 'Please state the name of the calendar you want events from.'});
|
||||||
@@ -382,7 +398,19 @@ eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => {
|
|||||||
|
|
||||||
let calendarId: number = calendarNames.get(calendarName)!.id;
|
let calendarId: number = calendarNames.get(calendarName)!.id;
|
||||||
|
|
||||||
if (! await CredentialService.hasAccess(calendarName, sessionId, sessionKey, password, ip)) {
|
// Holding the calendar's shared password, or signed in. The password path
|
||||||
|
// is what keeps iCal subscriptions working - a calendar client cannot
|
||||||
|
// send a cookie.
|
||||||
|
//
|
||||||
|
// The password is checked FIRST so that `public`, which needs no
|
||||||
|
// credential at all, short-circuits before signedInEditor runs. Otherwise
|
||||||
|
// every request from a browser that happens to hold a .nachklang.art
|
||||||
|
// cookie - which is any signed-in user on any of the four apps - would put
|
||||||
|
// an admin-database query in front of the anonymous public feed, with no
|
||||||
|
// timeout. Both operands are side-effect free, so the order is free to
|
||||||
|
// choose; this order is the one that keeps the public calendar
|
||||||
|
// independent of the admin database.
|
||||||
|
if (! await CredentialService.hasAccess(calendarName, password) && !await signedInEditor(req)) {
|
||||||
res.status(403).send({'message': 'You do not have access to the specified calendar.'});
|
res.status(403).send({'message': 'You do not have access to the specified calendar.'});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -413,22 +441,11 @@ eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => {
|
|||||||
* /calendar/events:
|
* /calendar/events:
|
||||||
* post:
|
* post:
|
||||||
* summary: Create a new event
|
* summary: Create a new event
|
||||||
* description: Creates a new event in the specified calendar. Authentication required.
|
* description: Creates a new event. Requires a signed-in account with the calendar permission.
|
||||||
* tags:
|
* tags:
|
||||||
* - calendar
|
* - calendar
|
||||||
* parameters:
|
* security:
|
||||||
* - in: query
|
* - AdminSessionCookie: []
|
||||||
* name: sessionId
|
|
||||||
* required: true
|
|
||||||
* schema:
|
|
||||||
* type: string
|
|
||||||
* description: Session ID for authentication
|
|
||||||
* - in: query
|
|
||||||
* name: sessionKey
|
|
||||||
* required: true
|
|
||||||
* schema:
|
|
||||||
* type: string
|
|
||||||
* description: Session key for authentication
|
|
||||||
* requestBody:
|
* requestBody:
|
||||||
* required: true
|
* required: true
|
||||||
* content:
|
* content:
|
||||||
@@ -495,16 +512,32 @@ eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => {
|
|||||||
* message:
|
* message:
|
||||||
* type: string
|
* type: string
|
||||||
* example: Required parameters missing
|
* example: Required parameters missing
|
||||||
* 403:
|
* 401:
|
||||||
* description: Forbidden - no access to create events
|
* description: Unauthorized - not signed in
|
||||||
* content:
|
* content:
|
||||||
* application/json:
|
* application/json:
|
||||||
* schema:
|
* schema:
|
||||||
* type: object
|
* type: object
|
||||||
* properties:
|
* properties:
|
||||||
|
* status:
|
||||||
|
* type: string
|
||||||
|
* example: UNAUTHORIZED
|
||||||
* message:
|
* message:
|
||||||
* type: string
|
* type: string
|
||||||
* example: You do not have access to the specified calendar.
|
* example: Anmeldung erforderlich.
|
||||||
|
* 403:
|
||||||
|
* description: Forbidden - the account lacks the calendar permission
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* status:
|
||||||
|
* type: string
|
||||||
|
* example: FORBIDDEN
|
||||||
|
* message:
|
||||||
|
* type: string
|
||||||
|
* example: "Für diesen Bereich fehlt dir die Berechtigung."
|
||||||
* 500:
|
* 500:
|
||||||
* description: Server error
|
* description: Server error
|
||||||
* content:
|
* content:
|
||||||
@@ -522,19 +555,9 @@ eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => {
|
|||||||
* type: string
|
* type: string
|
||||||
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
|
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
|
||||||
*/
|
*/
|
||||||
eventsRouter.post('/', async (req: Request, res: Response) => {
|
eventsRouter.post('/', requireCalendarAccess, async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
// Get params
|
const admin = adminOf(res);
|
||||||
let sessionId: string = req.query.sessionId as string ?? '';
|
|
||||||
let sessionKey: string = req.query.sessionKey as string ?? '';
|
|
||||||
let ip: string = req.socket.remoteAddress ?? '';
|
|
||||||
|
|
||||||
let user = await UserService.checkSession(sessionId, sessionKey, ip);
|
|
||||||
|
|
||||||
if (!user?.isActive) {
|
|
||||||
res.status(403).send({'message': 'You do not have access to the specified calendar.'});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
req.body.calendarId === undefined ||
|
req.body.calendarId === undefined ||
|
||||||
@@ -556,7 +579,9 @@ eventsRouter.post('/', async (req: Request, res: Response) => {
|
|||||||
endDateTime: new Date(req.body.endDateTime),
|
endDateTime: new Date(req.body.endDateTime),
|
||||||
createdDate: new Date(),
|
createdDate: new Date(),
|
||||||
location: req.body.location ?? '',
|
location: req.body.location ?? '',
|
||||||
createdById: user.userId ?? -1,
|
// LEGACY createdById is deliberately not set: there is no calendar
|
||||||
|
// user id any more, and migration 003 made the column nullable.
|
||||||
|
createdByUserId: admin.id,
|
||||||
url: req.body.url ?? '',
|
url: req.body.url ?? '',
|
||||||
wholeDay: req.body.wholeDay ?? false,
|
wholeDay: req.body.wholeDay ?? false,
|
||||||
repeatFrequency: req.body.repeatFrequency ?? '',
|
repeatFrequency: req.body.repeatFrequency ?? '',
|
||||||
@@ -585,9 +610,11 @@ eventsRouter.post('/', async (req: Request, res: Response) => {
|
|||||||
* /calendar/events/{eventId}:
|
* /calendar/events/{eventId}:
|
||||||
* put:
|
* put:
|
||||||
* summary: Update an existing event
|
* summary: Update an existing event
|
||||||
* description: Updates an existing event with the provided data. Authentication required.
|
* description: Updates an existing event. Requires a signed-in account with the calendar permission.
|
||||||
* tags:
|
* tags:
|
||||||
* - calendar
|
* - calendar
|
||||||
|
* security:
|
||||||
|
* - AdminSessionCookie: []
|
||||||
* parameters:
|
* parameters:
|
||||||
* - in: path
|
* - in: path
|
||||||
* name: eventId
|
* name: eventId
|
||||||
@@ -595,18 +622,6 @@ eventsRouter.post('/', async (req: Request, res: Response) => {
|
|||||||
* schema:
|
* schema:
|
||||||
* type: integer
|
* type: integer
|
||||||
* description: The ID of the event to update
|
* description: The ID of the event to update
|
||||||
* - in: query
|
|
||||||
* name: sessionId
|
|
||||||
* required: true
|
|
||||||
* schema:
|
|
||||||
* type: string
|
|
||||||
* description: Session ID for authentication
|
|
||||||
* - in: query
|
|
||||||
* name: sessionKey
|
|
||||||
* required: true
|
|
||||||
* schema:
|
|
||||||
* type: string
|
|
||||||
* description: Session key for authentication
|
|
||||||
* requestBody:
|
* requestBody:
|
||||||
* required: true
|
* required: true
|
||||||
* content:
|
* content:
|
||||||
@@ -639,9 +654,6 @@ eventsRouter.post('/', async (req: Request, res: Response) => {
|
|||||||
* location:
|
* location:
|
||||||
* type: string
|
* type: string
|
||||||
* example: "Musikhochschule, Karlsruhe"
|
* example: "Musikhochschule, Karlsruhe"
|
||||||
* createdBy:
|
|
||||||
* type: string
|
|
||||||
* example: "John Doe"
|
|
||||||
* url:
|
* url:
|
||||||
* type: string
|
* type: string
|
||||||
* example: "https://www.nachklang.art/events/concert"
|
* example: "https://www.nachklang.art/events/concert"
|
||||||
@@ -673,16 +685,32 @@ eventsRouter.post('/', async (req: Request, res: Response) => {
|
|||||||
* message:
|
* message:
|
||||||
* type: string
|
* type: string
|
||||||
* example: Required parameters missing
|
* example: Required parameters missing
|
||||||
* 403:
|
* 401:
|
||||||
* description: Forbidden - no access to update events
|
* description: Unauthorized - not signed in
|
||||||
* content:
|
* content:
|
||||||
* application/json:
|
* application/json:
|
||||||
* schema:
|
* schema:
|
||||||
* type: object
|
* type: object
|
||||||
* properties:
|
* properties:
|
||||||
|
* status:
|
||||||
|
* type: string
|
||||||
|
* example: UNAUTHORIZED
|
||||||
* message:
|
* message:
|
||||||
* type: string
|
* type: string
|
||||||
* example: You do not have access to the specified calendar.
|
* example: Anmeldung erforderlich.
|
||||||
|
* 403:
|
||||||
|
* description: Forbidden - the account lacks the calendar permission
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* status:
|
||||||
|
* type: string
|
||||||
|
* example: FORBIDDEN
|
||||||
|
* message:
|
||||||
|
* type: string
|
||||||
|
* example: "Für diesen Bereich fehlt dir die Berechtigung."
|
||||||
* 500:
|
* 500:
|
||||||
* description: Server error
|
* description: Server error
|
||||||
* content:
|
* content:
|
||||||
@@ -700,19 +728,9 @@ eventsRouter.post('/', async (req: Request, res: Response) => {
|
|||||||
* type: string
|
* type: string
|
||||||
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
|
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
|
||||||
*/
|
*/
|
||||||
eventsRouter.put('/:eventId', async (req: Request, res: Response) => {
|
eventsRouter.put('/:eventId', requireCalendarAccess, async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
// Get params
|
const admin = adminOf(res);
|
||||||
let sessionId: string = req.query.sessionId as string ?? '';
|
|
||||||
let sessionKey: string = req.query.sessionKey as string ?? '';
|
|
||||||
let ip: string = req.socket.remoteAddress ?? '';
|
|
||||||
|
|
||||||
let user = await UserService.checkSession(sessionId, sessionKey, ip);
|
|
||||||
|
|
||||||
if (!user?.isActive) {
|
|
||||||
res.status(403).send({'message': 'You do not have access to the specified calendar.'});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
req.params.eventId === undefined ||
|
req.params.eventId === undefined ||
|
||||||
@@ -735,8 +753,9 @@ eventsRouter.put('/:eventId', async (req: Request, res: Response) => {
|
|||||||
endDateTime: new Date(req.body.endDateTime),
|
endDateTime: new Date(req.body.endDateTime),
|
||||||
createdDate: new Date(),
|
createdDate: new Date(),
|
||||||
location: req.body.location ?? '',
|
location: req.body.location ?? '',
|
||||||
createdBy: req.body.createdBy ?? '',
|
// LEGACY createdById is deliberately not set: there is no calendar
|
||||||
createdById: user.userId ?? -1,
|
// user id any more, and migration 003 made the column nullable.
|
||||||
|
createdByUserId: admin.id,
|
||||||
url: req.body.url ?? '',
|
url: req.body.url ?? '',
|
||||||
wholeDay: req.body.wholeDay ?? false,
|
wholeDay: req.body.wholeDay ?? false,
|
||||||
repeatFrequency: req.body.repeatFrequency ?? '',
|
repeatFrequency: req.body.repeatFrequency ?? '',
|
||||||
@@ -768,9 +787,11 @@ eventsRouter.put('/:eventId', async (req: Request, res: Response) => {
|
|||||||
* /calendar/events/move/{eventId}:
|
* /calendar/events/move/{eventId}:
|
||||||
* put:
|
* put:
|
||||||
* summary: Move an event to a different calendar
|
* summary: Move an event to a different calendar
|
||||||
* description: Moves an existing event to a different calendar. Authentication required.
|
* description: Moves an event to a different calendar. Requires a signed-in account with the calendar permission.
|
||||||
* tags:
|
* tags:
|
||||||
* - calendar
|
* - calendar
|
||||||
|
* security:
|
||||||
|
* - AdminSessionCookie: []
|
||||||
* parameters:
|
* parameters:
|
||||||
* - in: path
|
* - in: path
|
||||||
* name: eventId
|
* name: eventId
|
||||||
@@ -778,18 +799,6 @@ eventsRouter.put('/:eventId', async (req: Request, res: Response) => {
|
|||||||
* schema:
|
* schema:
|
||||||
* type: integer
|
* type: integer
|
||||||
* description: The ID of the event to move
|
* description: The ID of the event to move
|
||||||
* - in: query
|
|
||||||
* name: sessionId
|
|
||||||
* required: true
|
|
||||||
* schema:
|
|
||||||
* type: string
|
|
||||||
* description: Session ID for authentication
|
|
||||||
* - in: query
|
|
||||||
* name: sessionKey
|
|
||||||
* required: true
|
|
||||||
* schema:
|
|
||||||
* type: string
|
|
||||||
* description: Session key for authentication
|
|
||||||
* requestBody:
|
* requestBody:
|
||||||
* required: true
|
* required: true
|
||||||
* content:
|
* content:
|
||||||
@@ -820,9 +829,6 @@ eventsRouter.put('/:eventId', async (req: Request, res: Response) => {
|
|||||||
* location:
|
* location:
|
||||||
* type: string
|
* type: string
|
||||||
* example: "Musikhochschule, Karlsruhe"
|
* example: "Musikhochschule, Karlsruhe"
|
||||||
* createdBy:
|
|
||||||
* type: string
|
|
||||||
* example: "John Doe"
|
|
||||||
* url:
|
* url:
|
||||||
* type: string
|
* type: string
|
||||||
* example: "https://www.nachklang.art/events/concert"
|
* example: "https://www.nachklang.art/events/concert"
|
||||||
@@ -854,16 +860,32 @@ eventsRouter.put('/:eventId', async (req: Request, res: Response) => {
|
|||||||
* message:
|
* message:
|
||||||
* type: string
|
* type: string
|
||||||
* example: Required parameters missing
|
* example: Required parameters missing
|
||||||
* 403:
|
* 401:
|
||||||
* description: Forbidden - no access to move events
|
* description: Unauthorized - not signed in
|
||||||
* content:
|
* content:
|
||||||
* application/json:
|
* application/json:
|
||||||
* schema:
|
* schema:
|
||||||
* type: object
|
* type: object
|
||||||
* properties:
|
* properties:
|
||||||
|
* status:
|
||||||
|
* type: string
|
||||||
|
* example: UNAUTHORIZED
|
||||||
* message:
|
* message:
|
||||||
* type: string
|
* type: string
|
||||||
* example: You do not have access to the specified calendar.
|
* example: Anmeldung erforderlich.
|
||||||
|
* 403:
|
||||||
|
* description: Forbidden - the account lacks the calendar permission
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* status:
|
||||||
|
* type: string
|
||||||
|
* example: FORBIDDEN
|
||||||
|
* message:
|
||||||
|
* type: string
|
||||||
|
* example: "Für diesen Bereich fehlt dir die Berechtigung."
|
||||||
* 500:
|
* 500:
|
||||||
* description: Server error
|
* description: Server error
|
||||||
* content:
|
* content:
|
||||||
@@ -881,19 +903,9 @@ eventsRouter.put('/:eventId', async (req: Request, res: Response) => {
|
|||||||
* type: string
|
* type: string
|
||||||
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
|
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
|
||||||
*/
|
*/
|
||||||
eventsRouter.put('/move/:eventId', async (req: Request, res: Response) => {
|
eventsRouter.put('/move/:eventId', requireCalendarAccess, async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
// Get params
|
const admin = adminOf(res);
|
||||||
let sessionId: string = req.query.sessionId as string ?? '';
|
|
||||||
let sessionKey: string = req.query.sessionKey as string ?? '';
|
|
||||||
let ip: string = req.socket.remoteAddress ?? '';
|
|
||||||
|
|
||||||
let user = await UserService.checkSession(sessionId, sessionKey, ip);
|
|
||||||
|
|
||||||
if (!user?.isActive) {
|
|
||||||
res.status(403).send({'message': 'You do not have access to the specified calendar.'});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
req.params.eventId === undefined ||
|
req.params.eventId === undefined ||
|
||||||
@@ -913,8 +925,9 @@ eventsRouter.put('/move/:eventId', async (req: Request, res: Response) => {
|
|||||||
endDateTime: new Date(req.body.endDateTime),
|
endDateTime: new Date(req.body.endDateTime),
|
||||||
createdDate: new Date(),
|
createdDate: new Date(),
|
||||||
location: req.body.location ?? '',
|
location: req.body.location ?? '',
|
||||||
createdBy: req.body.createdBy ?? '',
|
// LEGACY createdById is deliberately not set: there is no calendar
|
||||||
createdById: user.userId ?? -1,
|
// user id any more, and migration 003 made the column nullable.
|
||||||
|
createdByUserId: admin.id,
|
||||||
url: req.body.url ?? '',
|
url: req.body.url ?? '',
|
||||||
wholeDay: req.body.wholeDay ?? false,
|
wholeDay: req.body.wholeDay ?? false,
|
||||||
repeatFrequency: req.body.repeatFrequency ?? '',
|
repeatFrequency: req.body.repeatFrequency ?? '',
|
||||||
@@ -944,9 +957,11 @@ eventsRouter.put('/move/:eventId', async (req: Request, res: Response) => {
|
|||||||
* /calendar/events/{eventId}:
|
* /calendar/events/{eventId}:
|
||||||
* delete:
|
* delete:
|
||||||
* summary: Delete an event
|
* summary: Delete an event
|
||||||
* description: Deletes an existing event. Authentication required.
|
* description: Deletes an event. Requires a signed-in account with the calendar permission.
|
||||||
* tags:
|
* tags:
|
||||||
* - calendar
|
* - calendar
|
||||||
|
* security:
|
||||||
|
* - AdminSessionCookie: []
|
||||||
* parameters:
|
* parameters:
|
||||||
* - in: path
|
* - in: path
|
||||||
* name: eventId
|
* name: eventId
|
||||||
@@ -954,18 +969,6 @@ eventsRouter.put('/move/:eventId', async (req: Request, res: Response) => {
|
|||||||
* schema:
|
* schema:
|
||||||
* type: integer
|
* type: integer
|
||||||
* description: The ID of the event to delete
|
* description: The ID of the event to delete
|
||||||
* - in: query
|
|
||||||
* name: sessionId
|
|
||||||
* required: true
|
|
||||||
* schema:
|
|
||||||
* type: string
|
|
||||||
* description: Session ID for authentication
|
|
||||||
* - in: query
|
|
||||||
* name: sessionKey
|
|
||||||
* required: true
|
|
||||||
* schema:
|
|
||||||
* type: string
|
|
||||||
* description: Session key for authentication
|
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: Event deleted successfully
|
* description: Event deleted successfully
|
||||||
@@ -987,16 +990,32 @@ eventsRouter.put('/move/:eventId', async (req: Request, res: Response) => {
|
|||||||
* message:
|
* message:
|
||||||
* type: string
|
* type: string
|
||||||
* example: Required parameters missing
|
* example: Required parameters missing
|
||||||
* 403:
|
* 401:
|
||||||
* description: Forbidden - no access to delete events
|
* description: Unauthorized - not signed in
|
||||||
* content:
|
* content:
|
||||||
* application/json:
|
* application/json:
|
||||||
* schema:
|
* schema:
|
||||||
* type: object
|
* type: object
|
||||||
* properties:
|
* properties:
|
||||||
|
* status:
|
||||||
|
* type: string
|
||||||
|
* example: UNAUTHORIZED
|
||||||
* message:
|
* message:
|
||||||
* type: string
|
* type: string
|
||||||
* example: You do not have access to the specified calendar.
|
* example: Anmeldung erforderlich.
|
||||||
|
* 403:
|
||||||
|
* description: Forbidden - the account lacks the calendar permission
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* status:
|
||||||
|
* type: string
|
||||||
|
* example: FORBIDDEN
|
||||||
|
* message:
|
||||||
|
* type: string
|
||||||
|
* example: "Für diesen Bereich fehlt dir die Berechtigung."
|
||||||
* 500:
|
* 500:
|
||||||
* description: Server error
|
* description: Server error
|
||||||
* content:
|
* content:
|
||||||
@@ -1014,19 +1033,9 @@ eventsRouter.put('/move/:eventId', async (req: Request, res: Response) => {
|
|||||||
* type: string
|
* type: string
|
||||||
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
|
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
|
||||||
*/
|
*/
|
||||||
eventsRouter.delete('/:eventId', async (req: Request, res: Response) => {
|
eventsRouter.delete('/:eventId', requireCalendarAccess, async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
// Get params
|
const admin = adminOf(res);
|
||||||
let sessionId: string = req.query.sessionId as string ?? '';
|
|
||||||
let sessionKey: string = req.query.sessionKey as string ?? '';
|
|
||||||
let ip: string = req.socket.remoteAddress ?? '';
|
|
||||||
|
|
||||||
let user = await UserService.checkSession(sessionId, sessionKey, ip);
|
|
||||||
|
|
||||||
if (!user?.isActive) {
|
|
||||||
res.status(403).send({'message': 'You do not have access to the specified calendar.'});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
req.params.eventId === undefined
|
req.params.eventId === undefined
|
||||||
@@ -1046,7 +1055,9 @@ eventsRouter.delete('/:eventId', async (req: Request, res: Response) => {
|
|||||||
createdDate: new Date(),
|
createdDate: new Date(),
|
||||||
location: '',
|
location: '',
|
||||||
createdBy: '',
|
createdBy: '',
|
||||||
createdById: user.userId ?? -1,
|
// LEGACY createdById is deliberately not set: there is no calendar
|
||||||
|
// user id any more, and migration 003 made the column nullable.
|
||||||
|
createdByUserId: admin.id,
|
||||||
url: '',
|
url: '',
|
||||||
wholeDay: false,
|
wholeDay: false,
|
||||||
repeatFrequency: '',
|
repeatFrequency: '',
|
||||||
|
|||||||
@@ -1,29 +1,43 @@
|
|||||||
import * as dotenv from 'dotenv';
|
import * as dotenv from 'dotenv';
|
||||||
import {Guid} from 'guid-typescript';
|
import {Guid} from 'guid-typescript';
|
||||||
import {Event} from './event.interface';
|
import {Event} from './event.interface.js';
|
||||||
import {NachklangCalendarDB} from '../Calendar.db';
|
import {NachklangCalendarDB} from '../Calendar.db.js';
|
||||||
|
import * as AdminUsersService from '../../admin/users/users.admin.service.js';
|
||||||
|
import logger from '../../../middleware/logger.js';
|
||||||
|
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns all events for the given calendar
|
* How an event's creator is resolved, after step 5 of
|
||||||
* @param calendarId The calendar Id
|
* docs/calendar-auth-migration.md removed the legacy path.
|
||||||
|
*
|
||||||
|
* The creator is only ever rendered as a name - nothing authorises on it, there
|
||||||
|
* is no "only the creator may edit" rule anywhere - which is why an unresolvable
|
||||||
|
* one degrades to blank rather than to an error.
|
||||||
|
*
|
||||||
|
* Two sources remain, weaker first:
|
||||||
|
*
|
||||||
|
* 1. `created_by_name`, a snapshot of the name as it stood when the calendar
|
||||||
|
* had its own `users` table. Migration 002 took it, migration 004 dropped
|
||||||
|
* the table it was taken from, and nothing has written it since. It exists
|
||||||
|
* so the authorship of pre-cutover events survived that removal.
|
||||||
|
* 2. The admin module's `user.name`, looked up live for rows carrying an admin
|
||||||
|
* id. It wins, because it is the only one that follows a rename.
|
||||||
|
*
|
||||||
|
* The third source - joining the calendar's own `users` table on
|
||||||
|
* `created_by_id` - is gone with the table and the column.
|
||||||
*/
|
*/
|
||||||
export const getAllEvents = async (calendarId: number): Promise<Event[]> => {
|
|
||||||
let conn = await NachklangCalendarDB.getConnection();
|
|
||||||
let eventRows: Event[] = [];
|
|
||||||
try {
|
|
||||||
const calendarQuery = 'SELECT calendar_id, includes_calendars FROM calendars WHERE calendar_id = ?';
|
|
||||||
const calendarRes = await conn.query(calendarQuery, calendarId);
|
|
||||||
let calendarsToFetch: number[] = [calendarId];
|
|
||||||
for(let row of calendarRes) {
|
|
||||||
let includes: number[] = JSON.parse(row.includes_calendars);
|
|
||||||
calendarsToFetch = [...calendarsToFetch, ...includes];
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventsQuery = `
|
/**
|
||||||
SELECT e.calendar_id, e.uuid, e.created_date, e.created_by_id, u.full_name as created_by_name, u2.full_name as last_modified_by_name, v.* FROM events e
|
* The one SELECT the four read paths share; callers append their own WHERE and
|
||||||
|
* ORDER BY. `v.*` carries the version row's own creator columns, so only the
|
||||||
|
* `events` columns need naming.
|
||||||
|
*
|
||||||
|
* There are no joins to a users table any more. There is no users table.
|
||||||
|
*/
|
||||||
|
const EVENT_SELECT = `
|
||||||
|
SELECT e.calendar_id, e.uuid, e.created_date, e.created_by_user_id, e.created_by_name, v.* FROM events e
|
||||||
INNER JOIN (
|
INNER JOIN (
|
||||||
SELECT event_id, MAX(event_version_id) AS latest_version
|
SELECT event_id, MAX(event_version_id) AS latest_version
|
||||||
FROM event_versions
|
FROM event_versions
|
||||||
@@ -31,15 +45,15 @@ export const getAllEvents = async (calendarId: number): Promise<Event[]> => {
|
|||||||
) latest_versions
|
) latest_versions
|
||||||
ON e.event_id = latest_versions.event_id
|
ON e.event_id = latest_versions.event_id
|
||||||
INNER JOIN event_versions v
|
INNER JOIN event_versions v
|
||||||
ON v.event_id = latest_versions.event_id AND v.event_version_id = latest_versions.latest_version
|
ON v.event_id = latest_versions.event_id AND v.event_version_id = latest_versions.latest_version`;
|
||||||
LEFT OUTER JOIN users u ON u.user_id = e.created_by_id
|
|
||||||
LEFT OUTER JOIN users u2 ON u2.user_id = v.version_created_by_id
|
|
||||||
WHERE e.calendar_id IN (?) AND v.status = 'PUBLIC'
|
|
||||||
ORDER BY e.event_id`;
|
|
||||||
const eventsRes = await conn.query(eventsQuery, [calendarsToFetch]);
|
|
||||||
|
|
||||||
for (let row of eventsRes) {
|
/**
|
||||||
eventRows.push({
|
* Maps a result row to an Event. `status` is included only where it always
|
||||||
|
* was: the admin views and the by-id lookup return it, the two public listings
|
||||||
|
* do not.
|
||||||
|
*/
|
||||||
|
const toEvent = (row: any, includeStatus: boolean): Event => {
|
||||||
|
const event: Event = {
|
||||||
eventId: row.event_id,
|
eventId: row.event_id,
|
||||||
calendarId: row.calendar_id,
|
calendarId: row.calendar_id,
|
||||||
uuid: row.uuid,
|
uuid: row.uuid,
|
||||||
@@ -50,17 +64,101 @@ export const getAllEvents = async (calendarId: number): Promise<Event[]> => {
|
|||||||
createdDate: row.created_date,
|
createdDate: row.created_date,
|
||||||
lastModifiedDate: row.version_created_at,
|
lastModifiedDate: row.version_created_at,
|
||||||
location: row.location,
|
location: row.location,
|
||||||
|
// The archived name. resolveAdminNames below overwrites it for rows that
|
||||||
|
// carry an admin id, which is the only source that follows a rename.
|
||||||
createdBy: row.created_by_name,
|
createdBy: row.created_by_name,
|
||||||
createdById: row.created_by_id,
|
createdByUserId: row.created_by_user_id ?? null,
|
||||||
lastModifiedBy: row.last_modified_by_name,
|
lastModifiedBy: row.version_created_by_name,
|
||||||
lastModifiedById: row.version_created_by_id,
|
lastModifiedByUserId: row.version_created_by_user_id ?? null,
|
||||||
url: row.url,
|
url: row.url,
|
||||||
wholeDay: row.whole_day,
|
wholeDay: row.whole_day,
|
||||||
repeatFrequency: row.repeat_frequency
|
repeatFrequency: row.repeat_frequency
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (includeStatus) {
|
||||||
|
event.status = row.status;
|
||||||
}
|
}
|
||||||
|
|
||||||
return eventRows;
|
return event;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fills in creator/editor names for rows that carry an admin user id, by way
|
||||||
|
* of a single lookup against the admin database. The calendar cannot join
|
||||||
|
* against `user` - it is a different schema behind a different pool - and
|
||||||
|
* making it one would tie the two schemas together as tightly as a foreign key
|
||||||
|
* would.
|
||||||
|
*
|
||||||
|
* A failure here is swallowed on purpose. These endpoints include the public
|
||||||
|
* calendar the website reads anonymously, and a name is decoration: if the
|
||||||
|
* admin database is unreachable, an event should still render with whatever
|
||||||
|
* the snapshot holds rather than 500 the whole listing. The alternative
|
||||||
|
* would widen the public calendar's blast radius to include the admin
|
||||||
|
* database, which it has never depended on before.
|
||||||
|
*/
|
||||||
|
const resolveAdminNames = async (events: Event[]): Promise<void> => {
|
||||||
|
const ids = events
|
||||||
|
.flatMap(event => [event.createdByUserId, event.lastModifiedByUserId])
|
||||||
|
.filter((id): id is string => Boolean(id));
|
||||||
|
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let names: Map<string, string>;
|
||||||
|
try {
|
||||||
|
names = await AdminUsersService.findDisplayNames(ids);
|
||||||
|
} catch (e: any) {
|
||||||
|
logger.warn('Calendar: could not resolve creator names from the admin database: ' + e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
const createdBy = event.createdByUserId ? names.get(event.createdByUserId) : undefined;
|
||||||
|
if (createdBy) {
|
||||||
|
event.createdBy = createdBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastModifiedBy = event.lastModifiedByUserId ? names.get(event.lastModifiedByUserId) : undefined;
|
||||||
|
if (lastModifiedBy) {
|
||||||
|
event.lastModifiedBy = lastModifiedBy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The calendars a listing has to cover: the requested one plus whatever it
|
||||||
|
* declares in `includes_calendars`.
|
||||||
|
*/
|
||||||
|
const calendarsToFetch = async (conn: any, calendarId: number): Promise<number[]> => {
|
||||||
|
const calendarQuery = 'SELECT calendar_id, includes_calendars FROM calendars WHERE calendar_id = ?';
|
||||||
|
const calendarRes = await conn.query(calendarQuery, calendarId);
|
||||||
|
let calendars: number[] = [calendarId];
|
||||||
|
for (let row of calendarRes) {
|
||||||
|
let includes: number[] = JSON.parse(row.includes_calendars);
|
||||||
|
calendars = [...calendars, ...includes];
|
||||||
|
}
|
||||||
|
return calendars;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all events for the given calendar
|
||||||
|
* @param calendarId The calendar Id
|
||||||
|
*/
|
||||||
|
export const getAllEvents = async (calendarId: number): Promise<Event[]> => {
|
||||||
|
let conn = await NachklangCalendarDB.getConnection();
|
||||||
|
try {
|
||||||
|
const calendars = await calendarsToFetch(conn, calendarId);
|
||||||
|
|
||||||
|
const eventsQuery = `${EVENT_SELECT}
|
||||||
|
WHERE e.calendar_id IN (?) AND v.status = 'PUBLIC'
|
||||||
|
ORDER BY e.event_id`;
|
||||||
|
const eventsRes = await conn.query(eventsQuery, [calendars]);
|
||||||
|
|
||||||
|
const events = eventsRes.map((row: any) => toEvent(row, false));
|
||||||
|
await resolveAdminNames(events);
|
||||||
|
|
||||||
|
return events;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -76,48 +174,16 @@ export const getAllEvents = async (calendarId: number): Promise<Event[]> => {
|
|||||||
*/
|
*/
|
||||||
export const getAllEventsAdmin = async (calendarId: number): Promise<Event[]> => {
|
export const getAllEventsAdmin = async (calendarId: number): Promise<Event[]> => {
|
||||||
let conn = await NachklangCalendarDB.getConnection();
|
let conn = await NachklangCalendarDB.getConnection();
|
||||||
let eventRows: Event[] = [];
|
|
||||||
try {
|
try {
|
||||||
const eventsQuery = `
|
const eventsQuery = `${EVENT_SELECT}
|
||||||
SELECT e.calendar_id, e.uuid, e.created_date, e.created_by_id, u.full_name as created_by_name, u2.full_name as last_modified_by_name, v.* FROM events e
|
|
||||||
INNER JOIN (
|
|
||||||
SELECT event_id, MAX(event_version_id) AS latest_version
|
|
||||||
FROM event_versions
|
|
||||||
GROUP BY event_id
|
|
||||||
) latest_versions
|
|
||||||
ON e.event_id = latest_versions.event_id
|
|
||||||
INNER JOIN event_versions v
|
|
||||||
ON v.event_id = latest_versions.event_id AND v.event_version_id = latest_versions.latest_version
|
|
||||||
LEFT OUTER JOIN users u ON u.user_id = e.created_by_id
|
|
||||||
LEFT OUTER JOIN users u2 ON u2.user_id = v.version_created_by_id
|
|
||||||
WHERE e.calendar_id = ?
|
WHERE e.calendar_id = ?
|
||||||
ORDER BY e.event_id`;
|
ORDER BY e.event_id`;
|
||||||
const eventsRes = await conn.query(eventsQuery, calendarId);
|
const eventsRes = await conn.query(eventsQuery, calendarId);
|
||||||
|
|
||||||
for (let row of eventsRes) {
|
const events = eventsRes.map((row: any) => toEvent(row, true));
|
||||||
eventRows.push({
|
await resolveAdminNames(events);
|
||||||
eventId: row.event_id,
|
|
||||||
calendarId: row.calendar_id,
|
|
||||||
uuid: row.uuid,
|
|
||||||
name: row.name,
|
|
||||||
description: row.description,
|
|
||||||
startDateTime: row.start_datetime,
|
|
||||||
endDateTime: row.end_datetime,
|
|
||||||
createdDate: row.created_date,
|
|
||||||
lastModifiedDate: row.version_created_at,
|
|
||||||
location: row.location,
|
|
||||||
createdBy: row.created_by_name,
|
|
||||||
createdById: row.created_by_id,
|
|
||||||
lastModifiedBy: row.last_modified_by_name,
|
|
||||||
lastModifiedById: row.version_created_by_id,
|
|
||||||
url: row.url,
|
|
||||||
wholeDay: row.whole_day,
|
|
||||||
repeatFrequency: row.repeat_frequency,
|
|
||||||
status: row.status
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return eventRows;
|
return events;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -136,18 +202,7 @@ export const getAllEventsAdmin = async (calendarId: number): Promise<Event[]> =>
|
|||||||
export const getEventById = async (eventId: number): Promise<Event | null> => {
|
export const getEventById = async (eventId: number): Promise<Event | null> => {
|
||||||
let conn = await NachklangCalendarDB.getConnection();
|
let conn = await NachklangCalendarDB.getConnection();
|
||||||
try {
|
try {
|
||||||
const eventsQuery = `
|
const eventsQuery = `${EVENT_SELECT}
|
||||||
SELECT e.calendar_id, e.uuid, e.created_date, e.created_by_id, u.full_name as created_by_name, u2.full_name as last_modified_by_name, v.* FROM events e
|
|
||||||
INNER JOIN (
|
|
||||||
SELECT event_id, MAX(event_version_id) AS latest_version
|
|
||||||
FROM event_versions
|
|
||||||
GROUP BY event_id
|
|
||||||
) latest_versions
|
|
||||||
ON e.event_id = latest_versions.event_id
|
|
||||||
INNER JOIN event_versions v
|
|
||||||
ON v.event_id = latest_versions.event_id AND v.event_version_id = latest_versions.latest_version
|
|
||||||
LEFT OUTER JOIN users u ON u.user_id = e.created_by_id
|
|
||||||
LEFT OUTER JOIN users u2 ON u2.user_id = v.version_created_by_id
|
|
||||||
WHERE e.event_id = ?`;
|
WHERE e.event_id = ?`;
|
||||||
const eventsRes = await conn.query(eventsQuery, eventId);
|
const eventsRes = await conn.query(eventsQuery, eventId);
|
||||||
|
|
||||||
@@ -155,27 +210,10 @@ export const getEventById = async (eventId: number): Promise<Event | null> => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const row = eventsRes[0];
|
const event = toEvent(eventsRes[0], true);
|
||||||
return {
|
await resolveAdminNames([event]);
|
||||||
eventId: row.event_id,
|
|
||||||
calendarId: row.calendar_id,
|
return event;
|
||||||
uuid: row.uuid,
|
|
||||||
name: row.name,
|
|
||||||
description: row.description,
|
|
||||||
startDateTime: row.start_datetime,
|
|
||||||
endDateTime: row.end_datetime,
|
|
||||||
createdDate: row.created_date,
|
|
||||||
lastModifiedDate: row.version_created_at,
|
|
||||||
location: row.location,
|
|
||||||
createdBy: row.created_by_name,
|
|
||||||
createdById: row.created_by_id,
|
|
||||||
lastModifiedBy: row.last_modified_by_name,
|
|
||||||
lastModifiedById: row.version_created_by_id,
|
|
||||||
url: row.url,
|
|
||||||
wholeDay: row.whole_day,
|
|
||||||
repeatFrequency: row.repeat_frequency,
|
|
||||||
status: row.status
|
|
||||||
} as Event;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -193,11 +231,11 @@ export const createEvent = async (event: Event): Promise<number> => {
|
|||||||
try {
|
try {
|
||||||
await conn.beginTransaction();
|
await conn.beginTransaction();
|
||||||
let eventUUID = Guid.create().toString();
|
let eventUUID = Guid.create().toString();
|
||||||
const eventsQuery = 'INSERT INTO events (calendar_id, uuid, created_by_id) VALUES (?,?,?) RETURNING event_id';
|
const eventsQuery = 'INSERT INTO events (calendar_id, uuid, created_by_user_id) VALUES (?,?,?) RETURNING event_id';
|
||||||
const eventsRes = await conn.execute(eventsQuery, [event.calendarId, eventUUID, event.createdById]);
|
const eventsRes = await conn.execute(eventsQuery, [event.calendarId, eventUUID, event.createdByUserId ?? null]);
|
||||||
|
|
||||||
const versionQuery = 'INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, repeat_frequency, location, url, status, version_created_by_id) VALUES (?,?,?,?,?,?,?,?,?,?,?);'
|
const versionQuery = 'INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, repeat_frequency, location, url, status, version_created_by_user_id) VALUES (?,?,?,?,?,?,?,?,?,?,?);'
|
||||||
await conn.execute(versionQuery, [eventsRes[0].event_id, event.name, event.description, event.startDateTime, event.endDateTime, event.wholeDay, event.repeatFrequency, event.location, event.url, event.status, event.createdById]);
|
await conn.execute(versionQuery, [eventsRes[0].event_id, event.name, event.description, event.startDateTime, event.endDateTime, event.wholeDay, event.repeatFrequency, event.location, event.url, event.status, event.createdByUserId ?? null]);
|
||||||
|
|
||||||
await conn.commit();
|
await conn.commit();
|
||||||
|
|
||||||
@@ -218,8 +256,8 @@ export const updateEvent = async (event: Event): Promise<number> => {
|
|||||||
let conn = await NachklangCalendarDB.getConnection();
|
let conn = await NachklangCalendarDB.getConnection();
|
||||||
try {
|
try {
|
||||||
await conn.beginTransaction();
|
await conn.beginTransaction();
|
||||||
const versionQuery = 'INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, repeat_frequency, location, url, status, version_created_by_id) VALUES (?,?,?,?,?,?,?,?,?,?,?);'
|
const versionQuery = 'INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, repeat_frequency, location, url, status, version_created_by_user_id) VALUES (?,?,?,?,?,?,?,?,?,?,?);'
|
||||||
const versionRes = await conn.execute(versionQuery, [event.eventId, event.name, event.description, event.startDateTime, event.endDateTime, event.wholeDay, event.repeatFrequency, event.location, event.url, event.status, event.createdById]);
|
const versionRes = await conn.execute(versionQuery, [event.eventId, event.name, event.description, event.startDateTime, event.endDateTime, event.wholeDay, event.repeatFrequency, event.location, event.url, event.status, event.createdByUserId ?? null]);
|
||||||
|
|
||||||
await conn.commit();
|
await conn.commit();
|
||||||
|
|
||||||
@@ -240,8 +278,8 @@ export const deleteEvent = async (event: Event): Promise<boolean> => {
|
|||||||
let conn = await NachklangCalendarDB.getConnection();
|
let conn = await NachklangCalendarDB.getConnection();
|
||||||
try {
|
try {
|
||||||
await conn.beginTransaction();
|
await conn.beginTransaction();
|
||||||
const versionQuery = 'INSERT INTO event_versions (event_id, status, version_created_by_id) VALUES (?,?,?);'
|
const versionQuery = 'INSERT INTO event_versions (event_id, status, version_created_by_user_id) VALUES (?,?,?);'
|
||||||
const versionRes = await conn.execute(versionQuery, [event.eventId, 'DELETED', event.createdById]);
|
const versionRes = await conn.execute(versionQuery, [event.eventId, 'DELETED', event.createdByUserId ?? null]);
|
||||||
|
|
||||||
await conn.commit();
|
await conn.commit();
|
||||||
|
|
||||||
@@ -283,56 +321,23 @@ export const moveEvent = async (event: Event): Promise<boolean> => {
|
|||||||
export const getNextUpcomingEvent = async (calendarId: number): Promise<Event | null> => {
|
export const getNextUpcomingEvent = async (calendarId: number): Promise<Event | null> => {
|
||||||
let conn = await NachklangCalendarDB.getConnection();
|
let conn = await NachklangCalendarDB.getConnection();
|
||||||
try {
|
try {
|
||||||
const calendarQuery = 'SELECT calendar_id, includes_calendars FROM calendars WHERE calendar_id = ?';
|
const calendars = await calendarsToFetch(conn, calendarId);
|
||||||
const calendarRes = await conn.query(calendarQuery, calendarId);
|
|
||||||
let calendarsToFetch: number[] = [calendarId];
|
|
||||||
for(let row of calendarRes) {
|
|
||||||
let includes: number[] = JSON.parse(row.includes_calendars);
|
|
||||||
calendarsToFetch = [...calendarsToFetch, ...includes];
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const eventsQuery = `
|
const eventsQuery = `${EVENT_SELECT}
|
||||||
SELECT e.calendar_id, e.uuid, e.created_date, e.created_by_id, u.full_name as created_by_name, u2.full_name as last_modified_by_name, v.* FROM events e
|
|
||||||
INNER JOIN (
|
|
||||||
SELECT event_id, MAX(event_version_id) AS latest_version
|
|
||||||
FROM event_versions
|
|
||||||
GROUP BY event_id
|
|
||||||
) latest_versions
|
|
||||||
ON e.event_id = latest_versions.event_id
|
|
||||||
INNER JOIN event_versions v
|
|
||||||
ON v.event_id = latest_versions.event_id AND v.event_version_id = latest_versions.latest_version
|
|
||||||
LEFT OUTER JOIN users u ON u.user_id = e.created_by_id
|
|
||||||
LEFT OUTER JOIN users u2 ON u2.user_id = v.version_created_by_id
|
|
||||||
WHERE e.calendar_id IN (?) AND v.status = 'PUBLIC' AND v.start_datetime > ?
|
WHERE e.calendar_id IN (?) AND v.status = 'PUBLIC' AND v.start_datetime > ?
|
||||||
ORDER BY v.start_datetime ASC
|
ORDER BY v.start_datetime ASC
|
||||||
LIMIT 1`;
|
LIMIT 1`;
|
||||||
const eventsRes = await conn.query(eventsQuery, [calendarsToFetch, now]);
|
const eventsRes = await conn.query(eventsQuery, [calendars, now]);
|
||||||
|
|
||||||
if (eventsRes.length === 0) {
|
if (eventsRes.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const row = eventsRes[0];
|
const event = toEvent(eventsRes[0], false);
|
||||||
return {
|
await resolveAdminNames([event]);
|
||||||
eventId: row.event_id,
|
|
||||||
calendarId: row.calendar_id,
|
return event;
|
||||||
uuid: row.uuid,
|
|
||||||
name: row.name,
|
|
||||||
description: row.description,
|
|
||||||
startDateTime: row.start_datetime,
|
|
||||||
endDateTime: row.end_datetime,
|
|
||||||
createdDate: row.created_date,
|
|
||||||
lastModifiedDate: row.version_created_at,
|
|
||||||
location: row.location,
|
|
||||||
createdBy: row.created_by_name,
|
|
||||||
createdById: row.created_by_id,
|
|
||||||
lastModifiedBy: row.last_modified_by_name,
|
|
||||||
lastModifiedById: row.version_created_by_id,
|
|
||||||
url: row.url,
|
|
||||||
wholeDay: row.whole_day,
|
|
||||||
repeatFrequency: row.repeat_frequency
|
|
||||||
} as Event;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {Event} from './event.interface';
|
import {Event} from './event.interface.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface to external classes - Turns the given events into an ical string
|
* Interface to external classes - Turns the given events into an ical string
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
/**
|
|
||||||
* @swagger
|
|
||||||
* components:
|
|
||||||
* schemas:
|
|
||||||
* Session:
|
|
||||||
* type: object
|
|
||||||
* required:
|
|
||||||
* - sessionId
|
|
||||||
* - userId
|
|
||||||
* - sessionKey
|
|
||||||
* - sessionKeyHash
|
|
||||||
* - lastIP
|
|
||||||
* properties:
|
|
||||||
* sessionId:
|
|
||||||
* type: integer
|
|
||||||
* description: The unique identifier for the session
|
|
||||||
* example: 789
|
|
||||||
* userId:
|
|
||||||
* type: integer
|
|
||||||
* description: The ID of the user this session belongs to
|
|
||||||
* example: 456
|
|
||||||
* sessionKey:
|
|
||||||
* type: string
|
|
||||||
* description: The session key used for authentication
|
|
||||||
* example: "abc123def456"
|
|
||||||
* sessionKeyHash:
|
|
||||||
* type: string
|
|
||||||
* description: The hashed session key (not returned in API responses)
|
|
||||||
* example: "$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG"
|
|
||||||
* createdDate:
|
|
||||||
* type: string
|
|
||||||
* format: date-time
|
|
||||||
* description: The date and time when the session was created
|
|
||||||
* example: "2023-05-01T10:00:00.000Z"
|
|
||||||
* validUntil:
|
|
||||||
* type: string
|
|
||||||
* format: date-time
|
|
||||||
* description: The date and time until when the session is valid
|
|
||||||
* example: "2023-05-08T10:00:00.000Z"
|
|
||||||
* lastIP:
|
|
||||||
* type: string
|
|
||||||
* description: The last IP address used with this session
|
|
||||||
* example: "192.168.1.1"
|
|
||||||
*/
|
|
||||||
export interface Session {
|
|
||||||
sessionId: number;
|
|
||||||
userId: number;
|
|
||||||
sessionKey: string;
|
|
||||||
sessionKeyHash: string;
|
|
||||||
createdDate?: Date;
|
|
||||||
validUntil?: Date;
|
|
||||||
lastIP: string;
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
/**
|
|
||||||
* @swagger
|
|
||||||
* components:
|
|
||||||
* schemas:
|
|
||||||
* User:
|
|
||||||
* type: object
|
|
||||||
* required:
|
|
||||||
* - userId
|
|
||||||
* - fullName
|
|
||||||
* - passwordHash
|
|
||||||
* - email
|
|
||||||
* - isActive
|
|
||||||
* properties:
|
|
||||||
* userId:
|
|
||||||
* type: integer
|
|
||||||
* description: The unique identifier for the user
|
|
||||||
* example: 456
|
|
||||||
* fullName:
|
|
||||||
* type: string
|
|
||||||
* description: The full name of the user
|
|
||||||
* example: "John Doe"
|
|
||||||
* passwordHash:
|
|
||||||
* type: string
|
|
||||||
* description: The hashed password of the user (not returned in API responses)
|
|
||||||
* example: "$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG"
|
|
||||||
* email:
|
|
||||||
* type: string
|
|
||||||
* format: email
|
|
||||||
* description: The email address of the user
|
|
||||||
* example: "john.doe@nachklang.art"
|
|
||||||
* isActive:
|
|
||||||
* type: boolean
|
|
||||||
* description: Whether the user account is active
|
|
||||||
* example: true
|
|
||||||
*/
|
|
||||||
export interface User {
|
|
||||||
userId: number;
|
|
||||||
fullName: string;
|
|
||||||
passwordHash: string;
|
|
||||||
email: string;
|
|
||||||
isActive: boolean;
|
|
||||||
}
|
|
||||||
@@ -1,671 +0,0 @@
|
|||||||
/**
|
|
||||||
* Required External Modules and Interfaces
|
|
||||||
*/
|
|
||||||
|
|
||||||
import express, {Request, Response} from 'express';
|
|
||||||
import * as UserService from './users.service';
|
|
||||||
import {Session} from './session.interface';
|
|
||||||
import {User} from './user.interface';
|
|
||||||
import {Guid} from 'guid-typescript';
|
|
||||||
import logger from '../../../middleware/logger';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Router Definition
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const usersRouter = express.Router();
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Controller Definitions
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @swagger
|
|
||||||
* /calendar/users/register:
|
|
||||||
* post:
|
|
||||||
* summary: Register a new user
|
|
||||||
* description: Creates a new user account with the provided email, password, and full name. Only accepts official Nachklang email addresses.
|
|
||||||
* tags:
|
|
||||||
* - calendar
|
|
||||||
* requestBody:
|
|
||||||
* required: true
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* required:
|
|
||||||
* - email
|
|
||||||
* - password
|
|
||||||
* - fullName
|
|
||||||
* properties:
|
|
||||||
* email:
|
|
||||||
* type: string
|
|
||||||
* format: email
|
|
||||||
* example: john.doe@nachklang.art
|
|
||||||
* description: Must be an official Nachklang email address
|
|
||||||
* password:
|
|
||||||
* type: string
|
|
||||||
* format: password
|
|
||||||
* example: securePassword123
|
|
||||||
* fullName:
|
|
||||||
* type: string
|
|
||||||
* example: John Doe
|
|
||||||
* responses:
|
|
||||||
* 201:
|
|
||||||
* description: User registered successfully
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* sessionId:
|
|
||||||
* type: integer
|
|
||||||
* example: 123
|
|
||||||
* sessionKey:
|
|
||||||
* type: string
|
|
||||||
* example: abc123def456
|
|
||||||
* 400:
|
|
||||||
* description: Bad request - missing or invalid parameters
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* message:
|
|
||||||
* type: string
|
|
||||||
* example: Missing parameters
|
|
||||||
* 500:
|
|
||||||
* description: Server error
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* status:
|
|
||||||
* type: string
|
|
||||||
* example: PROCESSING_ERROR
|
|
||||||
* message:
|
|
||||||
* type: string
|
|
||||||
* example: Internal Server Error. Try again later.
|
|
||||||
* reference:
|
|
||||||
* type: string
|
|
||||||
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
|
|
||||||
*/
|
|
||||||
// POST users/register
|
|
||||||
usersRouter.post('/register', async (req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
const password: string = req.body.password;
|
|
||||||
const email: string = req.body.email;
|
|
||||||
const fullName: string = req.body.fullName;
|
|
||||||
const ip: string = req.socket.remoteAddress ?? '';
|
|
||||||
|
|
||||||
if (!password || !email || !fullName) {
|
|
||||||
// Missing
|
|
||||||
res.status(400).send(JSON.stringify({message: 'Missing parameters'}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const emailRegex = /^[a-zA-Z0-9\_\-\.]+@nachklang\.art$/;
|
|
||||||
|
|
||||||
if(!emailRegex.test(email)) {
|
|
||||||
res.status(400).send(JSON.stringify({message: 'Must use an official Nachklang email address'}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the user and a session
|
|
||||||
const session: Session = await UserService.createUser(email, password, fullName, ip);
|
|
||||||
|
|
||||||
// Send the session details back to the user
|
|
||||||
res.status(201).send({
|
|
||||||
sessionId: session.sessionId,
|
|
||||||
sessionKey: session.sessionKey
|
|
||||||
});
|
|
||||||
} catch (e: any) {
|
|
||||||
let errorGuid = Guid.create().toString();
|
|
||||||
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
|
|
||||||
res.status(500).send({
|
|
||||||
'status': 'PROCESSING_ERROR',
|
|
||||||
'message': 'Internal Server Error. Try again later.',
|
|
||||||
'reference': errorGuid
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @swagger
|
|
||||||
* /calendar/users/activate:
|
|
||||||
* get:
|
|
||||||
* summary: Activate a user account
|
|
||||||
* description: Activates a user account using the provided user ID and activation token.
|
|
||||||
* tags:
|
|
||||||
* - calendar
|
|
||||||
* parameters:
|
|
||||||
* - in: query
|
|
||||||
* name: id
|
|
||||||
* required: true
|
|
||||||
* schema:
|
|
||||||
* type: integer
|
|
||||||
* description: The ID of the user to activate
|
|
||||||
* - in: query
|
|
||||||
* name: token
|
|
||||||
* required: true
|
|
||||||
* schema:
|
|
||||||
* type: string
|
|
||||||
* description: The activation token sent to the user's email
|
|
||||||
* responses:
|
|
||||||
* 200:
|
|
||||||
* description: User activated successfully
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* status:
|
|
||||||
* type: string
|
|
||||||
* example: OK
|
|
||||||
* message:
|
|
||||||
* type: string
|
|
||||||
* example: User activated
|
|
||||||
* 400:
|
|
||||||
* description: Bad request - missing parameters or activation failed
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* status:
|
|
||||||
* type: string
|
|
||||||
* example: PROCESSING_ERROR
|
|
||||||
* message:
|
|
||||||
* type: string
|
|
||||||
* example: Error activating user. Please contact your administrator.
|
|
||||||
* 500:
|
|
||||||
* description: Server error
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* status:
|
|
||||||
* type: string
|
|
||||||
* example: PROCESSING_ERROR
|
|
||||||
* message:
|
|
||||||
* type: string
|
|
||||||
* example: Internal Server Error. Try again later.
|
|
||||||
* reference:
|
|
||||||
* type: string
|
|
||||||
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
|
|
||||||
*/
|
|
||||||
// GET /users/activate
|
|
||||||
usersRouter.get('/activate', async (req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
const userId: number = parseInt(req.query.id as string ?? '-1', 10);
|
|
||||||
const token: string = req.query.token as string ?? '';
|
|
||||||
|
|
||||||
if (!userId || !token) {
|
|
||||||
// Missing
|
|
||||||
res.status(400).send(JSON.stringify({message: 'Missing parameters'}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the user and a session
|
|
||||||
const success: boolean = await UserService.activateUser(userId, token);
|
|
||||||
|
|
||||||
// Send the session details back to the user
|
|
||||||
if(success) {
|
|
||||||
res.status(200).send({
|
|
||||||
'status': 'OK',
|
|
||||||
'message': 'User activated'
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.status(400).send({'status': 'PROCESSING_ERROR','message': 'Error activating user. Please contact your administrator.'});
|
|
||||||
} catch (e: any) {
|
|
||||||
let errorGuid = Guid.create().toString();
|
|
||||||
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
|
|
||||||
res.status(500).send({
|
|
||||||
'status': 'PROCESSING_ERROR',
|
|
||||||
'message': 'Internal Server Error. Try again later.',
|
|
||||||
'reference': errorGuid
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @swagger
|
|
||||||
* /calendar/users/login:
|
|
||||||
* post:
|
|
||||||
* summary: Login a user
|
|
||||||
* description: Authenticates a user with the provided email and password and returns a session.
|
|
||||||
* tags:
|
|
||||||
* - calendar
|
|
||||||
* requestBody:
|
|
||||||
* required: true
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* required:
|
|
||||||
* - email
|
|
||||||
* - password
|
|
||||||
* properties:
|
|
||||||
* email:
|
|
||||||
* type: string
|
|
||||||
* format: email
|
|
||||||
* example: john.doe@nachklang.art
|
|
||||||
* password:
|
|
||||||
* type: string
|
|
||||||
* format: password
|
|
||||||
* example: securePassword123
|
|
||||||
* responses:
|
|
||||||
* 200:
|
|
||||||
* description: Login successful
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* sessionId:
|
|
||||||
* type: integer
|
|
||||||
* example: 123
|
|
||||||
* sessionKey:
|
|
||||||
* type: string
|
|
||||||
* example: abc123def456
|
|
||||||
* 400:
|
|
||||||
* description: Bad request - missing parameters
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* message:
|
|
||||||
* type: string
|
|
||||||
* example: Missing parameters
|
|
||||||
* 401:
|
|
||||||
* description: Unauthorized - invalid credentials
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* message:
|
|
||||||
* type: string
|
|
||||||
* example: Wrong username and / or password
|
|
||||||
* sessionId:
|
|
||||||
* type: integer
|
|
||||||
* example: -1
|
|
||||||
* sessionKey:
|
|
||||||
* type: string
|
|
||||||
* example: ""
|
|
||||||
* 500:
|
|
||||||
* description: Server error
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* status:
|
|
||||||
* type: string
|
|
||||||
* example: PROCESSING_ERROR
|
|
||||||
* message:
|
|
||||||
* type: string
|
|
||||||
* example: Internal Server Error. Try again later.
|
|
||||||
* reference:
|
|
||||||
* type: string
|
|
||||||
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
|
|
||||||
*/
|
|
||||||
// POST users/login
|
|
||||||
usersRouter.post('/login', async (req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
const password: string = req.body.password;
|
|
||||||
const email: string = req.body.email;
|
|
||||||
const ip: string = req.socket.remoteAddress ?? '';
|
|
||||||
|
|
||||||
if (!password || !email) {
|
|
||||||
// Missing
|
|
||||||
res.status(400).send(JSON.stringify({message: 'Missing parameters'}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a session
|
|
||||||
const session: Session | null = await UserService.login(email, password, ip);
|
|
||||||
|
|
||||||
if (!session || !session.sessionId) {
|
|
||||||
// Error logging in, probably wrong username / password
|
|
||||||
res.status(401).send(JSON.stringify({message: 'Wrong username and / or password', sessionId: -1, sessionKey: ''}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send the session details back to the user
|
|
||||||
res.status(200).send({
|
|
||||||
sessionId: session.sessionId,
|
|
||||||
sessionKey: session.sessionKey
|
|
||||||
});
|
|
||||||
} catch (e: any) {
|
|
||||||
let errorGuid = Guid.create().toString();
|
|
||||||
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
|
|
||||||
res.status(500).send({
|
|
||||||
'status': 'PROCESSING_ERROR',
|
|
||||||
'message': 'Internal Server Error. Try again later.',
|
|
||||||
'reference': errorGuid
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @swagger
|
|
||||||
* /calendar/users/checkSessionValid:
|
|
||||||
* post:
|
|
||||||
* summary: Check if a session is valid
|
|
||||||
* description: Checks if the provided session is valid and returns the user information if it is.
|
|
||||||
* tags:
|
|
||||||
* - calendar
|
|
||||||
* requestBody:
|
|
||||||
* required: true
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* required:
|
|
||||||
* - sessionId
|
|
||||||
* - sessionKey
|
|
||||||
* properties:
|
|
||||||
* sessionId:
|
|
||||||
* type: integer
|
|
||||||
* example: 123
|
|
||||||
* sessionKey:
|
|
||||||
* type: string
|
|
||||||
* example: abc123def456
|
|
||||||
* responses:
|
|
||||||
* 200:
|
|
||||||
* description: Session is valid
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* $ref: '#/components/schemas/User'
|
|
||||||
* 401:
|
|
||||||
* description: Unauthorized - invalid session
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* messages:
|
|
||||||
* type: array
|
|
||||||
* items:
|
|
||||||
* type: string
|
|
||||||
* example: ["Invalid session"]
|
|
||||||
* 500:
|
|
||||||
* description: Server error
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* status:
|
|
||||||
* type: string
|
|
||||||
* example: PROCESSING_ERROR
|
|
||||||
* message:
|
|
||||||
* type: string
|
|
||||||
* example: Internal Server Error. Try again later.
|
|
||||||
* reference:
|
|
||||||
* type: string
|
|
||||||
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
|
|
||||||
*/
|
|
||||||
// POST users/checkSessionValid
|
|
||||||
usersRouter.post('/checkSessionValid', async (req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
const ip: string = req.socket.remoteAddress ?? '';
|
|
||||||
const session_id = req.body.sessionId;
|
|
||||||
const session_key = req.body.sessionKey;
|
|
||||||
|
|
||||||
if (!session_id || !session_key) {
|
|
||||||
// Error logging in, probably wrong username / password
|
|
||||||
res.status(401).send(JSON.stringify({messages: ['No session detected']}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const user: User | null = await UserService.checkSession(session_id, session_key, ip);
|
|
||||||
|
|
||||||
if (!user || !user.userId) {
|
|
||||||
// Error logging in, probably wrong username / password
|
|
||||||
res.status(401).send(JSON.stringify({messages: ['Invalid session']}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(200).send(user);
|
|
||||||
} catch (e: any) {
|
|
||||||
let errorGuid = Guid.create().toString();
|
|
||||||
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
|
|
||||||
res.status(500).send({
|
|
||||||
'status': 'PROCESSING_ERROR',
|
|
||||||
'message': 'Internal Server Error. Try again later.',
|
|
||||||
'reference': errorGuid
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @swagger
|
|
||||||
* /calendar/users/initiatePasswordReset:
|
|
||||||
* post:
|
|
||||||
* summary: Initiates a password reset
|
|
||||||
* description: Checks if the user exists and if so, initiates a password reset by sending an email to the user.
|
|
||||||
* tags:
|
|
||||||
* - calendar
|
|
||||||
* responses:
|
|
||||||
* 200:
|
|
||||||
* description: Success
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* messages:
|
|
||||||
* type: array
|
|
||||||
* items:
|
|
||||||
* type: string
|
|
||||||
* example: Success
|
|
||||||
* description: A list of status messages
|
|
||||||
* 400:
|
|
||||||
* description: Problem with the request. Please consider the returned detailed error.
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* messages:
|
|
||||||
* type: array
|
|
||||||
* items:
|
|
||||||
* type: string
|
|
||||||
* example: Missing parameters
|
|
||||||
* description: A list of error messages
|
|
||||||
* 401:
|
|
||||||
* description: Problem with authorizing the user. Please check the provided credentials.
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* messages:
|
|
||||||
* type: array
|
|
||||||
* items:
|
|
||||||
* type: string
|
|
||||||
* example: Invalid session
|
|
||||||
* description: A list of error messages
|
|
||||||
* 500:
|
|
||||||
* description: A server error occurred. Please try again. If this issue persists, contact the admin.
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* status:
|
|
||||||
* type: string
|
|
||||||
* description: The response status
|
|
||||||
* example: PROCESSING_ERROR
|
|
||||||
* message:
|
|
||||||
* type: string
|
|
||||||
* description: The detailed error message
|
|
||||||
* example: Internal Server Error. Try again later.
|
|
||||||
* reference:
|
|
||||||
* type: string
|
|
||||||
* description: An error reference for getting support concerning this error.
|
|
||||||
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
|
|
||||||
* requestBody:
|
|
||||||
* required: true
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* email:
|
|
||||||
* type: string
|
|
||||||
* example: patrick@nachklang.art
|
|
||||||
*/
|
|
||||||
usersRouter.post('/initiatePasswordReset', async(req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
const username = req.body.username;
|
|
||||||
|
|
||||||
if (!username) {
|
|
||||||
// Error logging in, probably wrong username / password
|
|
||||||
res.status(400).send(JSON.stringify({messages: ['No username given']}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const success: boolean = await UserService.initiatePasswordReset(username);
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
// Error logging in, probably wrong username / password
|
|
||||||
res.status(401).send(JSON.stringify({messages: ['Error']}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(200).send(JSON.stringify({messages: ['Success']}));
|
|
||||||
} catch (e: any) {
|
|
||||||
let errorGuid = Guid.create().toString();
|
|
||||||
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
|
|
||||||
res.status(500).send({
|
|
||||||
'status': 'PROCESSING_ERROR',
|
|
||||||
'message': 'Internal Server Error. Try again later.',
|
|
||||||
'reference': errorGuid
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @swagger
|
|
||||||
* /calendar/users/finalizePasswordReset:
|
|
||||||
* post:
|
|
||||||
* summary: Finalizes the password reset
|
|
||||||
* description: Checks if the given token is valid and if so, finalizes the password reset by setting the new password.
|
|
||||||
* tags:
|
|
||||||
* - calendar
|
|
||||||
* responses:
|
|
||||||
* 200:
|
|
||||||
* description: Success
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* messages:
|
|
||||||
* type: array
|
|
||||||
* items:
|
|
||||||
* type: string
|
|
||||||
* example: Success
|
|
||||||
* description: A list of status messages
|
|
||||||
* 400:
|
|
||||||
* description: Problem with the request. Please consider the returned detailed error.
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* messages:
|
|
||||||
* type: array
|
|
||||||
* items:
|
|
||||||
* type: string
|
|
||||||
* example: Missing parameters
|
|
||||||
* description: A list of error messages
|
|
||||||
* 401:
|
|
||||||
* description: Problem with authorizing the user. Please check the provided credentials.
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* messages:
|
|
||||||
* type: array
|
|
||||||
* items:
|
|
||||||
* type: string
|
|
||||||
* example: Invalid session
|
|
||||||
* description: A list of error messages
|
|
||||||
* 500:
|
|
||||||
* description: A server error occurred. Please try again. If this issue persists, contact the admin.
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* status:
|
|
||||||
* type: string
|
|
||||||
* description: The response status
|
|
||||||
* example: PROCESSING_ERROR
|
|
||||||
* message:
|
|
||||||
* type: string
|
|
||||||
* description: The detailed error message
|
|
||||||
* example: Internal Server Error. Try again later.
|
|
||||||
* reference:
|
|
||||||
* type: string
|
|
||||||
* description: An error reference for getting support concerning this error.
|
|
||||||
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
|
|
||||||
* requestBody:
|
|
||||||
* required: true
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* email:
|
|
||||||
* type: string
|
|
||||||
* example: patrick@nachklang.art
|
|
||||||
* token:
|
|
||||||
* type: string
|
|
||||||
* example: 3ccd147f-720b-4e29-a8b7-46b63de31555
|
|
||||||
* password:
|
|
||||||
* type: string
|
|
||||||
* example: ExtremelyBadPassword
|
|
||||||
*/
|
|
||||||
usersRouter.post('/finalizePasswordReset', async(req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
const username = req.body.username;
|
|
||||||
const token = req.body.token;
|
|
||||||
const newPassword = req.body.password;
|
|
||||||
|
|
||||||
if (!username) {
|
|
||||||
// Error logging in, probably wrong username / password
|
|
||||||
res.status(400).send(JSON.stringify({messages: ['No username, token or password given']}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const success: boolean = await UserService.finalizePasswordReset(username, token, newPassword);
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
// Error logging in, probably wrong username / password
|
|
||||||
res.status(401).send(JSON.stringify({messages: ['Error']}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(200).send(JSON.stringify({messages: ['Success']}));
|
|
||||||
} catch (e: any) {
|
|
||||||
let errorGuid = Guid.create().toString();
|
|
||||||
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
|
|
||||||
res.status(500).send({
|
|
||||||
'status': 'PROCESSING_ERROR',
|
|
||||||
'message': 'Internal Server Error. Try again later.',
|
|
||||||
'reference': errorGuid
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,296 +0,0 @@
|
|||||||
import * as dotenv from 'dotenv';
|
|
||||||
import * as bcrypt from 'bcrypt';
|
|
||||||
import {Guid} from 'guid-typescript';
|
|
||||||
import {User} from './user.interface';
|
|
||||||
import {Session} from './session.interface';
|
|
||||||
import {NachklangCalendarDB} from '../Calendar.db';
|
|
||||||
import {MailService} from "../../../common/common.mail";
|
|
||||||
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Data Model Interfaces
|
|
||||||
*/
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Service Methods
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a user record in the database, also creates a session. Returns the session if successful.
|
|
||||||
*/
|
|
||||||
export const createUser = async (email: string, password: string, fullName: string, ip: string): Promise<Session> => {
|
|
||||||
let conn = await NachklangCalendarDB.getConnection();
|
|
||||||
try {
|
|
||||||
await conn.beginTransaction();
|
|
||||||
|
|
||||||
// Hash password and generate + hash session key
|
|
||||||
const pwHash = bcrypt.hashSync(password, 10);
|
|
||||||
const sessionKey = Guid.create().toString();
|
|
||||||
const sessionKeyHash = bcrypt.hashSync(sessionKey, 10);
|
|
||||||
|
|
||||||
const activationToken = Guid.create().toString();
|
|
||||||
const activationTokenHash = bcrypt.hashSync(activationToken, 10);
|
|
||||||
|
|
||||||
// Create user entry in SQL
|
|
||||||
const userQuery = 'INSERT INTO users (email, password_hash, full_name, activation_token) VALUES (?, ?, ?, ?) RETURNING user_id';
|
|
||||||
const userIdRes = await conn.query(userQuery, [email, pwHash, fullName, activationTokenHash]);
|
|
||||||
|
|
||||||
// Get user id of the created user
|
|
||||||
let userId: number = -1;
|
|
||||||
for (const row of userIdRes) {
|
|
||||||
userId = row.user_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create session
|
|
||||||
const sessionQuery = 'INSERT INTO sessions (user_id, session_key_hash, created_date, valid_until, last_ip) VALUES (?,?,NOW(),DATE_ADD(NOW(), INTERVAL 30 DAY),?) RETURNING session_id';
|
|
||||||
const sessionIdRes = await conn.query(sessionQuery, [userId, sessionKeyHash, ip]);
|
|
||||||
await conn.commit();
|
|
||||||
|
|
||||||
// Get session id of the created session
|
|
||||||
let sessionId: number = -1;
|
|
||||||
for (const row of sessionIdRes) {
|
|
||||||
sessionId = row.session_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send email with activation link (after commit so we don't block on email
|
|
||||||
// delivery). sendMail never throws on a delivery failure - it logs and
|
|
||||||
// returns false - so a mail-server problem here can't roll back the
|
|
||||||
// already-committed user and leave registration reporting a false error.
|
|
||||||
await MailService.sendMail(email, 'Activate your Nachklang account', `Hi ${fullName},\n\nPlease click on the following link to activate your account:\n\nhttps://api.nachklang.art/calendar/users/activate?id=${userId}&token=${activationToken}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
sessionId: sessionId,
|
|
||||||
userId: userId,
|
|
||||||
sessionKey: sessionKey,
|
|
||||||
sessionKeyHash: 'HIDDEN',
|
|
||||||
lastIP: ip
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
await conn.rollback();
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
await conn.end();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const activateUser = async (userId: number, token: string): Promise<boolean> => {
|
|
||||||
let conn = await NachklangCalendarDB.getConnection();
|
|
||||||
try {
|
|
||||||
await conn.beginTransaction();
|
|
||||||
|
|
||||||
const checkTokenQuery = 'SELECT user_id, activation_token FROM users WHERE user_id = ? AND is_active = 0';
|
|
||||||
const userNameRes = await conn.query(checkTokenQuery, [userId]);
|
|
||||||
let storedTokenHash = '';
|
|
||||||
for (const row of userNameRes) {
|
|
||||||
storedTokenHash = row.activation_token;
|
|
||||||
}
|
|
||||||
if (!storedTokenHash || !bcrypt.compareSync(token, storedTokenHash)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const activateQuery = 'UPDATE users SET is_active = 1, activation_token = null WHERE user_id = ?';
|
|
||||||
const activateRes = await conn.execute(activateQuery, [userId]);
|
|
||||||
await conn.commit();
|
|
||||||
return activateRes.affectedRows !== 0;
|
|
||||||
} catch (err) {
|
|
||||||
await conn.rollback();
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
await conn.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if the given credentials are valid and creates a new session if they are.
|
|
||||||
* Returns the session information in case of a successful login
|
|
||||||
*/
|
|
||||||
export const login = async (email: string, password: string, ip: string): Promise<Session | null> => {
|
|
||||||
let conn = await NachklangCalendarDB.getConnection();
|
|
||||||
try {
|
|
||||||
await conn.beginTransaction();
|
|
||||||
|
|
||||||
// Get saved password hash
|
|
||||||
const query = 'SELECT user_id, password_hash FROM users WHERE email = ?';
|
|
||||||
const userRows = await conn.query(query, email);
|
|
||||||
let savedHash = '';
|
|
||||||
let userId = -1;
|
|
||||||
for (const row of userRows) {
|
|
||||||
savedHash = row.password_hash;
|
|
||||||
userId = row.user_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for correct password
|
|
||||||
if (!bcrypt.compareSync(password, savedHash)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate + hash session key
|
|
||||||
const sessionKey = Guid.create().toString();
|
|
||||||
const sessionKeyHash = bcrypt.hashSync(sessionKey, 10);
|
|
||||||
|
|
||||||
// Create session
|
|
||||||
const sessionQuery = 'INSERT INTO sessions (user_id, session_key_hash, created_date, valid_until, last_ip) VALUES (?,?,NOW(),DATE_ADD(NOW(), INTERVAL 30 DAY),?) RETURNING session_id';
|
|
||||||
const sessionIdRes = await conn.query(sessionQuery, [userId, sessionKeyHash, ip]);
|
|
||||||
await conn.commit();
|
|
||||||
|
|
||||||
// Get session id of the created session
|
|
||||||
let sessionId: number = -1;
|
|
||||||
for (const row of sessionIdRes) {
|
|
||||||
sessionId = row.session_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
sessionId: sessionId,
|
|
||||||
userId: userId,
|
|
||||||
sessionKey: sessionKey,
|
|
||||||
sessionKeyHash: 'HIDDEN',
|
|
||||||
lastIP: ip
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
await conn.rollback();
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
await conn.end();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if the given session information are valid and returns the user information if they are
|
|
||||||
*/
|
|
||||||
export const checkSession = async (sessionId: string, sessionKey: string, ip: string): Promise<User | null> => {
|
|
||||||
let conn = await NachklangCalendarDB.getConnection();
|
|
||||||
try {
|
|
||||||
await conn.beginTransaction();
|
|
||||||
|
|
||||||
// Get saved session key hash
|
|
||||||
const query = 'SELECT user_id, session_key_hash, valid_until FROM sessions WHERE session_id = ?';
|
|
||||||
const sessionRows = await conn.query(query, sessionId);
|
|
||||||
let savedHash = '';
|
|
||||||
let userId = -1;
|
|
||||||
let validUntil = new Date();
|
|
||||||
for (const row of sessionRows) {
|
|
||||||
savedHash = row.session_key_hash;
|
|
||||||
userId = row.user_id;
|
|
||||||
validUntil = row.valid_until;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for correct key
|
|
||||||
if (!bcrypt.compareSync(sessionKey, savedHash)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the session is still valid
|
|
||||||
if (validUntil <= new Date()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update session entry in SQL
|
|
||||||
const updateSessionsQuery = 'UPDATE sessions SET last_IP = ? WHERE session_id = ?';
|
|
||||||
await conn.query(updateSessionsQuery, [ip, sessionId]);
|
|
||||||
await conn.commit();
|
|
||||||
|
|
||||||
// Get the other required user information
|
|
||||||
const userQuery = 'SELECT user_id, email, full_name, is_active FROM users WHERE user_id = ?';
|
|
||||||
const userRows = await conn.query(userQuery, userId);
|
|
||||||
let email = '';
|
|
||||||
let fullName = '';
|
|
||||||
let is_active = false;
|
|
||||||
for (const row of userRows) {
|
|
||||||
email = row.email;
|
|
||||||
fullName = row.full_name;
|
|
||||||
is_active = row.is_active;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Everything is fine, return user information
|
|
||||||
return {
|
|
||||||
userId: userId,
|
|
||||||
email: email,
|
|
||||||
passwordHash: 'HIDDEN',
|
|
||||||
fullName: fullName,
|
|
||||||
isActive: is_active
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
await conn.rollback();
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
await conn.end();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const initiatePasswordReset = async (email: string): Promise<boolean> => {
|
|
||||||
let conn = await NachklangCalendarDB.getConnection();
|
|
||||||
try {
|
|
||||||
await conn.beginTransaction();
|
|
||||||
|
|
||||||
const checkUsernameQuery = 'SELECT user_id, full_Name FROM users WHERE email = ?';
|
|
||||||
const userNameRes = await conn.query(checkUsernameQuery, [email]);
|
|
||||||
if (userNameRes.length === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let userId: number = -1;
|
|
||||||
let fullName: string = '';
|
|
||||||
for(let row of userNameRes) {
|
|
||||||
userId = row.user_id;
|
|
||||||
fullName = row.full_Name;
|
|
||||||
}
|
|
||||||
|
|
||||||
let resetToken = Guid.create().toString();
|
|
||||||
let resetTokenHash = bcrypt.hashSync(resetToken, 10);
|
|
||||||
|
|
||||||
const updateQuery = 'UPDATE users SET pw_reset_token_hash = ? WHERE user_id = ?';
|
|
||||||
const updateRes = await conn.execute(updateQuery, [resetTokenHash, userId]);
|
|
||||||
|
|
||||||
if(updateRes.affectedRows === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
await conn.commit();
|
|
||||||
|
|
||||||
await MailService.sendMail(email, 'Password Reset', `Hello ${fullName},\n\nYou requested a password reset for your BonkApp account. If you did not request this, please ignore this email.\n\nTo reset your password, please use the following reset token:\n\n${resetToken}`);
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
await conn.rollback();
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
await conn.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const finalizePasswordReset = async (email: string, token: string, newPassword: string): Promise<boolean> => {
|
|
||||||
let conn = await NachklangCalendarDB.getConnection();
|
|
||||||
try {
|
|
||||||
await conn.beginTransaction();
|
|
||||||
|
|
||||||
const checkTokenQuery = 'SELECT user_id, pw_reset_token_hash FROM users WHERE email = ?';
|
|
||||||
const userNameRes = await conn.query(checkTokenQuery, [email]);
|
|
||||||
if (userNameRes.length === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let userId: string = '';
|
|
||||||
let tokenHash: string = '';
|
|
||||||
for(let row of userNameRes) {
|
|
||||||
userId = row.user_id;
|
|
||||||
tokenHash = row.pw_reset_token_hash;
|
|
||||||
}
|
|
||||||
|
|
||||||
if(!bcrypt.compareSync(token, tokenHash)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pwHash = bcrypt.hashSync(newPassword, 10);
|
|
||||||
const updatePasswordQuery = 'UPDATE users SET password_hash = ?, pw_reset_token_hash = NULL WHERE user_id = ?';
|
|
||||||
const updateRes = await conn.execute(updatePasswordQuery, [pwHash, userId]);
|
|
||||||
|
|
||||||
if(updateRes.affectedRows > 0) {
|
|
||||||
await conn.commit();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
} catch (err) {
|
|
||||||
await conn.rollback();
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
await conn.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import * as dotenv from 'dotenv';
|
import * as dotenv from 'dotenv';
|
||||||
|
import mariadb from 'mariadb';
|
||||||
const mariadb = require('mariadb');
|
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
@@ -10,8 +9,7 @@ export namespace NachklangFeedbackDB {
|
|||||||
user: process.env.DB_USER,
|
user: process.env.DB_USER,
|
||||||
password: process.env.DB_PASSWORD,
|
password: process.env.DB_PASSWORD,
|
||||||
database: process.env.FEEDBACK_DB,
|
database: process.env.FEEDBACK_DB,
|
||||||
connectionLimit: 5,
|
connectionLimit: 5
|
||||||
autoCommit: false
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getConnection = async () => {
|
export const getConnection = async () => {
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
* Required External Modules and Interfaces
|
* Required External Modules and Interfaces
|
||||||
*/
|
*/
|
||||||
import express, {Request, Response} from 'express';
|
import express, {Request, Response} from 'express';
|
||||||
import {publicRouter} from './public/public.router';
|
import {publicRouter} from './public/public.router.js';
|
||||||
import {adminRouter} from './admin/admin.router';
|
import {adminRouter} from './admin/admin.router.js';
|
||||||
import {sendServerError} from './feedback.errors';
|
import {sendServerError} from './feedback.errors.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Router Definition
|
* Router Definition
|
||||||
|
|||||||
@@ -1,19 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* @swagger
|
* @swagger
|
||||||
* components:
|
* components:
|
||||||
* parameters:
|
|
||||||
* SessionIdHeader:
|
|
||||||
* in: header
|
|
||||||
* name: X-Session-Id
|
|
||||||
* required: true
|
|
||||||
* schema:
|
|
||||||
* type: string
|
|
||||||
* SessionKeyHeader:
|
|
||||||
* in: header
|
|
||||||
* name: X-Session-Key
|
|
||||||
* required: true
|
|
||||||
* schema:
|
|
||||||
* type: string
|
|
||||||
* schemas:
|
* schemas:
|
||||||
* EventAdminSummary:
|
* EventAdminSummary:
|
||||||
* type: object
|
* type: object
|
||||||
@@ -81,7 +68,7 @@
|
|||||||
* type: boolean
|
* type: boolean
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {QuestionType, Song} from '../feedback.interface';
|
import {QuestionType, Song} from '../feedback.interface.js';
|
||||||
|
|
||||||
export interface EventAdminSummary {
|
export interface EventAdminSummary {
|
||||||
eventId: number;
|
eventId: number;
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
* Required External Modules and Interfaces
|
* Required External Modules and Interfaces
|
||||||
*/
|
*/
|
||||||
import express, {Request, Response} from 'express';
|
import express, {Request, Response} from 'express';
|
||||||
import {requireAdminAuth} from '../feedback.auth';
|
import {requireAdminAuth} from '../feedback.auth.js';
|
||||||
import {sendServerError} from '../feedback.errors';
|
import {sendServerError} from '../feedback.errors.js';
|
||||||
import {eventsAdminRouter} from './events.admin.router';
|
import {eventsAdminRouter} from './events.admin.router.js';
|
||||||
import {songsAdminRouter} from './songs.admin.router';
|
import {songsAdminRouter} from './songs.admin.router.js';
|
||||||
import {questionsAdminRouter} from './questions.admin.router';
|
import {questionsAdminRouter} from './questions.admin.router.js';
|
||||||
import {reportsAdminRouter} from './reports.admin.router';
|
import {reportsAdminRouter} from './reports.admin.router.js';
|
||||||
import * as ReportsAdminService from './reports.admin.service';
|
import * as ReportsAdminService from './reports.admin.service.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Router Definition
|
* Router Definition
|
||||||
@@ -26,9 +26,8 @@ adminRouter.use(requireAdminAuth);
|
|||||||
* summary: Validate the current admin session
|
* summary: Validate the current admin session
|
||||||
* description: Used by the Next.js middleware/proxy to gate /admin. Returns the authenticated admin's identity.
|
* description: Used by the Next.js middleware/proxy to gate /admin. Returns the authenticated admin's identity.
|
||||||
* tags: [feedback-admin]
|
* tags: [feedback-admin]
|
||||||
* parameters:
|
* security:
|
||||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
* - AdminSessionCookie: []
|
||||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: Success
|
* description: Success
|
||||||
@@ -43,6 +42,8 @@ adminRouter.use(requireAdminAuth);
|
|||||||
* type: string
|
* type: string
|
||||||
* 401:
|
* 401:
|
||||||
* description: Unauthorized
|
* description: Unauthorized
|
||||||
|
* 403:
|
||||||
|
* description: Signed in without the permission for this app, or account disabled
|
||||||
*/
|
*/
|
||||||
adminRouter.get('/me', (req: Request, res: Response) => {
|
adminRouter.get('/me', (req: Request, res: Response) => {
|
||||||
res.status(200).send({email: res.locals.admin.email, fullName: res.locals.admin.displayName});
|
res.status(200).send({email: res.locals.admin.email, fullName: res.locals.admin.displayName});
|
||||||
@@ -55,9 +56,9 @@ adminRouter.get('/me', (req: Request, res: Response) => {
|
|||||||
* summary: Delete a single submission
|
* summary: Delete a single submission
|
||||||
* description: Removes the submission and everything under it (its answers, guest book entry, newsletter signup) - for removing an individual abusive or inappropriate entry. Not a bulk moderation tool.
|
* description: Removes the submission and everything under it (its answers, guest book entry, newsletter signup) - for removing an individual abusive or inappropriate entry. Not a bulk moderation tool.
|
||||||
* tags: [feedback-admin]
|
* tags: [feedback-admin]
|
||||||
|
* security:
|
||||||
|
* - AdminSessionCookie: []
|
||||||
* parameters:
|
* parameters:
|
||||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
|
||||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
||||||
* - in: path
|
* - in: path
|
||||||
* name: submissionId
|
* name: submissionId
|
||||||
* required: true
|
* required: true
|
||||||
@@ -70,6 +71,8 @@ adminRouter.get('/me', (req: Request, res: Response) => {
|
|||||||
* description: Unknown submission
|
* description: Unknown submission
|
||||||
* 401:
|
* 401:
|
||||||
* description: Unauthorized
|
* description: Unauthorized
|
||||||
|
* 403:
|
||||||
|
* description: Signed in without the permission for this app, or account disabled
|
||||||
*/
|
*/
|
||||||
adminRouter.delete('/submissions/:submissionId', async (req: Request, res: Response) => {
|
adminRouter.delete('/submissions/:submissionId', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {NachklangFeedbackDB} from '../Feedback.db';
|
import {NachklangFeedbackDB} from '../Feedback.db.js';
|
||||||
import {formatDatetime} from '../feedback.dates';
|
import {formatDatetime} from '../feedback.dates.js';
|
||||||
|
|
||||||
const CSV_SEPARATOR = ';';
|
const CSV_SEPARATOR = ';';
|
||||||
const UTF8_BOM = '';
|
const UTF8_BOM = '';
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
* Required External Modules and Interfaces
|
* Required External Modules and Interfaces
|
||||||
*/
|
*/
|
||||||
import express, {Request, Response} from 'express';
|
import express, {Request, Response} from 'express';
|
||||||
import * as EventsAdminService from './events.admin.service';
|
import * as EventsAdminService from './events.admin.service.js';
|
||||||
import * as SongsAdminService from './songs.admin.service';
|
import * as SongsAdminService from './songs.admin.service.js';
|
||||||
import {sendServerError} from '../feedback.errors';
|
import {sendServerError} from '../feedback.errors.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Router Definition
|
* Router Definition
|
||||||
@@ -18,9 +18,8 @@ export const eventsAdminRouter = express.Router();
|
|||||||
* summary: List all events (admin)
|
* summary: List all events (admin)
|
||||||
* description: All events, published or not, past or future, with submission counts.
|
* description: All events, published or not, past or future, with submission counts.
|
||||||
* tags: [feedback-admin]
|
* tags: [feedback-admin]
|
||||||
* parameters:
|
* security:
|
||||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
* - AdminSessionCookie: []
|
||||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: Success
|
* description: Success
|
||||||
@@ -32,13 +31,14 @@ export const eventsAdminRouter = express.Router();
|
|||||||
* $ref: '#/components/schemas/EventAdminSummary'
|
* $ref: '#/components/schemas/EventAdminSummary'
|
||||||
* 401:
|
* 401:
|
||||||
* description: Unauthorized
|
* description: Unauthorized
|
||||||
|
* 403:
|
||||||
|
* description: Signed in without the permission for this app, or account disabled
|
||||||
* post:
|
* post:
|
||||||
* summary: Create an event
|
* summary: Create an event
|
||||||
* description: Auto-generates the slug from the name and event year; defaults feedback_deadline to event_date + 14 days 23:59:59 unless supplied.
|
* description: Auto-generates the slug from the name and event year; defaults feedback_deadline to event_date + 14 days 23:59:59 unless supplied.
|
||||||
* tags: [feedback-admin]
|
* tags: [feedback-admin]
|
||||||
* parameters:
|
* security:
|
||||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
* - AdminSessionCookie: []
|
||||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
||||||
* requestBody:
|
* requestBody:
|
||||||
* required: true
|
* required: true
|
||||||
* content:
|
* content:
|
||||||
@@ -68,6 +68,8 @@ export const eventsAdminRouter = express.Router();
|
|||||||
* description: Missing required fields
|
* description: Missing required fields
|
||||||
* 401:
|
* 401:
|
||||||
* description: Unauthorized
|
* description: Unauthorized
|
||||||
|
* 403:
|
||||||
|
* description: Signed in without the permission for this app, or account disabled
|
||||||
*/
|
*/
|
||||||
eventsAdminRouter.get('/', async (req: Request, res: Response) => {
|
eventsAdminRouter.get('/', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
@@ -101,9 +103,9 @@ eventsAdminRouter.post('/', async (req: Request, res: Response) => {
|
|||||||
* summary: Get one event (admin)
|
* summary: Get one event (admin)
|
||||||
* description: Full event detail including setlist and assigned questions.
|
* description: Full event detail including setlist and assigned questions.
|
||||||
* tags: [feedback-admin]
|
* tags: [feedback-admin]
|
||||||
|
* security:
|
||||||
|
* - AdminSessionCookie: []
|
||||||
* parameters:
|
* parameters:
|
||||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
|
||||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
||||||
* - in: path
|
* - in: path
|
||||||
* name: eventId
|
* name: eventId
|
||||||
* required: true
|
* required: true
|
||||||
@@ -120,12 +122,14 @@ eventsAdminRouter.post('/', async (req: Request, res: Response) => {
|
|||||||
* description: Unknown event
|
* description: Unknown event
|
||||||
* 401:
|
* 401:
|
||||||
* description: Unauthorized
|
* description: Unauthorized
|
||||||
|
* 403:
|
||||||
|
* description: Signed in without the permission for this app, or account disabled
|
||||||
* put:
|
* put:
|
||||||
* summary: Update an event
|
* summary: Update an event
|
||||||
* tags: [feedback-admin]
|
* tags: [feedback-admin]
|
||||||
|
* security:
|
||||||
|
* - AdminSessionCookie: []
|
||||||
* parameters:
|
* parameters:
|
||||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
|
||||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
||||||
* - in: path
|
* - in: path
|
||||||
* name: eventId
|
* name: eventId
|
||||||
* required: true
|
* required: true
|
||||||
@@ -138,13 +142,15 @@ eventsAdminRouter.post('/', async (req: Request, res: Response) => {
|
|||||||
* description: Unknown event
|
* description: Unknown event
|
||||||
* 401:
|
* 401:
|
||||||
* description: Unauthorized
|
* description: Unauthorized
|
||||||
|
* 403:
|
||||||
|
* description: Signed in without the permission for this app, or account disabled
|
||||||
* delete:
|
* delete:
|
||||||
* summary: Delete an event
|
* summary: Delete an event
|
||||||
* description: Refuses with 409 if submissions exist unless ?force=true is passed.
|
* description: Refuses with 409 if submissions exist unless ?force=true is passed.
|
||||||
* tags: [feedback-admin]
|
* tags: [feedback-admin]
|
||||||
|
* security:
|
||||||
|
* - AdminSessionCookie: []
|
||||||
* parameters:
|
* parameters:
|
||||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
|
||||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
||||||
* - in: path
|
* - in: path
|
||||||
* name: eventId
|
* name: eventId
|
||||||
* required: true
|
* required: true
|
||||||
@@ -163,6 +169,8 @@ eventsAdminRouter.post('/', async (req: Request, res: Response) => {
|
|||||||
* description: Submissions exist and force was not set
|
* description: Submissions exist and force was not set
|
||||||
* 401:
|
* 401:
|
||||||
* description: Unauthorized
|
* description: Unauthorized
|
||||||
|
* 403:
|
||||||
|
* description: Signed in without the permission for this app, or account disabled
|
||||||
*/
|
*/
|
||||||
eventsAdminRouter.get('/:eventId', async (req: Request, res: Response) => {
|
eventsAdminRouter.get('/:eventId', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
@@ -214,9 +222,9 @@ eventsAdminRouter.delete('/:eventId', async (req: Request, res: Response) => {
|
|||||||
* get:
|
* get:
|
||||||
* summary: Get an event's setlist
|
* summary: Get an event's setlist
|
||||||
* tags: [feedback-admin]
|
* tags: [feedback-admin]
|
||||||
|
* security:
|
||||||
|
* - AdminSessionCookie: []
|
||||||
* parameters:
|
* parameters:
|
||||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
|
||||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
||||||
* - in: path
|
* - in: path
|
||||||
* name: eventId
|
* name: eventId
|
||||||
* required: true
|
* required: true
|
||||||
@@ -227,12 +235,14 @@ eventsAdminRouter.delete('/:eventId', async (req: Request, res: Response) => {
|
|||||||
* description: Success
|
* description: Success
|
||||||
* 401:
|
* 401:
|
||||||
* description: Unauthorized
|
* description: Unauthorized
|
||||||
|
* 403:
|
||||||
|
* description: Signed in without the permission for this app, or account disabled
|
||||||
* post:
|
* post:
|
||||||
* summary: Add a song to an event's setlist
|
* summary: Add a song to an event's setlist
|
||||||
* tags: [feedback-admin]
|
* tags: [feedback-admin]
|
||||||
|
* security:
|
||||||
|
* - AdminSessionCookie: []
|
||||||
* parameters:
|
* parameters:
|
||||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
|
||||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
||||||
* - in: path
|
* - in: path
|
||||||
* name: eventId
|
* name: eventId
|
||||||
* required: true
|
* required: true
|
||||||
@@ -257,6 +267,8 @@ eventsAdminRouter.delete('/:eventId', async (req: Request, res: Response) => {
|
|||||||
* description: Missing title
|
* description: Missing title
|
||||||
* 401:
|
* 401:
|
||||||
* description: Unauthorized
|
* description: Unauthorized
|
||||||
|
* 403:
|
||||||
|
* description: Signed in without the permission for this app, or account disabled
|
||||||
*/
|
*/
|
||||||
eventsAdminRouter.get('/:eventId/songs', async (req: Request, res: Response) => {
|
eventsAdminRouter.get('/:eventId/songs', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
@@ -292,9 +304,9 @@ eventsAdminRouter.post('/:eventId/songs', async (req: Request, res: Response) =>
|
|||||||
* summary: Bulk reorder an event's setlist
|
* summary: Bulk reorder an event's setlist
|
||||||
* description: Rewrites song positions as a dense 0..n-1 sequence in one transaction.
|
* description: Rewrites song positions as a dense 0..n-1 sequence in one transaction.
|
||||||
* tags: [feedback-admin]
|
* tags: [feedback-admin]
|
||||||
|
* security:
|
||||||
|
* - AdminSessionCookie: []
|
||||||
* parameters:
|
* parameters:
|
||||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
|
||||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
||||||
* - in: path
|
* - in: path
|
||||||
* name: eventId
|
* name: eventId
|
||||||
* required: true
|
* required: true
|
||||||
@@ -317,6 +329,8 @@ eventsAdminRouter.post('/:eventId/songs', async (req: Request, res: Response) =>
|
|||||||
* description: Reordered
|
* description: Reordered
|
||||||
* 401:
|
* 401:
|
||||||
* description: Unauthorized
|
* description: Unauthorized
|
||||||
|
* 403:
|
||||||
|
* description: Signed in without the permission for this app, or account disabled
|
||||||
*/
|
*/
|
||||||
eventsAdminRouter.put('/:eventId/songs/order', async (req: Request, res: Response) => {
|
eventsAdminRouter.put('/:eventId/songs/order', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
@@ -334,9 +348,9 @@ eventsAdminRouter.put('/:eventId/songs/order', async (req: Request, res: Respons
|
|||||||
* get:
|
* get:
|
||||||
* summary: Get an event's assigned questions
|
* summary: Get an event's assigned questions
|
||||||
* tags: [feedback-admin]
|
* tags: [feedback-admin]
|
||||||
|
* security:
|
||||||
|
* - AdminSessionCookie: []
|
||||||
* parameters:
|
* parameters:
|
||||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
|
||||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
||||||
* - in: path
|
* - in: path
|
||||||
* name: eventId
|
* name: eventId
|
||||||
* required: true
|
* required: true
|
||||||
@@ -347,13 +361,15 @@ eventsAdminRouter.put('/:eventId/songs/order', async (req: Request, res: Respons
|
|||||||
* description: Success
|
* description: Success
|
||||||
* 401:
|
* 401:
|
||||||
* description: Unauthorized
|
* description: Unauthorized
|
||||||
|
* 403:
|
||||||
|
* description: Signed in without the permission for this app, or account disabled
|
||||||
* put:
|
* put:
|
||||||
* summary: Bulk-set an event's assigned questions
|
* summary: Bulk-set an event's assigned questions
|
||||||
* description: One transaction - inserts new, updates existing, deletes removed. Keeps the admin UI a simple save-the-whole-list form.
|
* description: One transaction - inserts new, updates existing, deletes removed. Keeps the admin UI a simple save-the-whole-list form.
|
||||||
* tags: [feedback-admin]
|
* tags: [feedback-admin]
|
||||||
|
* security:
|
||||||
|
* - AdminSessionCookie: []
|
||||||
* parameters:
|
* parameters:
|
||||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
|
||||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
||||||
* - in: path
|
* - in: path
|
||||||
* name: eventId
|
* name: eventId
|
||||||
* required: true
|
* required: true
|
||||||
@@ -383,6 +399,8 @@ eventsAdminRouter.put('/:eventId/songs/order', async (req: Request, res: Respons
|
|||||||
* description: Saved
|
* description: Saved
|
||||||
* 401:
|
* 401:
|
||||||
* description: Unauthorized
|
* description: Unauthorized
|
||||||
|
* 403:
|
||||||
|
* description: Signed in without the permission for this app, or account disabled
|
||||||
*/
|
*/
|
||||||
eventsAdminRouter.get('/:eventId/questions', async (req: Request, res: Response) => {
|
eventsAdminRouter.get('/:eventId/questions', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {NachklangFeedbackDB} from '../Feedback.db';
|
import {NachklangFeedbackDB} from '../Feedback.db.js';
|
||||||
import {Song} from '../feedback.interface';
|
import {Song} from '../feedback.interface.js';
|
||||||
import {CreateEventInput, EventAdminDetail, EventAdminQuestionAssignment, EventAdminSummary, UpdateEventInput} from './admin.interface';
|
import {CreateEventInput, EventAdminDetail, EventAdminQuestionAssignment, EventAdminSummary, UpdateEventInput} from './admin.interface.js';
|
||||||
import {formatDatetime} from '../feedback.dates';
|
import {formatDatetime} from '../feedback.dates.js';
|
||||||
|
|
||||||
const UMLAUT_MAP: Record<string, string> = {
|
const UMLAUT_MAP: Record<string, string> = {
|
||||||
'ä': 'ae', 'ö': 'oe', 'ü': 'ue', 'ß': 'ss',
|
'ä': 'ae', 'ö': 'oe', 'ü': 'ue', 'ß': 'ss',
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
* Required External Modules and Interfaces
|
* Required External Modules and Interfaces
|
||||||
*/
|
*/
|
||||||
import express, {Request, Response} from 'express';
|
import express, {Request, Response} from 'express';
|
||||||
import * as QuestionsAdminService from './questions.admin.service';
|
import * as QuestionsAdminService from './questions.admin.service.js';
|
||||||
import {sendServerError} from '../feedback.errors';
|
import {sendServerError} from '../feedback.errors.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Router Definition
|
* Router Definition
|
||||||
@@ -16,9 +16,9 @@ export const questionsAdminRouter = express.Router();
|
|||||||
* get:
|
* get:
|
||||||
* summary: List the question library
|
* summary: List the question library
|
||||||
* tags: [feedback-admin]
|
* tags: [feedback-admin]
|
||||||
|
* security:
|
||||||
|
* - AdminSessionCookie: []
|
||||||
* parameters:
|
* parameters:
|
||||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
|
||||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
||||||
* - in: query
|
* - in: query
|
||||||
* name: includeArchived
|
* name: includeArchived
|
||||||
* schema:
|
* schema:
|
||||||
@@ -34,12 +34,13 @@ export const questionsAdminRouter = express.Router();
|
|||||||
* $ref: '#/components/schemas/AdminQuestion'
|
* $ref: '#/components/schemas/AdminQuestion'
|
||||||
* 401:
|
* 401:
|
||||||
* description: Unauthorized
|
* description: Unauthorized
|
||||||
|
* 403:
|
||||||
|
* description: Signed in without the permission for this app, or account disabled
|
||||||
* post:
|
* post:
|
||||||
* summary: Create a question
|
* summary: Create a question
|
||||||
* tags: [feedback-admin]
|
* tags: [feedback-admin]
|
||||||
* parameters:
|
* security:
|
||||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
* - AdminSessionCookie: []
|
||||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
||||||
* requestBody:
|
* requestBody:
|
||||||
* required: true
|
* required: true
|
||||||
* content:
|
* content:
|
||||||
@@ -61,6 +62,8 @@ export const questionsAdminRouter = express.Router();
|
|||||||
* description: Missing or invalid fields
|
* description: Missing or invalid fields
|
||||||
* 401:
|
* 401:
|
||||||
* description: Unauthorized
|
* description: Unauthorized
|
||||||
|
* 403:
|
||||||
|
* description: Signed in without the permission for this app, or account disabled
|
||||||
*/
|
*/
|
||||||
questionsAdminRouter.get('/', async (req: Request, res: Response) => {
|
questionsAdminRouter.get('/', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
@@ -94,9 +97,9 @@ questionsAdminRouter.post('/', async (req: Request, res: Response) => {
|
|||||||
* summary: Edit a question's label/help text
|
* summary: Edit a question's label/help text
|
||||||
* description: question_type is immutable after creation - the admin UI offers "archive and create new" instead.
|
* description: question_type is immutable after creation - the admin UI offers "archive and create new" instead.
|
||||||
* tags: [feedback-admin]
|
* tags: [feedback-admin]
|
||||||
|
* security:
|
||||||
|
* - AdminSessionCookie: []
|
||||||
* parameters:
|
* parameters:
|
||||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
|
||||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
||||||
* - in: path
|
* - in: path
|
||||||
* name: questionId
|
* name: questionId
|
||||||
* required: true
|
* required: true
|
||||||
@@ -123,13 +126,15 @@ questionsAdminRouter.post('/', async (req: Request, res: Response) => {
|
|||||||
* description: Unknown question
|
* description: Unknown question
|
||||||
* 401:
|
* 401:
|
||||||
* description: Unauthorized
|
* description: Unauthorized
|
||||||
|
* 403:
|
||||||
|
* description: Signed in without the permission for this app, or account disabled
|
||||||
* delete:
|
* delete:
|
||||||
* summary: Archive (or hard-delete) a question
|
* summary: Archive (or hard-delete) a question
|
||||||
* description: Archives the question if it has ever been used; hard-deletes it if it has never been assigned to any event.
|
* description: Archives the question if it has ever been used; hard-deletes it if it has never been assigned to any event.
|
||||||
* tags: [feedback-admin]
|
* tags: [feedback-admin]
|
||||||
|
* security:
|
||||||
|
* - AdminSessionCookie: []
|
||||||
* parameters:
|
* parameters:
|
||||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
|
||||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
||||||
* - in: path
|
* - in: path
|
||||||
* name: questionId
|
* name: questionId
|
||||||
* required: true
|
* required: true
|
||||||
@@ -142,6 +147,8 @@ questionsAdminRouter.post('/', async (req: Request, res: Response) => {
|
|||||||
* description: Unknown question
|
* description: Unknown question
|
||||||
* 401:
|
* 401:
|
||||||
* description: Unauthorized
|
* description: Unauthorized
|
||||||
|
* 403:
|
||||||
|
* description: Signed in without the permission for this app, or account disabled
|
||||||
*/
|
*/
|
||||||
questionsAdminRouter.put('/:questionId', async (req: Request, res: Response) => {
|
questionsAdminRouter.put('/:questionId', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {NachklangFeedbackDB} from '../Feedback.db';
|
import {NachklangFeedbackDB} from '../Feedback.db.js';
|
||||||
import {QuestionType} from '../feedback.interface';
|
import {QuestionType} from '../feedback.interface.js';
|
||||||
import {AdminQuestion} from './admin.interface';
|
import {AdminQuestion} from './admin.interface.js';
|
||||||
|
|
||||||
const mapRow = (row: any): AdminQuestion => ({
|
const mapRow = (row: any): AdminQuestion => ({
|
||||||
questionId: row.question_id,
|
questionId: row.question_id,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {QuestionType} from '../feedback.interface';
|
import {QuestionType} from '../feedback.interface.js';
|
||||||
|
|
||||||
export interface SongPickResult {
|
export interface SongPickResult {
|
||||||
songId: number;
|
songId: number;
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
* Required External Modules and Interfaces
|
* Required External Modules and Interfaces
|
||||||
*/
|
*/
|
||||||
import express, {Request, Response} from 'express';
|
import express, {Request, Response} from 'express';
|
||||||
import * as ReportsAdminService from './reports.admin.service';
|
import * as ReportsAdminService from './reports.admin.service.js';
|
||||||
import * as CsvService from './csv.service';
|
import * as CsvService from './csv.service.js';
|
||||||
import * as EventsAdminService from './events.admin.service';
|
import * as EventsAdminService from './events.admin.service.js';
|
||||||
import {sendServerError} from '../feedback.errors';
|
import {sendServerError} from '../feedback.errors.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Router Definition
|
* Router Definition
|
||||||
@@ -19,9 +19,9 @@ export const reportsAdminRouter = express.Router();
|
|||||||
* summary: Aggregated feedback report for one event
|
* summary: Aggregated feedback report for one event
|
||||||
* description: Song-pick vote counts, song-rating averages, capped free-text list, guest book count, and newsletter sync counts.
|
* description: Song-pick vote counts, song-rating averages, capped free-text list, guest book count, and newsletter sync counts.
|
||||||
* tags: [feedback-admin]
|
* tags: [feedback-admin]
|
||||||
|
* security:
|
||||||
|
* - AdminSessionCookie: []
|
||||||
* parameters:
|
* parameters:
|
||||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
|
||||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
||||||
* - in: path
|
* - in: path
|
||||||
* name: eventId
|
* name: eventId
|
||||||
* required: true
|
* required: true
|
||||||
@@ -34,6 +34,8 @@ export const reportsAdminRouter = express.Router();
|
|||||||
* description: Unknown event
|
* description: Unknown event
|
||||||
* 401:
|
* 401:
|
||||||
* description: Unauthorized
|
* description: Unauthorized
|
||||||
|
* 403:
|
||||||
|
* description: Signed in without the permission for this app, or account disabled
|
||||||
*/
|
*/
|
||||||
reportsAdminRouter.get('/:eventId/report', async (req: Request, res: Response) => {
|
reportsAdminRouter.get('/:eventId/report', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
@@ -55,9 +57,9 @@ reportsAdminRouter.get('/:eventId/report', async (req: Request, res: Response) =
|
|||||||
* summary: Guest Book entries for one event
|
* summary: Guest Book entries for one event
|
||||||
* description: Newest first, paginated.
|
* description: Newest first, paginated.
|
||||||
* tags: [feedback-admin]
|
* tags: [feedback-admin]
|
||||||
|
* security:
|
||||||
|
* - AdminSessionCookie: []
|
||||||
* parameters:
|
* parameters:
|
||||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
|
||||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
||||||
* - in: path
|
* - in: path
|
||||||
* name: eventId
|
* name: eventId
|
||||||
* required: true
|
* required: true
|
||||||
@@ -81,6 +83,8 @@ reportsAdminRouter.get('/:eventId/report', async (req: Request, res: Response) =
|
|||||||
* description: Success
|
* description: Success
|
||||||
* 401:
|
* 401:
|
||||||
* description: Unauthorized
|
* description: Unauthorized
|
||||||
|
* 403:
|
||||||
|
* description: Signed in without the permission for this app, or account disabled
|
||||||
*/
|
*/
|
||||||
reportsAdminRouter.get('/:eventId/guestbook', async (req: Request, res: Response) => {
|
reportsAdminRouter.get('/:eventId/guestbook', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
@@ -101,9 +105,9 @@ reportsAdminRouter.get('/:eventId/guestbook', async (req: Request, res: Response
|
|||||||
* summary: Newsletter signups for one event
|
* summary: Newsletter signups for one event
|
||||||
* description: Includes sync_status, so failures can be handled manually. Newest first, paginated.
|
* description: Includes sync_status, so failures can be handled manually. Newest first, paginated.
|
||||||
* tags: [feedback-admin]
|
* tags: [feedback-admin]
|
||||||
|
* security:
|
||||||
|
* - AdminSessionCookie: []
|
||||||
* parameters:
|
* parameters:
|
||||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
|
||||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
||||||
* - in: path
|
* - in: path
|
||||||
* name: eventId
|
* name: eventId
|
||||||
* required: true
|
* required: true
|
||||||
@@ -127,6 +131,8 @@ reportsAdminRouter.get('/:eventId/guestbook', async (req: Request, res: Response
|
|||||||
* description: Success
|
* description: Success
|
||||||
* 401:
|
* 401:
|
||||||
* description: Unauthorized
|
* description: Unauthorized
|
||||||
|
* 403:
|
||||||
|
* description: Signed in without the permission for this app, or account disabled
|
||||||
*/
|
*/
|
||||||
reportsAdminRouter.get('/:eventId/newsletter', async (req: Request, res: Response) => {
|
reportsAdminRouter.get('/:eventId/newsletter', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
@@ -147,9 +153,9 @@ reportsAdminRouter.get('/:eventId/newsletter', async (req: Request, res: Respons
|
|||||||
* summary: CSV export of all answers for one event
|
* summary: CSV export of all answers for one event
|
||||||
* description: Long format, one row per answer. UTF-8 BOM, `;` separator, RFC 4180 escaping, formula-injection guard.
|
* description: Long format, one row per answer. UTF-8 BOM, `;` separator, RFC 4180 escaping, formula-injection guard.
|
||||||
* tags: [feedback-admin]
|
* tags: [feedback-admin]
|
||||||
|
* security:
|
||||||
|
* - AdminSessionCookie: []
|
||||||
* parameters:
|
* parameters:
|
||||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
|
||||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
||||||
* - in: path
|
* - in: path
|
||||||
* name: eventId
|
* name: eventId
|
||||||
* required: true
|
* required: true
|
||||||
@@ -162,6 +168,8 @@ reportsAdminRouter.get('/:eventId/newsletter', async (req: Request, res: Respons
|
|||||||
* text/csv: {}
|
* text/csv: {}
|
||||||
* 401:
|
* 401:
|
||||||
* description: Unauthorized
|
* description: Unauthorized
|
||||||
|
* 403:
|
||||||
|
* description: Signed in without the permission for this app, or account disabled
|
||||||
*/
|
*/
|
||||||
reportsAdminRouter.get('/:eventId/export/responses.csv', async (req: Request, res: Response) => {
|
reportsAdminRouter.get('/:eventId/export/responses.csv', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
@@ -187,9 +195,9 @@ reportsAdminRouter.get('/:eventId/export/responses.csv', async (req: Request, re
|
|||||||
* get:
|
* get:
|
||||||
* summary: CSV export of Guest Book entries for one event
|
* summary: CSV export of Guest Book entries for one event
|
||||||
* tags: [feedback-admin]
|
* tags: [feedback-admin]
|
||||||
|
* security:
|
||||||
|
* - AdminSessionCookie: []
|
||||||
* parameters:
|
* parameters:
|
||||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
|
||||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
||||||
* - in: path
|
* - in: path
|
||||||
* name: eventId
|
* name: eventId
|
||||||
* required: true
|
* required: true
|
||||||
@@ -202,6 +210,8 @@ reportsAdminRouter.get('/:eventId/export/responses.csv', async (req: Request, re
|
|||||||
* text/csv: {}
|
* text/csv: {}
|
||||||
* 401:
|
* 401:
|
||||||
* description: Unauthorized
|
* description: Unauthorized
|
||||||
|
* 403:
|
||||||
|
* description: Signed in without the permission for this app, or account disabled
|
||||||
*/
|
*/
|
||||||
reportsAdminRouter.get('/:eventId/export/guestbook.csv', async (req: Request, res: Response) => {
|
reportsAdminRouter.get('/:eventId/export/guestbook.csv', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {NachklangFeedbackDB} from '../Feedback.db';
|
import {NachklangFeedbackDB} from '../Feedback.db.js';
|
||||||
import {
|
import {
|
||||||
AnswerRow, EventReport, FreeTextReport, SongPickReport, SongRatingReport
|
AnswerRow, EventReport, FreeTextReport, SongPickReport, SongRatingReport
|
||||||
} from './reports.admin.interface';
|
} from './reports.admin.interface.js';
|
||||||
|
|
||||||
const FREE_TEXT_CAP = 500;
|
const FREE_TEXT_CAP = 500;
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
* Required External Modules and Interfaces
|
* Required External Modules and Interfaces
|
||||||
*/
|
*/
|
||||||
import express, {Request, Response} from 'express';
|
import express, {Request, Response} from 'express';
|
||||||
import * as SongsAdminService from './songs.admin.service';
|
import * as SongsAdminService from './songs.admin.service.js';
|
||||||
import {sendServerError} from '../feedback.errors';
|
import {sendServerError} from '../feedback.errors.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Router Definition
|
* Router Definition
|
||||||
@@ -16,9 +16,9 @@ export const songsAdminRouter = express.Router();
|
|||||||
* put:
|
* put:
|
||||||
* summary: Edit a song's title/composer
|
* summary: Edit a song's title/composer
|
||||||
* tags: [feedback-admin]
|
* tags: [feedback-admin]
|
||||||
|
* security:
|
||||||
|
* - AdminSessionCookie: []
|
||||||
* parameters:
|
* parameters:
|
||||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
|
||||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
||||||
* - in: path
|
* - in: path
|
||||||
* name: songId
|
* name: songId
|
||||||
* required: true
|
* required: true
|
||||||
@@ -45,13 +45,15 @@ export const songsAdminRouter = express.Router();
|
|||||||
* description: Unknown song
|
* description: Unknown song
|
||||||
* 401:
|
* 401:
|
||||||
* description: Unauthorized
|
* description: Unauthorized
|
||||||
|
* 403:
|
||||||
|
* description: Signed in without the permission for this app, or account disabled
|
||||||
* delete:
|
* delete:
|
||||||
* summary: Remove a song
|
* summary: Remove a song
|
||||||
* description: Past answers keep their song_title_snapshot even after the song is removed.
|
* description: Past answers keep their song_title_snapshot even after the song is removed.
|
||||||
* tags: [feedback-admin]
|
* tags: [feedback-admin]
|
||||||
|
* security:
|
||||||
|
* - AdminSessionCookie: []
|
||||||
* parameters:
|
* parameters:
|
||||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
|
||||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
||||||
* - in: path
|
* - in: path
|
||||||
* name: songId
|
* name: songId
|
||||||
* required: true
|
* required: true
|
||||||
@@ -64,6 +66,8 @@ export const songsAdminRouter = express.Router();
|
|||||||
* description: Unknown song
|
* description: Unknown song
|
||||||
* 401:
|
* 401:
|
||||||
* description: Unauthorized
|
* description: Unauthorized
|
||||||
|
* 403:
|
||||||
|
* description: Signed in without the permission for this app, or account disabled
|
||||||
*/
|
*/
|
||||||
songsAdminRouter.put('/:songId', async (req: Request, res: Response) => {
|
songsAdminRouter.put('/:songId', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {NachklangFeedbackDB} from '../Feedback.db';
|
import {NachklangFeedbackDB} from '../Feedback.db.js';
|
||||||
|
|
||||||
export const addSong = async (eventId: number, title: string, composer: string | null): Promise<number> => {
|
export const addSong = async (eventId: number, title: string, composer: string | null): Promise<number> => {
|
||||||
let conn = await NachklangFeedbackDB.getConnection();
|
let conn = await NachklangFeedbackDB.getConnection();
|
||||||
|
|||||||
@@ -1,78 +1,38 @@
|
|||||||
import express from 'express';
|
import {requireAppAccess} from '../admin/admin.middleware.js';
|
||||||
import * as UserService from '../calendar/users/users.service';
|
|
||||||
import {sendServerError} from './feedback.errors';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This file is the ONLY place in the feedback module that knows how admin
|
* This file is the ONLY place in the feedback module that knows how admin
|
||||||
* authentication works today. No route handler and no service outside this
|
* authentication works. No route handler and no service outside this file may
|
||||||
* file may import users.service, read session headers, or touch bcrypt.
|
* read session headers or resolve a user itself.
|
||||||
*
|
*
|
||||||
* Today: reuses the existing Calendar users/sessions mechanism. Any
|
* Today: the shared admin identity in `src/models/admin/`. A session cookie
|
||||||
* activated @nachklang.art account may administer feedback — no roles.
|
* set by /admin/auth on admin.nachklang.art, plus a `feedback` permission on
|
||||||
* Migrating to Keycloak later means writing a keycloakJwtAuthenticator
|
* the account. Both are re-checked on every request, so disabling a user or
|
||||||
* below and changing the one `activeAuthenticator` binding (plus the
|
* taking their feedback permission away takes effect immediately.
|
||||||
* frontend's login route handler) — nothing else in the feedback module
|
|
||||||
* needs to change.
|
|
||||||
*
|
*
|
||||||
* Explicitly forbidden: accepting sessionId/sessionKey from query
|
* Before 2026-09-06 this was a header session against the calendar users
|
||||||
* parameters, even "temporarily". That is the exact mistake documented in
|
* table, and any activated @nachklang.art account could administer feedback.
|
||||||
* DEFERRED_SECURITY.md item 1 for the Calendar domain, where credentials
|
* That is why the swap is a one-line binding: everything downstream only ever
|
||||||
* end up in access logs, browser history, proxy logs, and Referer headers.
|
* saw `requireAdminAuth` and `res.locals.admin`, and both still mean what
|
||||||
* Headers only.
|
* they meant. What changed is that access is now granted per user rather than
|
||||||
|
* implied by having an account.
|
||||||
|
*
|
||||||
|
* Explicitly forbidden: accepting session credentials from query parameters,
|
||||||
|
* even "temporarily". That is the exact mistake documented in
|
||||||
|
* DEFERRED_SECURITY.md item 1 for the Calendar domain, where credentials end
|
||||||
|
* up in access logs, browser history, proxy logs, and Referer headers.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// The only thing the rest of the feedback module knows about an admin.
|
// The only thing the rest of the feedback module knows about an admin. The
|
||||||
|
// shared middleware puts a superset of this on res.locals.admin.
|
||||||
export interface AdminIdentity {
|
export interface AdminIdentity {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pluggable strategy: extract + verify credentials from a request.
|
|
||||||
// Returns the identity, or null if unauthenticated. Throws only on
|
|
||||||
// infrastructure errors (e.g. the DB being unreachable).
|
|
||||||
export type AdminAuthenticator = (req: express.Request) => Promise<AdminIdentity | null>;
|
|
||||||
|
|
||||||
// Current implementation: reads X-Session-Id / X-Session-Key headers,
|
|
||||||
// delegates to the existing calendar UserService.checkSession(...).
|
|
||||||
export const sessionHeaderAuthenticator: AdminAuthenticator = async (req) => {
|
|
||||||
const sessionId = req.header('X-Session-Id');
|
|
||||||
const sessionKey = req.header('X-Session-Key');
|
|
||||||
if (!sessionId || !sessionKey) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ip = req.ip || '';
|
|
||||||
const user = await UserService.checkSession(sessionId, sessionKey, ip);
|
|
||||||
|
|
||||||
// Mirrors the Calendar domain's own convention: a valid session on an
|
|
||||||
// inactive (not yet activated) account is not sufficient.
|
|
||||||
if (!user || !user.isActive) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: String(user.userId),
|
|
||||||
email: user.email,
|
|
||||||
displayName: user.fullName
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Swap point: change this one binding to migrate to Keycloak.
|
|
||||||
export const activeAuthenticator: AdminAuthenticator = sessionHeaderAuthenticator;
|
|
||||||
|
|
||||||
// Express middleware used by every admin route. On success:
|
// Express middleware used by every admin route. On success:
|
||||||
// res.locals.admin = AdminIdentity, calls next(). On failure: 401.
|
// res.locals.admin = AdminAccess (an AdminIdentity plus permissions), calls
|
||||||
export const requireAdminAuth: express.RequestHandler = async (req, res, next) => {
|
// next(). On failure: 401 when not signed in, 403 when signed in without the
|
||||||
try {
|
// feedback permission.
|
||||||
const identity = await activeAuthenticator(req);
|
export const requireAdminAuth = requireAppAccess('feedback');
|
||||||
if (!identity) {
|
|
||||||
res.status(401).send({status: 'UNAUTHORIZED', message: 'Anmeldung erforderlich.'});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.locals.admin = identity;
|
|
||||||
next();
|
|
||||||
} catch (e: any) {
|
|
||||||
sendServerError(res, e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {Response} from 'express';
|
import {Response} from 'express';
|
||||||
import {Guid} from 'guid-typescript';
|
import {Guid} from 'guid-typescript';
|
||||||
import logger from '../../middleware/logger';
|
import logger from '../../middleware/logger.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The feedback module's standard catch-block response: log with a
|
* The feedback module's standard catch-block response: log with a
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
import * as dotenv from 'dotenv';
|
import * as dotenv from 'dotenv';
|
||||||
import {NachklangFeedbackDB} from './Feedback.db';
|
import {NachklangFeedbackDB} from './Feedback.db.js';
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {NachklangFeedbackDB} from '../Feedback.db';
|
import {NachklangFeedbackDB} from '../Feedback.db.js';
|
||||||
import logger from '../../../middleware/logger';
|
import logger from '../../../middleware/logger.js';
|
||||||
import {salesforceApexRestPost} from '../../../common/salesforce.client';
|
import {salesforceApexRestPost} from '../../../common/salesforce.client.js';
|
||||||
|
|
||||||
// Newsletter opt-ins sync to Salesforce, which already runs a full
|
// Newsletter opt-ins sync to Salesforce, which already runs a full
|
||||||
// double-opt-in subscription flow (Person Account for existing constituents,
|
// double-opt-in subscription flow (Person Account for existing constituents,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {NachklangFeedbackDB} from '../Feedback.db';
|
import {NachklangFeedbackDB} from '../Feedback.db.js';
|
||||||
import {EventConfig, EventSummary, Question, Song} from '../feedback.interface';
|
import {EventConfig, EventSummary, Question, Song} from '../feedback.interface.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns all events currently eligible to receive feedback:
|
* Returns all events currently eligible to receive feedback:
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
* Required External Modules and Interfaces
|
* Required External Modules and Interfaces
|
||||||
*/
|
*/
|
||||||
import express, {Request, Response} from 'express';
|
import express, {Request, Response} from 'express';
|
||||||
import logger from '../../../middleware/logger';
|
import logger from '../../../middleware/logger.js';
|
||||||
import {getEligibleEvents, getEventConfigBySlug} from './events.public.service';
|
import {getEligibleEvents, getEventConfigBySlug} from './events.public.service.js';
|
||||||
import {submitFeedback} from './submissions.service';
|
import {submitFeedback} from './submissions.service.js';
|
||||||
import {hashIp, isRateLimited, recordSubmission} from '../feedback.ratelimit';
|
import {hashIp, isRateLimited, recordSubmission} from '../feedback.ratelimit.js';
|
||||||
import {sendServerError} from '../feedback.errors';
|
import {sendServerError} from '../feedback.errors.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Router Definition
|
* Router Definition
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import {NachklangFeedbackDB} from '../Feedback.db';
|
import {NachklangFeedbackDB} from '../Feedback.db.js';
|
||||||
import {QuestionType} from '../feedback.interface';
|
import {QuestionType} from '../feedback.interface.js';
|
||||||
import {getEventConfigBySlug} from './events.public.service';
|
import {getEventConfigBySlug} from './events.public.service.js';
|
||||||
import {AnswerInput, GuestBookInput, NewsletterInput, SubmissionRequestBody} from './submission.interface';
|
import {AnswerInput, GuestBookInput, NewsletterInput, SubmissionRequestBody} from './submission.interface.js';
|
||||||
import {syncNewsletterSignup} from '../integrations/salesforce.service';
|
import {syncNewsletterSignup} from '../integrations/salesforce.service.js';
|
||||||
import logger from '../../../middleware/logger';
|
import logger from '../../../middleware/logger.js';
|
||||||
|
|
||||||
// Bump when the privacy/consent copy shown next to the newsletter opt-in
|
// Bump when the privacy/consent copy shown next to the newsletter opt-in
|
||||||
// changes; recorded per-signup so a past consent's exact wording is provable.
|
// changes; recorded per-signup so a past consent's exact wording is provable.
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import * as dotenv from 'dotenv';
|
import * as dotenv from 'dotenv';
|
||||||
|
import mariadb from 'mariadb';
|
||||||
const mariadb = require('mariadb');
|
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
@@ -10,8 +9,7 @@ export namespace NachklangTicketsDB {
|
|||||||
user: process.env.DB_USER,
|
user: process.env.DB_USER,
|
||||||
password: process.env.DB_PASSWORD,
|
password: process.env.DB_PASSWORD,
|
||||||
database: process.env.TICKETS_DB,
|
database: process.env.TICKETS_DB,
|
||||||
connectionLimit: 5,
|
connectionLimit: 5
|
||||||
autoCommit: false
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getConnection = async () => {
|
export const getConnection = async () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import {adminRouter} from './admin/admin.router';
|
import {adminRouter} from './admin/admin.router.js';
|
||||||
import {publicRouter} from './public/public.router';
|
import {publicRouter} from './public/public.router.js';
|
||||||
|
|
||||||
export const ticketsRouter = express.Router();
|
export const ticketsRouter = express.Router();
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import express, {Request, Response} from 'express';
|
import express, {Request, Response} from 'express';
|
||||||
import {requireAdminAuth} from '../tickets.auth';
|
import {requireAdminAuth} from '../tickets.auth.js';
|
||||||
import {vouchersAdminRouter} from './vouchers.admin.router';
|
import {vouchersAdminRouter} from './vouchers.admin.router.js';
|
||||||
import {redemptionsAdminRouter, voucherHistoryRouter} from './redemptions.admin.router';
|
import {redemptionsAdminRouter, voucherHistoryRouter} from './redemptions.admin.router.js';
|
||||||
import {eventsAdminRouter} from './events.admin.router';
|
import {eventsAdminRouter} from './events.admin.router.js';
|
||||||
|
|
||||||
export const adminRouter = express.Router();
|
export const adminRouter = express.Router();
|
||||||
|
|
||||||
@@ -16,14 +16,15 @@ adminRouter.use(requireAdminAuth);
|
|||||||
* get:
|
* get:
|
||||||
* summary: Validate the current admin session
|
* summary: Validate the current admin session
|
||||||
* tags: [tickets-admin]
|
* tags: [tickets-admin]
|
||||||
* parameters:
|
* security:
|
||||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
* - AdminSessionCookie: []
|
||||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: Success
|
* description: Success
|
||||||
* 401:
|
* 401:
|
||||||
* description: Unauthorized
|
* description: Unauthorized
|
||||||
|
* 403:
|
||||||
|
* description: Signed in without the permission for this app, or account disabled
|
||||||
*/
|
*/
|
||||||
adminRouter.get('/me', (req: Request, res: Response) => {
|
adminRouter.get('/me', (req: Request, res: Response) => {
|
||||||
res.status(200).send({email: res.locals.admin.email, fullName: res.locals.admin.displayName});
|
res.status(200).send({email: res.locals.admin.email, fullName: res.locals.admin.displayName});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import express, {Request, Response} from 'express';
|
import express, {Request, Response} from 'express';
|
||||||
import * as EventsAdminService from './events.admin.service';
|
import * as EventsAdminService from './events.admin.service.js';
|
||||||
import {sendServerError} from '../tickets.errors';
|
import {sendServerError} from '../tickets.errors.js';
|
||||||
|
|
||||||
export const eventsAdminRouter = express.Router();
|
export const eventsAdminRouter = express.Router();
|
||||||
|
|
||||||
@@ -11,14 +11,15 @@ export const eventsAdminRouter = express.Router();
|
|||||||
* summary: List concerts for the admin event picker
|
* summary: List concerts for the admin event picker
|
||||||
* description: Wraps the Calendar module's public-calendar admin listing (includes DRAFT events).
|
* description: Wraps the Calendar module's public-calendar admin listing (includes DRAFT events).
|
||||||
* tags: [tickets-admin]
|
* tags: [tickets-admin]
|
||||||
* parameters:
|
* security:
|
||||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
* - AdminSessionCookie: []
|
||||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: Success
|
* description: Success
|
||||||
* 401:
|
* 401:
|
||||||
* description: Unauthorized
|
* description: Unauthorized
|
||||||
|
* 403:
|
||||||
|
* description: Signed in without the permission for this app, or account disabled
|
||||||
*/
|
*/
|
||||||
eventsAdminRouter.get('/', async (req: Request, res: Response) => {
|
eventsAdminRouter.get('/', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
@@ -35,14 +36,15 @@ eventsAdminRouter.get('/', async (req: Request, res: Response) => {
|
|||||||
* summary: List public-calendar events not yet added to the ticket shop
|
* summary: List public-calendar events not yet added to the ticket shop
|
||||||
* description: Source list for the "add a concert" picker - the public calendar holds more than concerts, so events only appear in the ticket shop once explicitly added.
|
* description: Source list for the "add a concert" picker - the public calendar holds more than concerts, so events only appear in the ticket shop once explicitly added.
|
||||||
* tags: [tickets-admin]
|
* tags: [tickets-admin]
|
||||||
* parameters:
|
* security:
|
||||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
* - AdminSessionCookie: []
|
||||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: Success
|
* description: Success
|
||||||
* 401:
|
* 401:
|
||||||
* description: Unauthorized
|
* description: Unauthorized
|
||||||
|
* 403:
|
||||||
|
* description: Signed in without the permission for this app, or account disabled
|
||||||
*/
|
*/
|
||||||
eventsAdminRouter.get('/available', async (req: Request, res: Response) => {
|
eventsAdminRouter.get('/available', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
@@ -58,9 +60,9 @@ eventsAdminRouter.get('/available', async (req: Request, res: Response) => {
|
|||||||
* get:
|
* get:
|
||||||
* summary: Get a concert's voucher/capacity stats
|
* summary: Get a concert's voucher/capacity stats
|
||||||
* tags: [tickets-admin]
|
* tags: [tickets-admin]
|
||||||
|
* security:
|
||||||
|
* - AdminSessionCookie: []
|
||||||
* parameters:
|
* parameters:
|
||||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
|
||||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
||||||
* - in: path
|
* - in: path
|
||||||
* name: eventId
|
* name: eventId
|
||||||
* required: true
|
* required: true
|
||||||
@@ -75,6 +77,8 @@ eventsAdminRouter.get('/available', async (req: Request, res: Response) => {
|
|||||||
* $ref: '#/components/schemas/EventStats'
|
* $ref: '#/components/schemas/EventStats'
|
||||||
* 401:
|
* 401:
|
||||||
* description: Unauthorized
|
* description: Unauthorized
|
||||||
|
* 403:
|
||||||
|
* description: Signed in without the permission for this app, or account disabled
|
||||||
*/
|
*/
|
||||||
eventsAdminRouter.get('/:eventId/stats', async (req: Request, res: Response) => {
|
eventsAdminRouter.get('/:eventId/stats', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
@@ -91,9 +95,9 @@ eventsAdminRouter.get('/:eventId/stats', async (req: Request, res: Response) =>
|
|||||||
* summary: Set a concert's voucher settings
|
* summary: Set a concert's voucher settings
|
||||||
* description: Upserts capacity (null = uncapped), redemption deadline (null = none), and whether to collect a mailing address.
|
* description: Upserts capacity (null = uncapped), redemption deadline (null = none), and whether to collect a mailing address.
|
||||||
* tags: [tickets-admin]
|
* tags: [tickets-admin]
|
||||||
|
* security:
|
||||||
|
* - AdminSessionCookie: []
|
||||||
* parameters:
|
* parameters:
|
||||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
|
||||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
||||||
* - in: path
|
* - in: path
|
||||||
* name: eventId
|
* name: eventId
|
||||||
* required: true
|
* required: true
|
||||||
@@ -123,6 +127,8 @@ eventsAdminRouter.get('/:eventId/stats', async (req: Request, res: Response) =>
|
|||||||
* description: Saved
|
* description: Saved
|
||||||
* 401:
|
* 401:
|
||||||
* description: Unauthorized
|
* description: Unauthorized
|
||||||
|
* 403:
|
||||||
|
* description: Signed in without the permission for this app, or account disabled
|
||||||
*/
|
*/
|
||||||
eventsAdminRouter.put('/:eventId/settings', async (req: Request, res: Response) => {
|
eventsAdminRouter.put('/:eventId/settings', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
@@ -149,9 +155,9 @@ eventsAdminRouter.put('/:eventId/settings', async (req: Request, res: Response)
|
|||||||
* summary: Remove an event from the ticket shop
|
* summary: Remove an event from the ticket shop
|
||||||
* description: Deletes its settings row, so it drops out of the picker and reappears in the "add" list. Refused with 409 if vouchers already reference the event - existing vouchers/redemptions stay valid either way.
|
* description: Deletes its settings row, so it drops out of the picker and reappears in the "add" list. Refused with 409 if vouchers already reference the event - existing vouchers/redemptions stay valid either way.
|
||||||
* tags: [tickets-admin]
|
* tags: [tickets-admin]
|
||||||
|
* security:
|
||||||
|
* - AdminSessionCookie: []
|
||||||
* parameters:
|
* parameters:
|
||||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
|
||||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
||||||
* - in: path
|
* - in: path
|
||||||
* name: eventId
|
* name: eventId
|
||||||
* required: true
|
* required: true
|
||||||
@@ -164,6 +170,8 @@ eventsAdminRouter.put('/:eventId/settings', async (req: Request, res: Response)
|
|||||||
* description: Vouchers already reference this event
|
* description: Vouchers already reference this event
|
||||||
* 401:
|
* 401:
|
||||||
* description: Unauthorized
|
* description: Unauthorized
|
||||||
|
* 403:
|
||||||
|
* description: Signed in without the permission for this app, or account disabled
|
||||||
*/
|
*/
|
||||||
eventsAdminRouter.delete('/:eventId/settings', async (req: Request, res: Response) => {
|
eventsAdminRouter.delete('/:eventId/settings', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as CalendarEventsService from '../../calendar/events/events.service';
|
import * as CalendarEventsService from '../../calendar/events/events.service.js';
|
||||||
import {NachklangTicketsDB} from '../Tickets.db';
|
import {NachklangTicketsDB} from '../Tickets.db.js';
|
||||||
import {getEventTicketState} from '../tickets.capacity';
|
import {getEventTicketState} from '../tickets.capacity.js';
|
||||||
import {EventStats, EventTicketSettings} from '../tickets.interface';
|
import {EventStats, EventTicketSettings} from '../tickets.interface.js';
|
||||||
|
|
||||||
// Concerts are managed on the public calendar (calendarId 1) - see
|
// Concerts are managed on the public calendar (calendarId 1) - see
|
||||||
// docs/plan-ticket-shop.md. getAllEventsAdmin includes DRAFT events so
|
// docs/plan-ticket-shop.md. getAllEventsAdmin includes DRAFT events so
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import express, {Request, Response} from 'express';
|
import express, {Request, Response} from 'express';
|
||||||
import * as RedemptionsAdminService from './redemptions.admin.service';
|
import * as RedemptionsAdminService from './redemptions.admin.service.js';
|
||||||
import {sendServerError} from '../tickets.errors';
|
import {sendServerError} from '../tickets.errors.js';
|
||||||
|
|
||||||
export const redemptionsAdminRouter = express.Router();
|
export const redemptionsAdminRouter = express.Router();
|
||||||
|
|
||||||
@@ -11,9 +11,9 @@ export const redemptionsAdminRouter = express.Router();
|
|||||||
* summary: List redemptions (admin)
|
* summary: List redemptions (admin)
|
||||||
* description: Filterable by event and status (ACTIVE/UNDONE).
|
* description: Filterable by event and status (ACTIVE/UNDONE).
|
||||||
* tags: [tickets-admin]
|
* tags: [tickets-admin]
|
||||||
|
* security:
|
||||||
|
* - AdminSessionCookie: []
|
||||||
* parameters:
|
* parameters:
|
||||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
|
||||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
||||||
* - in: query
|
* - in: query
|
||||||
* name: eventId
|
* name: eventId
|
||||||
* schema:
|
* schema:
|
||||||
@@ -33,6 +33,8 @@ export const redemptionsAdminRouter = express.Router();
|
|||||||
* $ref: '#/components/schemas/RedemptionSummary'
|
* $ref: '#/components/schemas/RedemptionSummary'
|
||||||
* 401:
|
* 401:
|
||||||
* description: Unauthorized
|
* description: Unauthorized
|
||||||
|
* 403:
|
||||||
|
* description: Signed in without the permission for this app, or account disabled
|
||||||
*/
|
*/
|
||||||
redemptionsAdminRouter.get('/', async (req: Request, res: Response) => {
|
redemptionsAdminRouter.get('/', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
@@ -50,9 +52,9 @@ redemptionsAdminRouter.get('/', async (req: Request, res: Response) => {
|
|||||||
* get:
|
* get:
|
||||||
* summary: Get a single redemption (admin)
|
* summary: Get a single redemption (admin)
|
||||||
* tags: [tickets-admin]
|
* tags: [tickets-admin]
|
||||||
|
* security:
|
||||||
|
* - AdminSessionCookie: []
|
||||||
* parameters:
|
* parameters:
|
||||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
|
||||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
||||||
* - in: path
|
* - in: path
|
||||||
* name: redemptionId
|
* name: redemptionId
|
||||||
* required: true
|
* required: true
|
||||||
@@ -65,13 +67,15 @@ redemptionsAdminRouter.get('/', async (req: Request, res: Response) => {
|
|||||||
* description: Unknown redemption
|
* description: Unknown redemption
|
||||||
* 401:
|
* 401:
|
||||||
* description: Unauthorized
|
* description: Unauthorized
|
||||||
|
* 403:
|
||||||
|
* description: Signed in without the permission for this app, or account disabled
|
||||||
* patch:
|
* patch:
|
||||||
* summary: Edit a redemption's contact info and/or guest list
|
* summary: Edit a redemption's contact info and/or guest list
|
||||||
* description: Only fields present in the body are changed. Growing the guest count is re-checked against the voucher's max guests and the event's remaining capacity. Logs to the audit trail with an optional admin-supplied reason.
|
* description: Only fields present in the body are changed. Growing the guest count is re-checked against the voucher's max guests and the event's remaining capacity. Logs to the audit trail with an optional admin-supplied reason.
|
||||||
* tags: [tickets-admin]
|
* tags: [tickets-admin]
|
||||||
|
* security:
|
||||||
|
* - AdminSessionCookie: []
|
||||||
* parameters:
|
* parameters:
|
||||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
|
||||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
||||||
* - in: path
|
* - in: path
|
||||||
* name: redemptionId
|
* name: redemptionId
|
||||||
* required: true
|
* required: true
|
||||||
@@ -105,6 +109,8 @@ redemptionsAdminRouter.get('/', async (req: Request, res: Response) => {
|
|||||||
* description: Not active, exceeds max guests, or exceeds remaining capacity
|
* description: Not active, exceeds max guests, or exceeds remaining capacity
|
||||||
* 401:
|
* 401:
|
||||||
* description: Unauthorized
|
* description: Unauthorized
|
||||||
|
* 403:
|
||||||
|
* description: Signed in without the permission for this app, or account disabled
|
||||||
*/
|
*/
|
||||||
redemptionsAdminRouter.get('/:redemptionId', async (req: Request, res: Response) => {
|
redemptionsAdminRouter.get('/:redemptionId', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
@@ -161,9 +167,9 @@ redemptionsAdminRouter.patch('/:redemptionId', async (req: Request, res: Respons
|
|||||||
* summary: Undo a redemption
|
* summary: Undo a redemption
|
||||||
* description: Reopens the code (back to UNUSED) and marks the redemption UNDONE. Guest data is kept for the audit trail; a later re-redemption creates a new redemption record.
|
* description: Reopens the code (back to UNUSED) and marks the redemption UNDONE. Guest data is kept for the audit trail; a later re-redemption creates a new redemption record.
|
||||||
* tags: [tickets-admin]
|
* tags: [tickets-admin]
|
||||||
|
* security:
|
||||||
|
* - AdminSessionCookie: []
|
||||||
* parameters:
|
* parameters:
|
||||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
|
||||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
||||||
* - in: path
|
* - in: path
|
||||||
* name: redemptionId
|
* name: redemptionId
|
||||||
* required: true
|
* required: true
|
||||||
@@ -186,6 +192,8 @@ redemptionsAdminRouter.patch('/:redemptionId', async (req: Request, res: Respons
|
|||||||
* description: Redemption is not active
|
* description: Redemption is not active
|
||||||
* 401:
|
* 401:
|
||||||
* description: Unauthorized
|
* description: Unauthorized
|
||||||
|
* 403:
|
||||||
|
* description: Signed in without the permission for this app, or account disabled
|
||||||
*/
|
*/
|
||||||
redemptionsAdminRouter.post('/:redemptionId/undo', async (req: Request, res: Response) => {
|
redemptionsAdminRouter.post('/:redemptionId/undo', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
@@ -211,9 +219,9 @@ redemptionsAdminRouter.post('/:redemptionId/undo', async (req: Request, res: Res
|
|||||||
* summary: Resend the redemption confirmation email
|
* summary: Resend the redemption confirmation email
|
||||||
* description: Rebuilds the confirmation email from the stored redemption data and sends it again, then records the outcome on the redemption. Intended for redemptions whose original confirmation email failed.
|
* description: Rebuilds the confirmation email from the stored redemption data and sends it again, then records the outcome on the redemption. Intended for redemptions whose original confirmation email failed.
|
||||||
* tags: [tickets-admin]
|
* tags: [tickets-admin]
|
||||||
|
* security:
|
||||||
|
* - AdminSessionCookie: []
|
||||||
* parameters:
|
* parameters:
|
||||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
|
||||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
||||||
* - in: path
|
* - in: path
|
||||||
* name: redemptionId
|
* name: redemptionId
|
||||||
* required: true
|
* required: true
|
||||||
@@ -230,6 +238,8 @@ redemptionsAdminRouter.post('/:redemptionId/undo', async (req: Request, res: Res
|
|||||||
* description: The email relay rejected the send
|
* description: The email relay rejected the send
|
||||||
* 401:
|
* 401:
|
||||||
* description: Unauthorized
|
* description: Unauthorized
|
||||||
|
* 403:
|
||||||
|
* description: Signed in without the permission for this app, or account disabled
|
||||||
*/
|
*/
|
||||||
redemptionsAdminRouter.post('/:redemptionId/resend-confirmation', async (req: Request, res: Response) => {
|
redemptionsAdminRouter.post('/:redemptionId/resend-confirmation', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
@@ -259,9 +269,9 @@ redemptionsAdminRouter.post('/:redemptionId/resend-confirmation', async (req: Re
|
|||||||
* get:
|
* get:
|
||||||
* summary: Get a voucher's admin-action audit trail
|
* summary: Get a voucher's admin-action audit trail
|
||||||
* tags: [tickets-admin]
|
* tags: [tickets-admin]
|
||||||
|
* security:
|
||||||
|
* - AdminSessionCookie: []
|
||||||
* parameters:
|
* parameters:
|
||||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
|
||||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
||||||
* - in: path
|
* - in: path
|
||||||
* name: code
|
* name: code
|
||||||
* required: true
|
* required: true
|
||||||
@@ -278,6 +288,8 @@ redemptionsAdminRouter.post('/:redemptionId/resend-confirmation', async (req: Re
|
|||||||
* $ref: '#/components/schemas/AuditLogEntry'
|
* $ref: '#/components/schemas/AuditLogEntry'
|
||||||
* 401:
|
* 401:
|
||||||
* description: Unauthorized
|
* description: Unauthorized
|
||||||
|
* 403:
|
||||||
|
* description: Signed in without the permission for this app, or account disabled
|
||||||
*/
|
*/
|
||||||
export const voucherHistoryRouter = express.Router();
|
export const voucherHistoryRouter = express.Router();
|
||||||
voucherHistoryRouter.get('/:code/history', async (req: Request, res: Response) => {
|
voucherHistoryRouter.get('/:code/history', async (req: Request, res: Response) => {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import {NachklangTicketsDB} from '../Tickets.db';
|
import {NachklangTicketsDB} from '../Tickets.db.js';
|
||||||
import {getEventTicketState} from '../tickets.capacity';
|
import {getEventTicketState} from '../tickets.capacity.js';
|
||||||
import {recordConfirmationEmailResult, sendRedemptionConfirmation} from '../tickets.confirmation-email';
|
import {recordConfirmationEmailResult, sendRedemptionConfirmation} from '../tickets.confirmation-email.js';
|
||||||
import {AuditLogEntry, RedemptionSummary} from '../tickets.interface';
|
import {AuditLogEntry, RedemptionSummary} from '../tickets.interface.js';
|
||||||
import {isValidEmail} from '../tickets.validation';
|
import {isValidEmail} from '../tickets.validation.js';
|
||||||
|
|
||||||
const mapRedemptionRow = (row: any, guests: string[]): RedemptionSummary => ({
|
const mapRedemptionRow = (row: any, guests: string[]): RedemptionSummary => ({
|
||||||
redemptionId: row.redemption_id,
|
redemptionId: row.redemption_id,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import express, {Request, Response} from 'express';
|
import express, {Request, Response} from 'express';
|
||||||
import * as VouchersAdminService from './vouchers.admin.service';
|
import * as VouchersAdminService from './vouchers.admin.service.js';
|
||||||
import {sendServerError} from '../tickets.errors';
|
import {sendServerError} from '../tickets.errors.js';
|
||||||
|
|
||||||
export const vouchersAdminRouter = express.Router();
|
export const vouchersAdminRouter = express.Router();
|
||||||
|
|
||||||
@@ -11,9 +11,9 @@ export const vouchersAdminRouter = express.Router();
|
|||||||
* summary: List vouchers (admin)
|
* summary: List vouchers (admin)
|
||||||
* description: Filterable by event and status.
|
* description: Filterable by event and status.
|
||||||
* tags: [tickets-admin]
|
* tags: [tickets-admin]
|
||||||
|
* security:
|
||||||
|
* - AdminSessionCookie: []
|
||||||
* parameters:
|
* parameters:
|
||||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
|
||||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
||||||
* - in: query
|
* - in: query
|
||||||
* name: eventId
|
* name: eventId
|
||||||
* schema:
|
* schema:
|
||||||
@@ -33,6 +33,8 @@ export const vouchersAdminRouter = express.Router();
|
|||||||
* $ref: '#/components/schemas/VoucherCode'
|
* $ref: '#/components/schemas/VoucherCode'
|
||||||
* 401:
|
* 401:
|
||||||
* description: Unauthorized
|
* description: Unauthorized
|
||||||
|
* 403:
|
||||||
|
* description: Signed in without the permission for this app, or account disabled
|
||||||
*/
|
*/
|
||||||
vouchersAdminRouter.get('/', async (req: Request, res: Response) => {
|
vouchersAdminRouter.get('/', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
@@ -51,9 +53,8 @@ vouchersAdminRouter.get('/', async (req: Request, res: Response) => {
|
|||||||
* summary: Batch-generate wildcard codes
|
* summary: Batch-generate wildcard codes
|
||||||
* description: Generates `quantity` codes sharing the same eligible events and max-guest count, grouped under one batchId.
|
* description: Generates `quantity` codes sharing the same eligible events and max-guest count, grouped under one batchId.
|
||||||
* tags: [tickets-admin]
|
* tags: [tickets-admin]
|
||||||
* parameters:
|
* security:
|
||||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
* - AdminSessionCookie: []
|
||||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
||||||
* requestBody:
|
* requestBody:
|
||||||
* required: true
|
* required: true
|
||||||
* content:
|
* content:
|
||||||
@@ -87,6 +88,8 @@ vouchersAdminRouter.get('/', async (req: Request, res: Response) => {
|
|||||||
* description: Invalid input
|
* description: Invalid input
|
||||||
* 401:
|
* 401:
|
||||||
* description: Unauthorized
|
* description: Unauthorized
|
||||||
|
* 403:
|
||||||
|
* description: Signed in without the permission for this app, or account disabled
|
||||||
*/
|
*/
|
||||||
vouchersAdminRouter.post('/wildcard', async (req: Request, res: Response) => {
|
vouchersAdminRouter.post('/wildcard', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
@@ -112,9 +115,8 @@ vouchersAdminRouter.post('/wildcard', async (req: Request, res: Response) => {
|
|||||||
* summary: Bulk-create personalized codes
|
* summary: Bulk-create personalized codes
|
||||||
* description: One code per row (name, email, eligible events, max guests), grouped under one batchId.
|
* description: One code per row (name, email, eligible events, max guests), grouped under one batchId.
|
||||||
* tags: [tickets-admin]
|
* tags: [tickets-admin]
|
||||||
* parameters:
|
* security:
|
||||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
* - AdminSessionCookie: []
|
||||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
||||||
* requestBody:
|
* requestBody:
|
||||||
* required: true
|
* required: true
|
||||||
* content:
|
* content:
|
||||||
@@ -147,6 +149,8 @@ vouchersAdminRouter.post('/wildcard', async (req: Request, res: Response) => {
|
|||||||
* description: Invalid input
|
* description: Invalid input
|
||||||
* 401:
|
* 401:
|
||||||
* description: Unauthorized
|
* description: Unauthorized
|
||||||
|
* 403:
|
||||||
|
* description: Signed in without the permission for this app, or account disabled
|
||||||
*/
|
*/
|
||||||
vouchersAdminRouter.post('/personalized', async (req: Request, res: Response) => {
|
vouchersAdminRouter.post('/personalized', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
@@ -169,9 +173,9 @@ vouchersAdminRouter.post('/personalized', async (req: Request, res: Response) =>
|
|||||||
* get:
|
* get:
|
||||||
* summary: Get a single voucher (admin)
|
* summary: Get a single voucher (admin)
|
||||||
* tags: [tickets-admin]
|
* tags: [tickets-admin]
|
||||||
|
* security:
|
||||||
|
* - AdminSessionCookie: []
|
||||||
* parameters:
|
* parameters:
|
||||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
|
||||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
||||||
* - in: path
|
* - in: path
|
||||||
* name: code
|
* name: code
|
||||||
* required: true
|
* required: true
|
||||||
@@ -184,6 +188,8 @@ vouchersAdminRouter.post('/personalized', async (req: Request, res: Response) =>
|
|||||||
* description: Unknown code
|
* description: Unknown code
|
||||||
* 401:
|
* 401:
|
||||||
* description: Unauthorized
|
* description: Unauthorized
|
||||||
|
* 403:
|
||||||
|
* description: Signed in without the permission for this app, or account disabled
|
||||||
*/
|
*/
|
||||||
vouchersAdminRouter.get('/:code', async (req: Request, res: Response) => {
|
vouchersAdminRouter.get('/:code', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
@@ -205,9 +211,9 @@ vouchersAdminRouter.get('/:code', async (req: Request, res: Response) => {
|
|||||||
* summary: Void an unredeemed code
|
* summary: Void an unredeemed code
|
||||||
* description: Only allowed while the code is UNUSED. Logs to the voucher's audit trail.
|
* description: Only allowed while the code is UNUSED. Logs to the voucher's audit trail.
|
||||||
* tags: [tickets-admin]
|
* tags: [tickets-admin]
|
||||||
|
* security:
|
||||||
|
* - AdminSessionCookie: []
|
||||||
* parameters:
|
* parameters:
|
||||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
|
||||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
|
||||||
* - in: path
|
* - in: path
|
||||||
* name: code
|
* name: code
|
||||||
* required: true
|
* required: true
|
||||||
@@ -230,6 +236,8 @@ vouchersAdminRouter.get('/:code', async (req: Request, res: Response) => {
|
|||||||
* description: Code is not in UNUSED status
|
* description: Code is not in UNUSED status
|
||||||
* 401:
|
* 401:
|
||||||
* description: Unauthorized
|
* description: Unauthorized
|
||||||
|
* 403:
|
||||||
|
* description: Signed in without the permission for this app, or account disabled
|
||||||
*/
|
*/
|
||||||
vouchersAdminRouter.post('/:code/void', async (req: Request, res: Response) => {
|
vouchersAdminRouter.post('/:code/void', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import {Guid} from 'guid-typescript';
|
import {Guid} from 'guid-typescript';
|
||||||
import {NachklangTicketsDB} from '../Tickets.db';
|
import {NachklangTicketsDB} from '../Tickets.db.js';
|
||||||
import {generateUniqueCode} from '../tickets.codes';
|
import {generateUniqueCode} from '../tickets.codes.js';
|
||||||
import {VoucherCode, VoucherStatus} from '../tickets.interface';
|
import {VoucherCode, VoucherStatus} from '../tickets.interface.js';
|
||||||
import {isValidEmail} from '../tickets.validation';
|
import {isValidEmail} from '../tickets.validation.js';
|
||||||
|
|
||||||
export interface WildcardGenerateInput {
|
export interface WildcardGenerateInput {
|
||||||
eventIds: number[];
|
eventIds: number[];
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import express, {Request, Response} from 'express';
|
import express, {Request, Response} from 'express';
|
||||||
import * as VoucherPublicService from './voucher.public.service';
|
import * as VoucherPublicService from './voucher.public.service.js';
|
||||||
import {sendServerError} from '../tickets.errors';
|
import {sendServerError} from '../tickets.errors.js';
|
||||||
import {hashIp, redeemLimiter, validateLimiter} from '../tickets.ratelimit';
|
import {hashIp, redeemLimiter, validateLimiter} from '../tickets.ratelimit.js';
|
||||||
|
|
||||||
export const publicRouter = express.Router();
|
export const publicRouter = express.Router();
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import * as EventsService from '../../calendar/events/events.service';
|
import * as EventsService from '../../calendar/events/events.service.js';
|
||||||
import logger from '../../../middleware/logger';
|
import logger from '../../../middleware/logger.js';
|
||||||
import {NachklangTicketsDB} from '../Tickets.db';
|
import {NachklangTicketsDB} from '../Tickets.db.js';
|
||||||
import {getEventTicketState} from '../tickets.capacity';
|
import {getEventTicketState} from '../tickets.capacity.js';
|
||||||
import {recordConfirmationEmailResult, sendRedemptionConfirmation} from '../tickets.confirmation-email';
|
import {recordConfirmationEmailResult, sendRedemptionConfirmation} from '../tickets.confirmation-email.js';
|
||||||
import {EligibleEvent, RedeemRequest, VoucherValidation} from '../tickets.interface';
|
import {EligibleEvent, RedeemRequest, VoucherValidation} from '../tickets.interface.js';
|
||||||
import {isValidEmail} from '../tickets.validation';
|
import {isValidEmail} from '../tickets.validation.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds the eligible-events list for a code: for each event it's linked
|
* Builds the eligible-events list for a code: for each event it's linked
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
import express from 'express';
|
import {requireAppAccess} from '../admin/admin.middleware.js';
|
||||||
import * as UserService from '../calendar/users/users.service';
|
|
||||||
import {sendServerError} from './tickets.errors';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mirrors the Feedback module's feedback.auth.ts: this is the ONLY place in
|
* Mirrors the Feedback module's feedback.auth.ts: this is the ONLY place in
|
||||||
* the tickets module that knows how admin authentication works. No route
|
* the tickets module that knows how admin authentication works. No route
|
||||||
* handler and no service outside this file may import users.service, read
|
* handler and no service outside this file may read session headers or
|
||||||
* session headers, or touch bcrypt.
|
* resolve a user itself.
|
||||||
*
|
*
|
||||||
* Today: reuses the existing Calendar users/sessions mechanism. Any
|
* Today: the shared admin identity in `src/models/admin/`. A session cookie
|
||||||
* activated @nachklang.art account may administer vouchers - no roles, same
|
* set by /admin/auth on admin.nachklang.art, plus a `tickets` permission on
|
||||||
* policy as Feedback (see docs/plan-ticket-shop.md). A dedicated
|
* the account. Both are re-checked on every request, so disabling a user or
|
||||||
* roles/permissions model is explicitly out of scope for v1.
|
* taking their tickets permission away takes effect immediately.
|
||||||
*
|
*
|
||||||
* Explicitly forbidden: accepting sessionId/sessionKey from query
|
* Before 2026-09-06 this was a header session against the calendar users
|
||||||
* parameters - headers only (see DEFERRED_SECURITY.md item 1).
|
* table, and any activated @nachklang.art account could administer vouchers
|
||||||
|
* (see docs/plan-ticket-shop.md, which called a roles model out of scope for
|
||||||
|
* v1). It is in scope now, and lives in the admin module rather than here.
|
||||||
|
*
|
||||||
|
* Explicitly forbidden: accepting session credentials from query parameters -
|
||||||
|
* see DEFERRED_SECURITY.md item 1.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface AdminIdentity {
|
export interface AdminIdentity {
|
||||||
@@ -23,41 +26,6 @@ export interface AdminIdentity {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AdminAuthenticator = (req: express.Request) => Promise<AdminIdentity | null>;
|
// On failure: 401 when not signed in, 403 when signed in without the tickets
|
||||||
|
// permission.
|
||||||
export const sessionHeaderAuthenticator: AdminAuthenticator = async (req) => {
|
export const requireAdminAuth = requireAppAccess('tickets');
|
||||||
const sessionId = req.header('X-Session-Id');
|
|
||||||
const sessionKey = req.header('X-Session-Key');
|
|
||||||
if (!sessionId || !sessionKey) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ip = req.ip || '';
|
|
||||||
const user = await UserService.checkSession(sessionId, sessionKey, ip);
|
|
||||||
|
|
||||||
if (!user || !user.isActive) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: String(user.userId),
|
|
||||||
email: user.email,
|
|
||||||
displayName: user.fullName
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const activeAuthenticator: AdminAuthenticator = sessionHeaderAuthenticator;
|
|
||||||
|
|
||||||
export const requireAdminAuth: express.RequestHandler = async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const identity = await activeAuthenticator(req);
|
|
||||||
if (!identity) {
|
|
||||||
res.status(401).send({status: 'UNAUTHORIZED', message: 'Anmeldung erforderlich.'});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.locals.admin = identity;
|
|
||||||
next();
|
|
||||||
} catch (e: any) {
|
|
||||||
sendServerError(res, e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import * as EventsService from '../calendar/events/events.service';
|
import * as EventsService from '../calendar/events/events.service.js';
|
||||||
import * as IcalService from '../calendar/events/icalgenerator.service';
|
import * as IcalService from '../calendar/events/icalgenerator.service.js';
|
||||||
import {MailService} from '../../common/common.mail';
|
import {MailService} from '../../common/common.mail.js';
|
||||||
import logger from '../../middleware/logger';
|
import logger from '../../middleware/logger.js';
|
||||||
import {NachklangTicketsDB} from './Tickets.db';
|
import {NachklangTicketsDB} from './Tickets.db.js';
|
||||||
|
|
||||||
export type ConfirmationEmailStatus = 'SENT' | 'FAILED';
|
export type ConfirmationEmailStatus = 'SENT' | 'FAILED';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {Response} from 'express';
|
import {Response} from 'express';
|
||||||
import {Guid} from 'guid-typescript';
|
import {Guid} from 'guid-typescript';
|
||||||
import logger from '../../middleware/logger';
|
import logger from '../../middleware/logger.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The tickets module's standard catch-block response: log with a reference
|
* The tickets module's standard catch-block response: log with a reference
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import {vi, describe, it, expect, beforeEach, type Mock} from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../../src/models/admin/users/users.admin.service.js', () => ({
|
||||||
|
countActiveAdmins: vi.fn(),
|
||||||
|
findUserByEmail: vi.fn(),
|
||||||
|
grantPermission: vi.fn()
|
||||||
|
}));
|
||||||
|
vi.mock('../../src/models/admin/invitations/invitations.service.js', () => ({
|
||||||
|
hasOpenInvitationFor: vi.fn(),
|
||||||
|
createInvitation: vi.fn()
|
||||||
|
}));
|
||||||
|
vi.mock('../../src/models/admin/admin.mail.js', () => ({
|
||||||
|
sendInvitationMail: vi.fn()
|
||||||
|
}));
|
||||||
|
vi.mock('../../src/models/admin/admin.config.js', () => ({
|
||||||
|
ADMIN_BOOTSTRAP_EMAIL: 'boss@nachklang.art',
|
||||||
|
ADMIN_APP_URL: 'http://localhost:3002',
|
||||||
|
isProd: false
|
||||||
|
}));
|
||||||
|
|
||||||
|
import * as UsersService from '../../src/models/admin/users/users.admin.service.js';
|
||||||
|
import * as InvitationsService from '../../src/models/admin/invitations/invitations.service.js';
|
||||||
|
import {sendInvitationMail} from '../../src/models/admin/admin.mail.js';
|
||||||
|
import {bootstrapAdmin} from '../../src/models/admin/admin.bootstrap.js';
|
||||||
|
|
||||||
|
const countActiveAdmins = UsersService.countActiveAdmins as Mock;
|
||||||
|
const findUserByEmail = UsersService.findUserByEmail as Mock;
|
||||||
|
const grantPermission = UsersService.grantPermission as Mock;
|
||||||
|
const hasOpenInvitationFor = InvitationsService.hasOpenInvitationFor as Mock;
|
||||||
|
const createInvitation = InvitationsService.createInvitation as Mock;
|
||||||
|
const mockMail = sendInvitationMail as Mock;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
countActiveAdmins.mockReset();
|
||||||
|
findUserByEmail.mockReset();
|
||||||
|
grantPermission.mockReset();
|
||||||
|
hasOpenInvitationFor.mockReset();
|
||||||
|
createInvitation.mockReset();
|
||||||
|
mockMail.mockReset();
|
||||||
|
mockMail.mockResolvedValue(true);
|
||||||
|
createInvitation.mockResolvedValue({id: 1, token: 'raw-token', expiresAt: new Date()});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bootstrapAdmin', () => {
|
||||||
|
it('does nothing when an active admin already exists', async () => {
|
||||||
|
countActiveAdmins.mockResolvedValue(1);
|
||||||
|
|
||||||
|
await bootstrapAdmin();
|
||||||
|
|
||||||
|
expect(createInvitation).not.toHaveBeenCalled();
|
||||||
|
expect(grantPermission).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('grants admin directly when the bootstrap address is already a user', async () => {
|
||||||
|
countActiveAdmins.mockResolvedValue(0);
|
||||||
|
findUserByEmail.mockResolvedValue({id: 'u9', email: 'boss@nachklang.art'});
|
||||||
|
|
||||||
|
await bootstrapAdmin();
|
||||||
|
|
||||||
|
expect(grantPermission).toHaveBeenCalledWith('u9', 'admin', null);
|
||||||
|
expect(createInvitation).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not re-invite (or re-mail) while an open invitation exists', async () => {
|
||||||
|
countActiveAdmins.mockResolvedValue(0);
|
||||||
|
findUserByEmail.mockResolvedValue(null);
|
||||||
|
hasOpenInvitationFor.mockResolvedValue(true);
|
||||||
|
|
||||||
|
await bootstrapAdmin();
|
||||||
|
|
||||||
|
expect(createInvitation).not.toHaveBeenCalled();
|
||||||
|
expect(mockMail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invites with the admin permission when there is nothing to work with', async () => {
|
||||||
|
countActiveAdmins.mockResolvedValue(0);
|
||||||
|
findUserByEmail.mockResolvedValue(null);
|
||||||
|
hasOpenInvitationFor.mockResolvedValue(false);
|
||||||
|
|
||||||
|
await bootstrapAdmin();
|
||||||
|
|
||||||
|
expect(createInvitation).toHaveBeenCalledWith(
|
||||||
|
'boss@nachklang.art',
|
||||||
|
'Nachklang Admin',
|
||||||
|
[{app: 'admin', role: 'access'}],
|
||||||
|
null
|
||||||
|
);
|
||||||
|
expect(mockMail).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never throws when the database is unreachable at boot', async () => {
|
||||||
|
countActiveAdmins.mockRejectedValue(new Error('connect ECONNREFUSED'));
|
||||||
|
|
||||||
|
await expect(bootstrapAdmin()).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
|
||||||
|
// admin.config calls dotenv.config(), which would read the repo's own .env and
|
||||||
|
// quietly reintroduce NODE_ENV=development - the exact value several of these
|
||||||
|
// cases exist to remove. Stub it so the tests see only what they set.
|
||||||
|
vi.mock('dotenv', () => ({config: vi.fn()}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* admin.config reads the environment once at import, so every case here has to
|
||||||
|
* reset the module registry and re-import it. The two things worth pinning are
|
||||||
|
* the ones that are silent when wrong: which client-IP header is trusted, and
|
||||||
|
* whether an unset NODE_ENV counts as production.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ORIGINAL_ENV = {...process.env};
|
||||||
|
|
||||||
|
const loadConfig = async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
return import('../../src/models/admin/admin.config.js');
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env = {...ORIGINAL_ENV};
|
||||||
|
// dotenv.config() in admin.config does not overwrite what is already set,
|
||||||
|
// so setting these here is enough to keep the local .env out of the test.
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
delete process.env.CLIENT_IP_HEADERS;
|
||||||
|
delete process.env.TRUSTED_PROXY_IPS;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = {...ORIGINAL_ENV};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CLIENT_IP_HEADERS', () => {
|
||||||
|
it('defaults to the single header Plesk nginx sets', async () => {
|
||||||
|
const config = await loadConfig();
|
||||||
|
expect(config.CLIENT_IP_HEADERS).toEqual(['x-real-ip']);
|
||||||
|
expect(config.TRUST_NO_CLIENT_IP_HEADER).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads a comma-separated list', async () => {
|
||||||
|
process.env.CLIENT_IP_HEADERS = 'x-real-ip, cf-connecting-ip';
|
||||||
|
const config = await loadConfig();
|
||||||
|
expect(config.CLIENT_IP_HEADERS).toEqual(['x-real-ip', 'cf-connecting-ip']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trusts nothing when set to "none"', async () => {
|
||||||
|
// The escape hatch. An empty list is what better-auth reads as "no
|
||||||
|
// headers" - it only falls back to its own default when the option is
|
||||||
|
// absent - so this really does stop any header being believed.
|
||||||
|
process.env.CLIENT_IP_HEADERS = 'none';
|
||||||
|
const config = await loadConfig();
|
||||||
|
expect(config.CLIENT_IP_HEADERS).toEqual([]);
|
||||||
|
expect(config.TRUST_NO_CLIENT_IP_HEADER).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts the hatch case-insensitively and with stray whitespace', async () => {
|
||||||
|
process.env.CLIENT_IP_HEADERS = ' NONE ';
|
||||||
|
const config = await loadConfig();
|
||||||
|
expect(config.CLIENT_IP_HEADERS).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats an empty value as "use the default", not as the hatch', async () => {
|
||||||
|
// A blank line in a .env must not silently change how requests are
|
||||||
|
// bucketed - only the explicit word does that.
|
||||||
|
process.env.CLIENT_IP_HEADERS = '';
|
||||||
|
const config = await loadConfig();
|
||||||
|
expect(config.CLIENT_IP_HEADERS).toEqual(['x-real-ip']);
|
||||||
|
expect(config.TRUST_NO_CLIENT_IP_HEADER).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not mistake a header actually named none-ish for the hatch', async () => {
|
||||||
|
process.env.CLIENT_IP_HEADERS = 'x-none';
|
||||||
|
const config = await loadConfig();
|
||||||
|
expect(config.CLIENT_IP_HEADERS).toEqual(['x-none']);
|
||||||
|
expect(config.TRUST_NO_CLIENT_IP_HEADER).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('APP_ORIGINS', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
delete process.env.APP_ORIGINS;
|
||||||
|
});
|
||||||
|
|
||||||
|
// These reach better-auth's trustedOrigins, and the step 4 cutover made the
|
||||||
|
// tickets and feedback origins load-bearing: without them their sign-out
|
||||||
|
// call is rejected while everything else still works.
|
||||||
|
it('defaults to the three production frontends', async () => {
|
||||||
|
const config = await loadConfig();
|
||||||
|
expect(config.APP_ORIGINS).toEqual([
|
||||||
|
'https://tickets.nachklang.art',
|
||||||
|
'https://feedback.nachklang.art',
|
||||||
|
'https://calendar.nachklang.art'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is overridden wholesale by the environment, for a staging host', async () => {
|
||||||
|
process.env.APP_ORIGINS = 'https://tickets.staging.example, https://feedback.staging.example/';
|
||||||
|
const config = await loadConfig();
|
||||||
|
expect(config.APP_ORIGINS).toEqual([
|
||||||
|
'https://tickets.staging.example',
|
||||||
|
// Trailing slash stripped: an origin with one never matches.
|
||||||
|
'https://feedback.staging.example'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('always includes the admin app itself in ADMIN_ALLOWED_ORIGINS', async () => {
|
||||||
|
process.env.ADMIN_APP_URL = 'https://admin.nachklang.art';
|
||||||
|
const config = await loadConfig();
|
||||||
|
expect(config.ADMIN_ALLOWED_ORIGINS).toContain('https://admin.nachklang.art');
|
||||||
|
expect(config.ADMIN_ALLOWED_ORIGINS).toContain('https://tickets.nachklang.art');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isProd', () => {
|
||||||
|
it('is false only for the explicit relaxed environments', async () => {
|
||||||
|
process.env.NODE_ENV = 'development';
|
||||||
|
expect((await loadConfig()).isProd).toBe(false);
|
||||||
|
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
expect((await loadConfig()).isProd).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats an unset NODE_ENV as production, which is what a bare vhost gives', async () => {
|
||||||
|
delete process.env.NODE_ENV;
|
||||||
|
// Strict mode refuses to boot without these; supply them so the import
|
||||||
|
// gets far enough to answer the question being asked.
|
||||||
|
process.env.BETTER_AUTH_SECRET = 'x'.repeat(48);
|
||||||
|
process.env.API_BASE_URL = 'https://api.nachklang.art';
|
||||||
|
process.env.ADMIN_APP_URL = 'https://admin.nachklang.art';
|
||||||
|
|
||||||
|
expect((await loadConfig()).isProd).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to start without a signing key outside development', async () => {
|
||||||
|
delete process.env.NODE_ENV;
|
||||||
|
delete process.env.BETTER_AUTH_SECRET;
|
||||||
|
process.env.API_BASE_URL = 'https://api.nachklang.art';
|
||||||
|
process.env.ADMIN_APP_URL = 'https://admin.nachklang.art';
|
||||||
|
|
||||||
|
await expect(loadConfig()).rejects.toThrow(/BETTER_AUTH_SECRET/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import {vi, describe, it, expect, beforeEach, type Mock} from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../../src/common/common.mail.js', () => ({
|
||||||
|
MailService: {sendMail: vi.fn()}
|
||||||
|
}));
|
||||||
|
vi.mock('../../src/models/admin/admin.config.js', () => ({
|
||||||
|
ADMIN_APP_URL: 'https://admin.nachklang.art'
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {MailService} from '../../src/common/common.mail.js';
|
||||||
|
import {sendInvitationMail, sendPasswordResetMail} from '../../src/models/admin/admin.mail.js';
|
||||||
|
|
||||||
|
const sendMail = MailService.sendMail as Mock;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sendMail.mockReset();
|
||||||
|
sendMail.mockResolvedValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sendInvitationMail', () => {
|
||||||
|
it('points at the admin app and carries the token in the query string', async () => {
|
||||||
|
await sendInvitationMail('a@nachklang.art', 'Anna', 'tok-en_123', new Date('2026-09-12T10:00:00Z'));
|
||||||
|
|
||||||
|
const [to, subject, text, options] = sendMail.mock.calls[0];
|
||||||
|
expect(to).toBe('a@nachklang.art');
|
||||||
|
expect(subject).toBeTruthy();
|
||||||
|
expect(text).toContain('https://admin.nachklang.art/accept-invite?token=tok-en_123');
|
||||||
|
expect(options.html).toContain('https://admin.nachklang.art/accept-invite?token=tok-en_123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('url-encodes a token containing url-significant characters', async () => {
|
||||||
|
await sendInvitationMail('a@nachklang.art', 'Anna', 'a+b/c=d', new Date());
|
||||||
|
|
||||||
|
const [, , text] = sendMail.mock.calls[0];
|
||||||
|
expect(text).toContain('token=a%2Bb%2Fc%3Dd');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends both a text and an html part', async () => {
|
||||||
|
await sendInvitationMail('a@nachklang.art', 'Anna', 'tok', new Date());
|
||||||
|
|
||||||
|
const [, , text, options] = sendMail.mock.calls[0];
|
||||||
|
expect(text.length).toBeGreaterThan(0);
|
||||||
|
expect(options.html).toContain('<html');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes a name that contains html', async () => {
|
||||||
|
await sendInvitationMail('a@nachklang.art', '<script>alert(1)</script>', 'tok', new Date());
|
||||||
|
|
||||||
|
const [, , , options] = sendMail.mock.calls[0];
|
||||||
|
expect(options.html).not.toContain('<script>');
|
||||||
|
expect(options.html).toContain('<script>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a delivery failure to the caller rather than throwing', async () => {
|
||||||
|
sendMail.mockResolvedValue(false);
|
||||||
|
|
||||||
|
await expect(sendInvitationMail('a@nachklang.art', 'Anna', 'tok', new Date())).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sendPasswordResetMail', () => {
|
||||||
|
it('uses the url better-auth generated, unchanged', async () => {
|
||||||
|
const url = 'https://api.nachklang.art/admin/auth/reset-password/abc?callbackURL=x';
|
||||||
|
|
||||||
|
await sendPasswordResetMail('a@nachklang.art', 'Anna', url);
|
||||||
|
|
||||||
|
const [, , text, options] = sendMail.mock.calls[0];
|
||||||
|
expect(text).toContain(url);
|
||||||
|
expect(options.html).toContain('https://api.nachklang.art/admin/auth/reset-password/abc');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import {vi, describe, it, expect, beforeEach, type Mock} from 'vitest';
|
||||||
|
import {Request, Response} from 'express';
|
||||||
|
|
||||||
|
vi.mock('../../src/models/admin/admin.auth.js', () => ({
|
||||||
|
auth: {api: {getSession: vi.fn()}}
|
||||||
|
}));
|
||||||
|
vi.mock('../../src/models/admin/users/users.admin.service.js', () => ({
|
||||||
|
loadAccess: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {auth} from '../../src/models/admin/admin.auth.js';
|
||||||
|
import * as UsersService from '../../src/models/admin/users/users.admin.service.js';
|
||||||
|
import {requireAppAccess, requireSignedIn, resolveAccess} from '../../src/models/admin/admin.middleware.js';
|
||||||
|
|
||||||
|
const mockGetSession = auth.api.getSession as unknown as Mock;
|
||||||
|
const mockLoadAccess = UsersService.loadAccess as Mock;
|
||||||
|
|
||||||
|
const makeReq = (): Request => ({headers: {cookie: 'nachklang.session_token=abc'}} as unknown as Request);
|
||||||
|
|
||||||
|
const makeRes = (): Response => {
|
||||||
|
const res: any = {};
|
||||||
|
res.status = vi.fn().mockReturnValue(res);
|
||||||
|
res.send = vi.fn().mockReturnValue(res);
|
||||||
|
res.locals = {};
|
||||||
|
return res as Response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeUser = {
|
||||||
|
id: 'u1',
|
||||||
|
email: 'a@nachklang.art',
|
||||||
|
displayName: 'A',
|
||||||
|
disabled: false,
|
||||||
|
permissions: [
|
||||||
|
{app: 'feedback', role: 'access'},
|
||||||
|
{app: 'admin', role: 'access'}
|
||||||
|
],
|
||||||
|
apps: ['feedback', 'admin']
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('resolveAccess', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockGetSession.mockReset();
|
||||||
|
mockLoadAccess.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null without a valid session', async () => {
|
||||||
|
mockGetSession.mockResolvedValue(null);
|
||||||
|
expect(await resolveAccess(makeReq())).toBeNull();
|
||||||
|
expect(mockLoadAccess).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when the session points at a user row that is gone', async () => {
|
||||||
|
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
|
||||||
|
mockLoadAccess.mockResolvedValue(null);
|
||||||
|
expect(await resolveAccess(makeReq())).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves identity and permissions in a single permission query', async () => {
|
||||||
|
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
|
||||||
|
mockLoadAccess.mockResolvedValue(activeUser);
|
||||||
|
|
||||||
|
expect(await resolveAccess(makeReq())).toEqual({
|
||||||
|
id: 'u1',
|
||||||
|
email: 'a@nachklang.art',
|
||||||
|
displayName: 'A',
|
||||||
|
disabled: false,
|
||||||
|
permissions: [
|
||||||
|
{app: 'feedback', role: 'access'},
|
||||||
|
{app: 'admin', role: 'access'}
|
||||||
|
],
|
||||||
|
apps: ['feedback', 'admin']
|
||||||
|
});
|
||||||
|
// No cookieCache: exactly one lookup per request, never zero.
|
||||||
|
expect(mockLoadAccess).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('requireSignedIn', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockGetSession.mockReset();
|
||||||
|
mockLoadAccess.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('401s without a session', async () => {
|
||||||
|
mockGetSession.mockResolvedValue(null);
|
||||||
|
const res = makeRes();
|
||||||
|
const next = vi.fn();
|
||||||
|
|
||||||
|
await requireSignedIn(makeReq(), res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('403s a disabled user that still holds a valid cookie', async () => {
|
||||||
|
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
|
||||||
|
mockLoadAccess.mockResolvedValue({...activeUser, disabled: true});
|
||||||
|
const res = makeRes();
|
||||||
|
const next = vi.fn();
|
||||||
|
|
||||||
|
await requireSignedIn(makeReq(), res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('admits a signed-in user with no app permissions at all', async () => {
|
||||||
|
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
|
||||||
|
mockLoadAccess.mockResolvedValue({...activeUser, apps: []});
|
||||||
|
const res = makeRes();
|
||||||
|
const next = vi.fn();
|
||||||
|
|
||||||
|
await requireSignedIn(makeReq(), res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
expect(res.locals.admin.apps).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('requireAppAccess', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockGetSession.mockReset();
|
||||||
|
mockLoadAccess.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('401s without a session', async () => {
|
||||||
|
mockGetSession.mockResolvedValue(null);
|
||||||
|
const res = makeRes();
|
||||||
|
const next = vi.fn();
|
||||||
|
|
||||||
|
await requireAppAccess('feedback')(makeReq(), res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('403s a signed-in user without that app permission', async () => {
|
||||||
|
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
|
||||||
|
mockLoadAccess.mockResolvedValue({
|
||||||
|
...activeUser,
|
||||||
|
permissions: [{app: 'feedback', role: 'access'}],
|
||||||
|
apps: ['feedback']
|
||||||
|
});
|
||||||
|
const res = makeRes();
|
||||||
|
const next = vi.fn();
|
||||||
|
|
||||||
|
await requireAppAccess('tickets')(makeReq(), res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('403s a disabled user even when they hold the permission', async () => {
|
||||||
|
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
|
||||||
|
mockLoadAccess.mockResolvedValue({...activeUser, disabled: true});
|
||||||
|
const res = makeRes();
|
||||||
|
const next = vi.fn();
|
||||||
|
|
||||||
|
await requireAppAccess('feedback')(makeReq(), res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes through and exposes the identity the feedback/tickets services expect', async () => {
|
||||||
|
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
|
||||||
|
mockLoadAccess.mockResolvedValue(activeUser);
|
||||||
|
const res = makeRes();
|
||||||
|
const next = vi.fn();
|
||||||
|
|
||||||
|
await requireAppAccess('feedback')(makeReq(), res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
expect(res.locals.admin).toMatchObject({id: 'u1', email: 'a@nachklang.art', displayName: 'A'});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('500s (never allows through) when the permission query throws', async () => {
|
||||||
|
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
|
||||||
|
mockLoadAccess.mockRejectedValue(new Error('db down'));
|
||||||
|
const res = makeRes();
|
||||||
|
const next = vi.fn();
|
||||||
|
|
||||||
|
await requireAppAccess('feedback')(makeReq(), res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(500);
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The seam a finer per-app permission arrives through. Nothing passes a role
|
||||||
|
// today, so these two pin the behaviour before there is anything to break.
|
||||||
|
it('403s when a specific role is required and the user only holds another', async () => {
|
||||||
|
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
|
||||||
|
mockLoadAccess.mockResolvedValue({
|
||||||
|
...activeUser,
|
||||||
|
permissions: [{app: 'tickets', role: 'access'}],
|
||||||
|
apps: ['tickets']
|
||||||
|
});
|
||||||
|
const res = makeRes();
|
||||||
|
const next = vi.fn();
|
||||||
|
|
||||||
|
await requireAppAccess('tickets', 'refund')(makeReq(), res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes when the user holds exactly the required role', async () => {
|
||||||
|
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
|
||||||
|
mockLoadAccess.mockResolvedValue({
|
||||||
|
...activeUser,
|
||||||
|
permissions: [
|
||||||
|
{app: 'tickets', role: 'access'},
|
||||||
|
{app: 'tickets', role: 'refund'}
|
||||||
|
],
|
||||||
|
apps: ['tickets']
|
||||||
|
});
|
||||||
|
const res = makeRes();
|
||||||
|
const next = vi.fn();
|
||||||
|
|
||||||
|
await requireAppAccess('tickets', 'refund')(makeReq(), res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import {describe, expect, it} from 'vitest';
|
||||||
|
import {
|
||||||
|
ACCESS_ROLE,
|
||||||
|
appsOf,
|
||||||
|
isAppPermission,
|
||||||
|
isAppRole,
|
||||||
|
toPermissions
|
||||||
|
} from '../../src/models/admin/admin.schema.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The permission model is (app, role). These tests pin the two properties the
|
||||||
|
* rest of the module leans on: that the older `['tickets']` shape still means
|
||||||
|
* "tickets at the access role", and that nothing outside APP_ROLES gets in.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe('toPermissions', () => {
|
||||||
|
it('reads the full (app, role) form', () => {
|
||||||
|
expect(toPermissions([{app: 'tickets', role: 'access'}])).toEqual([
|
||||||
|
{app: 'tickets', role: 'access'}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads a plain app list as that app at the access role', () => {
|
||||||
|
expect(toPermissions(['feedback', 'admin'])).toEqual([
|
||||||
|
{app: 'feedback', role: ACCESS_ROLE},
|
||||||
|
{app: 'admin', role: ACCESS_ROLE}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts the two forms mixed, which is what a half-migrated caller sends', () => {
|
||||||
|
expect(toPermissions(['feedback', {app: 'tickets', role: 'access'}])).toEqual([
|
||||||
|
{app: 'feedback', role: ACCESS_ROLE},
|
||||||
|
{app: 'tickets', role: ACCESS_ROLE}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops duplicates of the same (app, role)', () => {
|
||||||
|
expect(toPermissions(['tickets', {app: 'tickets', role: 'access'}])).toEqual([
|
||||||
|
{app: 'tickets', role: ACCESS_ROLE}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects rather than silently dropping an unknown app', () => {
|
||||||
|
// Silently ignoring it would let "grant calendar + nonsense" look like a
|
||||||
|
// success while granting less than the caller asked for.
|
||||||
|
expect(toPermissions(['calendar', 'nonsense'])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unknown role', () => {
|
||||||
|
expect(toPermissions([{app: 'tickets', role: 'refund'}])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects anything that is not a list', () => {
|
||||||
|
expect(toPermissions('admin')).toBeNull();
|
||||||
|
expect(toPermissions(null)).toBeNull();
|
||||||
|
expect(toPermissions({app: 'admin', role: 'access'})).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads an empty list as "no permissions", not as invalid', () => {
|
||||||
|
expect(toPermissions([])).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isAppRole', () => {
|
||||||
|
it('accepts the access role for every app', () => {
|
||||||
|
expect(isAppRole('admin', ACCESS_ROLE)).toBe(true);
|
||||||
|
expect(isAppRole('calendar', ACCESS_ROLE)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a role that does not exist yet', () => {
|
||||||
|
expect(isAppRole('tickets', 'refund')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isAppPermission', () => {
|
||||||
|
it('needs both halves to be valid', () => {
|
||||||
|
expect(isAppPermission({app: 'tickets', role: ACCESS_ROLE})).toBe(true);
|
||||||
|
expect(isAppPermission({app: 'tickets'})).toBe(false);
|
||||||
|
expect(isAppPermission({role: ACCESS_ROLE})).toBe(false);
|
||||||
|
expect(isAppPermission(null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('appsOf', () => {
|
||||||
|
it('collapses several roles on one app to a single entry', () => {
|
||||||
|
// The point of the derived list: a user with two roles on tickets has
|
||||||
|
// access to tickets once, not twice.
|
||||||
|
const apps = appsOf([
|
||||||
|
{app: 'tickets', role: ACCESS_ROLE},
|
||||||
|
{app: 'tickets', role: 'future-role'},
|
||||||
|
{app: 'admin', role: ACCESS_ROLE}
|
||||||
|
]);
|
||||||
|
expect(apps).toEqual(['tickets', 'admin']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is empty for no permissions', () => {
|
||||||
|
expect(appsOf([])).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import {vi, describe, it, expect, beforeEach, type Mock} from 'vitest';
|
||||||
|
import express, {Request, Response} from 'express';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared body for the two cutover tests (2026-09-06). feedback.auth.ts and
|
||||||
|
* tickets.auth.ts used to carry their own header-session authenticator against
|
||||||
|
* the calendar users table; both are now one binding to the shared admin gate.
|
||||||
|
*
|
||||||
|
* What is worth asserting is not how that gate works - admin.middleware.test.ts
|
||||||
|
* owns that - but that each module is bound to *its own* app. The mocks live in
|
||||||
|
* the calling file because vi.mock is per-module-graph; only the assertions are
|
||||||
|
* shared.
|
||||||
|
*
|
||||||
|
* There used to be a tripwire here asserting neither module fell back to the
|
||||||
|
* calendar's header sessions. It went with step 5: the calendar users service
|
||||||
|
* no longer exists, so there is nothing left to fall back to and nothing to
|
||||||
|
* assert against.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface BindingMocks {
|
||||||
|
/** auth.api.getSession from the mocked admin.auth.js */
|
||||||
|
getSession: Mock;
|
||||||
|
/** loadAccess from the mocked users.admin.service.js */
|
||||||
|
loadAccess: Mock;
|
||||||
|
}
|
||||||
|
|
||||||
|
const makeReq = (): Request => ({headers: {cookie: 'nachklang.session_token=abc'}} as unknown as Request);
|
||||||
|
|
||||||
|
const makeRes = (): Response => {
|
||||||
|
const res: any = {};
|
||||||
|
res.status = vi.fn().mockReturnValue(res);
|
||||||
|
res.send = vi.fn().mockReturnValue(res);
|
||||||
|
res.locals = {};
|
||||||
|
return res as Response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const userWith = (...apps: string[]) => ({
|
||||||
|
id: 'u1',
|
||||||
|
email: 'a@nachklang.art',
|
||||||
|
displayName: 'Anna Admin',
|
||||||
|
disabled: false,
|
||||||
|
permissions: apps.map(app => ({app, role: 'access'})),
|
||||||
|
apps
|
||||||
|
});
|
||||||
|
|
||||||
|
export const describeAdminBinding = (
|
||||||
|
app: string,
|
||||||
|
otherApp: string,
|
||||||
|
middleware: express.RequestHandler,
|
||||||
|
mocks: () => BindingMocks
|
||||||
|
): void => {
|
||||||
|
describe(`${app} requireAdminAuth`, () => {
|
||||||
|
let m: BindingMocks;
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
const res = makeRes();
|
||||||
|
const next = vi.fn();
|
||||||
|
await middleware(makeReq(), res, next);
|
||||||
|
return {res, next};
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
m = mocks();
|
||||||
|
m.getSession.mockReset();
|
||||||
|
m.loadAccess.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('responds 401 and does not call next() without a session', async () => {
|
||||||
|
m.getSession.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const {res, next} = await run();
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`responds 403 for a signed-in user who only has ${otherApp}`, async () => {
|
||||||
|
m.getSession.mockResolvedValue({user: {id: 'u1'}});
|
||||||
|
m.loadAccess.mockResolvedValue(userWith(otherApp));
|
||||||
|
|
||||||
|
const {res, next} = await run();
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('responds 403 for a disabled user who still holds the permission', async () => {
|
||||||
|
m.getSession.mockResolvedValue({user: {id: 'u1'}});
|
||||||
|
m.loadAccess.mockResolvedValue({...userWith(app), disabled: true});
|
||||||
|
|
||||||
|
const {res, next} = await run();
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets res.locals.admin and calls next() with the permission', async () => {
|
||||||
|
m.getSession.mockResolvedValue({user: {id: 'u1'}});
|
||||||
|
m.loadAccess.mockResolvedValue(userWith(app));
|
||||||
|
|
||||||
|
const {res, next} = await run();
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
expect(res.locals.admin).toMatchObject({id: 'u1', email: 'a@nachklang.art', displayName: 'Anna Admin'});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import {vi, describe, it, expect, beforeEach, type Mock} from 'vitest';
|
||||||
|
import express from 'express';
|
||||||
|
import request from 'supertest';
|
||||||
|
|
||||||
|
vi.mock('../../src/models/admin/users/users.admin.service.js', () => ({
|
||||||
|
listUsers: vi.fn(),
|
||||||
|
getUserDetail: vi.fn(),
|
||||||
|
loadAccess: vi.fn(),
|
||||||
|
setPermissions: vi.fn(),
|
||||||
|
setPermissionsGuarded: vi.fn(),
|
||||||
|
disableUser: vi.fn(),
|
||||||
|
disableUserGuarded: vi.fn(),
|
||||||
|
enableUser: vi.fn(),
|
||||||
|
revokeSession: vi.fn(),
|
||||||
|
countActiveAdmins: vi.fn(),
|
||||||
|
userExists: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
import * as UsersService from '../../src/models/admin/users/users.admin.service.js';
|
||||||
|
import {usersAdminRouter} from '../../src/models/admin/users/users.admin.router.js';
|
||||||
|
|
||||||
|
const service = UsersService as unknown as Record<string, Mock>;
|
||||||
|
|
||||||
|
// The router always runs behind requireAppAccess('admin'), which is what puts
|
||||||
|
// res.locals.admin there; this stands in for it.
|
||||||
|
const makeApp = (callerId = 'me') => {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
res.locals.admin = {id: callerId, email: 'me@nachklang.art', displayName: 'Me', apps: ['admin']};
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
app.use('/admin/users', usersAdminRouter);
|
||||||
|
return app;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
for (const fn of Object.values(service)) {
|
||||||
|
if (typeof fn?.mockReset === 'function') {
|
||||||
|
fn.mockReset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
service.getUserDetail.mockResolvedValue({id: 'other', apps: []});
|
||||||
|
service.userExists.mockResolvedValue(true);
|
||||||
|
service.setPermissionsGuarded.mockResolvedValue('ok');
|
||||||
|
service.disableUserGuarded.mockResolvedValue('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT /admin/users/:id/permissions', () => {
|
||||||
|
it('rejects an unknown app name', async () => {
|
||||||
|
const res = await request(makeApp()).put('/admin/users/other/permissions').send({apps: ['calendar', 'nope']});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(service.setPermissionsGuarded).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-array body', async () => {
|
||||||
|
const res = await request(makeApp()).put('/admin/users/other/permissions').send({apps: 'admin'});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('404s for an unknown user', async () => {
|
||||||
|
service.userExists.mockResolvedValue(false);
|
||||||
|
|
||||||
|
const res = await request(makeApp()).put('/admin/users/ghost/permissions').send({apps: []});
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(service.setPermissionsGuarded).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to remove the caller\'s own admin permission', async () => {
|
||||||
|
service.loadAccess.mockResolvedValue({id: 'me', disabled: false, apps: ['admin']});
|
||||||
|
service.countActiveAdmins.mockResolvedValue(5);
|
||||||
|
|
||||||
|
const res = await request(makeApp('me')).put('/admin/users/me/permissions').send({apps: ['feedback']});
|
||||||
|
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect(service.setPermissionsGuarded).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The last-admin decision is made inside the write transaction (so two
|
||||||
|
// admins acting at once cannot both pass a check-then-act); the router's
|
||||||
|
// job is only to turn that verdict into a 409.
|
||||||
|
it('answers 409 when the service reports the last admin would be removed', async () => {
|
||||||
|
service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: ['admin']});
|
||||||
|
service.setPermissionsGuarded.mockResolvedValue('last-admin');
|
||||||
|
|
||||||
|
const res = await request(makeApp('me')).put('/admin/users/other/permissions').send({apps: []});
|
||||||
|
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows removing an admin while another active admin remains', async () => {
|
||||||
|
service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: ['admin']});
|
||||||
|
|
||||||
|
const res = await request(makeApp('me')).put('/admin/users/other/permissions').send({apps: ['tickets']});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(service.setPermissionsGuarded).toHaveBeenCalledWith(
|
||||||
|
'other',
|
||||||
|
[{app: 'tickets', role: 'access'}],
|
||||||
|
'me'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts the richer {permissions} body', async () => {
|
||||||
|
service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: []});
|
||||||
|
|
||||||
|
const res = await request(makeApp('me'))
|
||||||
|
.put('/admin/users/other/permissions')
|
||||||
|
.send({permissions: [{app: 'tickets', role: 'access'}]});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(service.setPermissionsGuarded).toHaveBeenCalledWith(
|
||||||
|
'other',
|
||||||
|
[{app: 'tickets', role: 'access'}],
|
||||||
|
'me'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a role that does not exist', async () => {
|
||||||
|
service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: []});
|
||||||
|
|
||||||
|
const res = await request(makeApp('me'))
|
||||||
|
.put('/admin/users/other/permissions')
|
||||||
|
.send({permissions: [{app: 'tickets', role: 'refund'}]});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(service.setPermissionsGuarded).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows granting permissions to someone who has none', async () => {
|
||||||
|
service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: []});
|
||||||
|
|
||||||
|
const res = await request(makeApp('me')).put('/admin/users/other/permissions').send({apps: ['feedback', 'tickets']});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(service.setPermissionsGuarded).toHaveBeenCalledWith(
|
||||||
|
'other',
|
||||||
|
[{app: 'feedback', role: 'access'}, {app: 'tickets', role: 'access'}],
|
||||||
|
'me'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /admin/users/:id/disable', () => {
|
||||||
|
it('refuses to disable the caller', async () => {
|
||||||
|
const res = await request(makeApp('me')).post('/admin/users/me/disable');
|
||||||
|
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect(service.disableUserGuarded).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers 409 when the service reports the last active admin would be disabled', async () => {
|
||||||
|
service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: ['admin']});
|
||||||
|
service.disableUserGuarded.mockResolvedValue('last-admin');
|
||||||
|
|
||||||
|
const res = await request(makeApp('me')).post('/admin/users/other/disable');
|
||||||
|
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables a non-admin user', async () => {
|
||||||
|
service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: ['feedback']});
|
||||||
|
|
||||||
|
const res = await request(makeApp('me')).post('/admin/users/other/disable');
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(service.disableUserGuarded).toHaveBeenCalledWith('other');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('404s for an unknown user', async () => {
|
||||||
|
service.loadAccess.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const res = await request(makeApp('me')).post('/admin/users/ghost/disable');
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /admin/users/:id/sessions/:sid', () => {
|
||||||
|
it('404s when the session does not belong to that user', async () => {
|
||||||
|
service.revokeSession.mockResolvedValue(false);
|
||||||
|
|
||||||
|
const res = await request(makeApp()).delete('/admin/users/other/sessions/s1');
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('204s on a successful revoke', async () => {
|
||||||
|
service.revokeSession.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const res = await request(makeApp()).delete('/admin/users/other/sessions/s1');
|
||||||
|
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
expect(service.revokeSession).toHaveBeenCalledWith('other', 's1');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import {describe, expect, it, beforeEach} from 'vitest';
|
||||||
|
|
||||||
|
import * as CredentialService from '../../src/models/calendar/events/credentials.service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The public calendar is read anonymously by nachklang.art to show the next
|
||||||
|
* upcoming event. That is a load-bearing property, not an accident: the step 4
|
||||||
|
* cutover moved every signed-in path onto session cookies and left these shared
|
||||||
|
* passwords behind only for iCal subscriptions, and the failure mode of getting
|
||||||
|
* it wrong is the public website silently losing its events feed.
|
||||||
|
*
|
||||||
|
* So this pins both halves: public needs nothing, and the restricted calendars
|
||||||
|
* still need something.
|
||||||
|
*/
|
||||||
|
describe('hasAccess', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.MEMBER_CREDENTIAL = 'member-secret';
|
||||||
|
process.env.CHOIR_CREDENTIAL = 'choir-secret';
|
||||||
|
process.env.MANAGEMENT_CREDENTIAL = 'management-secret';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets anyone read the public calendar with no password at all', async () => {
|
||||||
|
await expect(CredentialService.hasAccess('public', '')).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['members', 'member-secret'],
|
||||||
|
['choir', 'choir-secret'],
|
||||||
|
['management', 'management-secret'],
|
||||||
|
['birthdays', 'choir-secret']
|
||||||
|
])('refuses %s without the credential and allows it with one', async (calendar, secret) => {
|
||||||
|
await expect(CredentialService.hasAccess(calendar, '')).resolves.toBe(false);
|
||||||
|
await expect(CredentialService.hasAccess(calendar, 'wrong')).resolves.toBe(false);
|
||||||
|
await expect(CredentialService.hasAccess(calendar, secret)).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses an unknown calendar outright', async () => {
|
||||||
|
await expect(CredentialService.hasAccess('nope', 'member-secret')).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a calendar whose credential is not configured', async () => {
|
||||||
|
// An unset MEMBER_CREDENTIAL must not become "any password works", and in
|
||||||
|
// particular must not become "an absent password works".
|
||||||
|
delete process.env.MEMBER_CREDENTIAL;
|
||||||
|
|
||||||
|
await expect(CredentialService.hasAccess('members', '')).resolves.toBe(false);
|
||||||
|
await expect(CredentialService.hasAccess('members', undefined as any)).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import {describe, expect, it, vi, beforeEach} from 'vitest';
|
||||||
|
import express from 'express';
|
||||||
|
import request from 'supertest';
|
||||||
|
|
||||||
|
vi.mock('../../src/models/calendar/events/events.service.js', () => ({
|
||||||
|
getAllEvents: vi.fn(),
|
||||||
|
getAllEventsAdmin: vi.fn(),
|
||||||
|
getEventById: vi.fn(),
|
||||||
|
createEvent: vi.fn(),
|
||||||
|
updateEvent: vi.fn(),
|
||||||
|
deleteEvent: vi.fn(),
|
||||||
|
moveEvent: vi.fn(),
|
||||||
|
getNextUpcomingEvent: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../src/models/admin/admin.auth.js', () => ({
|
||||||
|
auth: {api: {getSession: vi.fn()}}
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../src/models/admin/users/users.admin.service.js', () => ({
|
||||||
|
loadAccess: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
import * as EventService from '../../src/models/calendar/events/events.service.js';
|
||||||
|
import {auth} from '../../src/models/admin/admin.auth.js';
|
||||||
|
import * as UsersService from '../../src/models/admin/users/users.admin.service.js';
|
||||||
|
import {eventsRouter} from '../../src/models/calendar/events/events.router.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 4 of docs/calendar-auth-migration.md at the route level. The unit test
|
||||||
|
* on credentials.service covers the password table; this covers the thing that
|
||||||
|
* table is wired into, which is where the interesting mistakes live:
|
||||||
|
*
|
||||||
|
* - the public calendar has to stay readable with no session and no password,
|
||||||
|
* - the shared password has to keep working for the restricted calendars,
|
||||||
|
* because iCal clients cannot send a cookie,
|
||||||
|
* - and every write has to be behind the session cookie *and* an explicit
|
||||||
|
* calendar permission, not merely behind "is signed in".
|
||||||
|
*/
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/calendar/events', eventsRouter);
|
||||||
|
|
||||||
|
const signedInAs = (apps: string[], disabled = false) => {
|
||||||
|
(auth.api.getSession as any).mockResolvedValue({user: {id: 'admin-1'}});
|
||||||
|
(UsersService.loadAccess as any).mockResolvedValue({
|
||||||
|
id: 'admin-1',
|
||||||
|
email: 'a@nachklang.art',
|
||||||
|
displayName: 'A',
|
||||||
|
disabled,
|
||||||
|
permissions: apps.map(app => ({app, role: 'access'})),
|
||||||
|
apps
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const signedOut = () => {
|
||||||
|
(auth.api.getSession as any).mockResolvedValue(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const validEvent = {
|
||||||
|
calendarId: 1,
|
||||||
|
name: 'Konzert',
|
||||||
|
startDateTime: '2026-04-18T19:00:00Z',
|
||||||
|
endDateTime: '2026-04-18T21:00:00Z'
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
process.env.MEMBER_CREDENTIAL = 'member-secret';
|
||||||
|
(EventService.getAllEvents as any).mockResolvedValue([]);
|
||||||
|
(EventService.getAllEventsAdmin as any).mockResolvedValue([]);
|
||||||
|
(EventService.getNextUpcomingEvent as any).mockResolvedValue({eventId: 1, name: 'Konzert'});
|
||||||
|
(EventService.createEvent as any).mockResolvedValue(1);
|
||||||
|
(EventService.updateEvent as any).mockResolvedValue(1);
|
||||||
|
(EventService.moveEvent as any).mockResolvedValue(true);
|
||||||
|
(EventService.deleteEvent as any).mockResolvedValue(true);
|
||||||
|
signedOut();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reading', () => {
|
||||||
|
it('serves the public calendar anonymously', async () => {
|
||||||
|
// The property nachklang.art depends on. No cookie, no password.
|
||||||
|
await request(app).get('/calendar/events/public/json').expect(200);
|
||||||
|
|
||||||
|
// And as the non-admin view: an anonymous caller must not see drafts.
|
||||||
|
expect(EventService.getAllEvents).toHaveBeenCalled();
|
||||||
|
expect(EventService.getAllEventsAdmin).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a restricted calendar with neither session nor password', async () => {
|
||||||
|
await request(app).get('/calendar/events/members/json').expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves a restricted calendar to a shared password, without drafts', async () => {
|
||||||
|
await request(app)
|
||||||
|
.get('/calendar/events/members/json')
|
||||||
|
.query({password: 'member-secret'})
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(EventService.getAllEvents).toHaveBeenCalled();
|
||||||
|
expect(EventService.getAllEventsAdmin).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives a signed-in editor the admin view instead', async () => {
|
||||||
|
signedInAs(['calendar']);
|
||||||
|
|
||||||
|
await request(app).get('/calendar/events/members/json').expect(200);
|
||||||
|
|
||||||
|
expect(EventService.getAllEventsAdmin).toHaveBeenCalled();
|
||||||
|
expect(EventService.getAllEvents).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a signed-in user without the calendar permission as anonymous', async () => {
|
||||||
|
// Not a 403: they may still read the public calendar like anyone else.
|
||||||
|
signedInAs(['tickets']);
|
||||||
|
|
||||||
|
await request(app).get('/calendar/events/public/json').expect(200);
|
||||||
|
expect(EventService.getAllEvents).toHaveBeenCalled();
|
||||||
|
expect(EventService.getAllEventsAdmin).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await request(app).get('/calendar/events/members/json').expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still serves the public calendar when the admin database is down', async () => {
|
||||||
|
(auth.api.getSession as any).mockRejectedValue(new Error('ECONNREFUSED'));
|
||||||
|
|
||||||
|
await request(app).get('/calendar/events/public/json').expect(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The endpoint www.nachklang.art actually calls for its next-event teaser.
|
||||||
|
// Tested separately from /json because it takes a different code path - it
|
||||||
|
// has no admin view and no editor branch - so covering /json proves nothing
|
||||||
|
// about it, and its failure is invisible until someone notices the website
|
||||||
|
// has gone quiet.
|
||||||
|
it('serves the next upcoming event anonymously on the public calendar', async () => {
|
||||||
|
await request(app).get('/calendar/events/public/json/next').expect(200);
|
||||||
|
|
||||||
|
// And without asking the admin database who the caller is: the public
|
||||||
|
// feed must not acquire a dependency it has never had.
|
||||||
|
expect(auth.api.getSession).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses the next upcoming event on a restricted calendar without a credential', async () => {
|
||||||
|
await request(app).get('/calendar/events/members/json/next').expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves the next upcoming event to a shared password', async () => {
|
||||||
|
await request(app)
|
||||||
|
.get('/calendar/events/members/json/next')
|
||||||
|
.query({password: 'member-secret'})
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves the next upcoming event to a signed-in editor', async () => {
|
||||||
|
signedInAs(['calendar']);
|
||||||
|
|
||||||
|
await request(app).get('/calendar/events/members/json/next').expect(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not consult the admin database for the anonymous public iCal export', async () => {
|
||||||
|
await request(app).get('/calendar/events/public/ical').expect(200);
|
||||||
|
|
||||||
|
expect(auth.api.getSession).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the shared password working on the iCal export', async () => {
|
||||||
|
(EventService.getAllEvents as any).mockResolvedValue([]);
|
||||||
|
|
||||||
|
await request(app).get('/calendar/events/public/ical').expect(200);
|
||||||
|
await request(app).get('/calendar/events/members/ical').expect(403);
|
||||||
|
await request(app)
|
||||||
|
.get('/calendar/events/members/ical')
|
||||||
|
.query({password: 'member-secret'})
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('writing', () => {
|
||||||
|
it.each([
|
||||||
|
['post', '/calendar/events'],
|
||||||
|
['put', '/calendar/events/1'],
|
||||||
|
['put', '/calendar/events/move/1'],
|
||||||
|
['delete', '/calendar/events/1']
|
||||||
|
])('%s %s answers 401 when signed out', async (method, path) => {
|
||||||
|
await (request(app) as any)[method](path).send(validEvent).expect(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['post', '/calendar/events'],
|
||||||
|
['put', '/calendar/events/1'],
|
||||||
|
['put', '/calendar/events/move/1'],
|
||||||
|
['delete', '/calendar/events/1']
|
||||||
|
])('%s %s answers 403 without the calendar permission', async (method, path) => {
|
||||||
|
signedInAs(['tickets', 'feedback', 'admin']);
|
||||||
|
await (request(app) as any)[method](path).send(validEvent).expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers 403 for a disabled account that still holds the permission', async () => {
|
||||||
|
signedInAs(['calendar'], true);
|
||||||
|
await request(app).post('/calendar/events').send(validEvent).expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records the writer as an admin user id, never a legacy one', async () => {
|
||||||
|
signedInAs(['calendar']);
|
||||||
|
|
||||||
|
await request(app).post('/calendar/events').send(validEvent).expect(201);
|
||||||
|
|
||||||
|
const written = (EventService.createEvent as any).mock.calls[0][0];
|
||||||
|
expect(written.createdByUserId).toBe('admin-1');
|
||||||
|
// Migration 003 made the legacy column nullable precisely so this can be unset.
|
||||||
|
expect(written.createdById).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses the shared password as a way to write', async () => {
|
||||||
|
// The passwords are a read fallback for clients that cannot hold a
|
||||||
|
// session. They must never become an editing credential.
|
||||||
|
await request(app)
|
||||||
|
.post('/calendar/events')
|
||||||
|
.query({password: 'member-secret'})
|
||||||
|
.send(validEvent)
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import {describe, expect, it, vi, beforeEach} from 'vitest';
|
||||||
|
|
||||||
|
const connection = {
|
||||||
|
query: vi.fn(),
|
||||||
|
execute: vi.fn(),
|
||||||
|
beginTransaction: vi.fn(),
|
||||||
|
commit: vi.fn(),
|
||||||
|
rollback: vi.fn(),
|
||||||
|
end: vi.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('../../src/models/calendar/Calendar.db.js', () => ({
|
||||||
|
NachklangCalendarDB: {getConnection: vi.fn(async () => connection)}
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../src/models/admin/users/users.admin.service.js', () => ({
|
||||||
|
findDisplayNames: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {NachklangCalendarDB} from '../../src/models/calendar/Calendar.db.js';
|
||||||
|
import * as AdminUsersService from '../../src/models/admin/users/users.admin.service.js';
|
||||||
|
import * as EventService from '../../src/models/calendar/events/events.service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 3 of docs/calendar-auth-migration.md. The property under test is that
|
||||||
|
* an event's creator resolves from the stronger of its two remaining sources -
|
||||||
|
* the live admin name, else the name archived before the legacy users table was
|
||||||
|
* removed - and that a failure to reach the admin database costs a name rather
|
||||||
|
* than the whole response: the public calendar is read anonymously by the
|
||||||
|
* website and has never depended on the admin database being up.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// One row of the shape the shared SELECT produces.
|
||||||
|
const row = (over: Record<string, unknown> = {}) => ({
|
||||||
|
event_id: 1,
|
||||||
|
calendar_id: 1,
|
||||||
|
uuid: 'uuid-1',
|
||||||
|
name: 'Konzert',
|
||||||
|
description: '',
|
||||||
|
start_datetime: new Date('2026-04-18T19:00:00Z'),
|
||||||
|
end_datetime: new Date('2026-04-18T21:00:00Z'),
|
||||||
|
created_date: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
version_created_at: new Date('2026-01-02T00:00:00Z'),
|
||||||
|
location: '',
|
||||||
|
created_by_user_id: null,
|
||||||
|
created_by_name: 'Archived Person',
|
||||||
|
version_created_by_user_id: null,
|
||||||
|
version_created_by_name: 'Archived Person',
|
||||||
|
url: '',
|
||||||
|
whole_day: 0,
|
||||||
|
repeat_frequency: '',
|
||||||
|
status: 'PUBLIC',
|
||||||
|
...over
|
||||||
|
});
|
||||||
|
|
||||||
|
/** getAllEvents runs the calendars lookup first, then the events query. */
|
||||||
|
const givenEvents = (...rows: unknown[]) => {
|
||||||
|
connection.query.mockReset();
|
||||||
|
connection.query
|
||||||
|
.mockResolvedValueOnce([{calendar_id: 1, includes_calendars: '[]'}])
|
||||||
|
.mockResolvedValueOnce(rows);
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
connection.end.mockResolvedValue(undefined);
|
||||||
|
(NachklangCalendarDB.getConnection as any).mockResolvedValue(connection);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('creator names', () => {
|
||||||
|
it('uses the archived name when the row has no admin id', async () => {
|
||||||
|
givenEvents(row());
|
||||||
|
|
||||||
|
const events = await EventService.getAllEvents(1);
|
||||||
|
|
||||||
|
expect(events[0].createdBy).toBe('Archived Person');
|
||||||
|
expect(events[0].createdByUserId).toBeNull();
|
||||||
|
// Nothing to resolve, so the admin database is not touched at all.
|
||||||
|
expect(AdminUsersService.findDisplayNames).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers the admin name when the row carries an admin id', async () => {
|
||||||
|
givenEvents(row({
|
||||||
|
created_by_user_id: 'admin-1',
|
||||||
|
version_created_by_user_id: 'admin-2'
|
||||||
|
}));
|
||||||
|
(AdminUsersService.findDisplayNames as any).mockResolvedValue(
|
||||||
|
new Map([['admin-1', 'Neue Person'], ['admin-2', 'Andere Person']])
|
||||||
|
);
|
||||||
|
|
||||||
|
const events = await EventService.getAllEvents(1);
|
||||||
|
|
||||||
|
expect(events[0].createdBy).toBe('Neue Person');
|
||||||
|
expect(events[0].lastModifiedBy).toBe('Andere Person');
|
||||||
|
expect(events[0].createdByUserId).toBe('admin-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers the live admin name over the snapshot', async () => {
|
||||||
|
// A renamed account has to win over an archive that was correct when it
|
||||||
|
// was taken - otherwise renaming someone would leave stale names behind.
|
||||||
|
givenEvents(row({created_by_user_id: 'admin-1', created_by_name: 'Archived Person'}));
|
||||||
|
(AdminUsersService.findDisplayNames as any).mockResolvedValue(new Map([['admin-1', 'Neue Person']]));
|
||||||
|
|
||||||
|
const events = await EventService.getAllEvents(1);
|
||||||
|
|
||||||
|
expect(events[0].createdBy).toBe('Neue Person');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the archived name when the admin account is gone', async () => {
|
||||||
|
givenEvents(row({created_by_user_id: 'deleted'}));
|
||||||
|
(AdminUsersService.findDisplayNames as any).mockResolvedValue(new Map());
|
||||||
|
|
||||||
|
const events = await EventService.getAllEvents(1);
|
||||||
|
|
||||||
|
expect(events[0].createdBy).toBe('Archived Person');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the name blank when a row has neither source', async () => {
|
||||||
|
// A post-cutover event whose author was later deleted from the admin
|
||||||
|
// module: no snapshot was ever taken for it, and the id resolves to
|
||||||
|
// nothing. Blank is the designed outcome - the creator is decoration.
|
||||||
|
givenEvents(row({created_by_user_id: 'deleted', created_by_name: null}));
|
||||||
|
(AdminUsersService.findDisplayNames as any).mockResolvedValue(new Map());
|
||||||
|
|
||||||
|
const events = await EventService.getAllEvents(1);
|
||||||
|
|
||||||
|
expect(events[0].createdBy).toBeNull();
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves a mixed result set in a single lookup', async () => {
|
||||||
|
givenEvents(
|
||||||
|
row({event_id: 1}),
|
||||||
|
row({event_id: 2, created_by_user_id: 'admin-1', version_created_by_user_id: 'admin-1'}),
|
||||||
|
row({event_id: 3, created_by_user_id: 'admin-1', version_created_by_user_id: 'admin-1'})
|
||||||
|
);
|
||||||
|
(AdminUsersService.findDisplayNames as any).mockResolvedValue(new Map([['admin-1', 'Neue Person']]));
|
||||||
|
|
||||||
|
const events = await EventService.getAllEvents(1);
|
||||||
|
|
||||||
|
expect(events.map(e => e.createdBy)).toEqual(['Archived Person', 'Neue Person', 'Neue Person']);
|
||||||
|
expect(AdminUsersService.findDisplayNames).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still returns the events when the admin database is unreachable', async () => {
|
||||||
|
givenEvents(row({created_by_user_id: 'admin-1'}));
|
||||||
|
(AdminUsersService.findDisplayNames as any).mockRejectedValue(new Error('ECONNREFUSED'));
|
||||||
|
|
||||||
|
const events = await EventService.getAllEvents(1);
|
||||||
|
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].name).toBe('Konzert');
|
||||||
|
// Degrades to the archived name rather than failing the request.
|
||||||
|
expect(events[0].createdBy).toBe('Archived Person');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('status', () => {
|
||||||
|
it('is omitted from the public listing and present in the admin one', async () => {
|
||||||
|
givenEvents(row());
|
||||||
|
const publicEvents = await EventService.getAllEvents(1);
|
||||||
|
expect(publicEvents[0].status).toBeUndefined();
|
||||||
|
|
||||||
|
connection.query.mockReset();
|
||||||
|
connection.query.mockResolvedValueOnce([row()]);
|
||||||
|
const adminEvents = await EventService.getAllEventsAdmin(1);
|
||||||
|
expect(adminEvents[0].status).toBe('PUBLIC');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,17 +5,18 @@
|
|||||||
// behaviour, and that a delivery failure is swallowed (returns false, never
|
// behaviour, and that a delivery failure is swallowed (returns false, never
|
||||||
// throws).
|
// throws).
|
||||||
|
|
||||||
jest.mock('../../src/common/salesforce.client');
|
import {vi, describe, it, expect, beforeEach, type Mock} from 'vitest';
|
||||||
jest.mock('../../src/middleware/logger', () => ({
|
vi.mock('../../src/common/salesforce.client.js');
|
||||||
|
vi.mock('../../src/middleware/logger.js', () => ({
|
||||||
__esModule: true,
|
__esModule: true,
|
||||||
default: {info: jest.fn(), warn: jest.fn(), error: jest.fn()}
|
default: {info: vi.fn(), warn: vi.fn(), error: vi.fn()}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import {MailService} from '../../src/common/common.mail';
|
import {MailService} from '../../src/common/common.mail.js';
|
||||||
import {salesforceApexRestPost, salesforceEnabled} from '../../src/common/salesforce.client';
|
import {salesforceApexRestPost, salesforceEnabled} from '../../src/common/salesforce.client.js';
|
||||||
|
|
||||||
const mockPost = salesforceApexRestPost as jest.Mock;
|
const mockPost = salesforceApexRestPost as Mock;
|
||||||
const mockEnabled = salesforceEnabled as jest.Mock;
|
const mockEnabled = salesforceEnabled as Mock;
|
||||||
|
|
||||||
const httpError = (status: number, body?: any): any => {
|
const httpError = (status: number, body?: any): any => {
|
||||||
const err: any = new Error('request failed with ' + status);
|
const err: any = new Error('request failed with ' + status);
|
||||||
@@ -24,7 +25,7 @@ const httpError = (status: number, body?: any): any => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockEnabled.mockReturnValue(true);
|
mockEnabled.mockReturnValue(true);
|
||||||
mockPost.mockResolvedValue({status: 'SENT'});
|
mockPost.mockResolvedValue({status: 'SENT'});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
// salesforce.client caches the OAuth token at module scope, so every test
|
// salesforce.client caches the OAuth token at module scope, so every test
|
||||||
// resets the module registry for a clean cache and re-requires axios + the
|
// resets the module registry for a clean cache and re-imports axios + the
|
||||||
// module under test after the reset (same approach as
|
// module under test after the reset (same approach as
|
||||||
// test/feedback/salesforce.service.test.ts).
|
// test/feedback/salesforce.service.test.ts). Mocked modules survive
|
||||||
|
// vi.resetModules(), so mock state is reset explicitly in beforeEach.
|
||||||
|
|
||||||
export {}; // isolate module scope from other script-style test files
|
import {vi, describe, it, expect, beforeEach, afterAll} from 'vitest';
|
||||||
|
vi.mock('axios');
|
||||||
|
|
||||||
jest.mock('axios');
|
const freshImports = async () => {
|
||||||
|
const axios: any = (await import('axios')).default;
|
||||||
const freshImports = () => {
|
const {salesforceApexRestPost, salesforceEnabled} = await import('../../src/common/salesforce.client.js');
|
||||||
const axios = require('axios');
|
|
||||||
const {salesforceApexRestPost, salesforceEnabled} = require('../../src/common/salesforce.client');
|
|
||||||
return {axios, salesforceApexRestPost, salesforceEnabled};
|
return {axios, salesforceApexRestPost, salesforceEnabled};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -23,7 +23,8 @@ const ENABLED_ENV = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.resetModules();
|
vi.resetModules();
|
||||||
|
vi.resetAllMocks();
|
||||||
process.env = {...ENABLED_ENV};
|
process.env = {...ENABLED_ENV};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -32,19 +33,19 @@ afterAll(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('salesforceEnabled', () => {
|
describe('salesforceEnabled', () => {
|
||||||
it('is true only when SALESFORCE_ENABLED === "true"', () => {
|
it('is true only when SALESFORCE_ENABLED === "true"', async () => {
|
||||||
process.env.SALESFORCE_ENABLED = 'true';
|
process.env.SALESFORCE_ENABLED = 'true';
|
||||||
expect(freshImports().salesforceEnabled()).toBe(true);
|
expect((await freshImports()).salesforceEnabled()).toBe(true);
|
||||||
|
|
||||||
jest.resetModules();
|
vi.resetModules();
|
||||||
process.env.SALESFORCE_ENABLED = 'false';
|
process.env.SALESFORCE_ENABLED = 'false';
|
||||||
expect(freshImports().salesforceEnabled()).toBe(false);
|
expect((await freshImports()).salesforceEnabled()).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('salesforceApexRestPost', () => {
|
describe('salesforceApexRestPost', () => {
|
||||||
it('fetches a token, posts to the given Apex REST path, and returns the response body', async () => {
|
it('fetches a token, posts to the given Apex REST path, and returns the response body', async () => {
|
||||||
const {axios, salesforceApexRestPost} = freshImports();
|
const {axios, salesforceApexRestPost} = await freshImports();
|
||||||
axios.post.mockImplementation((url: string) => {
|
axios.post.mockImplementation((url: string) => {
|
||||||
if (url.endsWith('/services/oauth2/token')) return Promise.resolve({data: {access_token: 'tok-1'}});
|
if (url.endsWith('/services/oauth2/token')) return Promise.resolve({data: {access_token: 'tok-1'}});
|
||||||
return Promise.resolve({data: {ok: true}});
|
return Promise.resolve({data: {ok: true}});
|
||||||
@@ -66,7 +67,7 @@ describe('salesforceApexRestPost', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('reuses the cached token across calls instead of fetching twice', async () => {
|
it('reuses the cached token across calls instead of fetching twice', async () => {
|
||||||
const {axios, salesforceApexRestPost} = freshImports();
|
const {axios, salesforceApexRestPost} = await freshImports();
|
||||||
axios.post.mockImplementation((url: string) => {
|
axios.post.mockImplementation((url: string) => {
|
||||||
if (url.endsWith('/services/oauth2/token')) return Promise.resolve({data: {access_token: 'tok-1'}});
|
if (url.endsWith('/services/oauth2/token')) return Promise.resolve({data: {access_token: 'tok-1'}});
|
||||||
return Promise.resolve({data: {}});
|
return Promise.resolve({data: {}});
|
||||||
@@ -80,7 +81,7 @@ describe('salesforceApexRestPost', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('retries once with a fresh token on a 401, then succeeds', async () => {
|
it('retries once with a fresh token on a 401, then succeeds', async () => {
|
||||||
const {axios, salesforceApexRestPost} = freshImports();
|
const {axios, salesforceApexRestPost} = await freshImports();
|
||||||
let tokenFetches = 0;
|
let tokenFetches = 0;
|
||||||
axios.post.mockImplementation((url: string) => {
|
axios.post.mockImplementation((url: string) => {
|
||||||
if (url.endsWith('/services/oauth2/token')) {
|
if (url.endsWith('/services/oauth2/token')) {
|
||||||
@@ -102,7 +103,7 @@ describe('salesforceApexRestPost', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('does not retry on a non-401 error and rethrows it', async () => {
|
it('does not retry on a non-401 error and rethrows it', async () => {
|
||||||
const {axios, salesforceApexRestPost} = freshImports();
|
const {axios, salesforceApexRestPost} = await freshImports();
|
||||||
axios.post.mockImplementation((url: string) => {
|
axios.post.mockImplementation((url: string) => {
|
||||||
if (url.endsWith('/services/oauth2/token')) return Promise.resolve({data: {access_token: 'tok-1'}});
|
if (url.endsWith('/services/oauth2/token')) return Promise.resolve({data: {access_token: 'tok-1'}});
|
||||||
const err: any = new Error('Server error');
|
const err: any = new Error('Server error');
|
||||||
@@ -117,7 +118,7 @@ describe('salesforceApexRestPost', () => {
|
|||||||
|
|
||||||
it('throws a clear error when client credentials are not configured', async () => {
|
it('throws a clear error when client credentials are not configured', async () => {
|
||||||
process.env.SALESFORCE_CLIENT_ID = '';
|
process.env.SALESFORCE_CLIENT_ID = '';
|
||||||
const {salesforceApexRestPost} = freshImports();
|
const {salesforceApexRestPost} = await freshImports();
|
||||||
|
|
||||||
await expect(salesforceApexRestPost('/services/apexrest/email/send', {})).rejects.toThrow('SALESFORCE_CLIENT_ID');
|
await expect(salesforceApexRestPost('/services/apexrest/email/send', {})).rejects.toThrow('SALESFORCE_CLIENT_ID');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {escapeCsvField} from '../../src/models/feedback/admin/csv.service';
|
import {describe, it, expect} from 'vitest';
|
||||||
import {formatDatetime} from '../../src/models/feedback/feedback.dates';
|
import {escapeCsvField} from '../../src/models/feedback/admin/csv.service.js';
|
||||||
|
import {formatDatetime} from '../../src/models/feedback/feedback.dates.js';
|
||||||
|
|
||||||
describe('escapeCsvField', () => {
|
describe('escapeCsvField', () => {
|
||||||
it('passes plain text through unchanged', () => {
|
it('passes plain text through unchanged', () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {computeDefaultDeadline, slugBase, slugifyName} from '../../src/models/feedback/admin/events.admin.service';
|
import {describe, it, expect} from 'vitest';
|
||||||
|
import {computeDefaultDeadline, slugBase, slugifyName} from '../../src/models/feedback/admin/events.admin.service.js';
|
||||||
|
|
||||||
describe('slugifyName', () => {
|
describe('slugifyName', () => {
|
||||||
it('lowercases and hyphenates', () => {
|
it('lowercases and hyphenates', () => {
|
||||||
|
|||||||
@@ -1,87 +1,18 @@
|
|||||||
import {Request, Response} from 'express';
|
import {vi, type Mock} from 'vitest';
|
||||||
|
|
||||||
jest.mock('../../src/models/calendar/users/users.service', () => ({
|
vi.mock('../../src/models/admin/admin.auth.js', () => ({
|
||||||
checkSession: jest.fn()
|
auth: {api: {getSession: vi.fn()}}
|
||||||
|
}));
|
||||||
|
vi.mock('../../src/models/admin/users/users.admin.service.js', () => ({
|
||||||
|
loadAccess: vi.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import * as UserService from '../../src/models/calendar/users/users.service';
|
import {auth} from '../../src/models/admin/admin.auth.js';
|
||||||
import {requireAdminAuth, sessionHeaderAuthenticator} from '../../src/models/feedback/feedback.auth';
|
import * as UsersService from '../../src/models/admin/users/users.admin.service.js';
|
||||||
|
import {requireAdminAuth} from '../../src/models/feedback/feedback.auth.js';
|
||||||
|
import {describeAdminBinding} from '../admin/auth-binding.js';
|
||||||
|
|
||||||
const mockCheckSession = UserService.checkSession as jest.Mock;
|
describeAdminBinding('feedback', 'tickets', requireAdminAuth, () => ({
|
||||||
|
getSession: auth.api.getSession as unknown as Mock,
|
||||||
const makeReq = (headers: Record<string, string>): Request => {
|
loadAccess: UsersService.loadAccess as Mock
|
||||||
return {
|
}));
|
||||||
header: (name: string) => headers[name],
|
|
||||||
ip: '203.0.113.42'
|
|
||||||
} as unknown as Request;
|
|
||||||
};
|
|
||||||
|
|
||||||
const makeRes = (): Response => {
|
|
||||||
const res: any = {};
|
|
||||||
res.status = jest.fn().mockReturnValue(res);
|
|
||||||
res.send = jest.fn().mockReturnValue(res);
|
|
||||||
res.locals = {};
|
|
||||||
return res as Response;
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('sessionHeaderAuthenticator', () => {
|
|
||||||
beforeEach(() => mockCheckSession.mockReset());
|
|
||||||
|
|
||||||
it('returns null when headers are missing', async () => {
|
|
||||||
const identity = await sessionHeaderAuthenticator(makeReq({}));
|
|
||||||
expect(identity).toBeNull();
|
|
||||||
expect(mockCheckSession).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null when checkSession finds no user', async () => {
|
|
||||||
mockCheckSession.mockResolvedValue(null);
|
|
||||||
const identity = await sessionHeaderAuthenticator(makeReq({'X-Session-Id': '1', 'X-Session-Key': 'k'}));
|
|
||||||
expect(identity).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null for a valid session on an inactive account', async () => {
|
|
||||||
mockCheckSession.mockResolvedValue({userId: 1, email: 'a@nachklang.art', fullName: 'A', isActive: false});
|
|
||||||
const identity = await sessionHeaderAuthenticator(makeReq({'X-Session-Id': '1', 'X-Session-Key': 'k'}));
|
|
||||||
expect(identity).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the identity for a valid session on an active account', async () => {
|
|
||||||
mockCheckSession.mockResolvedValue({userId: 1, email: 'a@nachklang.art', fullName: 'Anna Admin', isActive: true});
|
|
||||||
const identity = await sessionHeaderAuthenticator(makeReq({'X-Session-Id': '1', 'X-Session-Key': 'k'}));
|
|
||||||
expect(identity).toEqual({id: '1', email: 'a@nachklang.art', displayName: 'Anna Admin'});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes the session id and key from headers through to checkSession, never from query params', async () => {
|
|
||||||
mockCheckSession.mockResolvedValue({userId: 1, email: 'a@nachklang.art', fullName: 'A', isActive: true});
|
|
||||||
await sessionHeaderAuthenticator(makeReq({'X-Session-Id': '42', 'X-Session-Key': 'sekret'}));
|
|
||||||
expect(mockCheckSession).toHaveBeenCalledWith('42', 'sekret', '203.0.113.42');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('requireAdminAuth', () => {
|
|
||||||
beforeEach(() => mockCheckSession.mockReset());
|
|
||||||
|
|
||||||
it('responds 401 and does not call next() when unauthenticated', async () => {
|
|
||||||
mockCheckSession.mockResolvedValue(null);
|
|
||||||
const req = makeReq({});
|
|
||||||
const res = makeRes();
|
|
||||||
const next = jest.fn();
|
|
||||||
|
|
||||||
await requireAdminAuth(req, res, next);
|
|
||||||
|
|
||||||
expect(res.status).toHaveBeenCalledWith(401);
|
|
||||||
expect(next).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sets res.locals.admin and calls next() when authenticated', async () => {
|
|
||||||
mockCheckSession.mockResolvedValue({userId: 1, email: 'a@nachklang.art', fullName: 'Anna Admin', isActive: true});
|
|
||||||
const req = makeReq({'X-Session-Id': '1', 'X-Session-Key': 'k'});
|
|
||||||
const res = makeRes();
|
|
||||||
const next = jest.fn();
|
|
||||||
|
|
||||||
await requireAdminAuth(req, res, next);
|
|
||||||
|
|
||||||
expect(next).toHaveBeenCalled();
|
|
||||||
expect(res.locals.admin).toEqual({id: '1', email: 'a@nachklang.art', displayName: 'Anna Admin'});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {isHoneypotTriggered} from '../../src/models/feedback/public/public.router';
|
import {describe, it, expect} from 'vitest';
|
||||||
|
import {isHoneypotTriggered} from '../../src/models/feedback/public/public.router.js';
|
||||||
|
|
||||||
describe('isHoneypotTriggered', () => {
|
describe('isHoneypotTriggered', () => {
|
||||||
it('is false when the field is absent', () => {
|
it('is false when the field is absent', () => {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
// Isolated from ratelimit.test.ts because it needs to control whether
|
// Isolated from ratelimit.test.ts because it needs to control whether
|
||||||
// FEEDBACK_IP_SALT is present at module-load time, which a real dotenv.config()
|
// FEEDBACK_IP_SALT is present at module-load time, which a real dotenv.config()
|
||||||
// call would silently repopulate from the repo's .env file.
|
// call would silently repopulate from the repo's .env file.
|
||||||
jest.mock('dotenv', () => ({config: jest.fn()}));
|
import {vi, describe, it, expect, afterEach} from 'vitest';
|
||||||
jest.mock('../../src/models/feedback/Feedback.db', () => ({
|
vi.mock('dotenv', () => ({config: vi.fn()}));
|
||||||
NachklangFeedbackDB: {getConnection: jest.fn()}
|
vi.mock('../../src/models/feedback/Feedback.db.js', () => ({
|
||||||
|
NachklangFeedbackDB: {getConnection: vi.fn()}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('FEEDBACK_IP_SALT enforcement', () => {
|
describe('FEEDBACK_IP_SALT enforcement', () => {
|
||||||
@@ -11,18 +12,18 @@ describe('FEEDBACK_IP_SALT enforcement', () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
process.env.FEEDBACK_IP_SALT = originalSalt;
|
process.env.FEEDBACK_IP_SALT = originalSalt;
|
||||||
jest.resetModules();
|
vi.resetModules();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws at import time when FEEDBACK_IP_SALT is unset, rather than silently hashing unsalted', () => {
|
it('throws at import time when FEEDBACK_IP_SALT is unset, rather than silently hashing unsalted', async () => {
|
||||||
jest.resetModules();
|
vi.resetModules();
|
||||||
delete process.env.FEEDBACK_IP_SALT;
|
delete process.env.FEEDBACK_IP_SALT;
|
||||||
expect(() => require('../../src/models/feedback/feedback.ratelimit')).toThrow(/FEEDBACK_IP_SALT/);
|
await expect(import('../../src/models/feedback/feedback.ratelimit.js')).rejects.toThrow(/FEEDBACK_IP_SALT/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not throw when FEEDBACK_IP_SALT is set', () => {
|
it('does not throw when FEEDBACK_IP_SALT is set', async () => {
|
||||||
jest.resetModules();
|
vi.resetModules();
|
||||||
process.env.FEEDBACK_IP_SALT = 'a-real-salt';
|
process.env.FEEDBACK_IP_SALT = 'a-real-salt';
|
||||||
expect(() => require('../../src/models/feedback/feedback.ratelimit')).not.toThrow();
|
await expect(import('../../src/models/feedback/feedback.ratelimit.js')).resolves.toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {hashIp} from '../../src/models/feedback/feedback.ratelimit';
|
import {describe, it, expect} from 'vitest';
|
||||||
|
import {hashIp} from '../../src/models/feedback/feedback.ratelimit.js';
|
||||||
|
|
||||||
describe('hashIp', () => {
|
describe('hashIp', () => {
|
||||||
it('never returns the raw IP', () => {
|
it('never returns the raw IP', () => {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user