commit 38c24fc9dc93fe2fd088520541f97169f08b01fd Author: siliconsolder Date: Mon Sep 7 09:54:30 2026 +0000 Initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3348492 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +**/node_modules +**/dist +.git +.env +.env.* +docs +docker/postgres +docker/app +docker/backups +docker/logs +*.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a25dbbf --- /dev/null +++ b/.env.example @@ -0,0 +1,54 @@ +# Copy this file to .env in the Unraid deployment directory +# (/mnt/tank/appdata/notizarr/.env) and fill in real values. +# Never commit a real .env. + +# ---- Identity / URLs ---- +PUBLIC_BASE_URL=https://notizarr.example.com +PORT_WEB=8080 +PORT_INGEST=8081 + +# Host bind addresses used by docker/docker-compose.prod.yml. +# BIND_ADDR_INGEST defaults to 127.0.0.1 on purpose (see docs/phase1/02-threat-model.md) - +# set it to your LAN interface IP (e.g. 192.168.1.10) so LAN devices can publish, +# but never set it to 0.0.0.0 on a host with any WAN exposure. +BIND_ADDR_WEB=0.0.0.0 +BIND_ADDR_INGEST=127.0.0.1 + +# ---- Auth ---- +# oidc | proxy | disabled +AUTH_MODE=oidc +OIDC_ISSUER_URL= +OIDC_CLIENT_ID= +OIDC_CLIENT_SECRET= +OIDC_REDIRECT_URL=https://notizarr.example.com/api/v1/auth/oidc/callback +OIDC_SCOPES=openid profile email + +AUTH_PROXY_USER_HEADER= +AUTH_PROXY_EMAIL_HEADER= +AUTH_PROXY_GROUPS_HEADER= +TRUSTED_PROXY_CIDRS= + +# ---- Database ---- +POSTGRES_USER=notizarr +POSTGRES_PASSWORD=change-me +POSTGRES_DB=notizarr + +# ---- Push ---- +VAPID_SUBJECT=mailto:admin@example.com + +# ---- Ingestion safety ---- +# Comma-separated CIDRs allowed to hit the :8081 ingestion listener, +# enforced in-app as defense in depth beyond network-level firewalling. +INGESTION_ALLOWED_CIDRS=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 + +# ---- Retention / rate limiting defaults (editable later in Admin > Settings) ---- +RETENTION_DEFAULT_DAYS=90 +RATE_LIMIT_SUSTAINED_PER_MIN=60 +RATE_LIMIT_BURST_PER_10S=20 + +# ---- Ops ---- +LOG_LEVEL=info +DOCS_ENABLED=true + +# Image tag to run, if not building in place +NOTIZARR_IMAGE=notizarr:latest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e6f3c0d --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +node_modules/ +.pnpm-store/ +dist/ +.env +.env.local +*.log +.DS_Store +coverage/ + +apps/web/dist/ +apps/server/dist/ +packages/*/dist/ + +# Unraid deployment runtime data must never be committed even if a copy +# ends up under the repo during local testing +docker/postgres/ +docker/app/ +docker/logs/ + +# Database dumps — written to the repo root by both scripts/backup.sh and +# the in-app scheduled job (modules/admin/backup.ts), not docker/backups/. +/backups/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..46deac1 --- /dev/null +++ b/README.md @@ -0,0 +1,238 @@ +# Notizarr + +Self-hosted notification hub — replaces ntfy for a small homelab, with a +polished web UI/PWA, Web Push, per-topic push control, and scoped API tokens +for LAN publishers. Full design rationale lives in +[docs/phase1/](docs/phase1/00-README.md). + +Status: **Phases 2–9 complete**, Phase 10 (hardening/test-gap-filling) in +progress. Auth, topics, notification ingestion (canonical + ntfy-compat + +webhook), rate limiting, dedup, SSE live updates, retention cleanup, the +full user-facing UI, Web Push, and the admin UI are all built and verified +against a live dev stack. See +[docs/phase1/08-implementation-plan.md](docs/phase1/08-implementation-plan.md) +for the phase breakdown and [MVP acceptance status](#mvp-acceptance-status) +below for where things stand against the original 22 criteria. + +> **Note:** this was written in an environment without Node.js or Docker +> installed, so none of it is executed here — it's been verified +> incrementally against a real dev stack running on the target Unraid box +> instead. `AUTH_MODE=oidc` has since been tested end-to-end against a real +> Pocket ID instance over real HTTPS (via Pangolin), and Web Push has been +> confirmed delivering to an installed iPhone PWA — see "Auth modes" and +> [docs/phase1/05-pwa-web-push-design.md](docs/phase1/05-pwa-web-push-design.md). + +## Auth modes + +Set via `AUTH_MODE` (`.env` / `docker-compose.yml` environment): `disabled`, +`proxy`, or `oidc` — see [docs/phase1/02-threat-model.md](docs/phase1/02-threat-model.md). +`disabled` refuses to start when `NODE_ENV=production` (it grants every +request implicit admin access — dev/test only). `oidc` is implemented +against the `openid-client` v5 API but hasn't been run against a real IdP +yet; test it once Pocket ID (or similar) is configured. + +All mutating requests on the web listener (`POST`/`PUT`/`PATCH`/`DELETE` +under `/api/`) require a `X-Requested-With: notizarr` header — this is the +CSRF defense described in the threat model, not optional. The ingestion +listener (`:8081`) instead requires `Authorization: Bearer nzr_...` — create +a token via Settings → Admin → API Tokens. + +## Repo layout + +``` +apps/server/ Node.js + TypeScript backend (Fastify), two listeners +apps/web/ React + TypeScript + Vite frontend, installable PWA +packages/shared/ Reserved for cross-package types (unused so far) +docker/ docker-compose.yml (dev) and docker-compose.prod.yml (Unraid template) +scripts/ backup.sh / restore.sh for the Postgres database +docs/phase1/ Architecture, threat model, DB/API/PWA design, implementation plan +docs/ Migration guides (ntfy, Home Assistant/Seerr/Uptime Kuma) +``` + +Full breakdown: [docs/phase1/07-folder-structure.md](docs/phase1/07-folder-structure.md). + +## Local development + +Requires Node.js 22+ and pnpm (`corepack enable` will fetch the pinned +version automatically). + +```bash +pnpm install +pnpm dev +``` + +This runs the web app in `vite build --watch` and the server via `tsx +watch` concurrently. The server serves the built web assets itself — open +http://localhost:8080. `PORT_INGEST` (8081) is the LAN ingestion listener. + +Needs a local Postgres — either run one yourself and set `DATABASE_URL`, or +use the dev Docker Compose stack below, which includes one. + +## Local development via Docker + +```bash +docker compose -f docker/docker-compose.yml up -d +``` + +Brings up `notizarr-postgres-dev` and `notizarr-dev` (hot-reload: the repo +is bind-mounted in). Default host ports (deliberately not 8080/8081/5432, +since those are commonly already taken on an Unraid box): + +- Web UI: http://localhost:18080 +- Ingestion listener: http://localhost:18081 +- Postgres: `127.0.0.1:15432` (`psql -h localhost -p 15432 -U notizarr + notizarr`, password `notizarr_dev_password`) + +Whenever a `package.json` dependency changes, rebuild **and** renew the +anonymous `node_modules` volumes in one go, or the container keeps running +against the old, pre-change `node_modules`: + +```bash +docker compose -f docker/docker-compose.yml up -d --build --force-recreate -V +``` + +Override host ports if they also collide, either inline or via a +`docker/.env` file: + +```bash +HOST_PORT_WEB=28080 HOST_PORT_INGEST=28081 HOST_PORT_PG=25432 \ + docker compose -f docker/docker-compose.yml up -d +``` + +Check health: + +```bash +curl http://localhost:18080/health +curl http://localhost:18080/ready # 503 until Postgres is reachable +curl http://localhost:18081/health +``` + +Generate the PWA icon set once (needs `sharp`, already a devDependency — +this writes real PNGs into `apps/web/public/icons/`, committed to the repo +rather than regenerated on every build): + +```bash +docker compose -f docker/docker-compose.yml exec notizarr pnpm --filter @notizarr/web generate-icons +``` + +## Unraid deployment (production) + +Deployment root: `/mnt/tank/appdata/notizarr`. Full design: +[docs/phase1/06-docker-unraid-design.md](docs/phase1/06-docker-unraid-design.md). + +1. Build and tag the image (from a repo checkout, e.g. on the Unraid box or + any machine with Docker, then push/copy it to where Unraid can pull it — + or build directly on Unraid if the repo is checked out there): + ```bash + docker build -f apps/server/Dockerfile --target runtime -t notizarr:0.1.0 . + ``` +2. On the Unraid host, create the deployment directory structure: + ```bash + mkdir -p /mnt/tank/appdata/notizarr/{postgres,app,backups,logs} + ``` +3. Copy `docker/docker-compose.prod.yml` to + `/mnt/tank/appdata/notizarr/docker-compose.yml`, and `.env.example` to + `/mnt/tank/appdata/notizarr/.env` — fill in real values (DB password, + OIDC settings, `PUBLIC_BASE_URL`, and especially `BIND_ADDR_INGEST`, which + defaults to `127.0.0.1` until you explicitly set it to your LAN IP). +4. From `/mnt/tank/appdata/notizarr`: + ```bash + docker compose up -d + ``` + +Point Pangolin at `notizarr:8080` (same Docker network, preferred) or the +host's `PUBLIC_BASE_URL`. Never point it at 8081. + +## Backup and restore + +Everything that matters — notification history, users, topics, API tokens, +push subscriptions, the VAPID keypair, the session secret — lives in +Postgres (see [docs/phase1/06-docker-unraid-design.md](docs/phase1/06-docker-unraid-design.md#backup--restore)), +so backing up the database is a complete backup. + +```bash +cd /mnt/tank/appdata/notizarr +./scripts/backup.sh +``` + +Writes a timestamped, gzipped `pg_dump` plus a copy of the compose file +(env vars, including the DB password and OIDC client secret, are inline +there rather than in a separate `.env` — keep `backups/` off shared or +public storage) into `backups/`, keeping the last 14 of each. Restore +with: + +```bash +./scripts/restore.sh backups/notizarr-db-.sql.gz +``` + +This is destructive (replaces all current data) and asks for confirmation. +Consider scheduling `backup.sh` via an Unraid User Scripts cron entry for +regular backups. + +## Migrating from ntfy / integrating other services + +- [docs/migration-ntfy.md](docs/migration-ntfy.md) — endpoint mapping, + before/after examples, what's intentionally not replicated +- [docs/home-assistant.md](docs/home-assistant.md) — `rest_command` example + replacing `ntfy.publish`, plus Seerr, Uptime Kuma, and generic webhook + configuration + +## Troubleshooting + +- **A `docker compose up -d --build` didn't pick up a dependency change.** + Anonymous `node_modules` volumes are reused by default — add `-V + --force-recreate` (see [Local development via + Docker](#local-development-via-docker)). +- **A mutating API call returns `csrf_check_failed`.** The web app must + send `X-Requested-With: notizarr` on every `POST`/`PATCH`/`DELETE` + (`apps/web/src/lib/api.ts` does this automatically for the built-in UI — + this only bites hand-rolled `curl` testing). If the error is specifically + `"origin mismatch"`, the browser's `Origin` header didn't match the + request's own `Host` header — that's a real cross-origin request being + correctly blocked, not a false positive. +- **Push notifications don't work / diagnostics show "Service worker: Not + registered".** Service workers and the Push API require a secure context + — HTTPS, or `localhost`. A plain `http://:port` origin will never + register a service worker; this isn't a bug, it's the browser enforcing + the spec. Test push once Pangolin is serving Notizarr over real HTTPS. +- **iPhone won't show push notifications even over HTTPS.** Confirm iOS + 16.4+, and that Notizarr was installed via Share → Add to Home Screen — + Safari does not support Web Push for ordinary browser tabs, only + installed PWAs. Check Settings → Push diagnostics in the app. +- **`AUTH_MODE=disabled` refuses to start.** This is intentional when + `NODE_ENV=production` — that mode grants every request implicit admin + access and is dev/test-only (see `apps/server/src/index.ts`). Use `oidc` + or `proxy` for anything reachable off localhost. +- **First-ever login should be an admin but isn't.** Bootstrap-admin only + promotes the very first user *ever created* in the `users` table. If a + non-admin user was created first (e.g. during testing), promote them + manually via Settings → Admin → Users using an existing admin account, or + directly in Postgres if none exists yet. + +## MVP acceptance status + +All 22 hard acceptance criteria are built and verified against a live dev +stack reachable over real HTTPS (Docker Compose, Postgres healthy, +auth/topics/tokens/publish/push-toggle/PWA-install all confirmed working, +history and settings survive container restarts, OIDC login against a real +Pocket ID instance, and push confirmed arriving on an installed iPhone +PWA — 17–18 were the last two blocked on real HTTPS and are now closed +out too). + +## Commands + +| Command | Does | +|---|---| +| `pnpm dev` | Run web + server in watch mode | +| `pnpm build` | Production build of both | +| `pnpm typecheck` | TypeScript, no emit, across all workspaces | +| `pnpm lint` | ESLint across the repo | +| `pnpm test` | Tests across all workspaces | + +## Documentation + +- [docs/phase1/](docs/phase1/00-README.md) — architecture, threat model, + database model, API design, PWA/Web Push design, Docker/Unraid design, + implementation plan +- [docs/migration-ntfy.md](docs/migration-ntfy.md), + [docs/home-assistant.md](docs/home-assistant.md) — migration guides diff --git a/apps/server/Dockerfile b/apps/server/Dockerfile new file mode 100644 index 0000000..28379e0 --- /dev/null +++ b/apps/server/Dockerfile @@ -0,0 +1,63 @@ +# syntax=docker/dockerfile:1 +# +# Build context is the REPO ROOT (see docker/docker-compose.yml and +# docker/docker-compose.prod.yml: `context: ..`), because this image bundles +# both apps/server and the built apps/web static assets, plus the shared +# workspace package. + +FROM node:22-alpine AS base +RUN corepack enable +# pg_dump — used by modules/admin/backup.ts (Admin > Backups), not just the +# standalone scripts/backup.sh. Alpine's postgresql-client tracks a recent +# major version; pg_dump's wire protocol is compatible against our +# postgres:16-alpine server regardless of exact version match. +RUN apk add --no-cache postgresql-client +WORKDIR /workspace + +# ---- deps: install workspace dependencies (cached separately from source) ---- +FROM base AS deps +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./ +COPY apps/server/package.json apps/server/package.json +COPY apps/web/package.json apps/web/package.json +COPY packages/shared/package.json packages/shared/package.json +# Falls back to a non-frozen install until a pnpm-lock.yaml exists in the +# repo (run `pnpm install` locally once to generate it, then this can be +# tightened to `--frozen-lockfile` for reproducible builds). +RUN pnpm install + +# ---- dev: source comes from the docker-compose.yml bind mount at runtime ---- +FROM deps AS dev +ENV NODE_ENV=development +EXPOSE 8080 8081 +CMD ["pnpm", "run", "dev"] + +# ---- build: compile server + web for the production image ---- +FROM deps AS build +COPY . . +RUN pnpm --filter @notizarr/web build +RUN pnpm --filter @notizarr/server build + +# ---- runtime: minimal image, non-root, only what's needed to run ---- +# Deliberately not using `pnpm deploy` here: it's meant for publishable +# packages and its file-inclusion rules interact awkwardly with dist/ being +# gitignored. A plain workspace-root `pnpm install --prod` + explicit copies +# of the two build outputs is more predictable and easy to reason about. +FROM node:22-alpine AS runtime +RUN corepack enable +WORKDIR /app +ENV NODE_ENV=production +ENV STATIC_WEB_DIR=/app/apps/web/dist +RUN addgroup -S notizarr && adduser -S notizarr -G notizarr +COPY --from=build /workspace/package.json /workspace/pnpm-workspace.yaml /workspace/pnpm-lock.yaml* ./ +COPY --from=build /workspace/apps/server/package.json ./apps/server/package.json +COPY --from=build /workspace/apps/web/package.json ./apps/web/package.json +COPY --from=build /workspace/packages/shared/package.json ./packages/shared/package.json +RUN pnpm install --prod +COPY --from=build /workspace/apps/server/dist ./apps/server/dist +COPY --from=build /workspace/apps/web/dist ./apps/web/dist +COPY --from=build /workspace/apps/server/healthcheck.js ./apps/server/healthcheck.js +USER notizarr +EXPOSE 8080 8081 +HEALTHCHECK --interval=15s --timeout=5s --retries=3 --start-period=20s \ + CMD ["node", "apps/server/healthcheck.js"] +CMD ["node", "apps/server/dist/index.js"] diff --git a/apps/server/drizzle.config.ts b/apps/server/drizzle.config.ts new file mode 100644 index 0000000..6688886 --- /dev/null +++ b/apps/server/drizzle.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'drizzle-kit' + +export default defineConfig({ + schema: './src/db/schema.ts', + out: './src/db/migrations', + dialect: 'postgresql', + dbCredentials: { + url: + process.env.DATABASE_URL ?? + 'postgres://notizarr:notizarr_dev_password@localhost:5432/notizarr', + }, +}) diff --git a/apps/server/healthcheck.js b/apps/server/healthcheck.js new file mode 100644 index 0000000..3757ff4 --- /dev/null +++ b/apps/server/healthcheck.js @@ -0,0 +1,18 @@ +// Zero-dependency healthcheck used by the Docker HEALTHCHECK directive for +// both the dev and prod images (see docker/docker-compose.yml and +// apps/server/Dockerfile). Deliberately plain JS with no imports beyond +// node:http so it works from the pruned production image without needing +// the TypeScript toolchain. +import http from 'node:http' + +const port = process.env.PORT_WEB ?? 8080 + +const req = http.get(`http://127.0.0.1:${port}/health`, (res) => { + process.exit(res.statusCode === 200 ? 0 : 1) +}) + +req.on('error', () => process.exit(1)) +req.setTimeout(3000, () => { + req.destroy() + process.exit(1) +}) diff --git a/apps/server/package.json b/apps/server/package.json new file mode 100644 index 0000000..592ac2b --- /dev/null +++ b/apps/server/package.json @@ -0,0 +1,38 @@ +{ + "name": "@notizarr/server", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/index.js", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run --passWithNoTests", + "db:generate": "drizzle-kit generate", + "db:migrate": "tsx src/db/migrate.ts" + }, + "dependencies": { + "@fastify/cookie": "^11.0.1", + "@fastify/helmet": "^12.0.1", + "@fastify/static": "^10.1.2", + "@fastify/swagger": "^9.4.0", + "@fastify/swagger-ui": "^5.2.0", + "drizzle-orm": "^0.45.2", + "fastify": "^5.0.0", + "openid-client": "^5.6.5", + "pg": "^8.13.0", + "pino": "^9.4.0", + "web-push": "^3.6.7" + }, + "devDependencies": { + "@types/node": "^22.7.4", + "@types/pg": "^8.11.10", + "@types/web-push": "^3.6.4", + "drizzle-kit": "^0.31.9", + "pino-pretty": "^11.2.2", + "tsx": "^4.19.1", + "typescript": "^5.6.2", + "vitest": "^3.2.6" + } +} diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts new file mode 100644 index 0000000..216facb --- /dev/null +++ b/apps/server/src/config.ts @@ -0,0 +1,78 @@ +import { fileURLToPath } from 'node:url' +import path from 'node:path' + +function requireEnv(name: string, fallback?: string): string { + const value = process.env[name] ?? fallback + if (value === undefined) { + throw new Error(`Missing required environment variable: ${name}`) + } + return value +} + +function splitCsv(value: string): string[] { + return value + .split(',') + .map((part) => part.trim()) + .filter(Boolean) +} + +const serverSrcDir = fileURLToPath(new URL('.', import.meta.url)) +const defaultStaticWebDir = path.resolve(serverSrcDir, '../../web/dist') + +export const config = { + nodeEnv: process.env.NODE_ENV ?? 'development', + isProduction: process.env.NODE_ENV === 'production', + + portWeb: Number(process.env.PORT_WEB ?? 8080), + portIngest: Number(process.env.PORT_INGEST ?? 8081), + + publicBaseUrl: requireEnv('PUBLIC_BASE_URL', 'http://localhost:8080'), + + databaseUrl: requireEnv( + 'DATABASE_URL', + 'postgres://notizarr:notizarr_dev_password@localhost:5432/notizarr', + ), + + logLevel: process.env.LOG_LEVEL ?? 'info', + + authMode: (process.env.AUTH_MODE ?? 'disabled') as 'oidc' | 'proxy' | 'disabled', + + oidc: { + issuerUrl: process.env.OIDC_ISSUER_URL ?? '', + clientId: process.env.OIDC_CLIENT_ID ?? '', + clientSecret: process.env.OIDC_CLIENT_SECRET ?? '', + redirectUrl: process.env.OIDC_REDIRECT_URL ?? '', + scopes: process.env.OIDC_SCOPES ?? 'openid profile email', + }, + + authProxy: { + userHeader: (process.env.AUTH_PROXY_USER_HEADER ?? '').toLowerCase(), + emailHeader: (process.env.AUTH_PROXY_EMAIL_HEADER ?? '').toLowerCase(), + groupsHeader: (process.env.AUTH_PROXY_GROUPS_HEADER ?? '').toLowerCase(), + }, + trustedProxyCidrs: splitCsv(process.env.TRUSTED_PROXY_CIDRS ?? ''), + + ingestionAllowedCidrs: splitCsv( + process.env.INGESTION_ALLOWED_CIDRS ?? '10.0.0.0/8,172.16.0.0/12,192.168.0.0/16', + ), + + retentionDefaultDays: Number(process.env.RETENTION_DEFAULT_DAYS ?? 90), + + rateLimit: { + sustainedPerMinute: Number(process.env.RATE_LIMIT_SUSTAINED_PER_MIN ?? 60), + burstPer10Seconds: Number(process.env.RATE_LIMIT_BURST_PER_10S ?? 20), + }, + + docsEnabled: (process.env.DOCS_ENABLED ?? 'true') !== 'false', + + vapidSubject: process.env.VAPID_SUBJECT ?? 'mailto:admin@localhost', + + staticWebDir: process.env.STATIC_WEB_DIR ?? defaultStaticWebDir, + + // Repo root's backups/ dir by default — same three-levels-up path from + // apps/server/src (dev, via tsx) or apps/server/dist (prod build) either + // way, since both sit at the same depth under apps/server. + backupsDir: process.env.BACKUPS_DIR ?? path.resolve(serverSrcDir, '../../../backups'), +} + +export type Config = typeof config diff --git a/apps/server/src/db/client.ts b/apps/server/src/db/client.ts new file mode 100644 index 0000000..33b0313 --- /dev/null +++ b/apps/server/src/db/client.ts @@ -0,0 +1,17 @@ +import { drizzle } from 'drizzle-orm/node-postgres' +import pg from 'pg' +import { config } from '../config.js' +import * as schema from './schema.js' + +export const pool = new pg.Pool({ connectionString: config.databaseUrl }) + +export const db = drizzle(pool, { schema }) + +export async function checkDbConnection(): Promise { + try { + await pool.query('SELECT 1') + return true + } catch { + return false + } +} diff --git a/apps/server/src/db/migrate.ts b/apps/server/src/db/migrate.ts new file mode 100644 index 0000000..ec629f1 --- /dev/null +++ b/apps/server/src/db/migrate.ts @@ -0,0 +1,36 @@ +import { existsSync, mkdirSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { drizzle } from 'drizzle-orm/node-postgres' +import { migrate } from 'drizzle-orm/node-postgres/migrator' +import { pool } from './client.js' + +const migrationsFolder = path.resolve(fileURLToPath(new URL('.', import.meta.url)), 'migrations') + +// Runs on every server boot (see src/index.ts) so `docker compose up -d` +// always ends with an up-to-date schema, per docs/phase1/01-architecture.md +// ("On boot: run pending DB migrations"). Also runnable standalone: +// pnpm --filter @notizarr/server db:migrate +export async function runMigrations(): Promise { + // Guards a fresh clone before `drizzle-kit generate` has ever run — + // migrate() expects the folder to exist even if it's empty. + if (!existsSync(migrationsFolder)) { + mkdirSync(migrationsFolder, { recursive: true }) + } + const db = drizzle(pool) + await migrate(db, { migrationsFolder }) +} + +const isMainModule = process.argv[1] && import.meta.url === `file://${process.argv[1]}` + +if (isMainModule) { + runMigrations() + .then(() => { + console.log('Migrations applied successfully.') + process.exit(0) + }) + .catch((err: unknown) => { + console.error('Migration failed:', err) + process.exit(1) + }) +} diff --git a/apps/server/src/db/migrations/0000_cuddly_tarot.sql b/apps/server/src/db/migrations/0000_cuddly_tarot.sql new file mode 100644 index 0000000..981c669 --- /dev/null +++ b/apps/server/src/db/migrations/0000_cuddly_tarot.sql @@ -0,0 +1,265 @@ +DO $$ BEGIN + CREATE TYPE "public"."push_delivery_status" AS ENUM('pending', 'accepted_by_push_service', 'failed', 'invalid_subscription', 'expired'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + CREATE TYPE "public"."user_role" AS ENUM('admin', 'user'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "api_token_topics" ( + "token_id" uuid NOT NULL, + "topic_id" uuid NOT NULL, + CONSTRAINT "api_token_topics_token_id_topic_id_pk" PRIMARY KEY("token_id","topic_id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "api_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "token_hash" text NOT NULL, + "token_prefix" text NOT NULL, + "allowed_source" text, + "allowed_cidrs" text[], + "expires_at" timestamp with time zone, + "revoked_at" timestamp with time zone, + "created_by" uuid, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "last_used_at" timestamp with time zone, + "usage_count" bigint DEFAULT 0 NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "audit_log" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "actor_user_id" uuid, + "actor_token_id" uuid, + "action" text NOT NULL, + "target_type" text, + "target_id" text, + "metadata" jsonb, + "ip_address" "inet", + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "notifications" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "topic_id" uuid NOT NULL, + "source" text NOT NULL, + "title" text NOT NULL, + "message" text NOT NULL, + "external_url" text, + "image_url" text, + "attachment_filename" text, + "attachment_content_type" text, + "attachment_size_bytes" bigint, + "deduplication_key" text, + "occurrence_count" integer DEFAULT 1 NOT NULL, + "raw_priority" text, + "sender_token_id" uuid, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "push_delivery_attempts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "notification_id" uuid NOT NULL, + "push_subscription_id" uuid NOT NULL, + "status" "push_delivery_status" NOT NULL, + "http_status" integer, + "error_message" text, + "retry_count" integer DEFAULT 0 NOT NULL, + "attempted_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "push_subscriptions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "endpoint" text NOT NULL, + "p256dh" text NOT NULL, + "auth" text NOT NULL, + "user_agent" text, + "device_label" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "last_seen_at" timestamp with time zone DEFAULT now() NOT NULL, + "disabled_at" timestamp with time zone +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "system_settings" ( + "key" text PRIMARY KEY NOT NULL, + "value" jsonb NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "topic_members" ( + "topic_id" uuid NOT NULL, + "user_id" uuid NOT NULL, + CONSTRAINT "topic_members_topic_id_user_id_pk" PRIMARY KEY("topic_id","user_id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "topics" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "slug" text NOT NULL, + "display_name" text NOT NULL, + "description" text, + "icon" text, + "accent_color" text, + "retention_days" integer, + "restricted" boolean DEFAULT false NOT NULL, + "created_by" uuid, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "archived_at" timestamp with time zone +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "user_notification_state" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "notification_id" uuid NOT NULL, + "read" boolean DEFAULT false NOT NULL, + "read_at" timestamp with time zone, + "starred" boolean DEFAULT false NOT NULL, + "starred_at" timestamp with time zone +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "user_topic_subscriptions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "topic_id" uuid NOT NULL, + "push_enabled" boolean DEFAULT true NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "users" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "email" text NOT NULL, + "display_name" text NOT NULL, + "avatar_url" text, + "role" "user_role" DEFAULT 'user' NOT NULL, + "auth_subject" text NOT NULL, + "auth_issuer" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "last_login_at" timestamp with time zone +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "api_token_topics" ADD CONSTRAINT "api_token_topics_token_id_api_tokens_id_fk" FOREIGN KEY ("token_id") REFERENCES "public"."api_tokens"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "api_token_topics" ADD CONSTRAINT "api_token_topics_topic_id_topics_id_fk" FOREIGN KEY ("topic_id") REFERENCES "public"."topics"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "api_tokens" ADD CONSTRAINT "api_tokens_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_actor_user_id_users_id_fk" FOREIGN KEY ("actor_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_actor_token_id_api_tokens_id_fk" FOREIGN KEY ("actor_token_id") REFERENCES "public"."api_tokens"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "notifications" ADD CONSTRAINT "notifications_topic_id_topics_id_fk" FOREIGN KEY ("topic_id") REFERENCES "public"."topics"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "notifications" ADD CONSTRAINT "notifications_sender_token_id_api_tokens_id_fk" FOREIGN KEY ("sender_token_id") REFERENCES "public"."api_tokens"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "push_delivery_attempts" ADD CONSTRAINT "push_delivery_attempts_notification_id_notifications_id_fk" FOREIGN KEY ("notification_id") REFERENCES "public"."notifications"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "push_delivery_attempts" ADD CONSTRAINT "push_delivery_attempts_push_subscription_id_push_subscriptions_id_fk" FOREIGN KEY ("push_subscription_id") REFERENCES "public"."push_subscriptions"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "push_subscriptions" ADD CONSTRAINT "push_subscriptions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "topic_members" ADD CONSTRAINT "topic_members_topic_id_topics_id_fk" FOREIGN KEY ("topic_id") REFERENCES "public"."topics"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "topic_members" ADD CONSTRAINT "topic_members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "topics" ADD CONSTRAINT "topics_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "user_notification_state" ADD CONSTRAINT "user_notification_state_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "user_notification_state" ADD CONSTRAINT "user_notification_state_notification_id_notifications_id_fk" FOREIGN KEY ("notification_id") REFERENCES "public"."notifications"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "user_topic_subscriptions" ADD CONSTRAINT "user_topic_subscriptions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "user_topic_subscriptions" ADD CONSTRAINT "user_topic_subscriptions_topic_id_topics_id_fk" FOREIGN KEY ("topic_id") REFERENCES "public"."topics"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "api_tokens_token_hash_key" ON "api_tokens" USING btree ("token_hash");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "audit_log_created_idx" ON "audit_log" USING btree ("created_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "audit_log_action_idx" ON "audit_log" USING btree ("action");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "notifications_topic_created_idx" ON "notifications" USING btree ("topic_id","created_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "notifications_dedup_idx" ON "notifications" USING btree ("deduplication_key","topic_id","created_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "notifications_created_idx" ON "notifications" USING btree ("created_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "push_delivery_attempts_notification_idx" ON "push_delivery_attempts" USING btree ("notification_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "push_delivery_attempts_subscription_idx" ON "push_delivery_attempts" USING btree ("push_subscription_id","attempted_at");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "push_subscriptions_endpoint_key" ON "push_subscriptions" USING btree ("endpoint");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "topics_slug_key" ON "topics" USING btree ("slug");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "user_notification_state_user_notification_key" ON "user_notification_state" USING btree ("user_id","notification_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "user_notification_state_user_read_idx" ON "user_notification_state" USING btree ("user_id","read");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "user_notification_state_user_starred_idx" ON "user_notification_state" USING btree ("user_id","starred");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "user_topic_subscriptions_user_topic_key" ON "user_topic_subscriptions" USING btree ("user_id","topic_id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "users_email_key" ON "users" USING btree ("email");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "users_issuer_subject_key" ON "users" USING btree ("auth_issuer","auth_subject"); \ No newline at end of file diff --git a/apps/server/src/db/migrations/0001_purple_firestar.sql b/apps/server/src/db/migrations/0001_purple_firestar.sql new file mode 100644 index 0000000..918460d --- /dev/null +++ b/apps/server/src/db/migrations/0001_purple_firestar.sql @@ -0,0 +1 @@ +ALTER TABLE "user_topic_subscriptions" ADD COLUMN "muted_until" timestamp with time zone; \ No newline at end of file diff --git a/apps/server/src/db/migrations/meta/0000_snapshot.json b/apps/server/src/db/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..0251aa4 --- /dev/null +++ b/apps/server/src/db/migrations/meta/0000_snapshot.json @@ -0,0 +1,1321 @@ +{ + "id": "104fe2b6-6fff-4ce6-927b-c9bebba88dc6", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.api_token_topics": { + "name": "api_token_topics", + "schema": "", + "columns": { + "token_id": { + "name": "token_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "topic_id": { + "name": "topic_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "api_token_topics_token_id_api_tokens_id_fk": { + "name": "api_token_topics_token_id_api_tokens_id_fk", + "tableFrom": "api_token_topics", + "tableTo": "api_tokens", + "columnsFrom": [ + "token_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_token_topics_topic_id_topics_id_fk": { + "name": "api_token_topics_topic_id_topics_id_fk", + "tableFrom": "api_token_topics", + "tableTo": "topics", + "columnsFrom": [ + "topic_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "api_token_topics_token_id_topic_id_pk": { + "name": "api_token_topics_token_id_topic_id_pk", + "columns": [ + "token_id", + "topic_id" + ] + } + }, + "uniqueConstraints": {} + }, + "public.api_tokens": { + "name": "api_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed_source": { + "name": "allowed_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_cidrs": { + "name": "allowed_cidrs", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "usage_count": { + "name": "usage_count", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "api_tokens_token_hash_key": { + "name": "api_tokens_token_hash_key", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_tokens_created_by_users_id_fk": { + "name": "api_tokens_created_by_users_id_fk", + "tableFrom": "api_tokens", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_token_id": { + "name": "actor_token_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "inet", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_created_idx": { + "name": "audit_log_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_actor_user_id_users_id_fk": { + "name": "audit_log_actor_user_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_token_id_api_tokens_id_fk": { + "name": "audit_log_actor_token_id_api_tokens_id_fk", + "tableFrom": "audit_log", + "tableTo": "api_tokens", + "columnsFrom": [ + "actor_token_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "topic_id": { + "name": "topic_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attachment_filename": { + "name": "attachment_filename", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attachment_content_type": { + "name": "attachment_content_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attachment_size_bytes": { + "name": "attachment_size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "deduplication_key": { + "name": "deduplication_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "occurrence_count": { + "name": "occurrence_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "raw_priority": { + "name": "raw_priority", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_token_id": { + "name": "sender_token_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notifications_topic_created_idx": { + "name": "notifications_topic_created_idx", + "columns": [ + { + "expression": "topic_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_dedup_idx": { + "name": "notifications_dedup_idx", + "columns": [ + { + "expression": "deduplication_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "topic_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_created_idx": { + "name": "notifications_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_topic_id_topics_id_fk": { + "name": "notifications_topic_id_topics_id_fk", + "tableFrom": "notifications", + "tableTo": "topics", + "columnsFrom": [ + "topic_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_sender_token_id_api_tokens_id_fk": { + "name": "notifications_sender_token_id_api_tokens_id_fk", + "tableFrom": "notifications", + "tableTo": "api_tokens", + "columnsFrom": [ + "sender_token_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.push_delivery_attempts": { + "name": "push_delivery_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "notification_id": { + "name": "notification_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "push_subscription_id": { + "name": "push_subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "push_delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "http_status": { + "name": "http_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempted_at": { + "name": "attempted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "push_delivery_attempts_notification_idx": { + "name": "push_delivery_attempts_notification_idx", + "columns": [ + { + "expression": "notification_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "push_delivery_attempts_subscription_idx": { + "name": "push_delivery_attempts_subscription_idx", + "columns": [ + { + "expression": "push_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "push_delivery_attempts_notification_id_notifications_id_fk": { + "name": "push_delivery_attempts_notification_id_notifications_id_fk", + "tableFrom": "push_delivery_attempts", + "tableTo": "notifications", + "columnsFrom": [ + "notification_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "push_delivery_attempts_push_subscription_id_push_subscriptions_id_fk": { + "name": "push_delivery_attempts_push_subscription_id_push_subscriptions_id_fk", + "tableFrom": "push_delivery_attempts", + "tableTo": "push_subscriptions", + "columnsFrom": [ + "push_subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.push_subscriptions": { + "name": "push_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "p256dh": { + "name": "p256dh", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth": { + "name": "auth", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "device_label": { + "name": "device_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "push_subscriptions_endpoint_key": { + "name": "push_subscriptions_endpoint_key", + "columns": [ + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "push_subscriptions_user_id_users_id_fk": { + "name": "push_subscriptions_user_id_users_id_fk", + "tableFrom": "push_subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.topic_members": { + "name": "topic_members", + "schema": "", + "columns": { + "topic_id": { + "name": "topic_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "topic_members_topic_id_topics_id_fk": { + "name": "topic_members_topic_id_topics_id_fk", + "tableFrom": "topic_members", + "tableTo": "topics", + "columnsFrom": [ + "topic_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "topic_members_user_id_users_id_fk": { + "name": "topic_members_user_id_users_id_fk", + "tableFrom": "topic_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "topic_members_topic_id_user_id_pk": { + "name": "topic_members_topic_id_user_id_pk", + "columns": [ + "topic_id", + "user_id" + ] + } + }, + "uniqueConstraints": {} + }, + "public.topics": { + "name": "topics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retention_days": { + "name": "retention_days", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "restricted": { + "name": "restricted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "topics_slug_key": { + "name": "topics_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "topics_created_by_users_id_fk": { + "name": "topics_created_by_users_id_fk", + "tableFrom": "topics", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.user_notification_state": { + "name": "user_notification_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "notification_id": { + "name": "notification_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "read": { + "name": "read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "starred": { + "name": "starred", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "starred_at": { + "name": "starred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_notification_state_user_notification_key": { + "name": "user_notification_state_user_notification_key", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "notification_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_notification_state_user_read_idx": { + "name": "user_notification_state_user_read_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "read", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_notification_state_user_starred_idx": { + "name": "user_notification_state_user_starred_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "starred", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_notification_state_user_id_users_id_fk": { + "name": "user_notification_state_user_id_users_id_fk", + "tableFrom": "user_notification_state", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_notification_state_notification_id_notifications_id_fk": { + "name": "user_notification_state_notification_id_notifications_id_fk", + "tableFrom": "user_notification_state", + "tableTo": "notifications", + "columnsFrom": [ + "notification_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.user_topic_subscriptions": { + "name": "user_topic_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "topic_id": { + "name": "topic_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "push_enabled": { + "name": "push_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_topic_subscriptions_user_topic_key": { + "name": "user_topic_subscriptions_user_topic_key", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "topic_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_topic_subscriptions_user_id_users_id_fk": { + "name": "user_topic_subscriptions_user_id_users_id_fk", + "tableFrom": "user_topic_subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_topic_subscriptions_topic_id_topics_id_fk": { + "name": "user_topic_subscriptions_topic_id_topics_id_fk", + "tableFrom": "user_topic_subscriptions", + "tableTo": "topics", + "columnsFrom": [ + "topic_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "auth_subject": { + "name": "auth_subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_issuer": { + "name": "auth_issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "users_email_key": { + "name": "users_email_key", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_issuer_subject_key": { + "name": "users_issuer_subject_key", + "columns": [ + { + "expression": "auth_issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "auth_subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": { + "public.push_delivery_status": { + "name": "push_delivery_status", + "schema": "public", + "values": [ + "pending", + "accepted_by_push_service", + "failed", + "invalid_subscription", + "expired" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "admin", + "user" + ] + } + }, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/server/src/db/migrations/meta/0001_snapshot.json b/apps/server/src/db/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..c735798 --- /dev/null +++ b/apps/server/src/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,1366 @@ +{ + "id": "1692f02d-6203-4925-b5ff-2e45244c39f0", + "prevId": "104fe2b6-6fff-4ce6-927b-c9bebba88dc6", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.api_token_topics": { + "name": "api_token_topics", + "schema": "", + "columns": { + "token_id": { + "name": "token_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "topic_id": { + "name": "topic_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "api_token_topics_token_id_api_tokens_id_fk": { + "name": "api_token_topics_token_id_api_tokens_id_fk", + "tableFrom": "api_token_topics", + "tableTo": "api_tokens", + "columnsFrom": [ + "token_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_token_topics_topic_id_topics_id_fk": { + "name": "api_token_topics_topic_id_topics_id_fk", + "tableFrom": "api_token_topics", + "tableTo": "topics", + "columnsFrom": [ + "topic_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "api_token_topics_token_id_topic_id_pk": { + "name": "api_token_topics_token_id_topic_id_pk", + "columns": [ + "token_id", + "topic_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_tokens": { + "name": "api_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed_source": { + "name": "allowed_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_cidrs": { + "name": "allowed_cidrs", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "usage_count": { + "name": "usage_count", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "api_tokens_token_hash_key": { + "name": "api_tokens_token_hash_key", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_tokens_created_by_users_id_fk": { + "name": "api_tokens_created_by_users_id_fk", + "tableFrom": "api_tokens", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_token_id": { + "name": "actor_token_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "inet", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_created_idx": { + "name": "audit_log_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_actor_user_id_users_id_fk": { + "name": "audit_log_actor_user_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_token_id_api_tokens_id_fk": { + "name": "audit_log_actor_token_id_api_tokens_id_fk", + "tableFrom": "audit_log", + "tableTo": "api_tokens", + "columnsFrom": [ + "actor_token_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "topic_id": { + "name": "topic_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attachment_filename": { + "name": "attachment_filename", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attachment_content_type": { + "name": "attachment_content_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attachment_size_bytes": { + "name": "attachment_size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "deduplication_key": { + "name": "deduplication_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "occurrence_count": { + "name": "occurrence_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "raw_priority": { + "name": "raw_priority", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_token_id": { + "name": "sender_token_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notifications_topic_created_idx": { + "name": "notifications_topic_created_idx", + "columns": [ + { + "expression": "topic_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_dedup_idx": { + "name": "notifications_dedup_idx", + "columns": [ + { + "expression": "deduplication_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "topic_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_created_idx": { + "name": "notifications_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_topic_id_topics_id_fk": { + "name": "notifications_topic_id_topics_id_fk", + "tableFrom": "notifications", + "tableTo": "topics", + "columnsFrom": [ + "topic_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_sender_token_id_api_tokens_id_fk": { + "name": "notifications_sender_token_id_api_tokens_id_fk", + "tableFrom": "notifications", + "tableTo": "api_tokens", + "columnsFrom": [ + "sender_token_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_delivery_attempts": { + "name": "push_delivery_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "notification_id": { + "name": "notification_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "push_subscription_id": { + "name": "push_subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "push_delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "http_status": { + "name": "http_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempted_at": { + "name": "attempted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "push_delivery_attempts_notification_idx": { + "name": "push_delivery_attempts_notification_idx", + "columns": [ + { + "expression": "notification_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "push_delivery_attempts_subscription_idx": { + "name": "push_delivery_attempts_subscription_idx", + "columns": [ + { + "expression": "push_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "push_delivery_attempts_notification_id_notifications_id_fk": { + "name": "push_delivery_attempts_notification_id_notifications_id_fk", + "tableFrom": "push_delivery_attempts", + "tableTo": "notifications", + "columnsFrom": [ + "notification_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "push_delivery_attempts_push_subscription_id_push_subscriptions_id_fk": { + "name": "push_delivery_attempts_push_subscription_id_push_subscriptions_id_fk", + "tableFrom": "push_delivery_attempts", + "tableTo": "push_subscriptions", + "columnsFrom": [ + "push_subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_subscriptions": { + "name": "push_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "p256dh": { + "name": "p256dh", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth": { + "name": "auth", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "device_label": { + "name": "device_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "push_subscriptions_endpoint_key": { + "name": "push_subscriptions_endpoint_key", + "columns": [ + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "push_subscriptions_user_id_users_id_fk": { + "name": "push_subscriptions_user_id_users_id_fk", + "tableFrom": "push_subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.topic_members": { + "name": "topic_members", + "schema": "", + "columns": { + "topic_id": { + "name": "topic_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "topic_members_topic_id_topics_id_fk": { + "name": "topic_members_topic_id_topics_id_fk", + "tableFrom": "topic_members", + "tableTo": "topics", + "columnsFrom": [ + "topic_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "topic_members_user_id_users_id_fk": { + "name": "topic_members_user_id_users_id_fk", + "tableFrom": "topic_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "topic_members_topic_id_user_id_pk": { + "name": "topic_members_topic_id_user_id_pk", + "columns": [ + "topic_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.topics": { + "name": "topics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retention_days": { + "name": "retention_days", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "restricted": { + "name": "restricted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "topics_slug_key": { + "name": "topics_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "topics_created_by_users_id_fk": { + "name": "topics_created_by_users_id_fk", + "tableFrom": "topics", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_notification_state": { + "name": "user_notification_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "notification_id": { + "name": "notification_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "read": { + "name": "read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "starred": { + "name": "starred", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "starred_at": { + "name": "starred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_notification_state_user_notification_key": { + "name": "user_notification_state_user_notification_key", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "notification_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_notification_state_user_read_idx": { + "name": "user_notification_state_user_read_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "read", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_notification_state_user_starred_idx": { + "name": "user_notification_state_user_starred_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "starred", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_notification_state_user_id_users_id_fk": { + "name": "user_notification_state_user_id_users_id_fk", + "tableFrom": "user_notification_state", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_notification_state_notification_id_notifications_id_fk": { + "name": "user_notification_state_notification_id_notifications_id_fk", + "tableFrom": "user_notification_state", + "tableTo": "notifications", + "columnsFrom": [ + "notification_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_topic_subscriptions": { + "name": "user_topic_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "topic_id": { + "name": "topic_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "push_enabled": { + "name": "push_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "muted_until": { + "name": "muted_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_topic_subscriptions_user_topic_key": { + "name": "user_topic_subscriptions_user_topic_key", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "topic_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_topic_subscriptions_user_id_users_id_fk": { + "name": "user_topic_subscriptions_user_id_users_id_fk", + "tableFrom": "user_topic_subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_topic_subscriptions_topic_id_topics_id_fk": { + "name": "user_topic_subscriptions_topic_id_topics_id_fk", + "tableFrom": "user_topic_subscriptions", + "tableTo": "topics", + "columnsFrom": [ + "topic_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "auth_subject": { + "name": "auth_subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_issuer": { + "name": "auth_issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "users_email_key": { + "name": "users_email_key", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_issuer_subject_key": { + "name": "users_issuer_subject_key", + "columns": [ + { + "expression": "auth_issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "auth_subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.push_delivery_status": { + "name": "push_delivery_status", + "schema": "public", + "values": [ + "pending", + "accepted_by_push_service", + "failed", + "invalid_subscription", + "expired" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "admin", + "user" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/server/src/db/migrations/meta/_journal.json b/apps/server/src/db/migrations/meta/_journal.json new file mode 100644 index 0000000..22e164b --- /dev/null +++ b/apps/server/src/db/migrations/meta/_journal.json @@ -0,0 +1,20 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1788720295278, + "tag": "0000_cuddly_tarot", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1788772064420, + "tag": "0001_purple_firestar", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/apps/server/src/db/schema.integration.test.ts b/apps/server/src/db/schema.integration.test.ts new file mode 100644 index 0000000..5feea45 --- /dev/null +++ b/apps/server/src/db/schema.integration.test.ts @@ -0,0 +1,50 @@ +import { randomUUID } from 'node:crypto' +import { eq } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { db, pool } from './client.js' +import { runMigrations } from './migrate.js' +import { notifications, topics } from './schema.js' + +// Integration tests — need a real, migrated Postgres reachable via +// DATABASE_URL. The dev compose stack provides one; run these with: +// docker compose -f docker/docker-compose.yml exec notizarr \ +// pnpm --filter @notizarr/server test +describe('schema constraints', () => { + const testSlug = `test-topic-${randomUUID()}` + let topicId: string + + beforeAll(async () => { + await runMigrations() + const [topic] = await db + .insert(topics) + .values({ slug: testSlug, displayName: 'Test Topic' }) + .returning() + topicId = topic!.id + }) + + afterAll(async () => { + await db.delete(topics).where(eq(topics.slug, testSlug)) + await pool.end() + }) + + it('rejects a duplicate topic slug', async () => { + await expect( + db.insert(topics).values({ slug: testSlug, displayName: 'Duplicate' }), + ).rejects.toThrow() + }) + + it('cascades notification deletion when its topic is deleted', async () => { + const [notification] = await db + .insert(notifications) + .values({ topicId, source: 'test', title: 'Hello', message: 'World' }) + .returning() + + await db.delete(topics).where(eq(topics.id, topicId)) + + const remaining = await db + .select() + .from(notifications) + .where(eq(notifications.id, notification!.id)) + expect(remaining).toHaveLength(0) + }) +}) diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts new file mode 100644 index 0000000..69948b0 --- /dev/null +++ b/apps/server/src/db/schema.ts @@ -0,0 +1,293 @@ +// Drizzle schema matching docs/phase1/03-database-model.md exactly. One +// table per section of that document, in the same order, so the two stay +// easy to diff against each other. +// +// Full-text search (the `search_vector` / GIN index called out in the +// design doc) is intentionally deferred to Phase 5 when search is actually +// implemented, rather than guessed at here. +import { + bigint, + boolean, + customType, + index, + integer, + jsonb, + pgEnum, + pgTable, + primaryKey, + text, + timestamp, + uniqueIndex, + uuid, +} from 'drizzle-orm/pg-core' + +const inet = customType<{ data: string }>({ + dataType() { + return 'inet' + }, +}) + +export const roleEnum = pgEnum('user_role', ['admin', 'user']) + +export const pushDeliveryStatusEnum = pgEnum('push_delivery_status', [ + 'pending', + 'accepted_by_push_service', + 'failed', + 'invalid_subscription', + 'expired', +]) + +export const users = pgTable( + 'users', + { + id: uuid('id').primaryKey().defaultRandom(), + email: text('email').notNull(), + displayName: text('display_name').notNull(), + avatarUrl: text('avatar_url'), + role: roleEnum('role').notNull().default('user'), + authSubject: text('auth_subject').notNull(), + authIssuer: text('auth_issuer'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + lastLoginAt: timestamp('last_login_at', { withTimezone: true }), + }, + (table) => [ + uniqueIndex('users_email_key').on(table.email), + uniqueIndex('users_issuer_subject_key').on(table.authIssuer, table.authSubject), + ], +) + +export const topics = pgTable( + 'topics', + { + id: uuid('id').primaryKey().defaultRandom(), + slug: text('slug').notNull(), + displayName: text('display_name').notNull(), + description: text('description'), + icon: text('icon'), + accentColor: text('accent_color'), + retentionDays: integer('retention_days'), + restricted: boolean('restricted').notNull().default(false), + createdBy: uuid('created_by').references(() => users.id, { onDelete: 'set null' }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + archivedAt: timestamp('archived_at', { withTimezone: true }), + }, + (table) => [uniqueIndex('topics_slug_key').on(table.slug)], +) + +// Only consulted when topics.restricted = true. +export const topicMembers = pgTable( + 'topic_members', + { + topicId: uuid('topic_id') + .notNull() + .references(() => topics.id, { onDelete: 'cascade' }), + userId: uuid('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + }, + (table) => [primaryKey({ columns: [table.topicId, table.userId] })], +) + +// Absence of a row = default push_enabled=true for a topic the user can see. +export const userTopicSubscriptions = pgTable( + 'user_topic_subscriptions', + { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + topicId: uuid('topic_id') + .notNull() + .references(() => topics.id, { onDelete: 'cascade' }), + pushEnabled: boolean('push_enabled').notNull().default(true), + // Temporary snooze — independent of pushEnabled, so turning this off + // doesn't touch the user's actual on/off preference and just expires + // on its own. NULL or a past timestamp means "not snoozed". + mutedUntil: timestamp('muted_until', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + uniqueIndex('user_topic_subscriptions_user_topic_key').on(table.userId, table.topicId), + ], +) + +export const apiTokens = pgTable( + 'api_tokens', + { + id: uuid('id').primaryKey().defaultRandom(), + name: text('name').notNull(), + tokenHash: text('token_hash').notNull(), + tokenPrefix: text('token_prefix').notNull(), + allowedSource: text('allowed_source'), + allowedCidrs: text('allowed_cidrs').array(), + expiresAt: timestamp('expires_at', { withTimezone: true }), + revokedAt: timestamp('revoked_at', { withTimezone: true }), + createdBy: uuid('created_by').references(() => users.id, { onDelete: 'set null' }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + lastUsedAt: timestamp('last_used_at', { withTimezone: true }), + usageCount: bigint('usage_count', { mode: 'number' }).notNull().default(0), + }, + (table) => [uniqueIndex('api_tokens_token_hash_key').on(table.tokenHash)], +) + +// Join table: which topics a token may publish to. Empty = all topics. +export const apiTokenTopics = pgTable( + 'api_token_topics', + { + tokenId: uuid('token_id') + .notNull() + .references(() => apiTokens.id, { onDelete: 'cascade' }), + topicId: uuid('topic_id') + .notNull() + .references(() => topics.id, { onDelete: 'cascade' }), + }, + (table) => [primaryKey({ columns: [table.tokenId, table.topicId] })], +) + +export const notifications = pgTable( + 'notifications', + { + id: uuid('id').primaryKey().defaultRandom(), + topicId: uuid('topic_id') + .notNull() + .references(() => topics.id, { onDelete: 'cascade' }), + source: text('source').notNull(), + title: text('title').notNull(), + message: text('message').notNull(), + externalUrl: text('external_url'), + imageUrl: text('image_url'), + attachmentFilename: text('attachment_filename'), + attachmentContentType: text('attachment_content_type'), + attachmentSizeBytes: bigint('attachment_size_bytes', { mode: 'number' }), + deduplicationKey: text('deduplication_key'), + occurrenceCount: integer('occurrence_count').notNull().default(1), + rawPriority: text('raw_priority'), + senderTokenId: uuid('sender_token_id').references(() => apiTokens.id, { + onDelete: 'set null', + }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + index('notifications_topic_created_idx').on(table.topicId, table.createdAt), + index('notifications_dedup_idx').on( + table.deduplicationKey, + table.topicId, + table.createdAt, + ), + index('notifications_created_idx').on(table.createdAt), + ], +) + +// Per-user read/star state — notifications are shared rows across every +// user who can see the topic. +export const userNotificationState = pgTable( + 'user_notification_state', + { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + notificationId: uuid('notification_id') + .notNull() + .references(() => notifications.id, { onDelete: 'cascade' }), + read: boolean('read').notNull().default(false), + readAt: timestamp('read_at', { withTimezone: true }), + starred: boolean('starred').notNull().default(false), + starredAt: timestamp('starred_at', { withTimezone: true }), + }, + (table) => [ + uniqueIndex('user_notification_state_user_notification_key').on( + table.userId, + table.notificationId, + ), + index('user_notification_state_user_read_idx').on(table.userId, table.read), + index('user_notification_state_user_starred_idx').on(table.userId, table.starred), + ], +) + +export const pushSubscriptions = pgTable( + 'push_subscriptions', + { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + endpoint: text('endpoint').notNull(), + p256dh: text('p256dh').notNull(), + auth: text('auth').notNull(), + userAgent: text('user_agent'), + deviceLabel: text('device_label'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).notNull().defaultNow(), + disabledAt: timestamp('disabled_at', { withTimezone: true }), + }, + (table) => [uniqueIndex('push_subscriptions_endpoint_key').on(table.endpoint)], +) + +export const pushDeliveryAttempts = pgTable( + 'push_delivery_attempts', + { + id: uuid('id').primaryKey().defaultRandom(), + notificationId: uuid('notification_id') + .notNull() + .references(() => notifications.id, { onDelete: 'cascade' }), + pushSubscriptionId: uuid('push_subscription_id') + .notNull() + .references(() => pushSubscriptions.id, { onDelete: 'cascade' }), + status: pushDeliveryStatusEnum('status').notNull(), + httpStatus: integer('http_status'), + errorMessage: text('error_message'), + retryCount: integer('retry_count').notNull().default(0), + attemptedAt: timestamp('attempted_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + index('push_delivery_attempts_notification_idx').on(table.notificationId), + index('push_delivery_attempts_subscription_idx').on( + table.pushSubscriptionId, + table.attemptedAt, + ), + ], +) + +export const auditLog = pgTable( + 'audit_log', + { + id: uuid('id').primaryKey().defaultRandom(), + actorUserId: uuid('actor_user_id').references(() => users.id, { onDelete: 'set null' }), + actorTokenId: uuid('actor_token_id').references(() => apiTokens.id, { + onDelete: 'set null', + }), + action: text('action').notNull(), + targetType: text('target_type'), + targetId: text('target_id'), + metadata: jsonb('metadata'), + ipAddress: inet('ip_address'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + index('audit_log_created_idx').on(table.createdAt), + index('audit_log_action_idx').on(table.action), + ], +) + +// Singleton key/value store: VAPID keypair, global retention default, +// rate-limit overrides. +export const systemSettings = pgTable('system_settings', { + key: text('key').primaryKey(), + value: jsonb('value').notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}) + +export type User = typeof users.$inferSelect +export type NewUser = typeof users.$inferInsert +export type Topic = typeof topics.$inferSelect +export type NewTopic = typeof topics.$inferInsert +export type Notification = typeof notifications.$inferSelect +export type NewNotification = typeof notifications.$inferInsert +export type ApiToken = typeof apiTokens.$inferSelect +export type PushSubscription = typeof pushSubscriptions.$inferSelect +export type UserNotificationState = typeof userNotificationState.$inferSelect diff --git a/apps/server/src/db/settings.ts b/apps/server/src/db/settings.ts new file mode 100644 index 0000000..4fe7210 --- /dev/null +++ b/apps/server/src/db/settings.ts @@ -0,0 +1,34 @@ +import { eq } from 'drizzle-orm' +import { db } from './client.js' +import { systemSettings } from './schema.js' + +export async function getSetting(key: string): Promise { + const [row] = await db + .select() + .from(systemSettings) + .where(eq(systemSettings.key, key)) + .limit(1) + return row ? (row.value as T) : null +} + +export async function setSetting(key: string, value: T): Promise { + await db + .insert(systemSettings) + .values({ key, value: value as never }) + .onConflictDoUpdate({ + target: systemSettings.key, + set: { value: value as never, updatedAt: new Date() }, + }) +} + +// Generates a value on first call and persists it, so every subsequent +// boot (including after a redeploy) sees the same value — the same pattern +// used for the VAPID keypair in Phase 7 (docs/phase1/05-pwa-web-push-design.md). +export async function getOrCreateSetting(key: string, generate: () => T): Promise { + const existing = await getSetting(key) + if (existing !== null) return existing + + const value = generate() + await setSetting(key, value) + return value +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts new file mode 100644 index 0000000..33914d2 --- /dev/null +++ b/apps/server/src/index.ts @@ -0,0 +1,65 @@ +import { config } from './config.js' +import { checkDbConnection } from './db/client.js' +import { runMigrations } from './db/migrate.js' +import { buildIngestApp } from './listeners/ingest.js' +import { buildWebApp } from './listeners/web.js' +import { startBackupJob } from './modules/admin/backup.js' +import { startRetentionCleanupJob } from './modules/notifications/retention.js' + +// depends_on: condition: service_healthy (docker-compose.yml) only gets +// evaluated by `docker compose up` — it does NOT apply when Docker's own +// restart policy brings containers back after a daemon/host reboot, where +// each container restarts independently with no guaranteed order. Retrying +// here means a Postgres-not-ready-yet race self-heals instead of crashing +// the process once and sitting dead (tsx watch only restarts on a file +// change, not after a runtime crash — see docs/phase1/06-docker-unraid-design.md). +async function waitForDatabase(maxAttempts = 20, delayMs = 3000): Promise { + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + if (await checkDbConnection()) return + console.log( + `Database not reachable yet (attempt ${attempt}/${maxAttempts}), retrying in ${delayMs}ms...`, + ) + await new Promise((resolve) => setTimeout(resolve, delayMs)) + } + throw new Error(`Could not reach the database after ${maxAttempts} attempts`) +} + +async function main() { + if (config.authMode === 'disabled' && config.isProduction) { + throw new Error( + 'AUTH_MODE=disabled grants implicit admin access to every request and is only ' + + 'for local development. Refusing to start with NODE_ENV=production. Use oidc or proxy.', + ) + } + + await waitForDatabase() + await runMigrations() + + const webApp = await buildWebApp() + const ingestApp = await buildIngestApp() + + await webApp.listen({ port: config.portWeb, host: '0.0.0.0' }) + webApp.log.info(`notizarr web listener up on :${config.portWeb}`) + + await ingestApp.listen({ port: config.portIngest, host: '0.0.0.0' }) + ingestApp.log.info(`notizarr ingest listener up on :${config.portIngest}`) + + const retentionTimer = startRetentionCleanupJob((err) => webApp.log.error(err, 'retention cleanup failed')) + const backupTimer = startBackupJob((err) => webApp.log.error(err, 'scheduled backup failed')) + + const shutdown = async (signal: string) => { + webApp.log.info(`received ${signal}, shutting down`) + clearInterval(retentionTimer) + clearInterval(backupTimer) + await Promise.all([webApp.close(), ingestApp.close()]) + process.exit(0) + } + + process.on('SIGTERM', () => void shutdown('SIGTERM')) + process.on('SIGINT', () => void shutdown('SIGINT')) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/apps/server/src/lib/cidr.test.ts b/apps/server/src/lib/cidr.test.ts new file mode 100644 index 0000000..c80123b --- /dev/null +++ b/apps/server/src/lib/cidr.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { isIpInAnyCidr } from './cidr.js' + +describe('isIpInAnyCidr', () => { + it('matches an address inside a /8', () => { + expect(isIpInAnyCidr('10.1.2.3', ['10.0.0.0/8'])).toBe(true) + }) + + it('rejects an address outside all ranges', () => { + expect(isIpInAnyCidr('8.8.8.8', ['10.0.0.0/8', '192.168.0.0/16'])).toBe(false) + }) + + it('matches an exact /32', () => { + expect(isIpInAnyCidr('127.0.0.1', ['127.0.0.1/32'])).toBe(true) + expect(isIpInAnyCidr('127.0.0.2', ['127.0.0.1/32'])).toBe(false) + }) + + it('treats 0.0.0.0/0 as match-all', () => { + expect(isIpInAnyCidr('203.0.113.5', ['0.0.0.0/0'])).toBe(true) + }) + + it('normalizes IPv4-mapped IPv6 addresses', () => { + expect(isIpInAnyCidr('::ffff:192.168.1.50', ['192.168.0.0/16'])).toBe(true) + }) + + it('returns false when no CIDRs are configured', () => { + expect(isIpInAnyCidr('10.0.0.1', [])).toBe(false) + }) +}) diff --git a/apps/server/src/lib/cidr.ts b/apps/server/src/lib/cidr.ts new file mode 100644 index 0000000..9f211a2 --- /dev/null +++ b/apps/server/src/lib/cidr.ts @@ -0,0 +1,51 @@ +// Minimal, dependency-free CIDR matching. IPv4-focused (the realistic case +// for a LAN homelab); IPv6 support is "exact address" or an explicit "::/0" +// wildcard only — full IPv6 prefix matching can be added later if it's ever +// actually needed. + +function normalizeIp(ip: string): string { + // Node reports IPv4 clients as "::ffff:x.x.x.x" on a dual-stack listener. + const v4mapped = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i) + // The pattern has exactly one capture group, so a successful match always + // populates it. + return v4mapped ? v4mapped[1]! : ip +} + +function ipv4ToInt(ip: string): number | null { + const parts = ip.split('.') + if (parts.length !== 4) return null + let result = 0 + for (const part of parts) { + const n = Number(part) + if (!Number.isInteger(n) || n < 0 || n > 255) return null + result = (result << 8) | n + } + return result >>> 0 +} + +function matchesCidr(ip: string, cidr: string): boolean { + const trimmed = cidr.trim() + if (trimmed === '0.0.0.0/0' || trimmed === '::/0') return true + + const [rangeIp, prefixStr] = trimmed.split('/') + const ipInt = ipv4ToInt(ip) + const rangeInt = rangeIp ? ipv4ToInt(rangeIp) : null + + if (ipInt === null || rangeInt === null) { + // Not IPv4 on one side (e.g. an IPv6 literal) — fall back to exact match. + return ip === rangeIp + } + + const prefixLength = prefixStr === undefined ? 32 : Number(prefixStr) + if (!Number.isInteger(prefixLength) || prefixLength < 0 || prefixLength > 32) return false + if (prefixLength === 0) return true + + const mask = prefixLength === 32 ? 0xffffffff : (~0 << (32 - prefixLength)) >>> 0 + return (ipInt & mask) === (rangeInt & mask) +} + +export function isIpInAnyCidr(ip: string, cidrs: readonly string[]): boolean { + if (cidrs.length === 0) return false + const normalized = normalizeIp(ip) + return cidrs.some((cidr) => matchesCidr(normalized, cidr)) +} diff --git a/apps/server/src/lib/crypto-session.ts b/apps/server/src/lib/crypto-session.ts new file mode 100644 index 0000000..5d464eb --- /dev/null +++ b/apps/server/src/lib/crypto-session.ts @@ -0,0 +1,35 @@ +import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto' + +// Small AES-256-GCM envelope for the session cookie payload — deliberately +// not using a library like @fastify/secure-session here, since its +// sodium-native dependency has no prebuilt binary for Alpine/musl (see the +// comment in modules/auth/session.ts). Node's built-in crypto needs no +// native compilation. +const ALGORITHM = 'aes-256-gcm' +const IV_LENGTH = 12 +const AUTH_TAG_LENGTH = 16 + +export function encryptJson(key: Buffer, data: unknown): string { + const iv = randomBytes(IV_LENGTH) + const cipher = createCipheriv(ALGORITHM, key, iv) + const plaintext = Buffer.from(JSON.stringify(data), 'utf8') + const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]) + const authTag = cipher.getAuthTag() + return Buffer.concat([iv, authTag, encrypted]).toString('base64url') +} + +export function decryptJson(key: Buffer, value: string): T | null { + try { + const buf = Buffer.from(value, 'base64url') + const iv = buf.subarray(0, IV_LENGTH) + const authTag = buf.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH) + const encrypted = buf.subarray(IV_LENGTH + AUTH_TAG_LENGTH) + const decipher = createDecipheriv(ALGORITHM, key, iv) + decipher.setAuthTag(authTag) + const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]) + return JSON.parse(decrypted.toString('utf8')) as T + } catch { + // Tampered, expired-key-rotated, or malformed cookie — treat as no session. + return null + } +} diff --git a/apps/server/src/lib/priority.test.ts b/apps/server/src/lib/priority.test.ts new file mode 100644 index 0000000..4021f35 --- /dev/null +++ b/apps/server/src/lib/priority.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { isQuietPriority, isWithinQuietHours, resolvePriority } from './priority.js' + +describe('resolvePriority', () => { + it('defaults to 3 when no value is given', () => { + expect(resolvePriority(null)).toBe(3) + expect(resolvePriority(undefined)).toBe(3) + expect(resolvePriority('')).toBe(3) + }) + + it('maps ntfy word aliases to their numeric levels', () => { + expect(resolvePriority('min')).toBe(1) + expect(resolvePriority('low')).toBe(2) + expect(resolvePriority('default')).toBe(3) + expect(resolvePriority('high')).toBe(4) + expect(resolvePriority('urgent')).toBe(5) + expect(resolvePriority('max')).toBe(5) + }) + + it('is case-insensitive and trims whitespace on word aliases', () => { + expect(resolvePriority('MIN')).toBe(1) + expect(resolvePriority(' min ')).toBe(1) + }) + + it('parses bare numeric strings', () => { + expect(resolvePriority('1')).toBe(1) + expect(resolvePriority('5')).toBe(5) + }) + + it('falls back to default for unrecognized input', () => { + expect(resolvePriority('not-a-priority')).toBe(3) + }) +}) + +describe('isQuietPriority', () => { + it('is true only at min/1 and below', () => { + expect(isQuietPriority('min')).toBe(true) + expect(isQuietPriority('1')).toBe(true) + expect(isQuietPriority('low')).toBe(false) + expect(isQuietPriority('2')).toBe(false) + expect(isQuietPriority('default')).toBe(false) + expect(isQuietPriority(null)).toBe(false) + }) +}) + +describe('isWithinQuietHours', () => { + it('handles a same-day window', () => { + const noon = new Date('2026-01-01T12:00:00Z') + expect(isWithinQuietHours('09:00', '17:00', 'UTC', noon)).toBe(true) + const evening = new Date('2026-01-01T20:00:00Z') + expect(isWithinQuietHours('09:00', '17:00', 'UTC', evening)).toBe(false) + }) + + it('handles a window that wraps past midnight', () => { + const lateNight = new Date('2026-01-01T23:30:00Z') + expect(isWithinQuietHours('23:00', '07:00', 'UTC', lateNight)).toBe(true) + const earlyMorning = new Date('2026-01-01T05:00:00Z') + expect(isWithinQuietHours('23:00', '07:00', 'UTC', earlyMorning)).toBe(true) + const midday = new Date('2026-01-01T12:00:00Z') + expect(isWithinQuietHours('23:00', '07:00', 'UTC', midday)).toBe(false) + }) + + it('treats an identical start/end as always off', () => { + const anytime = new Date('2026-01-01T12:00:00Z') + expect(isWithinQuietHours('09:00', '09:00', 'UTC', anytime)).toBe(false) + }) + + it('resolves the window in the given timezone, not UTC', () => { + // 22:30 in Stockholm (UTC+1 in January) is 21:30 UTC — inside a + // 22:00-06:00 window in Stockholm time, but not if evaluated in UTC. + const time = new Date('2026-01-01T21:30:00Z') + expect(isWithinQuietHours('22:00', '06:00', 'Europe/Stockholm', time)).toBe(true) + expect(isWithinQuietHours('22:00', '06:00', 'UTC', time)).toBe(false) + }) +}) diff --git a/apps/server/src/lib/priority.ts b/apps/server/src/lib/priority.ts new file mode 100644 index 0000000..859198b --- /dev/null +++ b/apps/server/src/lib/priority.ts @@ -0,0 +1,58 @@ +// ntfy-style priority scale (docs/migration-ntfy.md) — reused here so a +// value already familiar to anyone migrating from ntfy behaves the way +// they'd expect: "min"/1 means "log it, don't buzz the phone." +const PRIORITY_ALIASES: Record = { + min: 1, + low: 2, + default: 3, + high: 4, + urgent: 5, + max: 5, +} + +const DEFAULT_PRIORITY = 3 +const MIN_PRIORITY_NO_PUSH = 1 + +export function resolvePriority(raw: string | null | undefined): number { + if (!raw) return DEFAULT_PRIORITY + const trimmed = raw.trim().toLowerCase() + // The `in` check above guarantees the key exists. + if (trimmed in PRIORITY_ALIASES) return PRIORITY_ALIASES[trimmed]! + const parsed = Number(trimmed) + return Number.isFinite(parsed) ? parsed : DEFAULT_PRIORITY +} + +// "min" priority notifications are stored and shown in-app like any other +// — they just don't trigger a push, so a noisy automation can stay in its +// normal topic instead of needing a dedicated quiet topic. +export function isQuietPriority(raw: string | null | undefined): boolean { + return resolvePriority(raw) <= MIN_PRIORITY_NO_PUSH +} + +// Household-wide "quiet hours" window (Admin > System) — true when `now` +// falls inside [start, end) in the given IANA timezone. Handles a window +// that wraps past midnight (e.g. 23:00-07:00) by checking OR instead of +// AND once start > end. +export function isWithinQuietHours(start: string, end: string, timezone: string, now = new Date()): boolean { + const parts = new Intl.DateTimeFormat('en-GB', { + timeZone: timezone, + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23', + }).formatToParts(now) + const hour = Number(parts.find((p) => p.type === 'hour')?.value ?? '0') + const minute = Number(parts.find((p) => p.type === 'minute')?.value ?? '0') + const nowMinutes = hour * 60 + minute + + const [startH, startM] = start.split(':').map(Number) + const [endH, endM] = end.split(':').map(Number) + const startMinutes = (startH ?? 0) * 60 + (startM ?? 0) + const endMinutes = (endH ?? 0) * 60 + (endM ?? 0) + + if (startMinutes === endMinutes) return false + + if (startMinutes < endMinutes) { + return nowMinutes >= startMinutes && nowMinutes < endMinutes + } + return nowMinutes >= startMinutes || nowMinutes < endMinutes +} diff --git a/apps/server/src/lib/rate-limiter.test.ts b/apps/server/src/lib/rate-limiter.test.ts new file mode 100644 index 0000000..fea949d --- /dev/null +++ b/apps/server/src/lib/rate-limiter.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { createDualWindowLimiter, TokenBucket } from './rate-limiter.js' + +describe('TokenBucket', () => { + it('allows up to capacity requests, then rejects', () => { + const bucket = new TokenBucket(3, 60_000) + expect(bucket.tryConsume('a')).toBe(true) + expect(bucket.tryConsume('a')).toBe(true) + expect(bucket.tryConsume('a')).toBe(true) + expect(bucket.tryConsume('a')).toBe(false) + }) + + it('tracks separate keys independently', () => { + const bucket = new TokenBucket(1, 60_000) + expect(bucket.tryConsume('a')).toBe(true) + expect(bucket.tryConsume('b')).toBe(true) + expect(bucket.tryConsume('a')).toBe(false) + }) +}) + +describe('createDualWindowLimiter', () => { + it('rejects once the smaller (burst) window is exhausted', () => { + const limiter = createDualWindowLimiter(100, 60_000, 2, 10_000) + expect(limiter.check('k')).toBe(true) + expect(limiter.check('k')).toBe(true) + expect(limiter.check('k')).toBe(false) + }) + + it('does not drain the sustained window on a burst-only rejection', () => { + const limiter = createDualWindowLimiter(100, 60_000, 1, 10_000) + expect(limiter.check('k')).toBe(true) + // Burst window is now empty; several more calls should all be + // rejected by burst without touching the (still nearly full) sustained + // bucket. + for (let i = 0; i < 5; i++) { + expect(limiter.check('k')).toBe(false) + } + }) +}) diff --git a/apps/server/src/lib/rate-limiter.ts b/apps/server/src/lib/rate-limiter.ts new file mode 100644 index 0000000..0efa779 --- /dev/null +++ b/apps/server/src/lib/rate-limiter.ts @@ -0,0 +1,71 @@ +// In-memory token bucket rate limiter. Single-instance app (see +// docs/phase1/01-architecture.md — no Redis), so process memory is a fine +// place for this; it resets on restart, which is an acceptable trade-off +// for a homelab-scale rate limiter. +interface Bucket { + tokens: number + lastRefillMs: number +} + +export class TokenBucket { + private buckets = new Map() + + constructor( + private readonly capacity: number, + private readonly windowMs: number, + ) {} + + private refillRatePerMs(): number { + return this.capacity / this.windowMs + } + + private currentTokens(key: string, now: number): { bucket: Bucket; tokens: number } { + const bucket = this.buckets.get(key) ?? { tokens: this.capacity, lastRefillMs: now } + const elapsed = now - bucket.lastRefillMs + const tokens = Math.min(this.capacity, bucket.tokens + elapsed * this.refillRatePerMs()) + return { bucket, tokens } + } + + /** Non-mutating: how many tokens would be available right now. */ + peek(key: string): number { + return this.currentTokens(key, Date.now()).tokens + } + + /** Spends one token. Only call after peek() (or checkRateLimit) confirmed room. */ + consume(key: string): void { + const now = Date.now() + const { bucket, tokens } = this.currentTokens(key, now) + bucket.tokens = tokens - 1 + bucket.lastRefillMs = now + this.buckets.set(key, bucket) + } + + /** Checks and, if there's room, consumes in one step. */ + tryConsume(key: string): boolean { + if (this.peek(key) < 1) return false + this.consume(key) + return true + } +} + +export function createDualWindowLimiter( + sustainedCapacity: number, + sustainedWindowMs: number, + burstCapacity: number, + burstWindowMs: number, +) { + const sustained = new TokenBucket(sustainedCapacity, sustainedWindowMs) + const burst = new TokenBucket(burstCapacity, burstWindowMs) + + // Checks both windows for `key` and consumes one token from each only if + // BOTH have room — a request that fails one window never silently drains + // the other. + return { + check(key: string): boolean { + if (burst.peek(key) < 1 || sustained.peek(key) < 1) return false + burst.consume(key) + sustained.consume(key) + return true + }, + } +} diff --git a/apps/server/src/listeners/ingest.ts b/apps/server/src/listeners/ingest.ts new file mode 100644 index 0000000..acd74b8 --- /dev/null +++ b/apps/server/src/listeners/ingest.ts @@ -0,0 +1,46 @@ +import Fastify, { type FastifyInstance } from 'fastify' +import { loggerOptions } from '../logger.js' +import { registerNotificationIngestRoutes } from '../modules/notifications/ingest-routes.js' +import { registerNtfyCompatRoutes } from '../modules/notifications/ntfy-routes.js' +import { registerWebhookRoutes } from '../modules/notifications/webhook-routes.js' + +// Listener B — LAN-only ingestion API, bearer-token authenticated. +// See docs/phase1/01-architecture.md and 02-threat-model.md: this listener +// never serves the SPA and never accepts session cookies. +export async function buildIngestApp(): Promise { + // Tighter than the web listener's default — a legitimate notification + // plus headers comfortably fits well under this; docs/phase1/02-threat-model.md, + // "Denial of service via large payloads". + const app = Fastify({ + logger: loggerOptions, + bodyLimit: 256 * 1024, + // ingest-routes.ts's `priority` field accepts a JSON string or number + // (an unquoted Jinja template value renders as the latter) — Ajv's + // strict mode otherwise warns on that `type: [...]` union. + ajv: { customOptions: { allowUnionTypes: true } }, + }) + + // The canonical/webhook routes expect JSON; the ntfy-compat route also + // accepts a raw-text body (typical `curl -d "message" .../topic` usage). + // Fastify's built-in application/json parser still takes precedence over + // this catch-all for content types it explicitly recognizes. + app.addContentTypeParser('*', { parseAs: 'string' }, (_req, body, done) => { + done(null, body) + }) + + app.get('/health', async () => ({ status: 'ok' })) + // Real ntfy servers expose this as their standard health/connectivity + // check, and some ntfy-compatible clients probe it before publishing — + // aliased here for that compatibility, not used internally. + app.get('/v1/health', async () => ({ status: 'ok' })) + + await registerNotificationIngestRoutes(app) + await registerWebhookRoutes(app) + // Registered last: POST /:topic is a single-segment catch-all, so the + // more specific literal routes above must exist first for readability + // (Fastify's router already prefers literal matches regardless of + // registration order, but this keeps the file honest about intent). + await registerNtfyCompatRoutes(app) + + return app +} diff --git a/apps/server/src/listeners/web.ts b/apps/server/src/listeners/web.ts new file mode 100644 index 0000000..6e2ce51 --- /dev/null +++ b/apps/server/src/listeners/web.ts @@ -0,0 +1,124 @@ +import { existsSync, mkdirSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import fastifyHelmet from '@fastify/helmet' +import fastifyStatic from '@fastify/static' +import fastifySwagger from '@fastify/swagger' +import fastifySwaggerUi from '@fastify/swagger-ui' +import Fastify, { type FastifyInstance } from 'fastify' +import { config } from '../config.js' +import { checkDbConnection } from '../db/client.js' +import { loggerOptions } from '../logger.js' +import { registerAdminRoutes } from '../modules/admin/routes.js' +import { registerAuthRoutes } from '../modules/auth/routes.js' +import { registerCsrfGuard } from '../modules/auth/csrf.js' +import { registerSession } from '../modules/auth/session.js' +import { registerNotificationReadRoutes } from '../modules/notifications/routes.js' +import { registerStreamRoutes } from '../modules/notifications/stream-routes.js' +import { registerPushRoutes } from '../modules/push/routes.js' +import { registerTokenRoutes } from '../modules/tokens/routes.js' +import { registerTopicRoutes } from '../modules/topics/routes.js' + +// In dev, apps/web/dist is written by `vite build --watch` and may not +// exist yet on a cold container start; @fastify/static shouldn't be handed +// a missing root directory. Seed a placeholder so startup never races the +// first build. +function ensureStaticDirReady(dir: string) { + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + const indexPath = path.join(dir, 'index.html') + if (!existsSync(indexPath)) { + writeFileSync( + indexPath, + 'NotizarrBuilding web assets, refresh shortly…', + ) + } +} + +// Listener A — public web/PWA + session-authenticated API. +// See docs/phase1/01-architecture.md. Push (Phase 7) and the rest of the +// admin API (Phase 8) land later; auth, topics, notifications, and API +// tokens are wired up as of Phase 4. +export async function buildWebApp(): Promise { + const app = Fastify({ logger: loggerOptions, bodyLimit: 1024 * 1024 }) + + // CSP/HSTS/frame-ancestors per docs/phase1/00-README.md's security + // requirements. crossOriginEmbedderPolicy is off because notification + // image_url values are arbitrary third-party https URLs the browser + // loads directly (docs/phase1/02-threat-model.md's SSRF mitigation) — + // COEP would require CORP headers from origins we don't control. HSTS is + // only sent in production so a plain-HTTP dev/LAN setup never gets stuck + // with a stale HSTS pin from testing. + await app.register(fastifyHelmet, { + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'"], + imgSrc: ["'self'", 'https:', 'data:'], + connectSrc: ["'self'"], + fontSrc: ["'self'"], + objectSrc: ["'none'"], + frameAncestors: ["'none'"], + baseUri: ["'self'"], + formAction: ["'self'"], + }, + }, + crossOriginEmbedderPolicy: false, + hsts: config.isProduction ? { maxAge: 15552000, includeSubDomains: true } : false, + }) + + app.get('/health', async () => ({ status: 'ok' })) + + app.get('/ready', async (_req, reply) => { + const dbOk = await checkDbConnection() + if (!dbOk) { + reply.code(503) + return { status: 'not_ready', db: false } + } + return { status: 'ready', db: true } + }) + + // Needs a DB round-trip to load/generate the session secret, so this is + // awaited before anything that depends on req.session. + await registerSession(app) + + registerCsrfGuard(app) + + if (config.docsEnabled) { + // Registered before the routes below so @fastify/swagger's `onRoute` + // hook picks up their schemas. + await app.register(fastifySwagger, { + openapi: { + info: { title: 'Notizarr API', version: '0.1.0' }, + }, + }) + await app.register(fastifySwaggerUi, { routePrefix: '/docs' }) + } + + await registerAuthRoutes(app) + await registerTokenRoutes(app) + await registerTopicRoutes(app) + await registerNotificationReadRoutes(app) + await registerStreamRoutes(app) + await registerPushRoutes(app) + await registerAdminRoutes(app) + + ensureStaticDirReady(config.staticWebDir) + + await app.register(fastifyStatic, { + root: config.staticWebDir, + prefix: '/', + decorateReply: true, + }) + + app.setNotFoundHandler((req, reply) => { + if (req.raw.method !== 'GET' || req.url.startsWith('/api/')) { + reply.code(404).send({ error: 'not_found' }) + return + } + reply.sendFile('index.html', config.staticWebDir) + }) + + return app +} diff --git a/apps/server/src/logger.ts b/apps/server/src/logger.ts new file mode 100644 index 0000000..be3c09e --- /dev/null +++ b/apps/server/src/logger.ts @@ -0,0 +1,29 @@ +import { config } from './config.js' + +// Redaction covers everything called out in docs/phase1/02-threat-model.md +// under "Secret leakage via logs" — extend this list whenever a module adds +// a new field that can carry a credential. +export const loggerOptions = { + level: config.logLevel, + redact: { + paths: [ + 'req.headers.authorization', + 'req.headers.cookie', + 'res.headers["set-cookie"]', + '*.password', + '*.token', + '*.tokenHash', + '*.clientSecret', + '*.vapidPrivateKey', + ], + remove: true, + }, + ...(config.isProduction + ? {} + : { + transport: { + target: 'pino-pretty', + options: { colorize: true, translateTime: 'HH:MM:ss', ignore: 'pid,hostname' }, + }, + }), +} diff --git a/apps/server/src/modules/admin/audit.ts b/apps/server/src/modules/admin/audit.ts new file mode 100644 index 0000000..9c5fd0e --- /dev/null +++ b/apps/server/src/modules/admin/audit.ts @@ -0,0 +1,8 @@ +import { desc } from 'drizzle-orm' +import { db } from '../../db/client.js' +import { auditLog } from '../../db/schema.js' + +export async function listAuditLog(limit = 100) { + const boundedLimit = Math.min(Math.max(limit, 1), 500) + return db.select().from(auditLog).orderBy(desc(auditLog.createdAt)).limit(boundedLimit) +} diff --git a/apps/server/src/modules/admin/backup.ts b/apps/server/src/modules/admin/backup.ts new file mode 100644 index 0000000..497b688 --- /dev/null +++ b/apps/server/src/modules/admin/backup.ts @@ -0,0 +1,119 @@ +import { spawn } from 'node:child_process' +import { createWriteStream, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs' +import path from 'node:path' +import { createGzip } from 'node:zlib' +import { config } from '../../config.js' + +// Matches scripts/backup.sh's naming so both paths (in-app and the +// standalone script) coexist cleanly in the same directory. +const BACKUP_FILENAME_PATTERN = /^notizarr-db-\d{8}-\d{6}\.sql\.gz$/ +const MAX_BACKUPS_TO_KEEP = 14 + +function backupsDir(): string { + if (!existsSync(config.backupsDir)) { + mkdirSync(config.backupsDir, { recursive: true }) + } + return config.backupsDir +} + +function timestamp(now = new Date()): string { + const pad = (n: number) => String(n).padStart(2, '0') + return ( + `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-` + + `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}` + ) +} + +function pruneOldBackups(dir: string): void { + const files = readdirSync(dir) + .filter((f) => BACKUP_FILENAME_PATTERN.test(f)) + .sort() + .reverse() + for (const stale of files.slice(MAX_BACKUPS_TO_KEEP)) { + unlinkSync(path.join(dir, stale)) + } +} + +export interface BackupResult { + filename: string + sizeBytes: number +} + +// Shells out to pg_dump rather than using the `pg` client library directly +// — a proper dump (schema + data, in pg_dump's own restorable format) isn't +// something worth reimplementing. `--dbname` accepts a full connection URI, +// so no credential parsing/reassembly here. spawn(), not exec(): arguments +// are passed as an array, never interpolated into a shell string. +export async function runBackup(): Promise { + const dir = backupsDir() + const filename = `notizarr-db-${timestamp()}.sql.gz` + const fullPath = path.join(dir, filename) + + await new Promise((resolve, reject) => { + const dump = spawn('pg_dump', ['--dbname', config.databaseUrl, '--format=plain']) + let stderr = '' + dump.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString() + }) + dump.on('error', reject) + + const out = createWriteStream(fullPath) + dump.stdout.pipe(createGzip()).pipe(out) + + out.on('error', reject) + dump.on('close', (code) => { + if (code === 0) resolve() + else reject(new Error(`pg_dump exited with code ${code}: ${stderr.slice(0, 2000)}`)) + }) + }) + + pruneOldBackups(dir) + + return { filename, sizeBytes: statSync(fullPath).size } +} + +export interface BackupFile { + filename: string + sizeBytes: number + createdAt: string +} + +export function listBackups(): BackupFile[] { + const dir = backupsDir() + return readdirSync(dir) + .filter((f) => BACKUP_FILENAME_PATTERN.test(f)) + .map((filename) => { + const stat = statSync(path.join(dir, filename)) + return { filename, sizeBytes: stat.size, createdAt: stat.mtime.toISOString() } + }) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) +} + +// Filename is validated against the fixed pattern above before it ever +// touches the filesystem — the only defense against path traversal that +// actually matters here, since the value comes straight from a URL param. +export function resolveBackupPath(filename: string): string | null { + if (!BACKUP_FILENAME_PATTERN.test(filename)) return null + const fullPath = path.join(backupsDir(), filename) + return existsSync(fullPath) ? fullPath : null +} + +const BACKUP_INTERVAL_MS = 24 * 60 * 60 * 1000 // 1 day + +// Daily, plus a catch-up run on boot if the newest backup is already +// stale (or there isn't one) — covers a fresh install and a boot after +// downtime longer than a day, without also firing on every `tsx watch` +// restart during dev, which happens far more often than once a day. +export function startBackupJob(onError: (err: unknown) => void = console.error): NodeJS.Timeout { + const run = () => { + runBackup().catch(onError) + } + + const [latest] = listBackups() + const isStale = !latest || Date.now() - new Date(latest.createdAt).getTime() > BACKUP_INTERVAL_MS + if (isStale) run() + + const timer = setInterval(run, BACKUP_INTERVAL_MS) + timer.unref() + return timer +} diff --git a/apps/server/src/modules/admin/devices.ts b/apps/server/src/modules/admin/devices.ts new file mode 100644 index 0000000..89e5a7c --- /dev/null +++ b/apps/server/src/modules/admin/devices.ts @@ -0,0 +1,20 @@ +import { desc, eq } from 'drizzle-orm' +import { db } from '../../db/client.js' +import { pushSubscriptions, users } from '../../db/schema.js' + +export async function listAllDevices() { + return db + .select({ + id: pushSubscriptions.id, + deviceLabel: pushSubscriptions.deviceLabel, + userAgent: pushSubscriptions.userAgent, + createdAt: pushSubscriptions.createdAt, + lastSeenAt: pushSubscriptions.lastSeenAt, + disabledAt: pushSubscriptions.disabledAt, + userEmail: users.email, + userDisplayName: users.displayName, + }) + .from(pushSubscriptions) + .innerJoin(users, eq(users.id, pushSubscriptions.userId)) + .orderBy(desc(pushSubscriptions.createdAt)) +} diff --git a/apps/server/src/modules/admin/routes.ts b/apps/server/src/modules/admin/routes.ts new file mode 100644 index 0000000..4201618 --- /dev/null +++ b/apps/server/src/modules/admin/routes.ts @@ -0,0 +1,293 @@ +import { createReadStream } from 'node:fs' +import type { FastifyInstance } from 'fastify' +import { config } from '../../config.js' +import { requireAdmin } from '../auth/guards.js' +import { invalidateOidcClient, resolveOidcConfig } from '../auth/oidc.js' +import { getStoredOidcSettings, setStoredOidcSettings } from '../auth/oidc-settings.js' +import { writeAuditLog } from '../audit/log.js' +import { listAuditLog } from './audit.js' +import { listBackups, resolveBackupPath, runBackup } from './backup.js' +import { listAllDevices } from './devices.js' +import { listSourceVolumes } from './sources.js' +import { getDashboardStats, getTopicStats } from './stats.js' +import { DEFAULT_QUIET_HOURS, getStoredSystemSettings, setStoredSystemSettings } from './system-settings.js' +import { listAllUsers, setUserRole } from './users.js' + +export async function registerAdminRoutes(app: FastifyInstance) { + app.get('/api/v1/admin/stats/dashboard', { preHandler: requireAdmin }, async () => { + return getDashboardStats() + }) + + app.get('/api/v1/admin/sources', { preHandler: requireAdmin }, async () => { + const sources = await listSourceVolumes() + return { sources } + }) + + app.get( + '/api/v1/admin/stats/topics/:slug', + { + preHandler: requireAdmin, + schema: { + params: { type: 'object', properties: { slug: { type: 'string' } }, required: ['slug'] }, + }, + }, + async (req, reply) => { + const { slug } = req.params as { slug: string } + const stats = await getTopicStats(slug) + if (!stats) { + reply.code(404) + return { error: 'not_found' } + } + return stats + }, + ) + + app.get('/api/v1/admin/audit-log', { preHandler: requireAdmin }, async (req) => { + const { limit } = req.query as { limit?: number } + const entries = await listAuditLog(limit) + return { entries } + }) + + app.get('/api/v1/admin/users', { preHandler: requireAdmin }, async () => { + const allUsers = await listAllUsers() + return { + users: allUsers.map((u) => ({ + id: u.id, + email: u.email, + displayName: u.displayName, + role: u.role, + createdAt: u.createdAt, + lastLoginAt: u.lastLoginAt, + })), + } + }) + + app.patch( + '/api/v1/admin/users/:id', + { + preHandler: requireAdmin, + schema: { + params: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] }, + body: { + type: 'object', + required: ['role'], + properties: { role: { type: 'string', enum: ['admin', 'user'] } }, + }, + }, + }, + async (req, reply) => { + const { id } = req.params as { id: string } + const { role } = req.body as { role: 'admin' | 'user' } + + // A lone admin locking themselves out would need direct DB access to + // recover — cheap enough to guard against here. + if (id === req.currentUser!.id && role !== 'admin') { + reply.code(400) + return { error: 'invalid_operation', message: "you can't remove your own admin role" } + } + + const updated = await setUserRole(id, role) + if (!updated) { + reply.code(404) + return { error: 'not_found' } + } + await writeAuditLog({ + actorUserId: req.currentUser!.id, + action: 'user.role_change', + targetType: 'user', + targetId: updated.id, + metadata: { email: updated.email, role: updated.role }, + ipAddress: req.ip, + }) + return { user: { id: updated.id, email: updated.email, role: updated.role } } + }, + ) + + app.get('/api/v1/admin/devices', { preHandler: requireAdmin }, async () => { + const devices = await listAllDevices() + return { devices } + }) + + app.get('/api/v1/admin/settings/oidc', { preHandler: requireAdmin }, async () => { + const stored = await getStoredOidcSettings() + const effective = await resolveOidcConfig() + return { + authMode: config.authMode, + // clientSecret is never sent back to the browser — only whether one is + // currently set, so the form can show "leave blank to keep existing". + issuerUrl: stored?.issuerUrl ?? '', + clientId: stored?.clientId ?? '', + clientSecretSet: Boolean(effective.clientSecret), + redirectUrl: stored?.redirectUrl ?? '', + scopes: stored?.scopes ?? '', + effectiveRedirectUrl: effective.redirectUrl || `${config.publicBaseUrl}/api/v1/auth/oidc/callback`, + } + }) + + app.patch( + '/api/v1/admin/settings/oidc', + { + preHandler: requireAdmin, + schema: { + body: { + type: 'object', + required: ['issuerUrl', 'clientId', 'redirectUrl', 'scopes'], + properties: { + issuerUrl: { type: 'string' }, + clientId: { type: 'string' }, + // Omitted/empty means "keep the currently stored secret" — + // mirrors how other integrations in this app handle secrets that + // shouldn't round-trip back to the browser once set. + clientSecret: { type: 'string' }, + redirectUrl: { type: 'string' }, + scopes: { type: 'string' }, + }, + }, + }, + }, + async (req) => { + const body = req.body as { + issuerUrl: string + clientId: string + clientSecret?: string + redirectUrl: string + scopes: string + } + + const existing = await getStoredOidcSettings() + const clientSecret = body.clientSecret?.trim() ? body.clientSecret.trim() : (existing?.clientSecret ?? '') + + await setStoredOidcSettings({ + issuerUrl: body.issuerUrl.trim(), + clientId: body.clientId.trim(), + clientSecret, + redirectUrl: body.redirectUrl.trim(), + scopes: body.scopes.trim(), + }) + invalidateOidcClient() + + // Never logs the secret itself — just that the settings changed. + await writeAuditLog({ + actorUserId: req.currentUser!.id, + action: 'settings.oidc_update', + targetType: 'system_settings', + targetId: 'oidc_config', + metadata: { issuerUrl: body.issuerUrl.trim(), clientId: body.clientId.trim() }, + ipAddress: req.ip, + }) + + return { status: 'ok' } + }, + ) + + app.get('/api/v1/admin/settings/system', { preHandler: requireAdmin }, async () => { + const stored = await getStoredSystemSettings() + return { + retentionDefaultDays: stored?.retentionDefaultDays ?? config.retentionDefaultDays, + vapidSubject: stored?.vapidSubject || config.vapidSubject, + quietHours: stored?.quietHours ?? DEFAULT_QUIET_HOURS, + } + }) + + app.patch( + '/api/v1/admin/settings/system', + { + preHandler: requireAdmin, + schema: { + body: { + type: 'object', + properties: { + retentionDefaultDays: { type: 'integer', minimum: 0 }, + vapidSubject: { type: 'string', minLength: 1 }, + quietHours: { + type: 'object', + properties: { + enabled: { type: 'boolean' }, + start: { type: 'string', pattern: '^\\d{2}:\\d{2}$' }, + end: { type: 'string', pattern: '^\\d{2}:\\d{2}$' }, + timezone: { type: 'string', minLength: 1 }, + minPriority: { type: 'integer', minimum: 1, maximum: 5 }, + }, + }, + }, + }, + }, + }, + async (req) => { + const body = req.body as { + retentionDefaultDays?: number + vapidSubject?: string + quietHours?: { + enabled: boolean + start: string + end: string + timezone: string + minPriority: number + } + } + const existing = await getStoredSystemSettings() + + await setStoredSystemSettings({ + retentionDefaultDays: body.retentionDefaultDays ?? existing?.retentionDefaultDays, + vapidSubject: body.vapidSubject?.trim() || existing?.vapidSubject, + quietHours: body.quietHours ?? existing?.quietHours, + }) + + await writeAuditLog({ + actorUserId: req.currentUser!.id, + action: 'settings.system_update', + targetType: 'system_settings', + targetId: 'system_config', + metadata: body as Record, + ipAddress: req.ip, + }) + + return { status: 'ok' } + }, + ) + + app.get('/api/v1/admin/backups', { preHandler: requireAdmin }, async () => { + return { backups: listBackups() } + }) + + app.post('/api/v1/admin/backups', { preHandler: requireAdmin }, async (req, reply) => { + try { + const result = await runBackup() + await writeAuditLog({ + actorUserId: req.currentUser!.id, + action: 'backup.create', + targetType: 'backup', + targetId: result.filename, + metadata: { sizeBytes: result.sizeBytes }, + ipAddress: req.ip, + }) + reply.code(201) + return { backup: result } + } catch (err) { + req.log.error(err, 'manual backup failed') + reply.code(500) + return { error: 'backup_failed', message: err instanceof Error ? err.message : String(err) } + } + }) + + app.get( + '/api/v1/admin/backups/:filename/download', + { + preHandler: requireAdmin, + schema: { + params: { type: 'object', properties: { filename: { type: 'string' } }, required: ['filename'] }, + }, + }, + async (req, reply) => { + const { filename } = req.params as { filename: string } + const fullPath = resolveBackupPath(filename) + if (!fullPath) { + reply.code(404) + return { error: 'not_found' } + } + reply.header('Content-Disposition', `attachment; filename="${filename}"`) + reply.type('application/gzip') + return createReadStream(fullPath) + }, + ) +} diff --git a/apps/server/src/modules/admin/sources.ts b/apps/server/src/modules/admin/sources.ts new file mode 100644 index 0000000..3bdd3d3 --- /dev/null +++ b/apps/server/src/modules/admin/sources.ts @@ -0,0 +1,21 @@ +import { gte, sql } from 'drizzle-orm' +import { db } from '../../db/client.js' +import { notifications } from '../../db/schema.js' + +const NOISY_THRESHOLD_PER_DAY = 200 + +export async function listSourceVolumes() { + const last24h = new Date(Date.now() - 24 * 60 * 60 * 1000) + + const rows = await db + .select({ + source: notifications.source, + last24h: sql`count(*)::int`, + }) + .from(notifications) + .where(gte(notifications.createdAt, last24h)) + .groupBy(notifications.source) + .orderBy(sql`count(*) desc`) + + return rows.map((row) => ({ ...row, noisy: row.last24h > NOISY_THRESHOLD_PER_DAY })) +} diff --git a/apps/server/src/modules/admin/stats.ts b/apps/server/src/modules/admin/stats.ts new file mode 100644 index 0000000..8aec1c7 --- /dev/null +++ b/apps/server/src/modules/admin/stats.ts @@ -0,0 +1,138 @@ +import { desc, eq, gte, sql } from 'drizzle-orm' +import { db } from '../../db/client.js' +import { + auditLog, + notifications, + pushDeliveryAttempts, + pushSubscriptions, + topics, + users, +} from '../../db/schema.js' +import { getTopicBySlug } from '../topics/repository.js' + +const FAILURE_STATUSES = sql`('failed', 'expired', 'invalid_subscription')` + +export async function getDashboardStats() { + const now = new Date() + const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + const last24h = new Date(now.getTime() - 24 * 60 * 60 * 1000) + + const [[notificationCounts], [pushCounts], [userCount], [topicCount], [deviceCount]] = + await Promise.all([ + db + .select({ + today: sql`count(*) filter (where ${notifications.createdAt} >= ${startOfToday})::int`, + last24h: sql`count(*) filter (where ${notifications.createdAt} >= ${last24h})::int`, + }) + .from(notifications), + db + .select({ + attempts: sql`count(*) filter (where ${pushDeliveryAttempts.attemptedAt} >= ${last24h})::int`, + failures: sql`count(*) filter (where ${pushDeliveryAttempts.attemptedAt} >= ${last24h} and ${pushDeliveryAttempts.status} in ${FAILURE_STATUSES})::int`, + }) + .from(pushDeliveryAttempts), + db.select({ c: sql`count(*)::int` }).from(users), + db.select({ c: sql`count(*)::int` }).from(topics), + db + .select({ c: sql`count(*)::int` }) + .from(pushSubscriptions) + .where(sql`${pushSubscriptions.disabledAt} is null`), + ]) + + const topTopics = await db + .select({ + slug: topics.slug, + displayName: topics.displayName, + total: sql`count(${notifications.id})::int`, + }) + .from(notifications) + .innerJoin(topics, eq(topics.id, notifications.topicId)) + .groupBy(topics.id, topics.slug, topics.displayName) + .orderBy(desc(sql`count(${notifications.id})`)) + .limit(5) + + const noisiestSources = await db + .select({ + source: notifications.source, + total: sql`count(*)::int`, + }) + .from(notifications) + .where(gte(notifications.createdAt, last24h)) + .groupBy(notifications.source) + .orderBy(desc(sql`count(*)`)) + .limit(5) + + const recentAuditEvents = await db + .select() + .from(auditLog) + .orderBy(desc(auditLog.createdAt)) + .limit(10) + + // A complete 14-day series including zero-count days (so a bar chart + // never has silent gaps) needs generate_series, which isn't expressible + // through the query builder — raw SQL via db.execute() is the + // straightforward option here. + const dailyResult = await db.execute<{ day: string; count: number }>(sql` + SELECT to_char(d::date, 'YYYY-MM-DD') AS day, count(n.id)::int AS count + FROM generate_series((current_date - interval '13 days')::date, current_date::date, interval '1 day') AS d + LEFT JOIN ${notifications} AS n ON date_trunc('day', n.created_at) = d + GROUP BY d + ORDER BY d + `) + const notificationsByDay = dailyResult.rows + + return { + notificationsToday: notificationCounts?.today ?? 0, + notificationsLast24h: notificationCounts?.last24h ?? 0, + pushAttemptsLast24h: pushCounts?.attempts ?? 0, + pushFailuresLast24h: pushCounts?.failures ?? 0, + activeDevices: deviceCount?.c ?? 0, + userCount: userCount?.c ?? 0, + topicCount: topicCount?.c ?? 0, + topTopics, + notificationsByDay, + // "unusually noisy" is a fixed, simple threshold rather than + // statistical anomaly detection — matches the "keep it simple" + // principle in docs/phase1/00-README.md. Tune via NOISY_SOURCE_THRESHOLD + // if it doesn't fit real usage. + noisiestSources: noisiestSources.map((s) => ({ ...s, noisy: s.total > 200 })), + recentAuditEvents, + } +} + +export interface TopicStats { + dailyCounts: { day: string; count: number }[] + topMessages: { message: string; total: number }[] +} + +// Grouping by the exact message text is a simple heuristic that happens to +// work well for templated senders (Home Assistant etc.) where the same +// event always produces byte-identical text — e.g. "Ytterdörren är öppen, +// glöm inte stänga 🚪" naturally becomes its own bucket, separate from +// "...är stängd". One-off messages (AI-generated descriptions, etc.) just +// end up as singleton buckets, which the `limit` below keeps out of the way. +export async function getTopicStats(slug: string): Promise { + const topic = await getTopicBySlug(slug) + if (!topic) return null + + const dailyResult = await db.execute<{ day: string; count: number }>(sql` + SELECT to_char(d::date, 'YYYY-MM-DD') AS day, count(n.id)::int AS count + FROM generate_series((current_date - interval '13 days')::date, current_date::date, interval '1 day') AS d + LEFT JOIN ${notifications} AS n ON date_trunc('day', n.created_at) = d AND n.topic_id = ${topic.id} + GROUP BY d + ORDER BY d + `) + + const topMessages = await db + .select({ + message: notifications.message, + total: sql`count(*)::int`, + }) + .from(notifications) + .where(eq(notifications.topicId, topic.id)) + .groupBy(notifications.message) + .orderBy(desc(sql`count(*)`)) + .limit(15) + + return { dailyCounts: dailyResult.rows, topMessages } +} diff --git a/apps/server/src/modules/admin/system-settings.ts b/apps/server/src/modules/admin/system-settings.ts new file mode 100644 index 0000000..5fadc63 --- /dev/null +++ b/apps/server/src/modules/admin/system-settings.ts @@ -0,0 +1,52 @@ +import { config } from '../../config.js' +import { getSetting, setSetting } from '../../db/settings.js' + +const SYSTEM_SETTINGS_KEY = 'system_config' + +export interface QuietHoursSettings { + enabled: boolean + start: string // "HH:MM", local to `timezone` + end: string // "HH:MM" — end < start means the window wraps past midnight + timezone: string // IANA name, e.g. "Europe/Stockholm" + minPriority: number // during the window, only notifications at/above this priority still push +} + +export interface StoredSystemSettings { + retentionDefaultDays?: number + vapidSubject?: string + quietHours?: QuietHoursSettings +} + +export const DEFAULT_QUIET_HOURS: QuietHoursSettings = { + enabled: false, + start: '23:00', + end: '07:00', + timezone: 'UTC', + minPriority: 4, +} + +// Admin-UI-editable overrides for RETENTION_DEFAULT_DAYS / VAPID_SUBJECT — +// same DB-first, env-fallback pattern as modules/auth/oidc-settings.ts, so +// these can be tuned without an env file edit + restart. +export async function getStoredSystemSettings(): Promise { + return getSetting(SYSTEM_SETTINGS_KEY) +} + +export async function setStoredSystemSettings(settings: StoredSystemSettings): Promise { + await setSetting(SYSTEM_SETTINGS_KEY, settings) +} + +export async function resolveRetentionDefaultDays(): Promise { + const stored = await getStoredSystemSettings() + return stored?.retentionDefaultDays ?? config.retentionDefaultDays +} + +export async function resolveVapidSubject(): Promise { + const stored = await getStoredSystemSettings() + return stored?.vapidSubject || config.vapidSubject +} + +export async function resolveQuietHours(): Promise { + const stored = await getStoredSystemSettings() + return stored?.quietHours ?? DEFAULT_QUIET_HOURS +} diff --git a/apps/server/src/modules/admin/users.ts b/apps/server/src/modules/admin/users.ts new file mode 100644 index 0000000..a0d2fbe --- /dev/null +++ b/apps/server/src/modules/admin/users.ts @@ -0,0 +1,12 @@ +import { desc, eq } from 'drizzle-orm' +import { db } from '../../db/client.js' +import { users } from '../../db/schema.js' + +export async function listAllUsers() { + return db.select().from(users).orderBy(desc(users.createdAt)) +} + +export async function setUserRole(id: string, role: 'admin' | 'user') { + const [updated] = await db.update(users).set({ role }).where(eq(users.id, id)).returning() + return updated ?? null +} diff --git a/apps/server/src/modules/audit/log.ts b/apps/server/src/modules/audit/log.ts new file mode 100644 index 0000000..d0ae67f --- /dev/null +++ b/apps/server/src/modules/audit/log.ts @@ -0,0 +1,28 @@ +import { db } from '../../db/client.js' +import { auditLog } from '../../db/schema.js' + +export interface WriteAuditLogInput { + actorUserId?: string | null + actorTokenId?: string | null + action: string + targetType?: string | null + targetId?: string | null + metadata?: Record | null + ipAddress?: string | null +} + +// Deliberately not used for every request — see docs/phase1/02-threat-model.md +// ("Retention / data minimization"): the audit log stays meaningful by only +// recording security-relevant events (auth, token/topic management, +// rate-limit rejections), not routine reads. +export async function writeAuditLog(input: WriteAuditLogInput): Promise { + await db.insert(auditLog).values({ + actorUserId: input.actorUserId ?? null, + actorTokenId: input.actorTokenId ?? null, + action: input.action, + targetType: input.targetType ?? null, + targetId: input.targetId ?? null, + metadata: input.metadata ?? null, + ipAddress: input.ipAddress ?? null, + }) +} diff --git a/apps/server/src/modules/auth/csrf.integration.test.ts b/apps/server/src/modules/auth/csrf.integration.test.ts new file mode 100644 index 0000000..fcf204f --- /dev/null +++ b/apps/server/src/modules/auth/csrf.integration.test.ts @@ -0,0 +1,66 @@ +import type { FastifyInstance } from 'fastify' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { pool } from '../../db/client.js' +import { buildWebApp } from '../../listeners/web.js' + +// Integration test — boots the real web app (needs a migrated Postgres for +// session-secret bootstrap). Run with: +// docker compose -f docker/docker-compose.yml exec notizarr \ +// pnpm --filter @notizarr/server test +describe('CSRF guard', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await buildWebApp() + await app.ready() + }) + + afterAll(async () => { + await app.close() + await pool.end() + }) + + it('rejects a mutating request without the CSRF header', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/admin/topics', + payload: { slug: 'csrf-test', displayName: 'CSRF Test' }, + }) + expect(res.statusCode).toBe(403) + expect(res.json().error).toBe('csrf_check_failed') + }) + + it('allows a GET request through without the CSRF header', async () => { + const res = await app.inject({ method: 'GET', url: '/health' }) + expect(res.statusCode).toBe(200) + }) + + it('rejects a cross-origin mutating request even with the CSRF header', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/admin/topics', + headers: { 'x-requested-with': 'notizarr', origin: 'http://evil.example', host: 'notizarr.local' }, + payload: { slug: 'csrf-test-2', displayName: 'CSRF Test 2' }, + }) + expect(res.statusCode).toBe(403) + expect(res.json().message).toBe('origin mismatch') + }) + + it('allows a same-origin mutating request regardless of which hostname is used', async () => { + // Regression test: the check must compare Origin against the request's + // own Host header, not a fixed configured URL — this server is + // legitimately reachable at more than one hostname (LAN IP, public + // domain via Pangolin). + const res = await app.inject({ + method: 'POST', + url: '/api/v1/admin/topics', + headers: { + 'x-requested-with': 'notizarr', + origin: 'http://192.168.1.50:18080', + host: '192.168.1.50:18080', + }, + payload: { slug: 'csrf-test-3', displayName: 'CSRF Test 3' }, + }) + expect(res.statusCode).not.toBe(403) + }) +}) diff --git a/apps/server/src/modules/auth/csrf.ts b/apps/server/src/modules/auth/csrf.ts new file mode 100644 index 0000000..dafc6ac --- /dev/null +++ b/apps/server/src/modules/auth/csrf.ts @@ -0,0 +1,50 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' + +const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']) +const REQUIRED_HEADER = 'x-requested-with' +const REQUIRED_HEADER_VALUE = 'notizarr' + +// Origin-check + custom-header CSRF defense for cookie-authenticated +// mutations on the web listener — see docs/phase1/02-threat-model.md, +// "Session hijacking / CSRF". A cross-site
POST can't set a custom +// header, and a cross-site fetch/XHR that tries to would fail CORS (no +// Access-Control-Allow-Origin is ever set), so this blocks CSRF without a +// token handshake. Every same-origin fetch the SPA makes for a mutating +// call must send X-Requested-With: notizarr. +// +// The Origin check compares against the request's own Host header, not a +// configured PUBLIC_BASE_URL — this server is legitimately reachable at +// more than one hostname (a LAN IP during setup/testing, the public domain +// through Pangolin in production), and a same-origin check should hold for +// all of them rather than only whichever one happened to be configured. +export function registerCsrfGuard(app: FastifyInstance): void { + app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => { + if (!req.url.startsWith('/api/')) return + if (!MUTATING_METHODS.has(req.method)) return + + const header = req.headers[REQUIRED_HEADER] + if (header !== REQUIRED_HEADER_VALUE) { + reply.code(403) + return reply.send({ + error: 'csrf_check_failed', + message: `missing or invalid ${REQUIRED_HEADER} header`, + }) + } + + const origin = req.headers.origin + if (origin) { + let originHost: string + try { + originHost = new URL(origin).host + } catch { + reply.code(403) + return reply.send({ error: 'csrf_check_failed', message: 'invalid origin header' }) + } + + if (originHost !== req.headers.host) { + reply.code(403) + return reply.send({ error: 'csrf_check_failed', message: 'origin mismatch' }) + } + } + }) +} diff --git a/apps/server/src/modules/auth/disabled.ts b/apps/server/src/modules/auth/disabled.ts new file mode 100644 index 0000000..ba31766 --- /dev/null +++ b/apps/server/src/modules/auth/disabled.ts @@ -0,0 +1,13 @@ +import { findOrCreateUser } from './users.js' + +// AUTH_MODE=disabled: every request is treated as one fixed local admin. +// Refused outright in production — see the startup check in src/index.ts — +// so this can never be the auth story for anything reachable off localhost. +export async function resolveDisabledModeUser() { + return findOrCreateUser({ + authIssuer: null, + authSubject: 'local-disabled-mode', + email: 'admin@localhost', + displayName: 'Local Admin', + }) +} diff --git a/apps/server/src/modules/auth/guards.ts b/apps/server/src/modules/auth/guards.ts new file mode 100644 index 0000000..7e86ac9 --- /dev/null +++ b/apps/server/src/modules/auth/guards.ts @@ -0,0 +1,43 @@ +import type { FastifyReply, FastifyRequest } from 'fastify' +import { config } from '../../config.js' +import type { User } from '../../db/schema.js' +import { resolveDisabledModeUser } from './disabled.js' +import { resolveProxyUser } from './proxy.js' +import { getSessionUser } from './session.js' + +declare module 'fastify' { + interface FastifyRequest { + currentUser?: User + } +} + +export async function resolveCurrentUser(req: FastifyRequest): Promise { + switch (config.authMode) { + case 'disabled': + return resolveDisabledModeUser() + case 'proxy': + return resolveProxyUser(req) + case 'oidc': + return getSessionUser(req) + default: + return null + } +} + +export async function requireAuth(req: FastifyRequest, reply: FastifyReply) { + const user = await resolveCurrentUser(req) + if (!user) { + reply.code(401) + return reply.send({ error: 'unauthorized' }) + } + req.currentUser = user +} + +export async function requireAdmin(req: FastifyRequest, reply: FastifyReply) { + await requireAuth(req, reply) + if (reply.sent) return + if (req.currentUser?.role !== 'admin') { + reply.code(403) + return reply.send({ error: 'forbidden', message: 'admin role required' }) + } +} diff --git a/apps/server/src/modules/auth/oidc-settings.ts b/apps/server/src/modules/auth/oidc-settings.ts new file mode 100644 index 0000000..4a506c8 --- /dev/null +++ b/apps/server/src/modules/auth/oidc-settings.ts @@ -0,0 +1,24 @@ +import { getSetting, setSetting } from '../../db/settings.js' + +const OIDC_SETTINGS_KEY = 'oidc_config' + +export interface StoredOidcSettings { + issuerUrl: string + clientId: string + clientSecret: string + redirectUrl: string + scopes: string +} + +// Admin-UI-editable override for the OIDC_ISSUER_URL/CLIENT_ID/CLIENT_SECRET/ +// REDIRECT_URL/SCOPES env vars — stored here rather than requiring a config +// file edit + restart every time a provider detail changes. AUTH_MODE +// itself stays an env var on purpose (it changes which routes/listeners +// exist at boot, so switching it always needs a restart regardless). +export async function getStoredOidcSettings(): Promise { + return getSetting(OIDC_SETTINGS_KEY) +} + +export async function setStoredOidcSettings(settings: StoredOidcSettings): Promise { + await setSetting(OIDC_SETTINGS_KEY, settings) +} diff --git a/apps/server/src/modules/auth/oidc.ts b/apps/server/src/modules/auth/oidc.ts new file mode 100644 index 0000000..f1d9ef3 --- /dev/null +++ b/apps/server/src/modules/auth/oidc.ts @@ -0,0 +1,68 @@ +import { generators, Issuer, type Client } from 'openid-client' +import { config } from '../../config.js' +import { getStoredOidcSettings } from './oidc-settings.js' + +// NOTE: this module has not been verified end-to-end against a real +// identity provider — it's implemented to the openid-client v5 API and the +// flow described in docs/phase1/02-threat-model.md (Authorization Code + +// PKCE, state/nonce/iss/aud/signature validated by client.callback()), but +// treat it as needing a first real test pass once a provider is configured. + +let clientPromise: Promise | null = null + +// Called after Admin > Auth saves new settings, so the next login attempt +// picks them up without needing a process restart. +export function invalidateOidcClient(): void { + clientPromise = null +} + +export interface EffectiveOidcConfig { + issuerUrl: string + clientId: string + clientSecret: string + redirectUrl: string + scopes: string +} + +// DB-stored settings (Admin > Auth) take precedence over env vars, so an +// admin can configure/change the OIDC provider without touching the +// deployment's .env. Falls back to env vars so a fresh install still works +// with just OIDC_ISSUER_URL etc. set. +export async function resolveOidcConfig(): Promise { + const stored = await getStoredOidcSettings() + return { + issuerUrl: stored?.issuerUrl || config.oidc.issuerUrl, + clientId: stored?.clientId || config.oidc.clientId, + clientSecret: stored?.clientSecret || config.oidc.clientSecret, + redirectUrl: stored?.redirectUrl || config.oidc.redirectUrl, + scopes: stored?.scopes || config.oidc.scopes, + } +} + +export async function getClient(): Promise { + if (!clientPromise) { + clientPromise = (async () => { + const cfg = await resolveOidcConfig() + if (!cfg.issuerUrl) { + throw new Error( + 'OIDC issuer URL is not configured — set it in Admin > Auth, or OIDC_ISSUER_URL.', + ) + } + const issuer = await Issuer.discover(cfg.issuerUrl) + return new issuer.Client({ + client_id: cfg.clientId, + client_secret: cfg.clientSecret, + redirect_uris: [cfg.redirectUrl], + response_types: ['code'], + }) + })() + // A discovery/config error shouldn't get stuck cached — clear it so the + // next login attempt (or the next settings save) can retry cleanly. + clientPromise.catch(() => { + clientPromise = null + }) + } + return clientPromise +} + +export { generators } diff --git a/apps/server/src/modules/auth/proxy.ts b/apps/server/src/modules/auth/proxy.ts new file mode 100644 index 0000000..3f7a1e6 --- /dev/null +++ b/apps/server/src/modules/auth/proxy.ts @@ -0,0 +1,46 @@ +import type { FastifyRequest } from 'fastify' +import { config } from '../../config.js' +import { isIpInAnyCidr } from '../../lib/cidr.js' +import { findOrCreateUser } from './users.js' + +export interface ProxyIdentity { + email: string + displayName: string +} + +// Only trusted when the request's direct TCP peer is inside +// TRUSTED_PROXY_CIDRS — deliberately the raw socket address, not +// X-Forwarded-For, so a client can't just set that header itself. See +// docs/phase1/02-threat-model.md, "Spoofed identity via proxy headers". +export function readTrustedProxyIdentity(req: FastifyRequest): ProxyIdentity | null { + if (!config.authProxy.emailHeader) return null + + const peerIp = req.socket.remoteAddress + if (!peerIp || !isIpInAnyCidr(peerIp, config.trustedProxyCidrs)) { + return null + } + + const email = req.headers[config.authProxy.emailHeader] as string | undefined + if (!email) return null + + const userHeaderValue = config.authProxy.userHeader + ? (req.headers[config.authProxy.userHeader] as string | undefined) + : undefined + + return { + email, + displayName: userHeaderValue ?? email, + } +} + +export async function resolveProxyUser(req: FastifyRequest) { + const identity = readTrustedProxyIdentity(req) + if (!identity) return null + + return findOrCreateUser({ + authIssuer: null, + authSubject: `proxy:${identity.email}`, + email: identity.email, + displayName: identity.displayName, + }) +} diff --git a/apps/server/src/modules/auth/routes.ts b/apps/server/src/modules/auth/routes.ts new file mode 100644 index 0000000..86fb20f --- /dev/null +++ b/apps/server/src/modules/auth/routes.ts @@ -0,0 +1,96 @@ +import type { FastifyInstance } from 'fastify' +import { config } from '../../config.js' +import type { User } from '../../db/schema.js' +import { writeAuditLog } from '../audit/log.js' +import { resolveCurrentUser } from './guards.js' +import { generators, getClient, resolveOidcConfig } from './oidc.js' +import { clearSession, setSessionUserId } from './session.js' +import { findOrCreateUser } from './users.js' + +function toPublicUser(user: User) { + return { id: user.id, email: user.email, displayName: user.displayName, role: user.role } +} + +export async function registerAuthRoutes(app: FastifyInstance) { + app.get('/api/v1/session', async (req) => { + const user = await resolveCurrentUser(req) + return { user: user ? toPublicUser(user) : null, authMode: config.authMode } + }) + + app.post('/api/v1/auth/logout', async (req) => { + const user = await resolveCurrentUser(req) + clearSession(req) + if (user) { + await writeAuditLog({ actorUserId: user.id, action: 'auth.logout', ipAddress: req.ip }) + } + return { status: 'ok' } + }) + + if (config.authMode !== 'oidc') return + + app.get('/api/v1/auth/oidc/login', async (req, reply) => { + const oidcClient = await getClient() + const oidcConfig = await resolveOidcConfig() + const codeVerifier = generators.codeVerifier() + const codeChallenge = generators.codeChallenge(codeVerifier) + const state = generators.state() + const nonce = generators.nonce() + + req.session.set('oidcCodeVerifier', codeVerifier) + req.session.set('oidcState', state) + req.session.set('oidcNonce', nonce) + + const authUrl = oidcClient.authorizationUrl({ + scope: oidcConfig.scopes, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + state, + nonce, + }) + reply.redirect(authUrl) + }) + + app.get('/api/v1/auth/oidc/callback', async (req, reply) => { + const oidcClient = await getClient() + const oidcConfig = await resolveOidcConfig() + const params = oidcClient.callbackParams(req.raw) + + const codeVerifier = req.session.get('oidcCodeVerifier') as string | undefined + const state = req.session.get('oidcState') as string | undefined + const nonce = req.session.get('oidcNonce') as string | undefined + + if (!codeVerifier || !state || !nonce) { + reply.code(400) + return { error: 'invalid_oidc_state', message: 'login flow expired or was not started here' } + } + + // client.callback() validates state, nonce, issuer, audience, and the + // ID token signature — the full checklist from + // docs/phase1/02-threat-model.md, "Forged/leaked OIDC tokens". + const tokenSet = await oidcClient.callback(oidcConfig.redirectUrl, params, { + code_verifier: codeVerifier, + state, + nonce, + }) + + const claims = tokenSet.claims() + const email = typeof claims.email === 'string' ? claims.email : undefined + const name = typeof claims.name === 'string' ? claims.name : undefined + + const user = await findOrCreateUser({ + authIssuer: claims.iss, + authSubject: claims.sub, + email: email ?? `${claims.sub}@${new URL(claims.iss).host}`, + displayName: name ?? email ?? claims.sub, + }) + + setSessionUserId(req, user.id) + req.session.set('oidcCodeVerifier', undefined) + req.session.set('oidcState', undefined) + req.session.set('oidcNonce', undefined) + + await writeAuditLog({ actorUserId: user.id, action: 'auth.login', ipAddress: req.ip }) + + reply.redirect(config.publicBaseUrl) + }) +} diff --git a/apps/server/src/modules/auth/session.ts b/apps/server/src/modules/auth/session.ts new file mode 100644 index 0000000..cba9e57 --- /dev/null +++ b/apps/server/src/modules/auth/session.ts @@ -0,0 +1,101 @@ +import { randomBytes } from 'node:crypto' +import fastifyCookie from '@fastify/cookie' +import type { FastifyInstance, FastifyRequest } from 'fastify' +import { config } from '../../config.js' +import { getOrCreateSetting } from '../../db/settings.js' +import { decryptJson, encryptJson } from '../../lib/crypto-session.js' +import { getUserById } from './users.js' + +const SESSION_SECRET_KEY = 'session_secret' +const SESSION_COOKIE_NAME = 'notizarr_session' +const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 30 // 30 days + +class Session { + private data: Record + dirty = false + cleared = false + + constructor(initial: Record) { + this.data = initial + } + + get(key: string): T | undefined { + return this.data[key] as T | undefined + } + + set(key: string, value: unknown): void { + if (value === undefined) { + delete this.data[key] + } else { + this.data[key] = value + } + this.dirty = true + } + + delete(): void { + this.cleared = true + } + + serialize(): Record { + return this.data + } +} + +declare module 'fastify' { + interface FastifyRequest { + session: Session + } +} + +// The session secret is generated once on first boot and persisted in +// Postgres (same durability pattern as the VAPID keypair in Phase 7) so it +// survives container recreation — a stable secret is required for existing +// session cookies to keep validating across restarts. +export async function registerSession(app: FastifyInstance): Promise { + const secretHex = await getOrCreateSetting(SESSION_SECRET_KEY, () => + randomBytes(32).toString('hex'), + ) + const key = Buffer.from(secretHex, 'hex') + + await app.register(fastifyCookie) + + app.addHook('onRequest', async (req: FastifyRequest) => { + const raw = req.cookies[SESSION_COOKIE_NAME] + const initial = raw ? (decryptJson>(key, raw) ?? {}) : {} + req.session = new Session(initial) + }) + + app.addHook('onSend', async (req: FastifyRequest, reply, payload) => { + if (!req.session) return payload + + if (req.session.cleared) { + reply.clearCookie(SESSION_COOKIE_NAME, { path: '/' }) + } else if (req.session.dirty) { + const value = encryptJson(key, req.session.serialize()) + reply.setCookie(SESSION_COOKIE_NAME, value, { + path: '/', + httpOnly: true, + secure: config.isProduction, + sameSite: 'lax', + maxAge: SESSION_MAX_AGE_SECONDS, + }) + } + return payload + }) +} + +export function setSessionUserId(req: FastifyRequest, userId: string): void { + req.session.set('userId', userId) +} + +export function clearSession(req: FastifyRequest): void { + req.session.delete() +} + +// AUTH_MODE=disabled and AUTH_MODE=proxy don't use this — see +// modules/auth/guards.ts, which only calls this for AUTH_MODE=oidc. +export async function getSessionUser(req: FastifyRequest) { + const userId = req.session.get('userId') + if (!userId) return null + return getUserById(userId) +} diff --git a/apps/server/src/modules/auth/users.ts b/apps/server/src/modules/auth/users.ts new file mode 100644 index 0000000..3d60b3a --- /dev/null +++ b/apps/server/src/modules/auth/users.ts @@ -0,0 +1,66 @@ +import { and, eq, isNull, sql } from 'drizzle-orm' +import { db } from '../../db/client.js' +import { users } from '../../db/schema.js' + +export async function getUserById(id: string) { + const [user] = await db.select().from(users).where(eq(users.id, id)).limit(1) + return user ?? null +} + +async function findByIssuerSubject(authIssuer: string | null, authSubject: string) { + const issuerClause = authIssuer === null ? isNull(users.authIssuer) : eq(users.authIssuer, authIssuer) + const [user] = await db + .select() + .from(users) + .where(and(issuerClause, eq(users.authSubject, authSubject))) + .limit(1) + return user ?? null +} + +async function isFirstUser(): Promise { + const [row] = await db.select({ count: sql`count(*)::int` }).from(users) + return (row?.count ?? 0) === 0 +} + +export interface UpsertIdentityInput { + authIssuer: string | null + authSubject: string + email: string + displayName: string + avatarUrl?: string | null +} + +// Shared by both OIDC callback and proxy-header auth: find the user for +// this identity, or create one. The very first user ever created is +// auto-promoted to admin (docs/phase1/02-threat-model.md, "bootstrap +// admin") so there's no chicken-and-egg problem on a fresh install. +export async function findOrCreateUser(input: UpsertIdentityInput) { + const existing = await findByIssuerSubject(input.authIssuer, input.authSubject) + if (existing) { + const [updated] = await db + .update(users) + .set({ lastLoginAt: new Date(), displayName: input.displayName, avatarUrl: input.avatarUrl ?? null }) + .where(eq(users.id, existing.id)) + .returning() + // Updating a row just found by its own id always returns exactly one row. + return updated! + } + + const shouldBeAdmin = await isFirstUser() + + const [created] = await db + .insert(users) + .values({ + email: input.email, + displayName: input.displayName, + avatarUrl: input.avatarUrl ?? null, + authIssuer: input.authIssuer, + authSubject: input.authSubject, + role: shouldBeAdmin ? 'admin' : 'user', + lastLoginAt: new Date(), + }) + .returning() + + // A single-row insert always returns exactly one row. + return created! +} diff --git a/apps/server/src/modules/notifications/events.ts b/apps/server/src/modules/notifications/events.ts new file mode 100644 index 0000000..ac27096 --- /dev/null +++ b/apps/server/src/modules/notifications/events.ts @@ -0,0 +1,17 @@ +import { EventEmitter } from 'node:events' +import type { Notification } from '../../db/schema.js' + +// In-process pub/sub for the SSE stream (docs/phase1/01-architecture.md — +// single instance, no Redis). Every connected /api/v1/stream client adds a +// listener here; setMaxListeners(0) removes Node's default 10-listener +// warning cap, since "many browser tabs open" is the normal case. +export const notificationEvents = new EventEmitter() +notificationEvents.setMaxListeners(0) + +export function emitNotificationCreated(notification: Notification): void { + notificationEvents.emit('notification.created', notification) +} + +export function emitNotificationUpdated(notification: Notification): void { + notificationEvents.emit('notification.updated', notification) +} diff --git a/apps/server/src/modules/notifications/ingest-routes.ts b/apps/server/src/modules/notifications/ingest-routes.ts new file mode 100644 index 0000000..4a2d5f9 --- /dev/null +++ b/apps/server/src/modules/notifications/ingest-routes.ts @@ -0,0 +1,104 @@ +import type { FastifyInstance } from 'fastify' +import { requireApiToken } from '../tokens/ingest-auth.js' +import { publishAndRespond } from './publish.js' + +const publishBodySchema = { + type: 'object', + properties: { + title: { type: 'string', minLength: 1, maxLength: 500 }, + message: { type: 'string', minLength: 1, maxLength: 10000 }, + source: { type: 'string' }, + url: { type: 'string' }, + image_url: { type: 'string' }, + deduplication_key: { type: 'string' }, + // ntfy-style scale (1-5, or min/low/default/high/urgent) — priority 1 + // ("min") is stored and shown in-app as usual but skipped during push + // fan-out, see modules/push/fanout.ts. Accepts a JSON number too since + // an unquoted Jinja template value in Home Assistant's payload renders + // as one; coerced to a string below either way, same as ntfy-routes.ts. + priority: { type: ['string', 'number'] }, + }, +} as const + +interface PublishBody { + title: string + message: string + source?: string + url?: string + image_url?: string + deduplication_key?: string + priority?: string | number +} + +// Ingestion listener (:8081) — bearer-token auth via requireApiToken. +// docs/phase1/04-api-design.md's two publish shapes: +// POST /api/v1/notifications (topic in the body) +// POST /api/v1/topics/:slug (topic in the path) +// ntfy-compat and webhook variants live in ntfy-routes.ts / webhook-routes.ts. +export async function registerNotificationIngestRoutes(app: FastifyInstance) { + app.post( + '/api/v1/notifications', + { + preHandler: requireApiToken, + schema: { + body: { + ...publishBodySchema, + required: ['topic', 'title', 'message'], + properties: { topic: { type: 'string', minLength: 1 }, ...publishBodySchema.properties }, + }, + }, + }, + async (req, reply) => { + const body = req.body as PublishBody & { topic: string } + const tokenAllowedSource = req.apiToken?.allowedSource ?? null + + if (tokenAllowedSource && body.source && body.source !== tokenAllowedSource) { + reply.code(403) + return { error: 'forbidden', message: 'source does not match token scope' } + } + + await publishAndRespond(req, reply, { + topicSlug: body.topic, + source: body.source ?? tokenAllowedSource ?? 'unknown', + title: body.title, + message: body.message, + externalUrl: body.url ?? null, + imageUrl: body.image_url ?? null, + deduplicationKey: body.deduplication_key ?? null, + rawPriority: body.priority === undefined ? null : String(body.priority), + }) + }, + ) + + app.post( + '/api/v1/topics/:slug', + { + preHandler: requireApiToken, + schema: { + params: { type: 'object', properties: { slug: { type: 'string' } }, required: ['slug'] }, + body: { ...publishBodySchema, required: ['title', 'message'] }, + }, + }, + async (req, reply) => { + const { slug } = req.params as { slug: string } + const body = req.body as PublishBody + const tokenAllowedSource = req.apiToken?.allowedSource ?? null + + if (tokenAllowedSource && body.source && body.source !== tokenAllowedSource) { + reply.code(403) + return { error: 'forbidden', message: 'source does not match token scope' } + } + + await publishAndRespond(req, reply, { + topicSlug: slug, + source: body.source ?? tokenAllowedSource ?? 'unknown', + title: body.title, + message: body.message, + externalUrl: body.url ?? null, + imageUrl: body.image_url ?? null, + deduplicationKey: body.deduplication_key ?? null, + rawPriority: body.priority === undefined ? null : String(body.priority), + }) + }, + ) +} diff --git a/apps/server/src/modules/notifications/ntfy-compat.integration.test.ts b/apps/server/src/modules/notifications/ntfy-compat.integration.test.ts new file mode 100644 index 0000000..722eb02 --- /dev/null +++ b/apps/server/src/modules/notifications/ntfy-compat.integration.test.ts @@ -0,0 +1,128 @@ +import { randomUUID } from 'node:crypto' +import type { FastifyInstance } from 'fastify' +import { eq } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { db, pool } from '../../db/client.js' +import { runMigrations } from '../../db/migrate.js' +import { apiTokens, notifications, topics, users } from '../../db/schema.js' +import { buildIngestApp } from '../../listeners/ingest.js' +import { createToken } from '../tokens/repository.js' + +// Integration test — same requirements/run command as +// modules/tokens/repository.integration.test.ts. Verifies +// docs/migration-ntfy.md's claim that header-based, JSON-body-based, and +// query-param-based ntfy-style requests all produce equivalent stored +// notifications through the same POST /:topic endpoint. +describe('ntfy-compat endpoint', () => { + let app: FastifyInstance + const testSlug = `test-ntfy-${randomUUID()}` + const testAuthSubject = `test-ntfy-user-${randomUUID()}` + let userId: string + let topicId: string + let tokenId: string + let plaintext: string + + // Within config.ts's default INGESTION_ALLOWED_CIDRS (10.0.0.0/8) so the + // source-address check in requireApiToken doesn't reject these. + const remoteAddress = '10.0.0.5' + + beforeAll(async () => { + await runMigrations() + app = await buildIngestApp() + await app.ready() + + const [user] = await db + .insert(users) + .values({ + email: `${testAuthSubject}@example.test`, + displayName: 'Test User', + authSubject: testAuthSubject, + role: 'admin', + }) + .returning() + userId = user!.id + + const [topic] = await db + .insert(topics) + .values({ slug: testSlug, displayName: 'Test Topic', createdBy: userId }) + .returning() + topicId = topic!.id + + const created = await createToken({ name: 'ntfy-compat test token', createdBy: userId }) + plaintext = created.plaintext + tokenId = created.token.id + }) + + afterAll(async () => { + await db.delete(notifications).where(eq(notifications.topicId, topicId)) + await db.delete(apiTokens).where(eq(apiTokens.id, tokenId)) + await db.delete(topics).where(eq(topics.id, topicId)) + await db.delete(users).where(eq(users.id, userId)) + await app.close() + await pool.end() + }) + + // The endpoint doesn't take a client-supplied dedup key, so tests + // identify their own row by a unique marker embedded in the message. + async function findByMessageMarker(marker: string) { + const rows = await db.select().from(notifications).where(eq(notifications.topicId, topicId)) + return rows.find((r) => r.message.includes(marker)) ?? null + } + + it('accepts a header-based publish (title/priority headers, plain-text body)', async () => { + const marker = randomUUID() + const res = await app.inject({ + method: 'POST', + url: `/${testSlug}`, + remoteAddress, + headers: { authorization: `Bearer ${plaintext}`, title: 'Header title', priority: 'min' }, + payload: `plain text body ${marker}`, + }) + + expect(res.statusCode).toBe(201) + const row = await findByMessageMarker(marker) + expect(row?.title).toBe('Header title') + expect(row?.rawPriority).toBe('min') + }) + + it('accepts a JSON-body publish with the same fields', async () => { + const marker = randomUUID() + const res = await app.inject({ + method: 'POST', + url: `/${testSlug}`, + remoteAddress, + headers: { authorization: `Bearer ${plaintext}`, 'content-type': 'application/json' }, + payload: JSON.stringify({ title: 'JSON title', message: `json body ${marker}`, priority: 'high' }), + }) + + expect(res.statusCode).toBe(201) + const row = await findByMessageMarker(marker) + expect(row?.title).toBe('JSON title') + expect(row?.rawPriority).toBe('high') + }) + + it('accepts a query-param-based publish', async () => { + const marker = randomUUID() + const res = await app.inject({ + method: 'POST', + url: `/${testSlug}?title=${encodeURIComponent('Query title')}&message=${encodeURIComponent(`query body ${marker}`)}&priority=low`, + remoteAddress, + headers: { authorization: `Bearer ${plaintext}` }, + }) + + expect(res.statusCode).toBe(201) + const row = await findByMessageMarker(marker) + expect(row?.title).toBe('Query title') + expect(row?.rawPriority).toBe('low') + }) + + it('rejects a request without a valid bearer token', async () => { + const res = await app.inject({ + method: 'POST', + url: `/${testSlug}`, + remoteAddress, + payload: 'no auth', + }) + expect(res.statusCode).toBe(401) + }) +}) diff --git a/apps/server/src/modules/notifications/ntfy-routes.ts b/apps/server/src/modules/notifications/ntfy-routes.ts new file mode 100644 index 0000000..2403f4b --- /dev/null +++ b/apps/server/src/modules/notifications/ntfy-routes.ts @@ -0,0 +1,78 @@ +import type { FastifyInstance } from 'fastify' +import { requireApiToken } from '../tokens/ingest-auth.js' +import { publishAndRespond } from './publish.js' + +function headerString(value: string | string[] | undefined): string | undefined { + return Array.isArray(value) ? value[0] : value +} + +// ntfy-compatibility endpoint — docs/phase1/04-api-design.md: eases +// migration from ntfy, not a full protocol clone (no priority-driven +// behavior, no attachment upload/proxying, no emoji tag mapping yet). +// Accepts, in order of precedence (highest first): headers, query +// parameters, JSON body, then a plain-text body as the message. Registered +// as POST /:topic, so it must not collide with any literal path — see the +// routing note in listeners/ingest.ts. +export async function registerNtfyCompatRoutes(app: FastifyInstance) { + app.post('/:topic', { preHandler: requireApiToken }, async (req, reply) => { + const { topic } = req.params as { topic: string } + const headers = req.headers + const query = req.query as Record + + let title: string | undefined + let message: string | undefined + let clickUrl: string | undefined + let attachUrl: string | undefined + let filename: string | undefined + let priority: string | undefined + + if (req.body && typeof req.body === 'object') { + const body = req.body as Record + title = typeof body.title === 'string' ? body.title : undefined + message = typeof body.message === 'string' ? body.message : undefined + clickUrl = typeof body.click === 'string' ? body.click : undefined + attachUrl = typeof body.attach === 'string' ? body.attach : undefined + filename = typeof body.filename === 'string' ? body.filename : undefined + priority = body.priority === undefined ? undefined : String(body.priority) + } else if (typeof req.body === 'string' && req.body.length > 0) { + message = req.body + } + + // Query params (?title=...) are a convenience for clients that can't + // easily set custom headers — applied before headers so headers still + // win if both are somehow present. + title = query.title ?? title + message = query.message ?? message + clickUrl = query.click ?? clickUrl + attachUrl = query.attach ?? attachUrl + filename = query.filename ?? filename + priority = query.priority ?? priority + + title = headerString(headers['title']) ?? title + clickUrl = headerString(headers['click']) ?? clickUrl + attachUrl = headerString(headers['attach']) ?? attachUrl + filename = headerString(headers['filename']) ?? filename + priority = headerString(headers['priority']) ?? priority + + if (!message) { + reply.code(422) + return { + error: 'validation_error', + message: 'a message body (plain text, JSON "message" field, or empty) is required', + } + } + + const tokenAllowedSource = req.apiToken?.allowedSource ?? null + + await publishAndRespond(req, reply, { + topicSlug: topic, + source: tokenAllowedSource ?? 'ntfy-compat', + title: title ?? topic, + message, + externalUrl: clickUrl ?? null, + imageUrl: attachUrl ?? null, + attachmentFilename: filename ?? null, + rawPriority: priority ?? null, + }) + }) +} diff --git a/apps/server/src/modules/notifications/publish.ts b/apps/server/src/modules/notifications/publish.ts new file mode 100644 index 0000000..78ef699 --- /dev/null +++ b/apps/server/src/modules/notifications/publish.ts @@ -0,0 +1,36 @@ +import type { FastifyReply, FastifyRequest } from 'fastify' +import { fanOutPush } from '../push/fanout.js' +import { createNotification, type CreateNotificationInput } from './repository.js' + +// Shared by every publish entry point (canonical, topic-path alias, +// ntfy-compat, webhook) so the topic-scope check and response shape stay +// identical no matter which endpoint a client used. +export async function publishAndRespond( + req: FastifyRequest, + reply: FastifyReply, + input: Omit, +): Promise { + if (req.apiTokenAllowedTopics && !req.apiTokenAllowedTopics.includes(input.topicSlug)) { + reply.code(403) + reply.send({ error: 'forbidden', message: `token is not scoped to topic: ${input.topicSlug}` }) + return + } + + const result = await createNotification({ ...input, senderTokenId: req.apiToken?.id ?? null }) + + if ('error' in result) { + reply.code(422) + reply.send({ error: result.error, message: `unknown topic: ${input.topicSlug}` }) + return + } + + reply.code(201) + reply.send({ id: result.notification.id, status: 'accepted' }) + + // Fire-and-forget: the LAN publisher's response must not wait on push + // delivery. Deduplicated repeats don't re-push, per + // docs/phase1/04-api-design.md's deduplication section. + if (!result.deduplicated) { + void fanOutPush(result.notification, input.topicSlug) + } +} diff --git a/apps/server/src/modules/notifications/repository.integration.test.ts b/apps/server/src/modules/notifications/repository.integration.test.ts new file mode 100644 index 0000000..d84d6dd --- /dev/null +++ b/apps/server/src/modules/notifications/repository.integration.test.ts @@ -0,0 +1,75 @@ +import { randomUUID } from 'node:crypto' +import { eq } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { db, pool } from '../../db/client.js' +import { runMigrations } from '../../db/migrate.js' +import { topics } from '../../db/schema.js' +import { createNotification } from './repository.js' + +// Integration tests — need a real, migrated Postgres. Run with: +// docker compose -f docker/docker-compose.yml exec notizarr \ +// pnpm --filter @notizarr/server test +describe('notification deduplication', () => { + const testSlug = `test-topic-${randomUUID()}` + let topicId: string + + beforeAll(async () => { + await runMigrations() + const [topic] = await db + .insert(topics) + .values({ slug: testSlug, displayName: 'Dedup Test Topic' }) + .returning() + topicId = topic!.id + }) + + afterAll(async () => { + await db.delete(topics).where(eq(topics.id, topicId)) + await pool.end() + }) + + it('increments occurrence_count instead of inserting a duplicate within the window', async () => { + const dedupKey = `dedup-${randomUUID()}` + + const first = await createNotification({ + topicSlug: testSlug, + source: 'test', + title: 'First', + message: 'First message', + deduplicationKey: dedupKey, + }) + if ('error' in first) throw new Error('unexpected error on first insert') + expect(first.deduplicated).toBe(false) + expect(first.notification.occurrenceCount).toBe(1) + + const second = await createNotification({ + topicSlug: testSlug, + source: 'test', + title: 'Second (dup)', + message: 'Second message', + deduplicationKey: dedupKey, + }) + if ('error' in second) throw new Error('unexpected error on second insert') + expect(second.deduplicated).toBe(true) + expect(second.notification.id).toBe(first.notification.id) + expect(second.notification.occurrenceCount).toBe(2) + }) + + it('does not deduplicate notifications with different keys', async () => { + const first = await createNotification({ + topicSlug: testSlug, + source: 'test', + title: 'A', + message: 'A message', + deduplicationKey: `key-a-${randomUUID()}`, + }) + const second = await createNotification({ + topicSlug: testSlug, + source: 'test', + title: 'B', + message: 'B message', + deduplicationKey: `key-b-${randomUUID()}`, + }) + if ('error' in first || 'error' in second) throw new Error('unexpected error') + expect(first.notification.id).not.toBe(second.notification.id) + }) +}) diff --git a/apps/server/src/modules/notifications/repository.ts b/apps/server/src/modules/notifications/repository.ts new file mode 100644 index 0000000..50beac6 --- /dev/null +++ b/apps/server/src/modules/notifications/repository.ts @@ -0,0 +1,278 @@ +import { and, desc, eq, gte, ilike, or, sql } from 'drizzle-orm' +import { db } from '../../db/client.js' +import { notifications, topics, userNotificationState } from '../../db/schema.js' +import { getTopicBySlug } from '../topics/repository.js' +import { emitNotificationCreated, emitNotificationUpdated } from './events.js' + +const DEDUP_WINDOW_MS = 5 * 60 * 1000 + +export interface CreateNotificationInput { + topicSlug: string + source: string + title: string + message: string + externalUrl?: string | null + imageUrl?: string | null + attachmentFilename?: string | null + deduplicationKey?: string | null + rawPriority?: string | null + senderTokenId?: string | null +} + +export type CreateNotificationResult = + | { notification: typeof notifications.$inferSelect; deduplicated: boolean } + | { error: 'topic_not_found' } + +// Rate limiting happens in modules/tokens/ingest-auth.ts, before this is +// ever called — this function's only job is "store it, always" plus the +// deduplication window described in docs/phase1/04-api-design.md. +export async function createNotification( + input: CreateNotificationInput, +): Promise { + const topic = await getTopicBySlug(input.topicSlug) + if (!topic) { + return { error: 'topic_not_found' } + } + + if (input.deduplicationKey) { + const windowStart = new Date(Date.now() - DEDUP_WINDOW_MS) + const [existing] = await db + .select() + .from(notifications) + .where( + and( + eq(notifications.topicId, topic.id), + eq(notifications.deduplicationKey, input.deduplicationKey), + gte(notifications.createdAt, windowStart), + ), + ) + .orderBy(desc(notifications.createdAt)) + .limit(1) + + if (existing) { + const [updated] = await db + .update(notifications) + .set({ occurrenceCount: sql`${notifications.occurrenceCount} + 1`, updatedAt: new Date() }) + .where(eq(notifications.id, existing.id)) + .returning() + + // Updating a row just found by its own id always returns exactly one row. + emitNotificationUpdated(updated!) + return { notification: updated!, deduplicated: true } + } + } + + const [notification] = await db + .insert(notifications) + .values({ + topicId: topic.id, + source: input.source, + title: input.title, + message: input.message, + externalUrl: input.externalUrl ?? null, + imageUrl: input.imageUrl ?? null, + attachmentFilename: input.attachmentFilename ?? null, + deduplicationKey: input.deduplicationKey ?? null, + rawPriority: input.rawPriority ?? null, + senderTokenId: input.senderTokenId ?? null, + }) + .returning() + + // A single-row insert always returns exactly one row. + emitNotificationCreated(notification!) + return { notification: notification!, deduplicated: false } +} + +export interface NotificationWithState { + id: string + topicId: string + topicSlug: string + topicDisplayName: string + source: string + title: string + message: string + externalUrl: string | null + imageUrl: string | null + occurrenceCount: number + rawPriority: string | null + createdAt: Date + updatedAt: Date + read: boolean + starred: boolean +} + +export interface ListNotificationsFilter { + topicSlug?: string + unreadOnly?: boolean + starredOnly?: boolean + q?: string + limit?: number +} + +function toNotificationWithState(row: { + notification: typeof notifications.$inferSelect + state: typeof userNotificationState.$inferSelect | null + topic: { slug: string; displayName: string } +}): NotificationWithState { + return { + ...row.notification, + topicSlug: row.topic.slug, + topicDisplayName: row.topic.displayName, + read: row.state?.read ?? false, + starred: row.state?.starred ?? false, + } +} + +export async function listNotificationsForUser( + userId: string, + filter: ListNotificationsFilter = {}, +): Promise { + const boundedLimit = Math.min(Math.max(filter.limit ?? 50, 1), 200) + + let topicId: string | undefined + if (filter.topicSlug) { + const topic = await getTopicBySlug(filter.topicSlug) + if (!topic) return [] + topicId = topic.id + } + + const conditions = [ + topicId ? eq(notifications.topicId, topicId) : undefined, + filter.unreadOnly ? sql`coalesce(${userNotificationState.read}, false) = false` : undefined, + filter.starredOnly ? eq(userNotificationState.starred, true) : undefined, + filter.q + ? or(ilike(notifications.title, `%${filter.q}%`), ilike(notifications.message, `%${filter.q}%`)) + : undefined, + ].filter((c): c is NonNullable => c !== undefined) + + const rows = await db + .select({ + notification: notifications, + state: userNotificationState, + topic: { slug: topics.slug, displayName: topics.displayName }, + }) + .from(notifications) + .innerJoin(topics, eq(topics.id, notifications.topicId)) + .leftJoin( + userNotificationState, + and( + eq(userNotificationState.notificationId, notifications.id), + eq(userNotificationState.userId, userId), + ), + ) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy(desc(notifications.createdAt)) + .limit(boundedLimit) + + return rows.map(toNotificationWithState) +} + +export async function getNotificationForUser( + userId: string, + id: string, +): Promise { + const [row] = await db + .select({ + notification: notifications, + state: userNotificationState, + topic: { slug: topics.slug, displayName: topics.displayName }, + }) + .from(notifications) + .innerJoin(topics, eq(topics.id, notifications.topicId)) + .leftJoin( + userNotificationState, + and( + eq(userNotificationState.notificationId, notifications.id), + eq(userNotificationState.userId, userId), + ), + ) + .where(eq(notifications.id, id)) + .limit(1) + + return row ? toNotificationWithState(row) : null +} + +export interface NotificationStateChanges { + read?: boolean + starred?: boolean +} + +export async function setNotificationState( + userId: string, + notificationId: string, + changes: NotificationStateChanges, +) { + const [existing] = await db + .select() + .from(userNotificationState) + .where( + and( + eq(userNotificationState.userId, userId), + eq(userNotificationState.notificationId, notificationId), + ), + ) + .limit(1) + + const patch = { + ...(changes.read !== undefined ? { read: changes.read, readAt: changes.read ? new Date() : null } : {}), + ...(changes.starred !== undefined + ? { starred: changes.starred, starredAt: changes.starred ? new Date() : null } + : {}), + } + + if (existing) { + const [updated] = await db + .update(userNotificationState) + .set(patch) + .where(eq(userNotificationState.id, existing.id)) + .returning() + return updated + } + + const [created] = await db + .insert(userNotificationState) + .values({ + userId, + notificationId, + read: changes.read ?? false, + readAt: changes.read ? new Date() : null, + starred: changes.starred ?? false, + starredAt: changes.starred ? new Date() : null, + }) + .returning() + return created +} + +// Marks every notification the user hasn't read yet as read — either +// globally or scoped to one topic. Uses a single INSERT ... ON CONFLICT +// rather than one row at a time, since a first-ever "mark all read" can +// touch thousands of rows that have no user_notification_state row yet. +export async function markAllRead(userId: string, topicSlug?: string): Promise { + let topicId: string | null = null + if (topicSlug) { + const topic = await getTopicBySlug(topicSlug) + if (!topic) return 0 + topicId = topic.id + } + + const result = await db.execute(sql` + INSERT INTO user_notification_state (user_id, notification_id, read, read_at) + SELECT ${userId}, n.id, true, now() + FROM ${notifications} AS n + ${topicId ? sql`WHERE n.topic_id = ${topicId}` : sql``} + ON CONFLICT (user_id, notification_id) + DO UPDATE SET read = true, read_at = now() + WHERE ${userNotificationState}.read = false + `) + return result.rowCount ?? 0 +} + +// Permanent — cascades to user_notification_state and +// push_delivery_attempts rows for this notification via the FK +// onDelete: 'cascade' constraints in db/schema.ts. Admin-only (see +// routes.ts): notifications are shared across every user who can see the +// topic, so deleting one isn't a per-user action. +export async function deleteNotification(id: string) { + const [deleted] = await db.delete(notifications).where(eq(notifications.id, id)).returning() + return deleted ?? null +} diff --git a/apps/server/src/modules/notifications/retention.integration.test.ts b/apps/server/src/modules/notifications/retention.integration.test.ts new file mode 100644 index 0000000..609eca2 --- /dev/null +++ b/apps/server/src/modules/notifications/retention.integration.test.ts @@ -0,0 +1,96 @@ +import { randomUUID } from 'node:crypto' +import { eq } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { db, pool } from '../../db/client.js' +import { runMigrations } from '../../db/migrate.js' +import { notifications, topics } from '../../db/schema.js' +import { runRetentionCleanup } from './retention.js' + +// Integration tests — need a real, migrated Postgres. Run with: +// docker compose -f docker/docker-compose.yml exec notizarr \ +// pnpm --filter @notizarr/server test +describe('retention cleanup', () => { + const shortRetentionSlug = `retention-short-${randomUUID()}` + const foreverSlug = `retention-forever-${randomUUID()}` + let shortTopicId: string + let foreverTopicId: string + + beforeAll(async () => { + await runMigrations() + + const [shortTopic] = await db + .insert(topics) + .values({ slug: shortRetentionSlug, displayName: 'Short Retention', retentionDays: 1 }) + .returning() + shortTopicId = shortTopic!.id + + const [foreverTopic] = await db + .insert(topics) + .values({ slug: foreverSlug, displayName: 'Forever Retention', retentionDays: 0 }) + .returning() + foreverTopicId = foreverTopic!.id + }) + + afterAll(async () => { + await db.delete(topics).where(eq(topics.id, shortTopicId)) + await db.delete(topics).where(eq(topics.id, foreverTopicId)) + await pool.end() + }) + + it('deletes notifications older than the topic retention window', async () => { + const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000) + + const [oldNotification] = await db + .insert(notifications) + .values({ + topicId: shortTopicId, + source: 'test', + title: 'Old', + message: 'Should be cleaned up', + createdAt: twoDaysAgo, + }) + .returning() + + const [recentNotification] = await db + .insert(notifications) + .values({ topicId: shortTopicId, source: 'test', title: 'Recent', message: 'Should survive' }) + .returning() + + await runRetentionCleanup() + + const oldStillExists = await db + .select() + .from(notifications) + .where(eq(notifications.id, oldNotification!.id)) + expect(oldStillExists).toHaveLength(0) + + const recentStillExists = await db + .select() + .from(notifications) + .where(eq(notifications.id, recentNotification!.id)) + expect(recentStillExists).toHaveLength(1) + }) + + it('never deletes notifications in a retentionDays=0 ("forever") topic', async () => { + const tenYearsAgo = new Date(Date.now() - 10 * 365 * 24 * 60 * 60 * 1000) + + const [veryOldNotification] = await db + .insert(notifications) + .values({ + topicId: foreverTopicId, + source: 'test', + title: 'Ancient', + message: 'Should never be cleaned up', + createdAt: tenYearsAgo, + }) + .returning() + + await runRetentionCleanup() + + const stillExists = await db + .select() + .from(notifications) + .where(eq(notifications.id, veryOldNotification!.id)) + expect(stillExists).toHaveLength(1) + }) +}) diff --git a/apps/server/src/modules/notifications/retention.ts b/apps/server/src/modules/notifications/retention.ts new file mode 100644 index 0000000..9aec195 --- /dev/null +++ b/apps/server/src/modules/notifications/retention.ts @@ -0,0 +1,41 @@ +import { and, eq, lt } from 'drizzle-orm' +import { db } from '../../db/client.js' +import { notifications, topics } from '../../db/schema.js' +import { resolveRetentionDefaultDays } from '../admin/system-settings.js' + +const CLEANUP_INTERVAL_MS = 60 * 60 * 1000 // 1 hour + +// Per-topic retentionDays overrides the global default; 0 means "forever". +// See docs/phase1/03-database-model.md, "Retention cleanup". +export async function runRetentionCleanup(): Promise { + const topicRows = await db.select().from(topics) + const retentionDefaultDays = await resolveRetentionDefaultDays() + let totalDeleted = 0 + + for (const topic of topicRows) { + const retentionDays = topic.retentionDays ?? retentionDefaultDays + if (retentionDays === 0) continue + + const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000) + const deleted = await db + .delete(notifications) + .where(and(eq(notifications.topicId, topic.id), lt(notifications.createdAt, cutoff))) + .returning({ id: notifications.id }) + + totalDeleted += deleted.length + } + + return totalDeleted +} + +export function startRetentionCleanupJob( + onError: (err: unknown) => void = console.error, +): NodeJS.Timeout { + const run = () => { + runRetentionCleanup().catch(onError) + } + run() // catch up on anything overdue from before this boot + const timer = setInterval(run, CLEANUP_INTERVAL_MS) + timer.unref() // never block process shutdown just to keep this alive + return timer +} diff --git a/apps/server/src/modules/notifications/routes.ts b/apps/server/src/modules/notifications/routes.ts new file mode 100644 index 0000000..f0981b1 --- /dev/null +++ b/apps/server/src/modules/notifications/routes.ts @@ -0,0 +1,175 @@ +import type { FastifyInstance } from 'fastify' +import { requireAdmin, requireAuth } from '../auth/guards.js' +import { writeAuditLog } from '../audit/log.js' +import { + deleteNotification, + getNotificationForUser, + listNotificationsForUser, + markAllRead, + setNotificationState, +} from './repository.js' + +const notificationSchema = { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + topicId: { type: 'string', format: 'uuid' }, + topicSlug: { type: 'string' }, + topicDisplayName: { type: 'string' }, + source: { type: 'string' }, + title: { type: 'string' }, + message: { type: 'string' }, + externalUrl: { type: ['string', 'null'] }, + imageUrl: { type: ['string', 'null'] }, + occurrenceCount: { type: 'integer' }, + rawPriority: { type: ['string', 'null'] }, + read: { type: 'boolean' }, + starred: { type: 'boolean' }, + createdAt: { type: 'string', format: 'date-time' }, + updatedAt: { type: 'string', format: 'date-time' }, + }, +} as const + +// Web listener (:8080) — read/mutate own read+star state. See +// ingest-routes.ts for the LAN publish endpoint on :8081. Full-text search +// here is a simple ILIKE, not the GIN-indexed tsvector search described in +// docs/phase1/03-database-model.md — good enough at homelab scale, upgrade +// path noted there if it's ever needed. +export async function registerNotificationReadRoutes(app: FastifyInstance) { + app.get( + '/api/v1/notifications', + { + preHandler: requireAuth, + schema: { + querystring: { + type: 'object', + properties: { + topic: { type: 'string' }, + unread: { type: 'boolean' }, + starred: { type: 'boolean' }, + q: { type: 'string' }, + limit: { type: 'integer', minimum: 1, maximum: 200 }, + }, + }, + response: { + 200: { + type: 'object', + properties: { notifications: { type: 'array', items: notificationSchema } }, + }, + }, + }, + }, + async (req) => { + const query = req.query as { + topic?: string + unread?: boolean + starred?: boolean + q?: string + limit?: number + } + const notifications = await listNotificationsForUser(req.currentUser!.id, { + topicSlug: query.topic, + unreadOnly: query.unread, + starredOnly: query.starred, + q: query.q, + limit: query.limit, + }) + return { notifications } + }, + ) + + app.post( + '/api/v1/notifications/mark-all-read', + { + preHandler: requireAuth, + schema: { + querystring: { type: 'object', properties: { topic: { type: 'string' } } }, + }, + }, + async (req) => { + const { topic } = req.query as { topic?: string } + const count = await markAllRead(req.currentUser!.id, topic) + return { status: 'ok', count } + }, + ) + + app.get( + '/api/v1/notifications/:id', + { + preHandler: requireAuth, + schema: { + params: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] }, + response: { + 200: { type: 'object', properties: { notification: notificationSchema } }, + 404: { type: 'object' }, + }, + }, + }, + async (req, reply) => { + const { id } = req.params as { id: string } + const notification = await getNotificationForUser(req.currentUser!.id, id) + if (!notification) { + reply.code(404) + return { error: 'not_found' } + } + return { notification } + }, + ) + + app.patch( + '/api/v1/notifications/:id', + { + preHandler: requireAuth, + schema: { + params: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] }, + body: { + type: 'object', + properties: { read: { type: 'boolean' }, starred: { type: 'boolean' } }, + }, + }, + }, + async (req, reply) => { + const { id } = req.params as { id: string } + const changes = req.body as { read?: boolean; starred?: boolean } + + const existing = await getNotificationForUser(req.currentUser!.id, id) + if (!existing) { + reply.code(404) + return { error: 'not_found' } + } + + await setNotificationState(req.currentUser!.id, id, changes) + const updated = await getNotificationForUser(req.currentUser!.id, id) + return { notification: updated } + }, + ) + + // Admin-only: notifications are shared across every user who can see the + // topic, so deleting one isn't a per-user action the way read/star are. + app.delete( + '/api/v1/notifications/:id', + { + preHandler: requireAdmin, + schema: { + params: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] }, + }, + }, + async (req, reply) => { + const { id } = req.params as { id: string } + const deleted = await deleteNotification(id) + if (!deleted) { + reply.code(404) + return { error: 'not_found' } + } + await writeAuditLog({ + actorUserId: req.currentUser!.id, + action: 'notification.delete', + targetType: 'notification', + targetId: deleted.id, + metadata: { title: deleted.title, topicId: deleted.topicId }, + ipAddress: req.ip, + }) + return { status: 'ok' } + }, + ) +} diff --git a/apps/server/src/modules/notifications/stream-routes.ts b/apps/server/src/modules/notifications/stream-routes.ts new file mode 100644 index 0000000..76a2cd7 --- /dev/null +++ b/apps/server/src/modules/notifications/stream-routes.ts @@ -0,0 +1,42 @@ +import type { FastifyInstance } from 'fastify' +import { requireAuth } from '../auth/guards.js' +import { notificationEvents } from './events.js' + +const HEARTBEAT_INTERVAL_MS = 25_000 + +// Server-Sent Events stream for live UI updates while a tab is open — see +// docs/phase1/01-architecture.md's realtime decision (SSE, no Redis, no +// WebSocket). Push notifications (Phase 7) cover the tab-closed case. +export async function registerStreamRoutes(app: FastifyInstance) { + app.get('/api/v1/stream', { preHandler: requireAuth }, async (req, reply) => { + reply.hijack() + + reply.raw.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }) + + const send = (event: string, data: unknown) => { + reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`) + } + + send('connected', { ok: true }) + + const onCreated = (notification: unknown) => send('notification.created', notification) + const onUpdated = (notification: unknown) => send('notification.updated', notification) + notificationEvents.on('notification.created', onCreated) + notificationEvents.on('notification.updated', onUpdated) + + const heartbeat = setInterval(() => { + reply.raw.write(': heartbeat\n\n') + }, HEARTBEAT_INTERVAL_MS) + + req.raw.on('close', () => { + clearInterval(heartbeat) + notificationEvents.off('notification.created', onCreated) + notificationEvents.off('notification.updated', onUpdated) + reply.raw.end() + }) + }) +} diff --git a/apps/server/src/modules/notifications/webhook-routes.ts b/apps/server/src/modules/notifications/webhook-routes.ts new file mode 100644 index 0000000..43d5531 --- /dev/null +++ b/apps/server/src/modules/notifications/webhook-routes.ts @@ -0,0 +1,41 @@ +import type { FastifyInstance } from 'fastify' +import { requireApiToken } from '../tokens/ingest-auth.js' +import { publishAndRespond } from './publish.js' + +// Generic webhook endpoint — MVP is a fixed simple mapping, not a +// configurable templating engine (docs/phase1/00-README.md decision #6: +// "the goal is easy migration, not full protocol cloning"). `:source` +// becomes the notification's source field. +export async function registerWebhookRoutes(app: FastifyInstance) { + app.post( + '/api/v1/webhook/:source', + { + preHandler: requireApiToken, + schema: { + params: { type: 'object', properties: { source: { type: 'string' } }, required: ['source'] }, + body: { + type: 'object', + required: ['topic', 'title', 'message'], + properties: { + topic: { type: 'string', minLength: 1 }, + title: { type: 'string', minLength: 1, maxLength: 500 }, + message: { type: 'string', minLength: 1, maxLength: 10000 }, + url: { type: 'string' }, + }, + }, + }, + }, + async (req, reply) => { + const { source } = req.params as { source: string } + const body = req.body as { topic: string; title: string; message: string; url?: string } + + await publishAndRespond(req, reply, { + topicSlug: body.topic, + source, + title: body.title, + message: body.message, + externalUrl: body.url ?? null, + }) + }, + ) +} diff --git a/apps/server/src/modules/push/fanout.ts b/apps/server/src/modules/push/fanout.ts new file mode 100644 index 0000000..d4f380f --- /dev/null +++ b/apps/server/src/modules/push/fanout.ts @@ -0,0 +1,127 @@ +import { and, eq } from 'drizzle-orm' +import type { Notification } from '../../db/schema.js' +import { db } from '../../db/client.js' +import { users, userTopicSubscriptions } from '../../db/schema.js' +import { isQuietPriority, isWithinQuietHours, resolvePriority } from '../../lib/priority.js' +import { resolveQuietHours, resolveVapidSubject } from '../admin/system-settings.js' +import { getTopicBySlug } from '../topics/repository.js' +import { + getActiveSubscriptionsForUser, + disableSubscription, + recordDeliveryAttempt, +} from './repository.js' +import { ensureVapidConfigured, webpush } from './vapid.js' + +interface PushPayload { + notificationId: string + topicSlug: string + title: string + body: string + icon: string +} + +async function isPushEnabledForUser(userId: string, topicId: string): Promise { + const [subscription] = await db + .select() + .from(userTopicSubscriptions) + .where(and(eq(userTopicSubscriptions.userId, userId), eq(userTopicSubscriptions.topicId, topicId))) + .limit(1) + // Absence of a row means push is on by default, per + // docs/phase1/03-database-model.md. A snooze (mutedUntil in the future) + // suppresses push independently of the on/off preference and just + // expires on its own — nothing needs to clear it. + if (!(subscription?.pushEnabled ?? true)) return false + if (subscription?.mutedUntil && subscription.mutedUntil.getTime() > Date.now()) return false + return true +} + +// Fire-and-forget from modules/notifications/publish.ts — ingestion must +// never block on push delivery (docs/phase1/04-api-design.md). Every +// failure here is caught and recorded, never thrown back at the caller. +export async function fanOutPush(notification: Notification, topicSlug: string): Promise { + try { + // A publisher (e.g. a Home Assistant automation) can mark a specific + // notification "min priority" to keep it in its normal topic — visible + // in history like any other — without waking anyone's phone for it. + if (isQuietPriority(notification.rawPriority)) return + + // Household-wide quiet hours (Admin > System): during the configured + // window, only notifications at/above minPriority still push — lower + // ones are still stored and shown in-app, same as isQuietPriority. + const quietHours = await resolveQuietHours() + if ( + quietHours.enabled && + resolvePriority(notification.rawPriority) < quietHours.minPriority && + isWithinQuietHours(quietHours.start, quietHours.end, quietHours.timezone) + ) { + return + } + + await ensureVapidConfigured(await resolveVapidSubject()) + + const topic = await getTopicBySlug(topicSlug) + if (!topic) return + const topicDisplayName = topic.displayName + + const allUsers = await db.select().from(users) + + for (const user of allUsers) { + const enabled = await isPushEnabledForUser(user.id, notification.topicId) + if (!enabled) continue + + const devices = await getActiveSubscriptionsForUser(user.id) + if (devices.length === 0) continue + + const payload: PushPayload = { + notificationId: notification.id, + topicSlug, + title: notification.title || topicDisplayName, + body: notification.message, + icon: '/icons/icon-192.png', + } + + for (const device of devices) { + try { + await webpush.sendNotification( + { + endpoint: device.endpoint, + keys: { p256dh: device.p256dh, auth: device.auth }, + }, + JSON.stringify(payload), + ) + await recordDeliveryAttempt({ + notificationId: notification.id, + pushSubscriptionId: device.id, + status: 'accepted_by_push_service', + }) + } catch (err) { + const statusCode = (err as { statusCode?: number }).statusCode + const message = err instanceof Error ? err.message : String(err) + + if (statusCode === 404 || statusCode === 410) { + await disableSubscription(device.id) + await recordDeliveryAttempt({ + notificationId: notification.id, + pushSubscriptionId: device.id, + status: 'expired', + httpStatus: statusCode, + errorMessage: 'subscription no longer valid, disabled', + }) + } else { + await recordDeliveryAttempt({ + notificationId: notification.id, + pushSubscriptionId: device.id, + status: 'failed', + httpStatus: statusCode ?? null, + errorMessage: message.slice(0, 500), + }) + } + } + } + } + } catch (err) { + // VAPID not configured, DB hiccup, etc. — never let a push problem + // affect the publish path that triggered this. + console.error('push fan-out failed:', err) + } +} diff --git a/apps/server/src/modules/push/repository.integration.test.ts b/apps/server/src/modules/push/repository.integration.test.ts new file mode 100644 index 0000000..ddd5b02 --- /dev/null +++ b/apps/server/src/modules/push/repository.integration.test.ts @@ -0,0 +1,88 @@ +import { randomUUID } from 'node:crypto' +import { eq } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { db, pool } from '../../db/client.js' +import { runMigrations } from '../../db/migrate.js' +import { users } from '../../db/schema.js' +import { deleteUserDevice, listUserDevices, upsertPushSubscription } from './repository.js' + +// Integration tests — need a real, migrated Postgres. Run with: +// docker compose -f docker/docker-compose.yml exec notizarr \ +// pnpm --filter @notizarr/server test +describe('push subscription ownership', () => { + let ownerId: string + let otherUserId: string + + beforeAll(async () => { + await runMigrations() + + const [owner] = await db + .insert(users) + .values({ + email: `owner-${randomUUID()}@example.test`, + displayName: 'Owner', + authSubject: `owner-${randomUUID()}`, + }) + .returning() + ownerId = owner!.id + + const [other] = await db + .insert(users) + .values({ + email: `other-${randomUUID()}@example.test`, + displayName: 'Other', + authSubject: `other-${randomUUID()}`, + }) + .returning() + otherUserId = other!.id + }) + + afterAll(async () => { + await db.delete(users).where(eq(users.id, ownerId)) + await db.delete(users).where(eq(users.id, otherUserId)) + await pool.end() + }) + + it("prevents deleting another user's device", async () => { + const device = await upsertPushSubscription({ + userId: ownerId, + endpoint: `https://push.example.test/${randomUUID()}`, + p256dh: 'test-p256dh', + auth: 'test-auth', + }) + + const deletedByWrongUser = await deleteUserDevice(otherUserId, device.id) + expect(deletedByWrongUser).toBeNull() + + const stillThere = await listUserDevices(ownerId) + expect(stillThere.some((d) => d.id === device.id)).toBe(true) + + const deletedByOwner = await deleteUserDevice(ownerId, device.id) + expect(deletedByOwner?.id).toBe(device.id) + + const goneNow = await listUserDevices(ownerId) + expect(goneNow.some((d) => d.id === device.id)).toBe(false) + }) + + it('resubscribing the same endpoint updates the existing row instead of duplicating it', async () => { + const endpoint = `https://push.example.test/${randomUUID()}` + + const first = await upsertPushSubscription({ + userId: ownerId, + endpoint, + p256dh: 'key-1', + auth: 'auth-1', + }) + const second = await upsertPushSubscription({ + userId: ownerId, + endpoint, + p256dh: 'key-2', + auth: 'auth-2', + }) + + expect(second.id).toBe(first.id) + expect(second.p256dh).toBe('key-2') + + await deleteUserDevice(ownerId, first.id) + }) +}) diff --git a/apps/server/src/modules/push/repository.ts b/apps/server/src/modules/push/repository.ts new file mode 100644 index 0000000..39c0bc4 --- /dev/null +++ b/apps/server/src/modules/push/repository.ts @@ -0,0 +1,113 @@ +import { and, desc, eq, isNull } from 'drizzle-orm' +import { db } from '../../db/client.js' +import { pushDeliveryAttempts, pushSubscriptions } from '../../db/schema.js' + +export interface UpsertSubscriptionInput { + userId: string + endpoint: string + p256dh: string + auth: string + userAgent?: string | null +} + +// Keyed by endpoint (unique) — resubscribing (e.g. after clearing site +// data) just refreshes the existing row rather than creating a duplicate. +export async function upsertPushSubscription(input: UpsertSubscriptionInput) { + const [existing] = await db + .select() + .from(pushSubscriptions) + .where(eq(pushSubscriptions.endpoint, input.endpoint)) + .limit(1) + + if (existing) { + const [updated] = await db + .update(pushSubscriptions) + .set({ + userId: input.userId, + p256dh: input.p256dh, + auth: input.auth, + userAgent: input.userAgent ?? null, + lastSeenAt: new Date(), + disabledAt: null, + }) + .where(eq(pushSubscriptions.id, existing.id)) + .returning() + // Updating a row just found by its own id always returns exactly one row. + return updated! + } + + const [created] = await db + .insert(pushSubscriptions) + .values({ + userId: input.userId, + endpoint: input.endpoint, + p256dh: input.p256dh, + auth: input.auth, + userAgent: input.userAgent ?? null, + }) + .returning() + // A single-row insert always returns exactly one row. + return created! +} + +export async function listUserDevices(userId: string) { + return db + .select() + .from(pushSubscriptions) + .where(eq(pushSubscriptions.userId, userId)) + .orderBy(desc(pushSubscriptions.createdAt)) +} + +export async function deleteUserDevice(userId: string, id: string) { + const [deleted] = await db + .delete(pushSubscriptions) + .where(and(eq(pushSubscriptions.id, id), eq(pushSubscriptions.userId, userId))) + .returning() + return deleted ?? null +} + +export async function disableSubscription(id: string) { + await db.update(pushSubscriptions).set({ disabledAt: new Date() }).where(eq(pushSubscriptions.id, id)) +} + +export async function getActiveSubscriptionsForUser(userId: string) { + return db + .select() + .from(pushSubscriptions) + .where(and(eq(pushSubscriptions.userId, userId), isNull(pushSubscriptions.disabledAt))) +} + +export interface RecordDeliveryAttemptInput { + notificationId: string + pushSubscriptionId: string + status: 'accepted_by_push_service' | 'failed' | 'invalid_subscription' | 'expired' + httpStatus?: number | null + errorMessage?: string | null +} + +export async function recordDeliveryAttempt(input: RecordDeliveryAttemptInput) { + await db.insert(pushDeliveryAttempts).values({ + notificationId: input.notificationId, + pushSubscriptionId: input.pushSubscriptionId, + status: input.status, + httpStatus: input.httpStatus ?? null, + errorMessage: input.errorMessage ?? null, + }) +} + +export async function getLastAttemptsForUserDevices(userId: string) { + const devices = await listUserDevices(userId) + const results: Record = {} + + for (const device of devices) { + const [last] = await db + .select() + .from(pushDeliveryAttempts) + .where(eq(pushDeliveryAttempts.pushSubscriptionId, device.id)) + .orderBy(desc(pushDeliveryAttempts.attemptedAt)) + .limit(1) + results[device.id] = last ?? null + } + + return results +} diff --git a/apps/server/src/modules/push/routes.ts b/apps/server/src/modules/push/routes.ts new file mode 100644 index 0000000..2306eea --- /dev/null +++ b/apps/server/src/modules/push/routes.ts @@ -0,0 +1,103 @@ +import type { FastifyInstance } from 'fastify' +import { requireAuth } from '../auth/guards.js' +import { resolveVapidSubject } from '../admin/system-settings.js' +import { + deleteUserDevice, + getLastAttemptsForUserDevices, + listUserDevices, + upsertPushSubscription, +} from './repository.js' +import { getVapidPublicKey } from './vapid.js' + +export async function registerPushRoutes(app: FastifyInstance) { + app.get('/api/v1/push/vapid-public-key', { preHandler: requireAuth }, async () => { + const publicKey = await getVapidPublicKey(await resolveVapidSubject()) + return { publicKey } + }) + + app.post( + '/api/v1/push/subscribe', + { + preHandler: requireAuth, + schema: { + body: { + type: 'object', + required: ['endpoint', 'keys'], + properties: { + endpoint: { type: 'string', minLength: 1 }, + keys: { + type: 'object', + required: ['p256dh', 'auth'], + properties: { + p256dh: { type: 'string', minLength: 1 }, + auth: { type: 'string', minLength: 1 }, + }, + }, + userAgent: { type: 'string' }, + }, + }, + }, + }, + async (req, reply) => { + const body = req.body as { + endpoint: string + keys: { p256dh: string; auth: string } + userAgent?: string + } + + const subscription = await upsertPushSubscription({ + userId: req.currentUser!.id, + endpoint: body.endpoint, + p256dh: body.keys.p256dh, + auth: body.keys.auth, + userAgent: body.userAgent ?? req.headers['user-agent'] ?? null, + }) + + reply.code(201) + return { subscription } + }, + ) + + app.get('/api/v1/devices', { preHandler: requireAuth }, async (req) => { + const devices = await listUserDevices(req.currentUser!.id) + return { devices } + }) + + app.delete( + '/api/v1/devices/:id', + { + preHandler: requireAuth, + schema: { + params: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] }, + }, + }, + async (req, reply) => { + const { id } = req.params as { id: string } + const deleted = await deleteUserDevice(req.currentUser!.id, id) + if (!deleted) { + reply.code(404) + return { error: 'not_found' } + } + return { status: 'ok' } + }, + ) + + app.get('/api/v1/push/diagnostics', { preHandler: requireAuth }, async (req) => { + const devices = await listUserDevices(req.currentUser!.id) + const lastAttempts = await getLastAttemptsForUserDevices(req.currentUser!.id) + const vapidConfigured = Boolean(await getVapidPublicKey(await resolveVapidSubject())) + + return { + vapidConfigured, + devices: devices.map((device) => ({ + id: device.id, + deviceLabel: device.deviceLabel, + userAgent: device.userAgent, + createdAt: device.createdAt, + lastSeenAt: device.lastSeenAt, + disabledAt: device.disabledAt, + lastDeliveryAttempt: lastAttempts[device.id] ?? null, + })), + } + }) +} diff --git a/apps/server/src/modules/push/vapid.ts b/apps/server/src/modules/push/vapid.ts new file mode 100644 index 0000000..9c7351b --- /dev/null +++ b/apps/server/src/modules/push/vapid.ts @@ -0,0 +1,34 @@ +import webpush from 'web-push' +import { getOrCreateSetting } from '../../db/settings.js' + +const VAPID_KEYS_SETTING = 'vapid_keys' + +export interface VapidKeyPair { + publicKey: string + privateKey: string +} + +let keysPromise: Promise | null = null + +// The keypair is generated once on first boot and persisted in Postgres — +// same durability pattern as the session secret — so that part is safe to +// cache forever. The subject is NOT cached with it: it's resolved fresh by +// the caller (Admin > System is DB-editable, see +// modules/admin/system-settings.js) on every call, and re-applying it to +// the `web-push` library here is cheap, so a subject change takes effect +// on the very next push without needing a restart. +export async function ensureVapidConfigured(subject: string): Promise { + if (!keysPromise) { + keysPromise = getOrCreateSetting(VAPID_KEYS_SETTING, () => webpush.generateVAPIDKeys()) + } + const keys = await keysPromise + webpush.setVapidDetails(subject, keys.publicKey, keys.privateKey) + return keys +} + +export async function getVapidPublicKey(subject: string): Promise { + const keys = await ensureVapidConfigured(subject) + return keys.publicKey +} + +export { webpush } diff --git a/apps/server/src/modules/tokens/crypto.ts b/apps/server/src/modules/tokens/crypto.ts new file mode 100644 index 0000000..cd1c661 --- /dev/null +++ b/apps/server/src/modules/tokens/crypto.ts @@ -0,0 +1,25 @@ +import { createHash, randomBytes } from 'node:crypto' + +const TOKEN_PREFIX = 'nzr_' + +export interface GeneratedToken { + plaintext: string + hash: string + prefix: string +} + +// Plaintext is shown to the admin exactly once; only the hash is ever +// stored (docs/phase1/02-threat-model.md, "API token theft / abuse"). +export function generateApiToken(): GeneratedToken { + const raw = randomBytes(32).toString('base64url') + const plaintext = `${TOKEN_PREFIX}${raw}` + return { + plaintext, + hash: hashApiToken(plaintext), + prefix: plaintext.slice(0, 12), + } +} + +export function hashApiToken(plaintext: string): string { + return createHash('sha256').update(plaintext).digest('hex') +} diff --git a/apps/server/src/modules/tokens/ingest-auth.ts b/apps/server/src/modules/tokens/ingest-auth.ts new file mode 100644 index 0000000..84eac1c --- /dev/null +++ b/apps/server/src/modules/tokens/ingest-auth.ts @@ -0,0 +1,100 @@ +import type { FastifyReply, FastifyRequest } from 'fastify' +import type { ApiToken } from '../../db/schema.js' +import { config } from '../../config.js' +import { isIpInAnyCidr } from '../../lib/cidr.js' +import { writeAuditLog } from '../audit/log.js' +import { ingestRateLimiter } from './rate-limit.js' +import { + findActiveTokenByPlaintext, + getAllowedTopicSlugsForToken, + touchTokenUsage, +} from './repository.js' + +declare module 'fastify' { + interface FastifyRequest { + apiToken?: ApiToken + apiTokenAllowedTopics?: string[] | null + } +} + +function rateLimited(reply: FastifyReply) { + reply.code(429) + reply.header('Retry-After', '10') + return reply.send({ + error: 'rate_limited', + message: 'too many requests', + retryAfterSeconds: 10, + }) +} + +// Ingestion listener preHandler: validates source address, rate limits, +// and the bearer token's identity. Per-request topic/source scope checks +// (which need the parsed body) happen in modules/notifications/publish.ts. +export async function requireApiToken(req: FastifyRequest, reply: FastifyReply) { + const peerIp = req.socket.remoteAddress + if (!peerIp || !isIpInAnyCidr(peerIp, config.ingestionAllowedCidrs)) { + reply.code(403) + return reply.send({ error: 'forbidden', message: 'source address not allowed' }) + } + + // IP-level limiting runs before token validation too, so a broken or + // malicious client can't use auth failures to sidestep rate limiting + // (e.g. brute-forcing tokens). + if (!ingestRateLimiter.check(`ip:${peerIp}`)) { + return rateLimited(reply) + } + + // Some third-party apps (webhook destinations that only expose a bare + // URL field, no custom headers) have no way to send an Authorization + // header — accept the token as a query param in that case. This is + // opt-in per request, not a replacement for header auth: prefer the + // header wherever the client can set one, since a URL can end up in + // server logs, browser history, or a Referer header, which a header + // never does. + const authHeader = req.headers.authorization + const queryToken = (req.query as Record).token + + let plaintext: string + if (authHeader?.startsWith('Bearer ')) { + plaintext = authHeader.slice('Bearer '.length).trim() + } else if (queryToken) { + plaintext = queryToken + } else { + reply.code(401) + return reply.send({ + error: 'unauthorized', + message: 'missing bearer token (Authorization header or ?token= query param)', + }) + } + + const token = await findActiveTokenByPlaintext(plaintext) + if (!token) { + reply.code(401) + return reply.send({ error: 'unauthorized', message: 'invalid, revoked, or expired token' }) + } + + if (!ingestRateLimiter.check(`token:${token.id}`)) { + await writeAuditLog({ + actorTokenId: token.id, + action: 'rate_limit.rejected', + targetType: 'api_token', + targetId: token.id, + ipAddress: peerIp, + }) + return rateLimited(reply) + } + + if ( + token.allowedCidrs && + token.allowedCidrs.length > 0 && + !isIpInAnyCidr(peerIp, token.allowedCidrs) + ) { + reply.code(403) + return reply.send({ error: 'forbidden', message: 'source address not allowed for this token' }) + } + + req.apiToken = token + req.apiTokenAllowedTopics = await getAllowedTopicSlugsForToken(token.id) + + await touchTokenUsage(token.id) +} diff --git a/apps/server/src/modules/tokens/rate-limit.ts b/apps/server/src/modules/tokens/rate-limit.ts new file mode 100644 index 0000000..8b9a8bb --- /dev/null +++ b/apps/server/src/modules/tokens/rate-limit.ts @@ -0,0 +1,11 @@ +import { config } from '../../config.js' +import { createDualWindowLimiter } from '../../lib/rate-limiter.js' + +// One limiter instance shared by every key (per-token, per-IP) — see +// docs/phase1/04-api-design.md, "Rate limiting (listener B)". +export const ingestRateLimiter = createDualWindowLimiter( + config.rateLimit.sustainedPerMinute, + 60_000, + config.rateLimit.burstPer10Seconds, + 10_000, +) diff --git a/apps/server/src/modules/tokens/repository.integration.test.ts b/apps/server/src/modules/tokens/repository.integration.test.ts new file mode 100644 index 0000000..79901fa --- /dev/null +++ b/apps/server/src/modules/tokens/repository.integration.test.ts @@ -0,0 +1,88 @@ +import { randomUUID } from 'node:crypto' +import { eq } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { db, pool } from '../../db/client.js' +import { runMigrations } from '../../db/migrate.js' +import { topics, users } from '../../db/schema.js' +import { + createToken, + findActiveTokenByPlaintext, + getAllowedTopicSlugsForToken, + revokeToken, +} from './repository.js' + +// Integration tests — need a real, migrated Postgres reachable via +// DATABASE_URL. Run with: +// docker compose -f docker/docker-compose.yml exec notizarr \ +// pnpm --filter @notizarr/server test +describe('api token repository', () => { + const testSlug = `test-topic-${randomUUID()}` + const testAuthSubject = `test-user-${randomUUID()}` + let userId: string + let topicId: string + + beforeAll(async () => { + await runMigrations() + + const [user] = await db + .insert(users) + .values({ + email: `${testAuthSubject}@example.test`, + displayName: 'Test User', + authSubject: testAuthSubject, + role: 'admin', + }) + .returning() + userId = user!.id + + const [topic] = await db + .insert(topics) + .values({ slug: testSlug, displayName: 'Test Topic', createdBy: userId }) + .returning() + topicId = topic!.id + }) + + afterAll(async () => { + await db.delete(topics).where(eq(topics.id, topicId)) + await db.delete(users).where(eq(users.id, userId)) + await pool.end() + }) + + it('creates a token, resolves it by plaintext, then rejects it once revoked', async () => { + const { token, plaintext } = await createToken({ name: 'Test Token', createdBy: userId }) + + const resolved = await findActiveTokenByPlaintext(plaintext) + expect(resolved?.id).toBe(token.id) + + await revokeToken(token.id) + + const afterRevoke = await findActiveTokenByPlaintext(plaintext) + expect(afterRevoke).toBeNull() + }) + + it('scopes a token to specific topics', async () => { + const { token } = await createToken({ + name: 'Scoped Token', + createdBy: userId, + topicSlugs: [testSlug], + }) + const allowed = await getAllowedTopicSlugsForToken(token.id) + expect(allowed).toEqual([testSlug]) + }) + + it('treats an unscoped token as allowed for every topic', async () => { + const { token } = await createToken({ name: 'Unscoped Token', createdBy: userId }) + const allowed = await getAllowedTopicSlugsForToken(token.id) + expect(allowed).toBeNull() + }) + + it('rejects an already-expired token', async () => { + const { plaintext } = await createToken({ + name: 'Expired Token', + createdBy: userId, + expiresAt: new Date(Date.now() - 1000), + }) + const resolved = await findActiveTokenByPlaintext(plaintext) + expect(resolved).toBeNull() + }) +}) diff --git a/apps/server/src/modules/tokens/repository.ts b/apps/server/src/modules/tokens/repository.ts new file mode 100644 index 0000000..b5a3c7c --- /dev/null +++ b/apps/server/src/modules/tokens/repository.ts @@ -0,0 +1,117 @@ +import { eq, inArray, sql } from 'drizzle-orm' +import { db } from '../../db/client.js' +import { apiTokens, apiTokenTopics, topics } from '../../db/schema.js' +import { generateApiToken, hashApiToken } from './crypto.js' + +export interface CreateTokenInput { + name: string + allowedSource?: string | null + allowedCidrs?: string[] | null + expiresAt?: Date | null + topicSlugs?: string[] + createdBy: string +} + +export async function createToken(input: CreateTokenInput) { + const generated = generateApiToken() + + const [token] = await db + .insert(apiTokens) + .values({ + name: input.name, + tokenHash: generated.hash, + tokenPrefix: generated.prefix, + allowedSource: input.allowedSource ?? null, + allowedCidrs: input.allowedCidrs ?? null, + expiresAt: input.expiresAt ?? null, + createdBy: input.createdBy, + }) + .returning() + + // A single-row insert always returns exactly one row. + const insertedToken = token! + + if (input.topicSlugs && input.topicSlugs.length > 0) { + const topicRows = await db.select().from(topics).where(inArray(topics.slug, input.topicSlugs)) + if (topicRows.length > 0) { + await db + .insert(apiTokenTopics) + .values(topicRows.map((t) => ({ tokenId: insertedToken.id, topicId: t.id }))) + } + } + + return { token: insertedToken, plaintext: generated.plaintext } +} + +export async function listTokens() { + return db.select().from(apiTokens).orderBy(apiTokens.createdAt) +} + +export async function getTokenById(id: string) { + const [token] = await db.select().from(apiTokens).where(eq(apiTokens.id, id)).limit(1) + return token ?? null +} + +export async function revokeToken(id: string) { + const [token] = await db + .update(apiTokens) + .set({ revokedAt: new Date() }) + .where(eq(apiTokens.id, id)) + .returning() + return token ?? null +} + +export type DeleteTokenResult = 'deleted' | 'not_found' | 'still_active' + +// Hard delete, unlike revokeToken — only permitted once a token is already +// revoked, so an admin can't accidentally erase the audit trail of a token +// that's still live. api_token_topics rows for it cascade via the FK +// onDelete: 'cascade' constraint in db/schema.ts. +export async function deleteToken(id: string): Promise { + const token = await getTokenById(id) + if (!token) return 'not_found' + if (!token.revokedAt) return 'still_active' + + await db.delete(apiTokens).where(eq(apiTokens.id, id)) + return 'deleted' +} + +// Keeps the token's name/scopes/expiry policy; only the secret changes. +export async function rotateToken(id: string) { + const generated = generateApiToken() + const [token] = await db + .update(apiTokens) + .set({ tokenHash: generated.hash, tokenPrefix: generated.prefix, revokedAt: null }) + .where(eq(apiTokens.id, id)) + .returning() + if (!token) return null + return { token, plaintext: generated.plaintext } +} + +export async function findActiveTokenByPlaintext(plaintext: string) { + const hash = hashApiToken(plaintext) + const [token] = await db.select().from(apiTokens).where(eq(apiTokens.tokenHash, hash)).limit(1) + + if (!token) return null + if (token.revokedAt) return null + if (token.expiresAt && token.expiresAt.getTime() < Date.now()) return null + return token +} + +export async function touchTokenUsage(id: string) { + await db + .update(apiTokens) + .set({ lastUsedAt: new Date(), usageCount: sql`${apiTokens.usageCount} + 1` }) + .where(eq(apiTokens.id, id)) +} + +// null = unrestricted (token may publish to any topic). +export async function getAllowedTopicSlugsForToken(tokenId: string): Promise { + const rows = await db + .select({ slug: topics.slug }) + .from(apiTokenTopics) + .innerJoin(topics, eq(apiTokenTopics.topicId, topics.id)) + .where(eq(apiTokenTopics.tokenId, tokenId)) + if (rows.length === 0) return null + return rows.map((r) => r.slug) +} diff --git a/apps/server/src/modules/tokens/routes.ts b/apps/server/src/modules/tokens/routes.ts new file mode 100644 index 0000000..7e345e1 --- /dev/null +++ b/apps/server/src/modules/tokens/routes.ts @@ -0,0 +1,138 @@ +import type { FastifyInstance } from 'fastify' +import type { ApiToken } from '../../db/schema.js' +import { requireAdmin } from '../auth/guards.js' +import { writeAuditLog } from '../audit/log.js' +import { createToken, deleteToken, listTokens, revokeToken, rotateToken } from './repository.js' + +// Never includes tokenHash. +function toPublicToken(token: ApiToken) { + return { + id: token.id, + name: token.name, + tokenPrefix: token.tokenPrefix, + allowedSource: token.allowedSource, + allowedCidrs: token.allowedCidrs, + expiresAt: token.expiresAt, + revokedAt: token.revokedAt, + createdAt: token.createdAt, + lastUsedAt: token.lastUsedAt, + usageCount: token.usageCount, + } +} + +export async function registerTokenRoutes(app: FastifyInstance) { + app.get('/api/v1/admin/tokens', { preHandler: requireAdmin }, async () => { + const tokens = await listTokens() + return { tokens: tokens.map(toPublicToken) } + }) + + app.post( + '/api/v1/admin/tokens', + { + preHandler: requireAdmin, + schema: { + body: { + type: 'object', + required: ['name'], + properties: { + name: { type: 'string', minLength: 1, maxLength: 200 }, + allowedSource: { type: 'string' }, + allowedCidrs: { type: 'array', items: { type: 'string' } }, + expiresAt: { type: 'string', format: 'date-time' }, + topicSlugs: { type: 'array', items: { type: 'string' } }, + }, + }, + }, + }, + async (req, reply) => { + const body = req.body as { + name: string + allowedSource?: string + allowedCidrs?: string[] + expiresAt?: string + topicSlugs?: string[] + } + + const { token, plaintext } = await createToken({ + name: body.name, + allowedSource: body.allowedSource ?? null, + allowedCidrs: body.allowedCidrs ?? null, + expiresAt: body.expiresAt ? new Date(body.expiresAt) : null, + topicSlugs: body.topicSlugs, + createdBy: req.currentUser!.id, + }) + + await writeAuditLog({ + actorUserId: req.currentUser!.id, + action: 'token.create', + targetType: 'api_token', + targetId: token.id, + metadata: { name: token.name }, + ipAddress: req.ip, + }) + + reply.code(201) + // plaintext is only ever returned here, at creation time. + return { token: toPublicToken(token), plaintext } + }, + ) + + app.post('/api/v1/admin/tokens/:id/rotate', { preHandler: requireAdmin }, async (req, reply) => { + const { id } = req.params as { id: string } + const result = await rotateToken(id) + if (!result) { + reply.code(404) + return { error: 'not_found' } + } + await writeAuditLog({ + actorUserId: req.currentUser!.id, + action: 'token.rotate', + targetType: 'api_token', + targetId: result.token.id, + metadata: { name: result.token.name }, + ipAddress: req.ip, + }) + return { token: toPublicToken(result.token), plaintext: result.plaintext } + }) + + app.delete('/api/v1/admin/tokens/:id', { preHandler: requireAdmin }, async (req, reply) => { + const { id } = req.params as { id: string } + const token = await revokeToken(id) + if (!token) { + reply.code(404) + return { error: 'not_found' } + } + await writeAuditLog({ + actorUserId: req.currentUser!.id, + action: 'token.revoke', + targetType: 'api_token', + targetId: token.id, + metadata: { name: token.name }, + ipAddress: req.ip, + }) + return { token: toPublicToken(token) } + }) + + // Hard delete — only for tokens already revoked via the endpoint above, + // so this can't be used to erase an active token's audit trail. + app.delete('/api/v1/admin/tokens/:id/purge', { preHandler: requireAdmin }, async (req, reply) => { + const { id } = req.params as { id: string } + const result = await deleteToken(id) + if (result === 'not_found') { + reply.code(404) + return { error: 'not_found' } + } + if (result === 'still_active') { + reply.code(400) + return { error: 'still_active', message: 'revoke the token before deleting it' } + } + await writeAuditLog({ + actorUserId: req.currentUser!.id, + action: 'token.delete', + targetType: 'api_token', + targetId: id, + ipAddress: req.ip, + }) + return { status: 'ok' } + }) +} diff --git a/apps/server/src/modules/topics/repository.ts b/apps/server/src/modules/topics/repository.ts new file mode 100644 index 0000000..6124d5c --- /dev/null +++ b/apps/server/src/modules/topics/repository.ts @@ -0,0 +1,199 @@ +import { and, eq, sql } from 'drizzle-orm' +import { db } from '../../db/client.js' +import { notifications, topics, userNotificationState, userTopicSubscriptions } from '../../db/schema.js' + +export async function listTopics() { + return db.select().from(topics).orderBy(topics.displayName) +} + +export interface TopicWithStats { + id: string + slug: string + displayName: string + description: string | null + icon: string | null + accentColor: string | null + retentionDays: number | null + restricted: boolean + createdAt: Date + updatedAt: Date + totalCount: number + unreadCount: number + latestAt: Date | null + pushEnabled: boolean + mutedUntil: Date | null +} + +type TopicRow = typeof topics.$inferSelect + +// Shared by listTopicsWithStats (all topics) and getTopicWithStatsBySlug +// (one topic, e.g. the topic detail page's push toggle) so the two never +// drift out of sync — a prior version of this file computed pushEnabled +// only in the list query, which silently made the detail page's toggle +// always read as "on" regardless of actual state. +async function attachStats(userId: string, topic: TopicRow): Promise { + const [counts] = await db + .select({ + totalCount: sql`count(*)::int`, + latestAt: sql`max(${notifications.createdAt})`, + }) + .from(notifications) + .where(eq(notifications.topicId, topic.id)) + + const [unread] = await db + .select({ unreadCount: sql`count(*)::int` }) + .from(notifications) + .leftJoin( + userNotificationState, + and( + eq(userNotificationState.notificationId, notifications.id), + eq(userNotificationState.userId, userId), + ), + ) + .where( + and( + eq(notifications.topicId, topic.id), + sql`coalesce(${userNotificationState.read}, false) = false`, + ), + ) + + const [subscription] = await db + .select() + .from(userTopicSubscriptions) + .where(and(eq(userTopicSubscriptions.userId, userId), eq(userTopicSubscriptions.topicId, topic.id))) + .limit(1) + + return { + ...topic, + totalCount: counts?.totalCount ?? 0, + latestAt: counts?.latestAt ?? null, + unreadCount: unread?.unreadCount ?? 0, + pushEnabled: subscription?.pushEnabled ?? true, + mutedUntil: subscription?.mutedUntil ?? null, + } +} + +// One extra pair of small queries per topic rather than a single mega-join — +// simpler to get right and fine at homelab scale (a handful to a few dozen +// topics), matching the "keep it simple" principle in +// docs/phase1/00-README.md. Can become a single aggregated query later if +// topic counts ever make that worthwhile. +export async function listTopicsWithStats(userId: string): Promise { + const topicRows = await listTopics() + return Promise.all(topicRows.map((topic) => attachStats(userId, topic))) +} + +export async function getTopicWithStatsBySlug( + userId: string, + slug: string, +): Promise { + const [topic] = await db.select().from(topics).where(eq(topics.slug, slug)).limit(1) + if (!topic) return null + return attachStats(userId, topic) +} + +interface SubscriptionPatch { + pushEnabled?: boolean + mutedUntil?: Date | null +} + +async function upsertTopicSubscription(userId: string, topicId: string, patch: SubscriptionPatch) { + const [existing] = await db + .select() + .from(userTopicSubscriptions) + .where(and(eq(userTopicSubscriptions.userId, userId), eq(userTopicSubscriptions.topicId, topicId))) + .limit(1) + + if (existing) { + const [updated] = await db + .update(userTopicSubscriptions) + .set({ ...patch, updatedAt: new Date() }) + .where(eq(userTopicSubscriptions.id, existing.id)) + .returning() + return updated + } + + const [created] = await db + .insert(userTopicSubscriptions) + .values({ userId, topicId, pushEnabled: true, ...patch }) + .returning() + return created +} + +export async function setTopicPushEnabled(userId: string, topicId: string, pushEnabled: boolean) { + return upsertTopicSubscription(userId, topicId, { pushEnabled }) +} + +// Temporary mute — set mutedUntil to null to clear it early. Independent of +// pushEnabled, so it always expires back to whatever the user's actual +// on/off preference already was. +export async function setTopicMutedUntil(userId: string, topicId: string, mutedUntil: Date | null) { + return upsertTopicSubscription(userId, topicId, { mutedUntil }) +} + +export async function getTopicBySlug(slug: string) { + const [topic] = await db.select().from(topics).where(eq(topics.slug, slug)).limit(1) + return topic ?? null +} + +export interface CreateTopicInput { + slug: string + displayName: string + description?: string | null + icon?: string | null + accentColor?: string | null + retentionDays?: number | null + restricted?: boolean + createdBy: string +} + +export async function createTopic(input: CreateTopicInput) { + const [topic] = await db + .insert(topics) + .values({ + slug: input.slug, + displayName: input.displayName, + description: input.description ?? null, + icon: input.icon ?? null, + accentColor: input.accentColor ?? null, + retentionDays: input.retentionDays ?? null, + restricted: input.restricted ?? false, + createdBy: input.createdBy, + }) + .returning() + // A single-row insert always returns exactly one row. + return topic! +} + +export interface UpdateTopicInput { + displayName?: string + description?: string | null + icon?: string | null + accentColor?: string | null + retentionDays?: number | null + restricted?: boolean + archived?: boolean +} + +export async function updateTopic(id: string, input: UpdateTopicInput) { + const { archived, ...rest } = input + + const [updated] = await db + .update(topics) + .set({ + ...rest, + ...(archived !== undefined ? { archivedAt: archived ? new Date() : null } : {}), + updatedAt: new Date(), + }) + .where(eq(topics.id, id)) + .returning() + return updated ?? null +} + +// Permanent — cascades to notifications, user_notification_state, +// push_delivery_attempts, and api_token_topics rows for this topic via the +// FK onDelete: 'cascade' constraints in db/schema.ts. +export async function deleteTopic(id: string) { + const [deleted] = await db.delete(topics).where(eq(topics.id, id)).returning() + return deleted ?? null +} diff --git a/apps/server/src/modules/topics/routes.ts b/apps/server/src/modules/topics/routes.ts new file mode 100644 index 0000000..d7d82d4 --- /dev/null +++ b/apps/server/src/modules/topics/routes.ts @@ -0,0 +1,273 @@ +import type { FastifyInstance } from 'fastify' +import { requireAdmin, requireAuth } from '../auth/guards.js' +import { writeAuditLog } from '../audit/log.js' +import { + createTopic, + deleteTopic, + getTopicBySlug, + getTopicWithStatsBySlug, + listTopicsWithStats, + setTopicMutedUntil, + setTopicPushEnabled, + updateTopic, +} from './repository.js' + +const topicSchema = { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + slug: { type: 'string' }, + displayName: { type: 'string' }, + description: { type: ['string', 'null'] }, + icon: { type: ['string', 'null'] }, + accentColor: { type: ['string', 'null'] }, + retentionDays: { type: ['integer', 'null'] }, + restricted: { type: 'boolean' }, + createdAt: { type: 'string', format: 'date-time' }, + updatedAt: { type: 'string', format: 'date-time' }, + }, +} as const + +const topicWithStatsSchema = { + type: 'object', + properties: { + ...topicSchema.properties, + totalCount: { type: 'integer' }, + unreadCount: { type: 'integer' }, + latestAt: { type: ['string', 'null'], format: 'date-time' }, + pushEnabled: { type: 'boolean' }, + mutedUntil: { type: ['string', 'null'], format: 'date-time' }, + }, +} as const + +// Read routes require a logged-in user (any role); per-topic visibility +// filtering for `restricted` topics is Phase 8. The create route lives at +// its final Phase-8 path, /api/v1/admin/topics, gated by requireAdmin. +export async function registerTopicRoutes(app: FastifyInstance) { + app.get( + '/api/v1/topics', + { + preHandler: requireAuth, + schema: { + response: { + 200: { + type: 'object', + properties: { topics: { type: 'array', items: topicWithStatsSchema } }, + }, + }, + }, + }, + async (req) => { + const topics = await listTopicsWithStats(req.currentUser!.id) + return { topics } + }, + ) + + app.get( + '/api/v1/topics/:slug', + { + preHandler: requireAuth, + schema: { + params: { type: 'object', properties: { slug: { type: 'string' } }, required: ['slug'] }, + response: { + 200: { type: 'object', properties: { topic: topicWithStatsSchema } }, + 404: { type: 'object' }, + }, + }, + }, + async (req, reply) => { + const { slug } = req.params as { slug: string } + const topic = await getTopicWithStatsBySlug(req.currentUser!.id, slug) + if (!topic) { + reply.code(404) + return { error: 'not_found' } + } + return { topic } + }, + ) + + app.patch( + '/api/v1/topics/:slug/subscription', + { + preHandler: requireAuth, + schema: { + params: { type: 'object', properties: { slug: { type: 'string' } }, required: ['slug'] }, + body: { + type: 'object', + required: ['pushEnabled'], + properties: { pushEnabled: { type: 'boolean' } }, + }, + }, + }, + async (req, reply) => { + const { slug } = req.params as { slug: string } + const { pushEnabled } = req.body as { pushEnabled: boolean } + + const topic = await getTopicBySlug(slug) + if (!topic) { + reply.code(404) + return { error: 'not_found' } + } + + const subscription = await setTopicPushEnabled(req.currentUser!.id, topic.id, pushEnabled) + return { subscription } + }, + ) + + app.patch( + '/api/v1/topics/:slug/snooze', + { + preHandler: requireAuth, + schema: { + params: { type: 'object', properties: { slug: { type: 'string' } }, required: ['slug'] }, + body: { + type: 'object', + // Minutes from now to snooze push for, or null to clear an + // existing snooze early — independent of the pushEnabled toggle. + properties: { minutes: { type: ['integer', 'null'], minimum: 1 } }, + }, + }, + }, + async (req, reply) => { + const { slug } = req.params as { slug: string } + const { minutes } = req.body as { minutes: number | null } + + const topic = await getTopicBySlug(slug) + if (!topic) { + reply.code(404) + return { error: 'not_found' } + } + + const mutedUntil = minutes ? new Date(Date.now() + minutes * 60_000) : null + const subscription = await setTopicMutedUntil(req.currentUser!.id, topic.id, mutedUntil) + return { subscription } + }, + ) + + app.post( + '/api/v1/admin/topics', + { + preHandler: requireAdmin, + schema: { + body: { + type: 'object', + required: ['slug', 'displayName'], + properties: { + slug: { type: 'string', minLength: 1, maxLength: 100, pattern: '^[a-zA-Z0-9_-]+$' }, + displayName: { type: 'string', minLength: 1, maxLength: 200 }, + description: { type: 'string' }, + icon: { type: 'string' }, + accentColor: { type: 'string' }, + retentionDays: { type: 'integer', minimum: 0 }, + restricted: { type: 'boolean' }, + }, + }, + response: { + 201: { type: 'object', properties: { topic: topicSchema } }, + 409: { type: 'object' }, + }, + }, + }, + async (req, reply) => { + const body = req.body as { + slug: string + displayName: string + description?: string + icon?: string + accentColor?: string + retentionDays?: number + restricted?: boolean + } + + const existing = await getTopicBySlug(body.slug) + if (existing) { + reply.code(409) + return { error: 'conflict', message: 'a topic with this slug already exists' } + } + + const topic = await createTopic({ ...body, createdBy: req.currentUser!.id }) + await writeAuditLog({ + actorUserId: req.currentUser!.id, + action: 'topic.create', + targetType: 'topic', + targetId: topic.id, + metadata: { slug: topic.slug, displayName: topic.displayName }, + ipAddress: req.ip, + }) + reply.code(201) + return { topic } + }, + ) + + app.patch( + '/api/v1/admin/topics/:id', + { + preHandler: requireAdmin, + schema: { + params: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] }, + body: { + type: 'object', + properties: { + displayName: { type: 'string', minLength: 1, maxLength: 200 }, + description: { type: 'string' }, + icon: { type: 'string' }, + accentColor: { type: 'string' }, + retentionDays: { type: 'integer', minimum: 0 }, + restricted: { type: 'boolean' }, + archived: { type: 'boolean' }, + }, + }, + response: { + 200: { type: 'object', properties: { topic: topicSchema } }, + 404: { type: 'object' }, + }, + }, + }, + async (req, reply) => { + const { id } = req.params as { id: string } + const body = req.body as Parameters[1] + + const topic = await updateTopic(id, body) + if (!topic) { + reply.code(404) + return { error: 'not_found' } + } + await writeAuditLog({ + actorUserId: req.currentUser!.id, + action: 'topic.update', + targetType: 'topic', + targetId: topic.id, + metadata: body as Record, + ipAddress: req.ip, + }) + return { topic } + }, + ) + + app.delete( + '/api/v1/admin/topics/:id', + { + preHandler: requireAdmin, + schema: { + params: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] }, + }, + }, + async (req, reply) => { + const { id } = req.params as { id: string } + const topic = await deleteTopic(id) + if (!topic) { + reply.code(404) + return { error: 'not_found' } + } + await writeAuditLog({ + actorUserId: req.currentUser!.id, + action: 'topic.delete', + targetType: 'topic', + targetId: topic.id, + metadata: { slug: topic.slug, displayName: topic.displayName }, + ipAddress: req.ip, + }) + return { status: 'ok' } + }, + ) +} diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json new file mode 100644 index 0000000..3fbff51 --- /dev/null +++ b/apps/server/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src"] +} diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..7819fd9 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,18 @@ + + + + + + + + + + + + Notizarr + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..6bc0c6e --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,36 @@ +{ + "name": "@notizarr/web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite build --watch", + "build": "vite build", + "preview": "vite preview", + "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.sw.json --noEmit", + "test": "echo \"no tests yet\" && exit 0", + "generate-icons": "node scripts/generate-icons.mjs" + }, + "dependencies": { + "@tanstack/react-query": "^5.59.0", + "lucide-react": "^0.451.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^7.18.3" + }, + "devDependencies": { + "@types/react": "^18.3.9", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.47", + "sharp": "^0.35.0", + "tailwindcss": "^3.4.13", + "typescript": "^5.6.2", + "vite": "^5.4.8", + "vite-plugin-pwa": "^0.20.5", + "workbox-precaching": "^7.1.0", + "workbox-routing": "^7.1.0", + "workbox-strategies": "^7.1.0" + } +} diff --git a/apps/web/postcss.config.js b/apps/web/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/apps/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/apps/web/public/icons/.gitkeep b/apps/web/public/icons/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/web/public/icons/apple-touch-icon.png b/apps/web/public/icons/apple-touch-icon.png new file mode 100644 index 0000000..279bfd6 Binary files /dev/null and b/apps/web/public/icons/apple-touch-icon.png differ diff --git a/apps/web/public/icons/favicon.svg b/apps/web/public/icons/favicon.svg new file mode 100644 index 0000000..e48d907 --- /dev/null +++ b/apps/web/public/icons/favicon.svg @@ -0,0 +1,17 @@ + + + + + + + + + + diff --git a/apps/web/public/icons/icon-192.png b/apps/web/public/icons/icon-192.png new file mode 100644 index 0000000..3e9f9ae Binary files /dev/null and b/apps/web/public/icons/icon-192.png differ diff --git a/apps/web/public/icons/icon-512.png b/apps/web/public/icons/icon-512.png new file mode 100644 index 0000000..91412a7 Binary files /dev/null and b/apps/web/public/icons/icon-512.png differ diff --git a/apps/web/public/icons/maskable-512.png b/apps/web/public/icons/maskable-512.png new file mode 100644 index 0000000..d095031 Binary files /dev/null and b/apps/web/public/icons/maskable-512.png differ diff --git a/apps/web/scripts/generate-icons.mjs b/apps/web/scripts/generate-icons.mjs new file mode 100644 index 0000000..0c57085 --- /dev/null +++ b/apps/web/scripts/generate-icons.mjs @@ -0,0 +1,42 @@ +// One-off generator, not part of the app's runtime or build. Run manually +// whenever the icon design changes: +// pnpm --filter @notizarr/web exec node scripts/generate-icons.mjs +// Requires `sharp` (devDependency) — rasterizes the hand-written SVG +// sources into the PNG sizes PWA manifests / iOS actually need. Output is +// committed to public/icons/, so this script does not need to run in CI or +// in the Docker build. +import { mkdir, readFile, writeFile, copyFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import sharp from 'sharp' + +const scriptsDir = fileURLToPath(new URL('.', import.meta.url)) +const outDir = path.resolve(scriptsDir, '../public/icons') + +async function render(svgPath, outPath, size) { + const svg = await readFile(svgPath) + await sharp(svg).resize(size, size).png().toFile(outPath) + console.log(`wrote ${path.relative(scriptsDir, outPath)} (${size}x${size})`) +} + +async function main() { + await mkdir(outDir, { recursive: true }) + + const standard = path.join(scriptsDir, 'icon-standard.svg') + const maskable = path.join(scriptsDir, 'icon-maskable.svg') + + await render(standard, path.join(outDir, 'icon-192.png'), 192) + await render(standard, path.join(outDir, 'icon-512.png'), 512) + await render(maskable, path.join(outDir, 'maskable-512.png'), 512) + await render(maskable, path.join(outDir, 'apple-touch-icon.png'), 180) + + await copyFile(standard, path.join(outDir, 'favicon.svg')) + console.log('wrote favicon.svg') + + await writeFile(path.join(outDir, '.gitkeep'), '') +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/apps/web/scripts/icon-maskable.svg b/apps/web/scripts/icon-maskable.svg new file mode 100644 index 0000000..b40c080 --- /dev/null +++ b/apps/web/scripts/icon-maskable.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + diff --git a/apps/web/scripts/icon-standard.svg b/apps/web/scripts/icon-standard.svg new file mode 100644 index 0000000..e48d907 --- /dev/null +++ b/apps/web/scripts/icon-standard.svg @@ -0,0 +1,17 @@ + + + + + + + + + + diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..618bf68 --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,97 @@ +import { Navigate, Route, Routes } from 'react-router-dom' +import { AppShell } from './components/layout/AppShell' +import { useSession } from './hooks/useSession' +import { AdminLayout } from './pages/admin/AdminLayout' +import { AuditLogPage } from './pages/admin/AuditLogPage' +import { DashboardPage } from './pages/admin/DashboardPage' +import { DevicesAdminPage } from './pages/admin/DevicesAdminPage' +import { SourcesAdminPage } from './pages/admin/SourcesAdminPage' +import { AuthSettingsAdminPage } from './pages/admin/AuthSettingsAdminPage' +import { SystemSettingsAdminPage } from './pages/admin/SystemSettingsAdminPage' +import { BackupsAdminPage } from './pages/admin/BackupsAdminPage' +import { TopicStatsAdminPage } from './pages/admin/TopicStatsAdminPage' +import { TokensAdminPage } from './pages/admin/TokensAdminPage' +import { TopicsAdminPage } from './pages/admin/TopicsAdminPage' +import { UsersAdminPage } from './pages/admin/UsersAdminPage' +import { DiagnosticsPage } from './pages/DiagnosticsPage' +import { InboxPage } from './pages/InboxPage' +import { NotificationDetailPage } from './pages/NotificationDetailPage' +import { SearchPage } from './pages/SearchPage' +import { SettingsPage } from './pages/SettingsPage' +import { StarredPage } from './pages/StarredPage' +import { TopicDetailPage } from './pages/TopicDetailPage' +import { TopicsPage } from './pages/TopicsPage' + +function SignInScreen() { + return ( +
+
+ N +
+
+

Notizarr

+

Sign in to see your notifications.

+
+ + Sign in + +
+ ) +} + +export default function App() { + const { data: session, isLoading } = useSession() + + if (isLoading) { + return
+ } + + if (!session?.user) { + if (session?.authMode === 'oidc') return + // proxy mode with no trusted headers reaching us, or a config problem — + // there's nothing a client-side redirect can do about either. + return ( +
+ Not authenticated. Check AUTH_MODE / trusted proxy configuration. +
+ ) + } + + const isAdmin = session.user.role === 'admin' + + return ( + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + {isAdmin && ( + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + )} + + } /> + + + ) +} diff --git a/apps/web/src/components/admin/DailyBarChart.tsx b/apps/web/src/components/admin/DailyBarChart.tsx new file mode 100644 index 0000000..46f9a46 --- /dev/null +++ b/apps/web/src/components/admin/DailyBarChart.tsx @@ -0,0 +1,27 @@ +// Hand-rolled instead of pulling in a charting library — this is one bar +// chart on one admin page, and the design brief calls for graphs to be +// "tasteful and minimal", not feature-rich. +export function DailyBarChart({ data }: { data: { day: string; count: number }[] }) { + const max = Math.max(1, ...data.map((d) => d.count)) + + return ( +
+ {data.map((d) => { + const heightPct = (d.count / max) * 100 + const date = new Date(`${d.day}T00:00:00`) + const label = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + return ( +
+
0 ? 4 : 1)}%` }} + /> +
+ {label}: {d.count} +
+
+ ) + })} +
+ ) +} diff --git a/apps/web/src/components/admin/DonutChart.tsx b/apps/web/src/components/admin/DonutChart.tsx new file mode 100644 index 0000000..e5f69f6 --- /dev/null +++ b/apps/web/src/components/admin/DonutChart.tsx @@ -0,0 +1,49 @@ +// CSS conic-gradient donut — same "hand-rolled, no charting library" call +// as DailyBarChart. A dedicated color per slice comes from the caller so +// it stays consistent with wherever else that entity is colored (e.g. +// colorForLabel for sources). +export interface DonutSlice { + label: string + value: number + color: string +} + +export function DonutChart({ data, size = 112 }: { data: DonutSlice[]; size?: number }) { + const total = data.reduce((sum, d) => sum + d.value, 0) + + let cursor = 0 + const stops = data + .filter((d) => d.value > 0) + .map((d) => { + const start = cursor + cursor += (d.value / total) * 100 + return `${d.color} ${start}% ${cursor}%` + }) + + const gradient = + total > 0 + ? `conic-gradient(${stops.join(', ')})` + : 'conic-gradient(var(--color-base-border, #262b36) 0% 100%)' + + return ( +
+
+
+
+ {total.toLocaleString()} +
+
+
    + {data.map((d) => ( +
  • + + {d.label} + + {total > 0 ? Math.round((d.value / total) * 100) : 0}% + +
  • + ))} +
+
+ ) +} diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx new file mode 100644 index 0000000..db350fa --- /dev/null +++ b/apps/web/src/components/layout/AppShell.tsx @@ -0,0 +1,32 @@ +import { Outlet } from 'react-router-dom' +import { useNotificationStream } from '../../hooks/useNotificationStream' +import { usePushDeepLink } from '../../hooks/usePushDeepLink' +import { BottomNav } from './BottomNav' +import { Sidebar } from './Sidebar' + +// h-[100dvh] + an internally-scrolling
, rather than `position: +// fixed` + a scrolling page, is deliberate: fixed-position bottom nav bars +// are notoriously unreliable on iOS (dynamic toolbar height, viewport +// quirks, inconsistent behavior between Safari/installed-PWA/other +// webviews) — a normal flex layout where the nav is just the last child +// pins it correctly everywhere without fighting any of that. +export function AppShell() { + useNotificationStream() + usePushDeepLink() + + return ( +
+ +
+ {/* overflow-x-hidden is explicit, not redundant with overflow-y-auto — + per the CSS overflow spec, setting only overflow-y makes overflow-x + compute to auto too, so long unbroken content (a URL, a long word) + could otherwise make this scroll sideways within its own box. */} +
+ +
+ +
+
+ ) +} diff --git a/apps/web/src/components/layout/BottomNav.tsx b/apps/web/src/components/layout/BottomNav.tsx new file mode 100644 index 0000000..4b7cb6b --- /dev/null +++ b/apps/web/src/components/layout/BottomNav.tsx @@ -0,0 +1,37 @@ +import { NavLink } from 'react-router-dom' +import { cn } from '../../lib/utils' +import { navItems } from './nav-items' + +export function BottomNav() { + return ( + + ) +} diff --git a/apps/web/src/components/layout/Sidebar.tsx b/apps/web/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..6c4ec74 --- /dev/null +++ b/apps/web/src/components/layout/Sidebar.tsx @@ -0,0 +1,39 @@ +import { NavLink } from 'react-router-dom' +import { cn } from '../../lib/utils' +import { navItems } from './nav-items' + +export function Sidebar() { + return ( + + ) +} diff --git a/apps/web/src/components/layout/nav-items.ts b/apps/web/src/components/layout/nav-items.ts new file mode 100644 index 0000000..02b1c0d --- /dev/null +++ b/apps/web/src/components/layout/nav-items.ts @@ -0,0 +1,9 @@ +import { Bell, Hash, Search, Settings, Star } from 'lucide-react' + +export const navItems = [ + { to: '/', label: 'Inbox', icon: Bell, end: true }, + { to: '/topics', label: 'Topics', icon: Hash, end: false }, + { to: '/search', label: 'Search', icon: Search, end: false }, + { to: '/starred', label: 'Starred', icon: Star, end: false }, + { to: '/settings', label: 'Settings', icon: Settings, end: false }, +] as const diff --git a/apps/web/src/components/notifications/NotificationRow.tsx b/apps/web/src/components/notifications/NotificationRow.tsx new file mode 100644 index 0000000..863b10a --- /dev/null +++ b/apps/web/src/components/notifications/NotificationRow.tsx @@ -0,0 +1,156 @@ +import { BellOff, Check, Star } from 'lucide-react' +import { useRef, useState } from 'react' +import { Link } from 'react-router-dom' +import { useSetNotificationState } from '../../hooks/useNotifications' +import { cn, colorForLabel, formatRelativeTime } from '../../lib/utils' +import { isQuietPriority } from '../../lib/priority' +import type { Notification } from '../../types' + +const SWIPE_THRESHOLD = 64 + +export function NotificationRow({ + notification, + showTopic = true, +}: { + notification: Notification + showTopic?: boolean +}) { + const avatarColor = colorForLabel(notification.source) + const setState = useSetNotificationState() + + // Hand-rolled swipe-to-toggle-read, touch only — pointer position is + // tracked in refs (not state) so a swipe doesn't cause a re-render on + // every pixel of movement; `dragX` state only updates for the visual + // transform, and `wasSwipe` suppresses the Link's navigation on release + // so a swipe doesn't also open the notification. + const startX = useRef(null) + const startY = useRef(null) + const wasSwipe = useRef(false) + const [dragX, setDragX] = useState(0) + const [dragging, setDragging] = useState(false) + + const handlePointerDown = (e: React.PointerEvent) => { + if (e.pointerType !== 'touch') return + startX.current = e.clientX + startY.current = e.clientY + } + + const handlePointerMove = (e: React.PointerEvent) => { + if (startX.current === null || startY.current === null) return + const dx = e.clientX - startX.current + const dy = e.clientY - startY.current + if (Math.abs(dy) > Math.abs(dx) + 8) return // vertical scroll, not a swipe + setDragging(true) + setDragX(Math.max(-120, Math.min(120, dx))) + } + + const endDrag = () => { + if (Math.abs(dragX) > SWIPE_THRESHOLD) { + wasSwipe.current = true + setState.mutate({ id: notification.id, changes: { read: !notification.read } }) + } + startX.current = null + startY.current = null + setDragging(false) + setDragX(0) + } + + const handleClick = (e: React.MouseEvent) => { + if (wasSwipe.current) { + e.preventDefault() + wasSwipe.current = false + } + } + + const revealingRead = dragX !== 0 + + return ( +
+ {revealingRead && ( +
0 + ? 'justify-start bg-emerald-500/15 text-emerald-400' + : 'justify-end bg-emerald-500/15 text-emerald-400', + )} + > + + + {notification.read ? 'Mark unread' : 'Mark read'} + +
+ )} + +
+
+ {notification.imageUrl ? ( + + ) : ( + + {notification.source.slice(0, 2)} + + )} +
+ {!notification.read && ( + + )} +
+ +
+
+

+ {notification.title} +

+ {notification.starred && ( + + )} +
+

{notification.message}

+

+ {showTopic && ( + <> + + {notification.topicDisplayName} + + · + + )} + {formatRelativeTime(notification.createdAt)} + {notification.occurrenceCount > 1 && ( + <> + · + ×{notification.occurrenceCount} + + )} + {isQuietPriority(notification.rawPriority) && ( + <> + · + + + + + )} +

+
+ +
+ ) +} diff --git a/apps/web/src/components/settings/PushSection.tsx b/apps/web/src/components/settings/PushSection.tsx new file mode 100644 index 0000000..6a99acf --- /dev/null +++ b/apps/web/src/components/settings/PushSection.tsx @@ -0,0 +1,103 @@ +import { Smartphone, X } from 'lucide-react' +import { Link } from 'react-router-dom' +import { useDeleteDevice, useDevices } from '../../hooks/usePushDevices' +import { usePushSubscription } from '../../hooks/usePushSubscription' +import { formatRelativeTime } from '../../lib/utils' +import { friendlyDeviceName } from '../../lib/userAgent' + +function PermissionBadge({ permission }: { permission: string }) { + const styles: Record = { + granted: 'bg-emerald-500/15 text-emerald-400', + denied: 'bg-red-500/15 text-red-400', + default: 'bg-base-raised text-slate-400', + unsupported: 'bg-base-raised text-slate-500', + } + return ( + + {permission} + + ) +} + +export function PushSection() { + const { permission, subscription, isBusy, error, enable, disable, supported } = usePushSubscription() + const { data: devices } = useDevices() + const deleteDevice = useDeleteDevice() + + return ( +
+
+

Push notifications

+ +
+ + {!supported && ( +

+ This browser doesn't support Web Push. On iPhone, install Notizarr to the Home Screen first + (Share → Add to Home Screen) — Safari only supports push for installed PWAs. +

+ )} + + {supported && ( + <> +

+ {subscription + ? 'Push is enabled on this device. New notifications will arrive even when Notizarr is closed.' + : 'Enable to get notified here even when the app is closed.'} +

+ + + + {permission === 'denied' && ( +

+ Notifications are blocked for this site — re-enable them in your browser's site settings. +

+ )} + {error &&

{error}

} + + )} + + {devices && devices.length > 0 && ( +
+

+ Devices ({devices.length}) +

+ {devices.map((device) => ( +
+ +
+

+ {device.deviceLabel ?? friendlyDeviceName(device.userAgent)} +

+

+ {device.disabledAt ? 'Disabled' : `Last seen ${formatRelativeTime(device.lastSeenAt)}`} +

+
+ +
+ ))} +
+ )} + + + Push diagnostics → + +
+ ) +} diff --git a/apps/web/src/components/topics/TopicCard.tsx b/apps/web/src/components/topics/TopicCard.tsx new file mode 100644 index 0000000..009d4b3 --- /dev/null +++ b/apps/web/src/components/topics/TopicCard.tsx @@ -0,0 +1,45 @@ +import { Bell, BellOff, Hash } from 'lucide-react' +import { Link } from 'react-router-dom' +import { formatRelativeTime } from '../../lib/utils' +import type { Topic } from '../../types' + +export function TopicCard({ topic }: { topic: Topic }) { + return ( + +
+ {topic.icon ? ( + {topic.icon} + ) : ( + + )} +
+ +
+
+

{topic.displayName}

+ {topic.unreadCount > 0 && ( + + {topic.unreadCount} + + )} +
+

+ {topic.totalCount.toLocaleString()} total + {topic.latestAt && <> · latest {formatRelativeTime(topic.latestAt)}} +

+
+ + {topic.pushEnabled ? ( + + ) : ( + + )} + + ) +} diff --git a/apps/web/src/components/ui/Checkbox.tsx b/apps/web/src/components/ui/Checkbox.tsx new file mode 100644 index 0000000..0b1fb65 --- /dev/null +++ b/apps/web/src/components/ui/Checkbox.tsx @@ -0,0 +1,30 @@ +import { Check } from 'lucide-react' +import { cn } from '../../lib/utils' + +export function Checkbox({ + checked, + onChange, + label, +}: { + checked: boolean + onChange: (checked: boolean) => void + label?: string +}) { + return ( + + ) +} diff --git a/apps/web/src/components/ui/EmptyState.tsx b/apps/web/src/components/ui/EmptyState.tsx new file mode 100644 index 0000000..3f8a3e7 --- /dev/null +++ b/apps/web/src/components/ui/EmptyState.tsx @@ -0,0 +1,27 @@ +import type { LucideIcon } from 'lucide-react' +import type { ReactNode } from 'react' + +export function EmptyState({ + icon: Icon, + title, + description, + action, +}: { + icon: LucideIcon + title: string + description?: string + action?: ReactNode +}) { + return ( +
+
+ +
+
+

{title}

+ {description &&

{description}

} +
+ {action} +
+ ) +} diff --git a/apps/web/src/components/ui/PageHeader.tsx b/apps/web/src/components/ui/PageHeader.tsx new file mode 100644 index 0000000..e172e4e --- /dev/null +++ b/apps/web/src/components/ui/PageHeader.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from 'react' + +export function PageHeader({ + title, + subtitle, + action, +}: { + title: string + subtitle?: string + action?: ReactNode +}) { + return ( +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+ {action} +
+ ) +} diff --git a/apps/web/src/components/ui/Skeleton.tsx b/apps/web/src/components/ui/Skeleton.tsx new file mode 100644 index 0000000..19ab9da --- /dev/null +++ b/apps/web/src/components/ui/Skeleton.tsx @@ -0,0 +1,29 @@ +import { cn } from '../../lib/utils' + +export function Skeleton({ className }: { className?: string }) { + return
+} + +export function TopicCardSkeleton() { + return ( +
+ +
+ + +
+
+ ) +} + +export function NotificationRowSkeleton() { + return ( +
+ +
+ + +
+
+ ) +} diff --git a/apps/web/src/components/ui/Switch.tsx b/apps/web/src/components/ui/Switch.tsx new file mode 100644 index 0000000..d8ba338 --- /dev/null +++ b/apps/web/src/components/ui/Switch.tsx @@ -0,0 +1,35 @@ +import { cn } from '../../lib/utils' + +export function Switch({ + checked, + onChange, + disabled, + label, +}: { + checked: boolean + onChange: (checked: boolean) => void + disabled?: boolean + label?: string +}) { + return ( + + ) +} diff --git a/apps/web/src/hooks/useAdmin.ts b/apps/web/src/hooks/useAdmin.ts new file mode 100644 index 0000000..7e4b90e --- /dev/null +++ b/apps/web/src/hooks/useAdmin.ts @@ -0,0 +1,293 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { apiFetch, apiPatch, apiPost } from '../lib/api' + +export interface DashboardStats { + notificationsToday: number + notificationsLast24h: number + pushAttemptsLast24h: number + pushFailuresLast24h: number + activeDevices: number + userCount: number + topicCount: number + topTopics: { slug: string; displayName: string; total: number }[] + notificationsByDay: { day: string; count: number }[] + noisiestSources: { source: string; total: number; noisy: boolean }[] + recentAuditEvents: AuditEntry[] +} + +export interface AuditEntry { + id: string + actorUserId: string | null + actorTokenId: string | null + action: string + targetType: string | null + targetId: string | null + metadata: Record | null + ipAddress: string | null + createdAt: string +} + +export interface AdminUser { + id: string + email: string + displayName: string + role: 'admin' | 'user' + createdAt: string + lastLoginAt: string | null +} + +export interface AdminDevice { + id: string + deviceLabel: string | null + userAgent: string | null + createdAt: string + lastSeenAt: string + disabledAt: string | null + userEmail: string + userDisplayName: string +} + +export interface AdminToken { + id: string + name: string + tokenPrefix: string + allowedSource: string | null + allowedCidrs: string[] | null + expiresAt: string | null + revokedAt: string | null + createdAt: string + lastUsedAt: string | null + usageCount: number +} + +export function useDashboardStats() { + return useQuery({ + queryKey: ['admin', 'dashboard'], + queryFn: () => apiFetch('/api/v1/admin/stats/dashboard'), + refetchInterval: 30_000, + }) +} + +export function useAdminUsers() { + return useQuery({ + queryKey: ['admin', 'users'], + queryFn: () => apiFetch<{ users: AdminUser[] }>('/api/v1/admin/users').then((r) => r.users), + }) +} + +export function useSetUserRole() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ id, role }: { id: string; role: 'admin' | 'user' }) => + apiPatch(`/api/v1/admin/users/${id}`, { role }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }), + }) +} + +export function useAdminDevices() { + return useQuery({ + queryKey: ['admin', 'devices'], + queryFn: () => apiFetch<{ devices: AdminDevice[] }>('/api/v1/admin/devices').then((r) => r.devices), + }) +} + +export interface TopicStats { + dailyCounts: { day: string; count: number }[] + topMessages: { message: string; total: number }[] +} + +export function useTopicStats(slug: string | undefined) { + return useQuery({ + queryKey: ['admin', 'stats', 'topics', slug], + queryFn: () => apiFetch(`/api/v1/admin/stats/topics/${slug}`), + enabled: Boolean(slug), + }) +} + +export function useAdminSources() { + return useQuery({ + queryKey: ['admin', 'sources'], + queryFn: () => + apiFetch<{ sources: { source: string; last24h: number; noisy: boolean }[] }>( + '/api/v1/admin/sources', + ).then((r) => r.sources), + }) +} + +export interface OidcSettings { + authMode: 'oidc' | 'proxy' | 'disabled' + issuerUrl: string + clientId: string + clientSecretSet: boolean + redirectUrl: string + scopes: string + effectiveRedirectUrl: string +} + +export function useOidcSettings() { + return useQuery({ + queryKey: ['admin', 'settings', 'oidc'], + queryFn: () => apiFetch('/api/v1/admin/settings/oidc'), + }) +} + +export function useUpdateOidcSettings() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (input: { + issuerUrl: string + clientId: string + clientSecret?: string + redirectUrl: string + scopes: string + }) => apiPatch('/api/v1/admin/settings/oidc', input), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin', 'settings', 'oidc'] }), + }) +} + +export interface QuietHoursSettings { + enabled: boolean + start: string + end: string + timezone: string + minPriority: number +} + +export interface SystemSettings { + retentionDefaultDays: number + vapidSubject: string + quietHours: QuietHoursSettings +} + +export function useSystemSettings() { + return useQuery({ + queryKey: ['admin', 'settings', 'system'], + queryFn: () => apiFetch('/api/v1/admin/settings/system'), + }) +} + +export function useUpdateSystemSettings() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (input: Partial) => apiPatch('/api/v1/admin/settings/system', input), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin', 'settings', 'system'] }), + }) +} + +export interface BackupFile { + filename: string + sizeBytes: number + createdAt: string +} + +export function useBackups() { + return useQuery({ + queryKey: ['admin', 'backups'], + queryFn: () => apiFetch<{ backups: BackupFile[] }>('/api/v1/admin/backups').then((r) => r.backups), + refetchInterval: 30_000, + }) +} + +export function useCreateBackup() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: () => apiPost<{ backup: BackupFile }>('/api/v1/admin/backups'), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin', 'backups'] }), + }) +} + +export function useAuditLog() { + return useQuery({ + queryKey: ['admin', 'audit-log'], + queryFn: () => apiFetch<{ entries: AuditEntry[] }>('/api/v1/admin/audit-log').then((r) => r.entries), + }) +} + +export function useAdminTokens() { + return useQuery({ + queryKey: ['admin', 'tokens'], + queryFn: () => apiFetch<{ tokens: AdminToken[] }>('/api/v1/admin/tokens').then((r) => r.tokens), + }) +} + +export function useCreateToken() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (input: { name: string; allowedSource?: string; topicSlugs?: string[] }) => + apiPost<{ token: AdminToken; plaintext: string }>('/api/v1/admin/tokens', input), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin', 'tokens'] }), + }) +} + +export function useRevokeToken() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: string) => apiFetch(`/api/v1/admin/tokens/${id}`, { method: 'DELETE' }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin', 'tokens'] }), + }) +} + +export function useDeleteToken() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: string) => apiFetch(`/api/v1/admin/tokens/${id}/purge`, { method: 'DELETE' }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin', 'tokens'] }), + }) +} + +export function useRotateToken() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: string) => + apiPost<{ token: AdminToken; plaintext: string }>(`/api/v1/admin/tokens/${id}/rotate`), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin', 'tokens'] }), + }) +} + +export function useCreateTopic() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (input: { slug: string; displayName: string; description?: string }) => + apiPost('/api/v1/admin/topics', input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['topics'] }) + queryClient.invalidateQueries({ queryKey: ['admin', 'dashboard'] }) + }, + }) +} + +export function useUpdateTopic() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ + id, + changes, + }: { + id: string + changes: { retentionDays?: number | null; restricted?: boolean; archived?: boolean } + }) => apiPatch(`/api/v1/admin/topics/${id}`, changes), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['topics'] }), + }) +} + +export function useDeleteTopic() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: string) => apiFetch(`/api/v1/admin/topics/${id}`, { method: 'DELETE' }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['topics'] }) + queryClient.invalidateQueries({ queryKey: ['admin', 'dashboard'] }) + }, + }) +} + +export function useDeleteNotification() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: string) => apiFetch(`/api/v1/notifications/${id}`, { method: 'DELETE' }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['notifications'] }) + queryClient.invalidateQueries({ queryKey: ['topics'] }) + }, + }) +} diff --git a/apps/web/src/hooks/useNotificationStream.ts b/apps/web/src/hooks/useNotificationStream.ts new file mode 100644 index 0000000..179ac0d --- /dev/null +++ b/apps/web/src/hooks/useNotificationStream.ts @@ -0,0 +1,25 @@ +import { useQueryClient } from '@tanstack/react-query' +import { useEffect } from 'react' + +// Subscribes to the SSE stream (docs/phase1/01-architecture.md's realtime +// design) and invalidates the relevant queries on every event, so the UI +// updates the moment a new notification arrives without polling. +export function useNotificationStream() { + const queryClient = useQueryClient() + + useEffect(() => { + const source = new EventSource('/api/v1/stream') + + const invalidate = () => { + queryClient.invalidateQueries({ queryKey: ['notifications'] }) + queryClient.invalidateQueries({ queryKey: ['topics'] }) + } + + source.addEventListener('notification.created', invalidate) + source.addEventListener('notification.updated', invalidate) + + return () => { + source.close() + } + }, [queryClient]) +} diff --git a/apps/web/src/hooks/useNotifications.ts b/apps/web/src/hooks/useNotifications.ts new file mode 100644 index 0000000..c579379 --- /dev/null +++ b/apps/web/src/hooks/useNotifications.ts @@ -0,0 +1,64 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { apiFetch, apiPatch, apiPost } from '../lib/api' +import type { Notification } from '../types' + +export interface NotificationFilter { + topic?: string + unread?: boolean + starred?: boolean + q?: string +} + +function toQueryString(filter: NotificationFilter): string { + const params = new URLSearchParams() + if (filter.topic) params.set('topic', filter.topic) + if (filter.unread) params.set('unread', 'true') + if (filter.starred) params.set('starred', 'true') + if (filter.q) params.set('q', filter.q) + const qs = params.toString() + return qs ? `?${qs}` : '' +} + +export function useNotifications(filter: NotificationFilter = {}) { + return useQuery({ + queryKey: ['notifications', filter], + queryFn: () => + apiFetch<{ notifications: Notification[] }>(`/api/v1/notifications${toQueryString(filter)}`).then( + (r) => r.notifications, + ), + }) +} + +export function useNotification(id: string | undefined) { + return useQuery({ + queryKey: ['notifications', 'detail', id], + queryFn: () => apiFetch<{ notification: Notification }>(`/api/v1/notifications/${id}`).then((r) => r.notification), + enabled: Boolean(id), + }) +} + +export function useMarkAllRead() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (topic?: string) => + apiPost<{ status: string; count: number }>( + `/api/v1/notifications/mark-all-read${topic ? `?topic=${encodeURIComponent(topic)}` : ''}`, + ), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['notifications'] }) + queryClient.invalidateQueries({ queryKey: ['topics'] }) + }, + }) +} + +export function useSetNotificationState() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ id, changes }: { id: string; changes: { read?: boolean; starred?: boolean } }) => + apiPatch<{ notification: Notification }>(`/api/v1/notifications/${id}`, changes), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['notifications'] }) + queryClient.invalidateQueries({ queryKey: ['topics'] }) + }, + }) +} diff --git a/apps/web/src/hooks/usePushDeepLink.ts b/apps/web/src/hooks/usePushDeepLink.ts new file mode 100644 index 0000000..6ed33f3 --- /dev/null +++ b/apps/web/src/hooks/usePushDeepLink.ts @@ -0,0 +1,24 @@ +import { useEffect } from 'react' +import { useNavigate } from 'react-router-dom' + +// Handles the "app already open" half of the deep-link contract in +// docs/phase1/05-pwa-web-push-design.md: sw.ts's notificationclick handler +// focuses an existing window and postMessages it the target path instead of +// doing a full navigation itself (a service worker can't touch React +// Router's client-side history). +export function usePushDeepLink() { + const navigate = useNavigate() + + useEffect(() => { + if (!('serviceWorker' in navigator)) return + + const onMessage = (event: MessageEvent) => { + if (event.data?.type === 'navigate' && typeof event.data.path === 'string') { + navigate(event.data.path) + } + } + + navigator.serviceWorker.addEventListener('message', onMessage) + return () => navigator.serviceWorker.removeEventListener('message', onMessage) + }, [navigate]) +} diff --git a/apps/web/src/hooks/usePushDevices.ts b/apps/web/src/hooks/usePushDevices.ts new file mode 100644 index 0000000..bcfcd31 --- /dev/null +++ b/apps/web/src/hooks/usePushDevices.ts @@ -0,0 +1,50 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { apiFetch } from '../lib/api' + +export interface Device { + id: string + deviceLabel: string | null + userAgent: string | null + createdAt: string + lastSeenAt: string + disabledAt: string | null +} + +export interface DeliveryAttempt { + status: string + httpStatus: number | null + errorMessage: string | null + attemptedAt: string +} + +export interface DiagnosticsDevice extends Device { + lastDeliveryAttempt: DeliveryAttempt | null +} + +export function useDevices() { + return useQuery({ + queryKey: ['devices'], + queryFn: () => apiFetch<{ devices: Device[] }>('/api/v1/devices').then((r) => r.devices), + }) +} + +export function usePushDiagnostics() { + return useQuery({ + queryKey: ['push-diagnostics'], + queryFn: () => + apiFetch<{ vapidConfigured: boolean; devices: DiagnosticsDevice[] }>( + '/api/v1/push/diagnostics', + ), + }) +} + +export function useDeleteDevice() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: string) => apiFetch(`/api/v1/devices/${id}`, { method: 'DELETE' }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['devices'] }) + queryClient.invalidateQueries({ queryKey: ['push-diagnostics'] }) + }, + }) +} diff --git a/apps/web/src/hooks/usePushSubscription.ts b/apps/web/src/hooks/usePushSubscription.ts new file mode 100644 index 0000000..aa67202 --- /dev/null +++ b/apps/web/src/hooks/usePushSubscription.ts @@ -0,0 +1,80 @@ +import { useCallback, useEffect, useState } from 'react' +import { apiFetch, apiPost } from '../lib/api' +import { isPushSupported, urlBase64ToUint8Array } from '../lib/push' + +export type PushPermission = NotificationPermission | 'unsupported' + +export function usePushSubscription() { + const [permission, setPermission] = useState( + isPushSupported() ? Notification.permission : 'unsupported', + ) + const [subscription, setSubscription] = useState(null) + const [isBusy, setIsBusy] = useState(false) + const [error, setError] = useState(null) + + const refresh = useCallback(async () => { + if (!isPushSupported()) return + const registration = await navigator.serviceWorker.ready + const sub = await registration.pushManager.getSubscription() + setSubscription(sub) + setPermission(Notification.permission) + }, []) + + useEffect(() => { + void refresh() + }, [refresh]) + + const enable = useCallback(async () => { + if (!isPushSupported()) { + setError('Push is not supported in this browser.') + return + } + setIsBusy(true) + setError(null) + try { + const result = await Notification.requestPermission() + setPermission(result) + if (result !== 'granted') { + setError('Notification permission was not granted.') + return + } + + const { publicKey } = await apiFetch<{ publicKey: string }>('/api/v1/push/vapid-public-key') + const registration = await navigator.serviceWorker.ready + const sub = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(publicKey), + }) + + const json = sub.toJSON() + await apiPost('/api/v1/push/subscribe', { + endpoint: json.endpoint, + keys: json.keys, + userAgent: navigator.userAgent, + }) + + setSubscription(sub) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to enable push notifications.') + } finally { + setIsBusy(false) + } + }, []) + + const disable = useCallback(async () => { + setIsBusy(true) + setError(null) + try { + const registration = await navigator.serviceWorker.ready + const sub = await registration.pushManager.getSubscription() + await sub?.unsubscribe() + setSubscription(null) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to disable push notifications.') + } finally { + setIsBusy(false) + } + }, []) + + return { permission, subscription, isBusy, error, enable, disable, supported: isPushSupported() } +} diff --git a/apps/web/src/hooks/useSession.ts b/apps/web/src/hooks/useSession.ts new file mode 100644 index 0000000..d188eb6 --- /dev/null +++ b/apps/web/src/hooks/useSession.ts @@ -0,0 +1,25 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { apiFetch, apiPost } from '../lib/api' +import type { SessionResponse } from '../types' + +export function useSession() { + return useQuery({ + queryKey: ['session'], + queryFn: () => apiFetch('/api/v1/session'), + staleTime: 60_000, + }) +} + +export function useLogout() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: () => apiPost('/api/v1/auth/logout'), + // Full reload rather than just invalidating the session query — clears + // any in-memory query cache holding data from the now-logged-out user, + // and re-runs App.tsx's session check from a clean slate. + onSuccess: () => { + queryClient.clear() + window.location.assign('/') + }, + }) +} diff --git a/apps/web/src/hooks/useTopics.ts b/apps/web/src/hooks/useTopics.ts new file mode 100644 index 0000000..09bb2e7 --- /dev/null +++ b/apps/web/src/hooks/useTopics.ts @@ -0,0 +1,41 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { apiFetch, apiPatch } from '../lib/api' +import type { Topic } from '../types' + +export function useTopics() { + return useQuery({ + queryKey: ['topics'], + queryFn: () => apiFetch<{ topics: Topic[] }>('/api/v1/topics').then((r) => r.topics), + }) +} + +export function useTopic(slug: string | undefined) { + return useQuery({ + queryKey: ['topics', slug], + queryFn: () => apiFetch<{ topic: Topic }>(`/api/v1/topics/${slug}`).then((r) => r.topic), + enabled: Boolean(slug), + }) +} + +export function useSetTopicPush() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ slug, pushEnabled }: { slug: string; pushEnabled: boolean }) => + apiPatch(`/api/v1/topics/${slug}/subscription`, { pushEnabled }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['topics'] }) + }, + }) +} + +// `minutes: null` clears an active snooze early. +export function useSnoozeTopicPush() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ slug, minutes }: { slug: string; minutes: number | null }) => + apiPatch(`/api/v1/topics/${slug}/snooze`, { minutes }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['topics'] }) + }, + }) +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css new file mode 100644 index 0000000..5fdfe5c --- /dev/null +++ b/apps/web/src/index.css @@ -0,0 +1,66 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +html, +body, +#root { + height: 100%; +} + +/* Notizarr is dark-first with no light mode, so this is unconditional — + tells the browser to use its own dark widget theme for anything it + renders itself: + pickers, checkboxes, scrollbars. Without it those stay light regardless + of how the surrounding elements are styled, since that part of the UI + isn't actually drawn by our CSS. */ +:root { + color-scheme: dark; +} + +body { + font-family: + ui-sans-serif, + system-ui, + -apple-system, + 'Segoe UI', + Roboto, + 'Helvetica Neue', + Arial, + sans-serif; + -webkit-font-smoothing: antialiased; +} + +/* iOS Safari auto-zooms the page on focus for any text input under 16px, + and doesn't always zoom back out cleanly — forcing 16px here (rather + than bumping every input's Tailwind text size, which would also change + how they look) is the standard fix. !important is genuinely needed, not + just convenient: Tailwind's own size utilities (e.g. text-sm) are class + selectors, which beat a plain `input { }` element selector in the + cascade regardless of source order, so a bare rule here silently loses + to whatever text-size class an individual input uses. */ +input, +textarea, +select { + font-size: 16px !important; +} + +/* Safe-area padding for iPhone notch / Dynamic Island / home indicator. */ +.safe-top { + padding-top: env(safe-area-inset-top); +} +.safe-bottom { + padding-bottom: env(safe-area-inset-bottom); +} + +::-webkit-scrollbar { + width: 10px; + height: 10px; +} +::-webkit-scrollbar-thumb { + background: theme('colors.base.border'); + border-radius: 999px; +} +::-webkit-scrollbar-track { + background: transparent; +} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts new file mode 100644 index 0000000..2fd6313 --- /dev/null +++ b/apps/web/src/lib/api.ts @@ -0,0 +1,45 @@ +const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']) + +export class ApiError extends Error { + constructor( + public status: number, + public body: unknown, + ) { + super(typeof body === 'object' && body && 'message' in body ? String(body.message) : `Request failed (${status})`) + } +} + +// Every mutating call needs X-Requested-With — the CSRF defense described +// in docs/phase1/02-threat-model.md. Cookies are same-origin, so no +// Authorization header is needed here (that's only for LAN publishers). +export async function apiFetch(path: string, init: RequestInit = {}): Promise { + const method = (init.method ?? 'GET').toUpperCase() + const headers = new Headers(init.headers) + if (MUTATING_METHODS.has(method)) { + headers.set('X-Requested-With', 'notizarr') + } + if (init.body && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json') + } + + const res = await fetch(path, { ...init, method, headers, credentials: 'same-origin' }) + + if (res.status === 204) return undefined as T + + const contentType = res.headers.get('content-type') ?? '' + const body = contentType.includes('application/json') ? await res.json() : await res.text() + + if (!res.ok) { + throw new ApiError(res.status, body) + } + + return body as T +} + +export function apiPatch(path: string, data: unknown): Promise { + return apiFetch(path, { method: 'PATCH', body: JSON.stringify(data) }) +} + +export function apiPost(path: string, data?: unknown): Promise { + return apiFetch(path, { method: 'POST', body: data ? JSON.stringify(data) : undefined }) +} diff --git a/apps/web/src/lib/priority.ts b/apps/web/src/lib/priority.ts new file mode 100644 index 0000000..797d1ed --- /dev/null +++ b/apps/web/src/lib/priority.ts @@ -0,0 +1,20 @@ +// Mirrors apps/server/src/lib/priority.ts — kept in sync by hand since +// there's no shared package wired up yet (packages/shared is still an +// empty placeholder). Only used for display here; the server is the one +// that actually decides whether to push. +const PRIORITY_ALIASES: Record = { + min: 1, + low: 2, + default: 3, + high: 4, + urgent: 5, + max: 5, +} + +export function isQuietPriority(raw: string | null | undefined): boolean { + if (!raw) return false + const trimmed = raw.trim().toLowerCase() + // The `in` check guarantees the key exists. + const value = trimmed in PRIORITY_ALIASES ? PRIORITY_ALIASES[trimmed]! : Number(trimmed) + return Number.isFinite(value) && value <= 1 +} diff --git a/apps/web/src/lib/push.ts b/apps/web/src/lib/push.ts new file mode 100644 index 0000000..7d50cbc --- /dev/null +++ b/apps/web/src/lib/push.ts @@ -0,0 +1,23 @@ +// PushManager.subscribe() needs the VAPID public key as a raw Uint8Array, +// but the server hands it over base64url-encoded — this is the standard +// conversion (documented in every Web Push tutorial, not Notizarr-specific). +export function urlBase64ToUint8Array(base64String: string): Uint8Array { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4) + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/') + const rawData = atob(base64) + // Uint8Array.from()'s inferred type is the looser Uint8Array + // (which also covers SharedArrayBuffer) — this call always backs onto a + // plain ArrayBuffer, which is what PushManager.subscribe() requires. + return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0))) as Uint8Array +} + +export function isPushSupported(): boolean { + return 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window +} + +export function isStandalone(): boolean { + return ( + window.matchMedia('(display-mode: standalone)').matches || + (navigator as unknown as { standalone?: boolean }).standalone === true + ) +} diff --git a/apps/web/src/lib/userAgent.ts b/apps/web/src/lib/userAgent.ts new file mode 100644 index 0000000..f6d4d78 --- /dev/null +++ b/apps/web/src/lib/userAgent.ts @@ -0,0 +1,27 @@ +// Client-side-only, display-purpose UA parsing — not stored, computed +// fresh from the raw userAgent string wherever a device is listed. No +// library: a handful of substring checks covers the realistic homelab +// device set without pulling in a UA-parsing dependency for this. +export function friendlyDeviceName(userAgent: string | null | undefined): string { + if (!userAgent) return 'Unknown device' + + const ua = userAgent + + let device: string + if (/iPhone/.test(ua)) device = 'iPhone' + else if (/iPad/.test(ua)) device = 'iPad' + else if (/Android/.test(ua)) device = 'Android' + else if (/Macintosh/.test(ua)) device = 'Mac' + else if (/Windows/.test(ua)) device = 'Windows PC' + else if (/Linux/.test(ua)) device = 'Linux PC' + else device = 'Device' + + let browser: string | null = null + if (/EdgA?\//.test(ua)) browser = 'Edge' + else if (/OPR\//.test(ua)) browser = 'Opera' + else if (/CriOS|Chrome\//.test(ua)) browser = 'Chrome' + else if (/FxiOS|Firefox\//.test(ua)) browser = 'Firefox' + else if (/Version\/.*Safari/.test(ua)) browser = 'Safari' + + return browser ? `${device} (${browser})` : device +} diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts new file mode 100644 index 0000000..2b1ba24 --- /dev/null +++ b/apps/web/src/lib/utils.ts @@ -0,0 +1,54 @@ +export function cn(...values: Array): string { + return values.filter(Boolean).join(' ') +} + +const RELATIVE_UNITS: Array<[Intl.RelativeTimeFormatUnit, number]> = [ + ['year', 1000 * 60 * 60 * 24 * 365], + ['month', 1000 * 60 * 60 * 24 * 30], + ['week', 1000 * 60 * 60 * 24 * 7], + ['day', 1000 * 60 * 60 * 24], + ['hour', 1000 * 60 * 60], + ['minute', 1000 * 60], +] + +const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) + +export function formatRelativeTime(dateInput: string | Date): string { + const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput + const diffMs = date.getTime() - Date.now() + const absMs = Math.abs(diffMs) + + if (absMs < 30_000) return 'just now' + + for (const [unit, unitMs] of RELATIVE_UNITS) { + if (absMs >= unitMs) { + return rtf.format(Math.round(diffMs / unitMs), unit) + } + } + return rtf.format(Math.round(diffMs / 1000), 'second') +} + +export function formatExactTime(dateInput: string | Date): string { + const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput + return new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(date) +} + +// Deterministic, muted accent color per source/topic string, so avatar +// circles in a list read as visually distinct at a glance instead of one +// flat gray repeated down the page. Fixed saturation/lightness keeps every +// result legible against the dark background. +export function colorForLabel(label: string): { bg: string; fg: string } { + let hash = 0 + for (let i = 0; i < label.length; i++) { + hash = (hash << 5) - hash + label.charCodeAt(i) + hash |= 0 + } + const hue = Math.abs(hash) % 360 + return { + bg: `hsl(${hue} 55% 22%)`, + fg: `hsl(${hue} 85% 78%)`, + } +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000..7446d66 --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,30 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import React from 'react' +import ReactDOM from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import App from './App' +import './index.css' + +const rootElement = document.getElementById('root') +if (!rootElement) { + throw new Error('Missing #root element') +} + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 10_000, + retry: 1, + }, + }, +}) + +ReactDOM.createRoot(rootElement).render( + + + + + + + , +) diff --git a/apps/web/src/pages/DiagnosticsPage.tsx b/apps/web/src/pages/DiagnosticsPage.tsx new file mode 100644 index 0000000..c1e6a86 --- /dev/null +++ b/apps/web/src/pages/DiagnosticsPage.tsx @@ -0,0 +1,126 @@ +import { ArrowLeft, CheckCircle2, XCircle } from 'lucide-react' +import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import { PageHeader } from '../components/ui/PageHeader' +import { usePushDiagnostics } from '../hooks/usePushDevices' +import { usePushSubscription } from '../hooks/usePushSubscription' +import { isPushSupported, isStandalone } from '../lib/push' +import { formatExactTime } from '../lib/utils' +import { friendlyDeviceName } from '../lib/userAgent' + +function Row({ label, ok, detail }: { label: string; ok: boolean | null; detail: string }) { + return ( +
+
+

{label}

+

{detail}

+
+ {ok === null ? ( + + ) : ok ? ( + + ) : ( + + )} +
+ ) +} + +// Makes iOS troubleshooting tractable, per docs/phase1/05-pwa-web-push-design.md +// — this is deliberately explicit about what Web Push *can't* guarantee +// rather than implying certainty it doesn't have. +export function DiagnosticsPage() { + const { permission, subscription, supported } = usePushSubscription() + const { data: diagnostics } = usePushDiagnostics() + const [swActive, setSwActive] = useState(null) + + useEffect(() => { + if (!('serviceWorker' in navigator)) { + setSwActive(false) + return + } + navigator.serviceWorker.getRegistration().then((reg) => { + setSwActive(Boolean(reg?.active)) + }) + }, []) + + return ( +
+
+ + + +

Push diagnostics

+
+ +
+
+ + + + + + +
+ + {diagnostics && diagnostics.devices.length > 0 && ( +
+

Delivery history

+
+ {diagnostics.devices.map((device) => ( +
+

+ {device.deviceLabel ?? friendlyDeviceName(device.userAgent)} +

+ {device.lastDeliveryAttempt ? ( +

+ Last attempt: {device.lastDeliveryAttempt.status} at{' '} + {formatExactTime(device.lastDeliveryAttempt.attemptedAt)} + {device.lastDeliveryAttempt.errorMessage && + ` — ${device.lastDeliveryAttempt.errorMessage}`} +

+ ) : ( +

No delivery attempts yet

+ )} +
+ ))} +
+
+ )} + +

+ Web Push confirms the push service accepted a message — it can't guarantee the OS actually + displayed it on screen. On iPhone, push requires iOS 16.4+ and Notizarr must be installed to + the Home Screen first; Safari does not support push for ordinary browser tabs. +

+
+
+ ) +} diff --git a/apps/web/src/pages/InboxPage.tsx b/apps/web/src/pages/InboxPage.tsx new file mode 100644 index 0000000..1ab44db --- /dev/null +++ b/apps/web/src/pages/InboxPage.tsx @@ -0,0 +1,59 @@ +import { CheckCheck, Inbox } from 'lucide-react' +import { useMarkAllRead, useNotifications } from '../hooks/useNotifications' +import { NotificationRow } from '../components/notifications/NotificationRow' +import { NotificationRowSkeleton } from '../components/ui/Skeleton' +import { EmptyState } from '../components/ui/EmptyState' +import { PageHeader } from '../components/ui/PageHeader' + +export function InboxPage() { + const { data: notifications, isLoading } = useNotifications() + const markAllRead = useMarkAllRead() + + const hasUnread = notifications?.some((n) => !n.read) ?? false + + return ( +
+ markAllRead.mutate(undefined)} + className="flex items-center gap-1.5 rounded-lg border border-base-border px-3 py-1.5 text-xs font-medium text-slate-400 transition-colors hover:bg-base-hover hover:text-slate-200 disabled:opacity-50" + > + + Mark all read + + ) + } + /> + + {isLoading && ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ )} + + {!isLoading && notifications?.length === 0 && ( + + )} + + {!isLoading && notifications && notifications.length > 0 && ( +
+ {notifications.map((n) => ( + + ))} +
+ )} +
+ ) +} diff --git a/apps/web/src/pages/NotificationDetailPage.tsx b/apps/web/src/pages/NotificationDetailPage.tsx new file mode 100644 index 0000000..87992b7 --- /dev/null +++ b/apps/web/src/pages/NotificationDetailPage.tsx @@ -0,0 +1,138 @@ +import { ArrowLeft, BellOff, ExternalLink, Star, Trash2 } from 'lucide-react' +import { useEffect } from 'react' +import { Link, useNavigate, useParams } from 'react-router-dom' +import { Skeleton } from '../components/ui/Skeleton' +import { useDeleteNotification } from '../hooks/useAdmin' +import { useNotification, useSetNotificationState } from '../hooks/useNotifications' +import { useSession } from '../hooks/useSession' +import { isQuietPriority } from '../lib/priority' +import { formatExactTime, formatRelativeTime } from '../lib/utils' + +export function NotificationDetailPage() { + const { slug, id } = useParams<{ slug: string; id: string }>() + const navigate = useNavigate() + const { data: session } = useSession() + const { data: notification, isLoading } = useNotification(id) + const setState = useSetNotificationState() + const deleteNotification = useDeleteNotification() + const isAdmin = session?.user?.role === 'admin' + + useEffect(() => { + if (notification && !notification.read) { + setState.mutate({ id: notification.id, changes: { read: true } }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [notification?.id]) + + return ( +
+
+ + + Back + + {notification && ( +
+ + {isAdmin && ( + + )} +
+ )} +
+ + {isLoading && ( +
+ + + +
+ )} + + {notification && ( +
+
+
+ + {notification.topicDisplayName} + + · + {notification.source} + {notification.occurrenceCount > 1 && ( + <> + · + occurred ×{notification.occurrenceCount} + + )} + {isQuietPriority(notification.rawPriority) && ( + <> + · + + + no push sent + + + )} +
+

+ {notification.title} +

+

+ {formatExactTime(notification.createdAt)} · {formatRelativeTime(notification.createdAt)} +

+
+ + {notification.imageUrl && ( + + )} + +

+ {notification.message} +

+ + {notification.externalUrl && ( + + Open link + + + )} +
+ )} +
+ ) +} diff --git a/apps/web/src/pages/SearchPage.tsx b/apps/web/src/pages/SearchPage.tsx new file mode 100644 index 0000000..a724a7a --- /dev/null +++ b/apps/web/src/pages/SearchPage.tsx @@ -0,0 +1,60 @@ +import { Search as SearchIcon } from 'lucide-react' +import { useState } from 'react' +import { NotificationRow } from '../components/notifications/NotificationRow' +import { EmptyState } from '../components/ui/EmptyState' +import { NotificationRowSkeleton } from '../components/ui/Skeleton' +import { useNotifications } from '../hooks/useNotifications' + +export function SearchPage() { + const [query, setQuery] = useState('') + const { data: notifications, isLoading, isFetching } = useNotifications( + query.trim().length > 0 ? { q: query.trim() } : {}, + ) + const hasQuery = query.trim().length > 0 + + return ( +
+
+

Search

+
+ + setQuery(e.target.value)} + placeholder="Search titles and messages…" + className="w-full rounded-lg border border-base-border bg-base-surface py-2.5 pl-9 pr-3 text-sm text-slate-100 placeholder:text-slate-500 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent" + /> +
+
+ + {!hasQuery && ( + + )} + + {hasQuery && isLoading && ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ )} + + {hasQuery && !isLoading && notifications?.length === 0 && !isFetching && ( + + )} + + {hasQuery && notifications && notifications.length > 0 && ( +
+ {notifications.map((n) => ( + + ))} +
+ )} +
+ ) +} diff --git a/apps/web/src/pages/SettingsPage.tsx b/apps/web/src/pages/SettingsPage.tsx new file mode 100644 index 0000000..c07ca2f --- /dev/null +++ b/apps/web/src/pages/SettingsPage.tsx @@ -0,0 +1,65 @@ +import { LogOut, ShieldCheck } from 'lucide-react' +import { Link } from 'react-router-dom' +import { useLogout, useSession } from '../hooks/useSession' +import { PageHeader } from '../components/ui/PageHeader' +import { PushSection } from '../components/settings/PushSection' + +export function SettingsPage() { + const { data: session } = useSession() + const logout = useLogout() + + return ( +
+ + +
+
+

Profile

+
+
+
Name
+
{session?.user?.displayName ?? '—'}
+
+
+
Email
+
{session?.user?.email ?? '—'}
+
+
+
Role
+
{session?.user?.role ?? '—'}
+
+
+
Auth mode
+
{session?.authMode ?? '—'}
+
+
+
+ + + + {session?.user?.role === 'admin' && ( + + +
+

Admin

+

Topics, tokens, users, devices, audit log

+
+ + )} + + +
+
+ ) +} diff --git a/apps/web/src/pages/StarredPage.tsx b/apps/web/src/pages/StarredPage.tsx new file mode 100644 index 0000000..9672bf4 --- /dev/null +++ b/apps/web/src/pages/StarredPage.tsx @@ -0,0 +1,40 @@ +import { Star } from 'lucide-react' +import { NotificationRow } from '../components/notifications/NotificationRow' +import { EmptyState } from '../components/ui/EmptyState' +import { PageHeader } from '../components/ui/PageHeader' +import { NotificationRowSkeleton } from '../components/ui/Skeleton' +import { useNotifications } from '../hooks/useNotifications' + +export function StarredPage() { + const { data: notifications, isLoading } = useNotifications({ starred: true }) + + return ( +
+ + + {isLoading && ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ )} + + {!isLoading && notifications?.length === 0 && ( + + )} + + {!isLoading && notifications && notifications.length > 0 && ( +
+ {notifications.map((n) => ( + + ))} +
+ )} +
+ ) +} diff --git a/apps/web/src/pages/TopicDetailPage.tsx b/apps/web/src/pages/TopicDetailPage.tsx new file mode 100644 index 0000000..af0d567 --- /dev/null +++ b/apps/web/src/pages/TopicDetailPage.tsx @@ -0,0 +1,122 @@ +import { AlarmClock, ArrowLeft, CheckCheck, Inbox } from 'lucide-react' +import { Link, useParams } from 'react-router-dom' +import { NotificationRow } from '../components/notifications/NotificationRow' +import { EmptyState } from '../components/ui/EmptyState' +import { NotificationRowSkeleton } from '../components/ui/Skeleton' +import { Switch } from '../components/ui/Switch' +import { useMarkAllRead, useNotifications } from '../hooks/useNotifications' +import { useSetTopicPush, useSnoozeTopicPush, useTopic } from '../hooks/useTopics' + +const SNOOZE_OPTIONS = [ + { label: '1 hour', minutes: 60 }, + { label: '4 hours', minutes: 240 }, + { label: '8 hours', minutes: 480 }, + { label: '24 hours', minutes: 1440 }, +] + +export function TopicDetailPage() { + const { slug } = useParams<{ slug: string }>() + const { data: topic } = useTopic(slug) + const { data: notifications, isLoading } = useNotifications({ topic: slug }) + const setPush = useSetTopicPush() + const snoozePush = useSnoozeTopicPush() + const markAllRead = useMarkAllRead() + + const hasUnread = notifications?.some((n) => !n.read) ?? false + const snoozedUntil = topic?.mutedUntil && new Date(topic.mutedUntil) > new Date() ? topic.mutedUntil : null + + return ( +
+
+
+ + + +
+

+ {topic?.displayName ?? slug} +

+ {topic?.description && ( +

{topic.description}

+ )} +
+
+ + {slug && ( +
+ {hasUnread && ( + + )} + + Push + setPush.mutate({ slug, pushEnabled })} + label="Push notifications for this topic" + /> +
+ )} +
+ + {isLoading && ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ )} + + {!isLoading && notifications?.length === 0 && ( + + )} + + {!isLoading && notifications && notifications.length > 0 && ( +
+ {notifications.map((n) => ( + + ))} +
+ )} +
+ ) +} diff --git a/apps/web/src/pages/TopicsPage.tsx b/apps/web/src/pages/TopicsPage.tsx new file mode 100644 index 0000000..fb2e125 --- /dev/null +++ b/apps/web/src/pages/TopicsPage.tsx @@ -0,0 +1,32 @@ +import { Hash } from 'lucide-react' +import { useTopics } from '../hooks/useTopics' +import { TopicCard } from '../components/topics/TopicCard' +import { TopicCardSkeleton } from '../components/ui/Skeleton' +import { EmptyState } from '../components/ui/EmptyState' +import { PageHeader } from '../components/ui/PageHeader' + +export function TopicsPage() { + const { data: topics, isLoading } = useTopics() + + return ( +
+ + +
+ {isLoading && + Array.from({ length: 4 }).map((_, i) => )} + + {!isLoading && topics?.length === 0 && ( + + )} + + {!isLoading && + topics?.map((topic) => )} +
+
+ ) +} diff --git a/apps/web/src/pages/admin/AdminLayout.tsx b/apps/web/src/pages/admin/AdminLayout.tsx new file mode 100644 index 0000000..a064d38 --- /dev/null +++ b/apps/web/src/pages/admin/AdminLayout.tsx @@ -0,0 +1,57 @@ +import { ArrowLeft } from 'lucide-react' +import { Link, NavLink, Outlet } from 'react-router-dom' +import { cn } from '../../lib/utils' + +const tabs = [ + { to: '/admin', label: 'Dashboard', end: true }, + { to: '/admin/topics', label: 'Topics' }, + { to: '/admin/tokens', label: 'API Tokens' }, + { to: '/admin/users', label: 'Users' }, + { to: '/admin/devices', label: 'Devices' }, + { to: '/admin/sources', label: 'Sources' }, + { to: '/admin/auth', label: 'Auth' }, + { to: '/admin/system', label: 'System' }, + { to: '/admin/backups', label: 'Backups' }, + { to: '/admin/audit-log', label: 'Audit Log' }, +] + +export function AdminLayout() { + return ( +
+
+ + + Back to app + +

Admin

+
+ +
+ {tabs.map((tab) => ( + + cn( + 'shrink-0 border-b-2 px-3 py-2.5 text-sm font-medium transition-colors', + isActive + ? 'border-accent text-accent-bright' + : 'border-transparent text-slate-500 hover:text-slate-300', + ) + } + > + {tab.label} + + ))} +
+ +
+ +
+
+ ) +} diff --git a/apps/web/src/pages/admin/AuditLogPage.tsx b/apps/web/src/pages/admin/AuditLogPage.tsx new file mode 100644 index 0000000..54fb434 --- /dev/null +++ b/apps/web/src/pages/admin/AuditLogPage.tsx @@ -0,0 +1,40 @@ +import { useAuditLog } from '../../hooks/useAdmin' +import { formatExactTime } from '../../lib/utils' + +export function AuditLogPage() { + const { data: entries } = useAuditLog() + + return ( +
+ + + + + + + + + + + {entries?.map((entry) => ( + + + + + + + ))} + {entries?.length === 0 && ( + + + + )} + +
ActionTargetIPWhen
{entry.action} + {entry.targetType ? `${entry.targetType}:${entry.targetId}` : '—'} + {entry.ipAddress ?? '—'}{formatExactTime(entry.createdAt)}
+ Nothing logged yet. +
+
+ ) +} diff --git a/apps/web/src/pages/admin/AuthSettingsAdminPage.tsx b/apps/web/src/pages/admin/AuthSettingsAdminPage.tsx new file mode 100644 index 0000000..3869b3a --- /dev/null +++ b/apps/web/src/pages/admin/AuthSettingsAdminPage.tsx @@ -0,0 +1,147 @@ +import { type FormEvent, useEffect, useState } from 'react' +import { useOidcSettings, useUpdateOidcSettings } from '../../hooks/useAdmin' + +const AUTH_MODE_LABEL: Record = { + oidc: 'OIDC', + proxy: 'Trusted proxy header', + disabled: 'Disabled (dev only)', +} + +export function AuthSettingsAdminPage() { + const { data: settings } = useOidcSettings() + const updateSettings = useUpdateOidcSettings() + + const [issuerUrl, setIssuerUrl] = useState('') + const [clientId, setClientId] = useState('') + const [clientSecret, setClientSecret] = useState('') + const [redirectUrl, setRedirectUrl] = useState('') + const [scopes, setScopes] = useState('') + const [formError, setFormError] = useState(null) + const [saved, setSaved] = useState(false) + + // Seed the form once settings load — a plain effect rather than syncing on + // every refetch, so an admin's in-progress edits aren't clobbered by the + // background refetch after a save. + useEffect(() => { + if (!settings) return + setIssuerUrl(settings.issuerUrl) + setClientId(settings.clientId) + setRedirectUrl(settings.redirectUrl || settings.effectiveRedirectUrl) + setScopes(settings.scopes || 'openid profile email') + }, [settings]) + + const handleSubmit = (e: FormEvent) => { + e.preventDefault() + setFormError(null) + setSaved(false) + updateSettings.mutate( + { + issuerUrl, + clientId, + clientSecret: clientSecret.trim() ? clientSecret.trim() : undefined, + redirectUrl, + scopes, + }, + { + onSuccess: () => { + setClientSecret('') + setSaved(true) + }, + onError: (err) => setFormError(err instanceof Error ? err.message : 'Failed to save settings'), + }, + ) + } + + return ( +
+
+

+ Login mode: {settings ? (AUTH_MODE_LABEL[settings.authMode] ?? settings.authMode) : '…'} +

+

+ Switching login modes changes which routes the server exposes at startup, so it's set via the{' '} + AUTH_MODE environment variable and + needs a restart. The OIDC provider details below can be changed here at any time without a + restart — they take effect on the next login attempt. +

+
+ + +
+ + setIssuerUrl(e.target.value)} + placeholder="https://id.example.com" + className="w-full rounded-lg border border-base-border bg-base-raised px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:border-accent focus:outline-none" + /> +
+ +
+ + setClientId(e.target.value)} + placeholder="notizarr" + className="w-full rounded-lg border border-base-border bg-base-raised px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:border-accent focus:outline-none" + /> +
+ +
+ + setClientSecret(e.target.value)} + placeholder={settings?.clientSecretSet ? 'Leave blank to keep the current secret' : 'Not set'} + autoComplete="off" + className="w-full rounded-lg border border-base-border bg-base-raised px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:border-accent focus:outline-none" + /> +
+ +
+ + setRedirectUrl(e.target.value)} + placeholder={settings?.effectiveRedirectUrl} + className="w-full rounded-lg border border-base-border bg-base-raised px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:border-accent focus:outline-none" + /> +

+ Register this exact URL as an allowed redirect URI in your identity provider's client + settings. +

+
+ +
+ + setScopes(e.target.value)} + placeholder="openid profile email" + className="w-full rounded-lg border border-base-border bg-base-raised px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:border-accent focus:outline-none" + /> +
+ +
+ + {saved && !formError &&

Saved.

} + {formError &&

{formError}

} +
+ +
+ ) +} diff --git a/apps/web/src/pages/admin/BackupsAdminPage.tsx b/apps/web/src/pages/admin/BackupsAdminPage.tsx new file mode 100644 index 0000000..79690c8 --- /dev/null +++ b/apps/web/src/pages/admin/BackupsAdminPage.tsx @@ -0,0 +1,67 @@ +import { Database, Download } from 'lucide-react' +import { useBackups, useCreateBackup } from '../../hooks/useAdmin' +import { formatRelativeTime } from '../../lib/utils' + +function formatSize(bytes: number): string { + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} + +export function BackupsAdminPage() { + const { data: backups } = useBackups() + const createBackup = useCreateBackup() + + return ( +
+
+
+

Database backups

+

+ Runs automatically once a day and keeps the last 14. A full pg_dump covers everything — history, users, topics, tokens, the VAPID keypair. +

+
+ +
+ + {createBackup.isError && ( +

+ {createBackup.error instanceof Error ? createBackup.error.message : 'Backup failed.'} +

+ )} + +
+ {backups?.length === 0 && ( +

No backups yet — the daily job hasn't run.

+ )} + {backups?.map((b) => ( +
+
+

{b.filename}

+

+ {formatSize(b.sizeBytes)} · {formatRelativeTime(b.createdAt)} +

+
+ + + Download + +
+ ))} +
+
+ ) +} diff --git a/apps/web/src/pages/admin/DashboardPage.tsx b/apps/web/src/pages/admin/DashboardPage.tsx new file mode 100644 index 0000000..1611e87 --- /dev/null +++ b/apps/web/src/pages/admin/DashboardPage.tsx @@ -0,0 +1,136 @@ +import { DailyBarChart } from '../../components/admin/DailyBarChart' +import { DonutChart } from '../../components/admin/DonutChart' +import { useDashboardStats } from '../../hooks/useAdmin' +import { colorForLabel, formatRelativeTime } from '../../lib/utils' + +function StatCard({ label, value }: { label: string; value: number | string }) { + return ( +
+

{label}

+

{value}

+
+ ) +} + +export function DashboardPage() { + const { data: stats, isLoading } = useDashboardStats() + + if (isLoading || !stats) { + return

Loading…

+ } + + const pushSuccessRate = + stats.pushAttemptsLast24h === 0 + ? null + : Math.round( + ((stats.pushAttemptsLast24h - stats.pushFailuresLast24h) / stats.pushAttemptsLast24h) * 100, + ) + + return ( +
+
+ + + + + + +
+ +
+

Notifications, last 14 days

+ +
+ +
+
+

Push delivery (24h)

+ {stats.pushAttemptsLast24h === 0 ? ( +

No push attempts in the last 24 hours.

+ ) : ( + + )} +
+ +
+

Top topics share

+ {stats.topTopics.length === 0 ? ( +

No notifications yet.

+ ) : ( + ({ + label: t.displayName, + value: t.total, + color: colorForLabel(t.slug).fg, + }))} + /> + )} +
+
+ +
+
+

Top topics

+ {stats.topTopics.length === 0 ? ( +

No notifications yet.

+ ) : ( +
    + {stats.topTopics.map((t) => ( +
  • + {t.displayName} + {t.total.toLocaleString()} +
  • + ))} +
+ )} +
+ +
+

Noisiest sources (24h)

+ {stats.noisiestSources.length === 0 ? ( +

No activity in the last 24 hours.

+ ) : ( +
    + {stats.noisiestSources.map((s) => ( +
  • + {s.source} + + {s.total.toLocaleString()}/day{s.noisy && ' ⚠'} + +
  • + ))} +
+ )} +
+
+ +
+

Recent events

+ {stats.recentAuditEvents.length === 0 ? ( +

Nothing logged yet.

+ ) : ( +
    + {stats.recentAuditEvents.map((e) => ( +
  • + {e.action} + {formatRelativeTime(e.createdAt)} +
  • + ))} +
+ )} +
+
+ ) +} diff --git a/apps/web/src/pages/admin/DevicesAdminPage.tsx b/apps/web/src/pages/admin/DevicesAdminPage.tsx new file mode 100644 index 0000000..d0dcae8 --- /dev/null +++ b/apps/web/src/pages/admin/DevicesAdminPage.tsx @@ -0,0 +1,29 @@ +import { useAdminDevices } from '../../hooks/useAdmin' +import { formatRelativeTime } from '../../lib/utils' +import { friendlyDeviceName } from '../../lib/userAgent' + +export function DevicesAdminPage() { + const { data: devices } = useAdminDevices() + + return ( +
+ {devices?.length === 0 &&

No devices registered yet.

} + {devices?.map((device) => ( +
+
+

+ {device.deviceLabel ?? friendlyDeviceName(device.userAgent)} +

+

+ {device.userDisplayName} ({device.userEmail}) ·{' '} + {device.disabledAt ? 'disabled' : `last seen ${formatRelativeTime(device.lastSeenAt)}`} +

+
+
+ ))} +
+ ) +} diff --git a/apps/web/src/pages/admin/SourcesAdminPage.tsx b/apps/web/src/pages/admin/SourcesAdminPage.tsx new file mode 100644 index 0000000..e3705bd --- /dev/null +++ b/apps/web/src/pages/admin/SourcesAdminPage.tsx @@ -0,0 +1,43 @@ +import { useAdminSources } from '../../hooks/useAdmin' + +export function SourcesAdminPage() { + const { data: sources } = useAdminSources() + + return ( +
+ + + + + + + + + + {sources?.map((s) => ( + + + + + + ))} + {sources?.length === 0 && ( + + + + )} + +
SourceLast 24hStatus
{s.source}{s.last24h.toLocaleString()} + {s.noisy ? ( + + Unusually noisy + + ) : ( + Normal + )} +
+ No publishing activity in the last 24 hours. +
+
+ ) +} diff --git a/apps/web/src/pages/admin/SystemSettingsAdminPage.tsx b/apps/web/src/pages/admin/SystemSettingsAdminPage.tsx new file mode 100644 index 0000000..7324cba --- /dev/null +++ b/apps/web/src/pages/admin/SystemSettingsAdminPage.tsx @@ -0,0 +1,184 @@ +import { type FormEvent, useEffect, useState } from 'react' +import { Switch } from '../../components/ui/Switch' +import { useSystemSettings, useUpdateSystemSettings } from '../../hooks/useAdmin' + +const PRIORITY_OPTIONS = [ + { label: 'Low (2) and above', value: 2 }, + { label: 'Default (3) and above', value: 3 }, + { label: 'High (4) and above', value: 4 }, + { label: 'Urgent (5) only', value: 5 }, +] + +export function SystemSettingsAdminPage() { + const { data: settings } = useSystemSettings() + const updateSettings = useUpdateSystemSettings() + + const [retentionDefaultDays, setRetentionDefaultDays] = useState('') + const [vapidSubject, setVapidSubject] = useState('') + const [quietEnabled, setQuietEnabled] = useState(false) + const [quietStart, setQuietStart] = useState('23:00') + const [quietEnd, setQuietEnd] = useState('07:00') + const [quietTimezone, setQuietTimezone] = useState('UTC') + const [quietMinPriority, setQuietMinPriority] = useState(4) + const [formError, setFormError] = useState(null) + const [saved, setSaved] = useState(false) + + useEffect(() => { + if (!settings) return + setRetentionDefaultDays(String(settings.retentionDefaultDays)) + setVapidSubject(settings.vapidSubject) + setQuietEnabled(settings.quietHours.enabled) + setQuietStart(settings.quietHours.start) + setQuietEnd(settings.quietHours.end) + setQuietTimezone(settings.quietHours.timezone) + setQuietMinPriority(settings.quietHours.minPriority) + }, [settings]) + + const handleSubmit = (e: FormEvent) => { + e.preventDefault() + setFormError(null) + setSaved(false) + updateSettings.mutate( + { + retentionDefaultDays: Number(retentionDefaultDays), + vapidSubject: vapidSubject.trim(), + quietHours: { + enabled: quietEnabled, + start: quietStart, + end: quietEnd, + timezone: quietTimezone.trim() || 'UTC', + minPriority: quietMinPriority, + }, + }, + { + onSuccess: () => setSaved(true), + onError: (err) => setFormError(err instanceof Error ? err.message : 'Failed to save settings'), + }, + ) + } + + return ( +
+
+
+ + setRetentionDefaultDays(e.target.value)} + className="w-full rounded-lg border border-base-border bg-base-raised px-3 py-2 text-sm text-slate-100 focus:border-accent focus:outline-none" + /> +

+ Applied to any topic that doesn't set its own retention (Admin → Topics). 0 means forever. + Picked up by the hourly cleanup job — no restart needed. +

+
+ +
+ + setVapidSubject(e.target.value)} + placeholder="mailto:you@example.com" + className="w-full rounded-lg border border-base-border bg-base-raised px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:border-accent focus:outline-none" + /> +

+ A mailto: or{' '} + https: contact identifying this + server to push services — Apple's push service specifically rejects VAPID JWTs whose + subject isn't one of those. Takes effect on the next push, no restart needed. +

+
+
+ +
+
+
+

Quiet hours

+

+ Household-wide — during this window, only high-priority notifications still push. +

+
+ +
+ + {quietEnabled && ( + <> +
+
+ + setQuietStart(e.target.value)} + className="w-full rounded-lg border border-base-border bg-base-raised px-3 py-2 text-sm text-slate-100 focus:border-accent focus:outline-none" + /> +
+
+ + setQuietEnd(e.target.value)} + className="w-full rounded-lg border border-base-border bg-base-raised px-3 py-2 text-sm text-slate-100 focus:border-accent focus:outline-none" + /> +
+
+ +
+ + setQuietTimezone(e.target.value)} + placeholder="Europe/Stockholm" + className="w-full rounded-lg border border-base-border bg-base-raised px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:border-accent focus:outline-none" + /> +

+ IANA timezone name (e.g. Europe/Stockholm). + The server itself typically runs in UTC, so this is what makes "23:00" mean your + actual bedtime. +

+
+ +
+ + +

+ Anything below this still gets stored and shown in-app — it just won't buzz anyone's + phone during the window. +

+
+ + )} +
+ +
+ + {saved && !formError &&

Saved.

} + {formError &&

{formError}

} +
+
+ ) +} diff --git a/apps/web/src/pages/admin/TokensAdminPage.tsx b/apps/web/src/pages/admin/TokensAdminPage.tsx new file mode 100644 index 0000000..78b72fd --- /dev/null +++ b/apps/web/src/pages/admin/TokensAdminPage.tsx @@ -0,0 +1,183 @@ +import { Copy, Plus, RotateCw, ShieldOff, Trash2, X } from 'lucide-react' +import { type FormEvent, useState } from 'react' +import { + useAdminTokens, + useCreateToken, + useDeleteToken, + useRevokeToken, + useRotateToken, +} from '../../hooks/useAdmin' +import { formatRelativeTime } from '../../lib/utils' + +function PlaintextBanner({ plaintext, onDismiss }: { plaintext: string; onDismiss: () => void }) { + return ( +
+

+ Copy this token now — it won't be shown again. +

+
+ + {plaintext} + + +
+ +
+ ) +} + +export function TokensAdminPage() { + const { data: tokens } = useAdminTokens() + const createToken = useCreateToken() + const revokeToken = useRevokeToken() + const rotateToken = useRotateToken() + const deleteToken = useDeleteToken() + const [name, setName] = useState('') + const [newPlaintext, setNewPlaintext] = useState(null) + + const handleCreate = (e: FormEvent) => { + e.preventDefault() + createToken.mutate( + { name }, + { + onSuccess: (result) => { + setNewPlaintext(result.plaintext) + setName('') + }, + }, + ) + } + + const revokedTokens = tokens?.filter((t) => t.revokedAt) ?? [] + + const handleDeleteAllRevoked = () => { + if ( + !window.confirm( + `Permanently delete ${revokedTokens.length} revoked token${revokedTokens.length === 1 ? '' : 's'}? This can't be undone.`, + ) + ) { + return + } + for (const t of revokedTokens) deleteToken.mutate(t.id) + } + + return ( +
+ {newPlaintext && ( + setNewPlaintext(null)} /> + )} + + {revokedTokens.length > 0 && ( +
+

+ {revokedTokens.length} revoked token{revokedTokens.length === 1 ? '' : 's'} cluttering + this list. +

+ +
+ )} + +
+
+ + setName(e.target.value)} + placeholder="Home Assistant" + className="w-full rounded-lg border border-base-border bg-base-raised px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:border-accent focus:outline-none" + /> +
+ +
+ +
+ {tokens?.map((token) => ( +
+
+

{token.name}

+

+ {token.tokenPrefix}… ·{' '} + {token.revokedAt + ? 'revoked' + : token.lastUsedAt + ? `last used ${formatRelativeTime(token.lastUsedAt)}` + : 'never used'}{' '} + · {token.usageCount.toLocaleString()} uses +

+
+ + + +
+ ))} +
+
+ ) +} diff --git a/apps/web/src/pages/admin/TopicStatsAdminPage.tsx b/apps/web/src/pages/admin/TopicStatsAdminPage.tsx new file mode 100644 index 0000000..b698efb --- /dev/null +++ b/apps/web/src/pages/admin/TopicStatsAdminPage.tsx @@ -0,0 +1,54 @@ +import { ArrowLeft } from 'lucide-react' +import { Link, useParams } from 'react-router-dom' +import { DailyBarChart } from '../../components/admin/DailyBarChart' +import { useTopicStats } from '../../hooks/useAdmin' +import { useTopic } from '../../hooks/useTopics' + +export function TopicStatsAdminPage() { + const { slug } = useParams<{ slug: string }>() + const { data: topic } = useTopic(slug) + const { data: stats, isLoading } = useTopicStats(slug) + + return ( +
+ + + Back to topics + + +

+ {topic?.displayName ?? slug} — stats +

+ + {isLoading || !stats ? ( +

Loading…

+ ) : ( + <> +
+

Notifications, last 14 days

+ +
+ +
+

Most common notifications

+ {stats.topMessages.length === 0 ? ( +

No notifications in this topic yet.

+ ) : ( +
    + {stats.topMessages.map((m) => ( +
  • + {m.message} + {m.total.toLocaleString()} +
  • + ))} +
+ )} +
+ + )} +
+ ) +} diff --git a/apps/web/src/pages/admin/TopicsAdminPage.tsx b/apps/web/src/pages/admin/TopicsAdminPage.tsx new file mode 100644 index 0000000..d1d2ca8 --- /dev/null +++ b/apps/web/src/pages/admin/TopicsAdminPage.tsx @@ -0,0 +1,233 @@ +import { Archive, BarChart3, Plus, Trash2, X } from 'lucide-react' +import { type FormEvent, useState } from 'react' +import { Link } from 'react-router-dom' +import { Checkbox } from '../../components/ui/Checkbox' +import { Switch } from '../../components/ui/Switch' +import { cn } from '../../lib/utils' +import { useCreateTopic, useDeleteTopic, useUpdateTopic } from '../../hooks/useAdmin' +import { useTopics } from '../../hooks/useTopics' + +const RETENTION_OPTIONS = [ + { label: '7 days', value: 7 }, + { label: '30 days', value: 30 }, + { label: '90 days', value: 90 }, + { label: '365 days', value: 365 }, + { label: 'Forever', value: 0 }, +] + +export function TopicsAdminPage() { + const { data: topics } = useTopics() + const createTopic = useCreateTopic() + const updateTopic = useUpdateTopic() + const deleteTopic = useDeleteTopic() + const [slug, setSlug] = useState('') + const [displayName, setDisplayName] = useState('') + const [formError, setFormError] = useState(null) + const [selected, setSelected] = useState>(new Set()) + + const handleCreate = (e: FormEvent) => { + e.preventDefault() + setFormError(null) + createTopic.mutate( + { slug, displayName }, + { + onSuccess: () => { + setSlug('') + setDisplayName('') + }, + onError: (err) => setFormError(err instanceof Error ? err.message : 'Failed to create topic'), + }, + ) + } + + const toggleSelected = (id: string) => { + setSelected((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } + + const handleBulkRetention = (days: number) => { + selected.forEach((id) => updateTopic.mutate({ id, changes: { retentionDays: days } })) + setSelected(new Set()) + } + + const handleBulkArchive = () => { + if (!window.confirm(`Archive ${selected.size} topic${selected.size === 1 ? '' : 's'}?`)) return + selected.forEach((id) => updateTopic.mutate({ id, changes: { archived: true } })) + setSelected(new Set()) + } + + return ( +
+
+
+ + setSlug(e.target.value)} + placeholder="homeassistant" + className="w-full rounded-lg border border-base-border bg-base-raised px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:border-accent focus:outline-none" + /> +
+
+ + setDisplayName(e.target.value)} + placeholder="Home Assistant" + className="w-full rounded-lg border border-base-border bg-base-raised px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:border-accent focus:outline-none" + /> +
+ + {formError &&

{formError}

} +
+ + {selected.size > 0 && ( +
+ + + {selected.size} + + selected + + +
+ + + + + + +
+ )} + +
+ {topics?.map((topic) => { + const isSelected = selected.has(topic.id) + return ( +
+ toggleSelected(topic.id)} + label={`Select ${topic.displayName}`} + /> + +
+

{topic.displayName}

+

+ {topic.slug} · {topic.totalCount.toLocaleString()} total +

+
+ + + + + + + + Stats + + + +
+ ) + })} +
+
+ ) +} diff --git a/apps/web/src/pages/admin/UsersAdminPage.tsx b/apps/web/src/pages/admin/UsersAdminPage.tsx new file mode 100644 index 0000000..81502cc --- /dev/null +++ b/apps/web/src/pages/admin/UsersAdminPage.tsx @@ -0,0 +1,37 @@ +import { useSession } from '../../hooks/useSession' +import { useAdminUsers, useSetUserRole } from '../../hooks/useAdmin' +import { formatRelativeTime } from '../../lib/utils' + +export function UsersAdminPage() { + const { data: users } = useAdminUsers() + const { data: session } = useSession() + const setRole = useSetUserRole() + + return ( +
+ {users?.map((user) => ( +
+
+

{user.displayName}

+

+ {user.email} ·{' '} + {user.lastLoginAt ? `last seen ${formatRelativeTime(user.lastLoginAt)}` : 'never logged in'} +

+
+ +
+ ))} +
+ ) +} diff --git a/apps/web/src/sw.ts b/apps/web/src/sw.ts new file mode 100644 index 0000000..743ccfe --- /dev/null +++ b/apps/web/src/sw.ts @@ -0,0 +1,75 @@ +/// +import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching' +import { registerRoute } from 'workbox-routing' +import { NetworkFirst } from 'workbox-strategies' + +declare const self: ServiceWorkerGlobalScope & { + __WB_MANIFEST: Array<{ url: string; revision: string | null }> +} + +// App-shell precache, injected at build time by vite-plugin-pwa. +precacheAndRoute(self.__WB_MANIFEST) +cleanupOutdatedCaches() + +// Short-lived cache so a flaky connection still shows the last-known +// notification list instead of a blank screen — never for mutating +// requests, and never for the SSE stream (a NetworkFirst strategy awaits +// the full response body, which for an open event-stream never resolves). +registerRoute( + ({ url, request }) => + request.method === 'GET' && + url.pathname.startsWith('/api/') && + url.pathname !== '/api/v1/stream', + new NetworkFirst({ cacheName: 'notizarr-api', networkTimeoutSeconds: 5 }), +) + +interface PushPayload { + notificationId: string + topicSlug: string + title: string + body: string + icon?: string +} + +self.addEventListener('push', (event) => { + if (!event.data) return + const payload = event.data.json() as PushPayload + const deepLink = `/topics/${payload.topicSlug}/messages/${payload.notificationId}` + + event.waitUntil( + self.registration.showNotification(payload.title, { + body: payload.body, + icon: payload.icon ?? '/icons/icon-192.png', + badge: '/icons/icon-192.png', + tag: payload.notificationId, + data: { deepLink }, + }), + ) +}) + +self.addEventListener('notificationclick', (event) => { + event.notification.close() + const deepLink = (event.notification.data as { deepLink?: string } | undefined)?.deepLink ?? '/' + + event.waitUntil( + (async () => { + const allClients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }) + for (const client of allClients) { + if ('focus' in client) { + await client.focus() + client.postMessage({ type: 'navigate', path: deepLink }) + return + } + } + await self.clients.openWindow(deepLink) + })(), + ) +}) + +self.addEventListener('install', () => { + void self.skipWaiting() +}) + +self.addEventListener('activate', (event) => { + event.waitUntil(self.clients.claim()) +}) diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts new file mode 100644 index 0000000..a67fb24 --- /dev/null +++ b/apps/web/src/types.ts @@ -0,0 +1,47 @@ +export interface SessionUser { + id: string + email: string + displayName: string + role: 'admin' | 'user' +} + +export interface SessionResponse { + user: SessionUser | null + authMode: 'oidc' | 'proxy' | 'disabled' +} + +export interface Topic { + id: string + slug: string + displayName: string + description: string | null + icon: string | null + accentColor: string | null + retentionDays: number | null + restricted: boolean + totalCount: number + unreadCount: number + latestAt: string | null + pushEnabled: boolean + mutedUntil: string | null + createdAt: string + updatedAt: string +} + +export interface Notification { + id: string + topicId: string + topicSlug: string + topicDisplayName: string + source: string + title: string + message: string + externalUrl: string | null + imageUrl: string | null + occurrenceCount: number + rawPriority: string | null + read: boolean + starred: boolean + createdAt: string + updatedAt: string +} diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts new file mode 100644 index 0000000..3da8530 --- /dev/null +++ b/apps/web/tailwind.config.ts @@ -0,0 +1,59 @@ +import type { Config } from 'tailwindcss' + +// Notizarr's visual identity: dark-first, quiet neutrals, one confident +// accent. Deliberately not shadcn's default palette — see +// docs/phase1/01-architecture.md's UI quality requirements. +export default { + content: ['./index.html', './src/**/*.{ts,tsx}'], + darkMode: 'class', + theme: { + extend: { + colors: { + base: { + bg: '#0a0b0e', + surface: '#131620', + raised: '#181c28', + border: '#232838', + hover: '#1b1f2c', + }, + accent: { + DEFAULT: '#818cf8', + dim: '#6366f1', + bright: '#a5b4fc', + }, + }, + fontFamily: { + sans: [ + 'ui-sans-serif', + 'system-ui', + '-apple-system', + 'Segoe UI', + 'Roboto', + 'Helvetica Neue', + 'Arial', + 'sans-serif', + ], + }, + boxShadow: { + card: '0 1px 2px 0 rgb(0 0 0 / 0.4)', + raised: '0 4px 16px -4px rgb(0 0 0 / 0.5)', + }, + borderRadius: { + xl: '0.875rem', + '2xl': '1.125rem', + }, + animation: { + 'fade-in': 'fade-in 0.2s ease-out', + 'slide-up': 'slide-up 0.25s cubic-bezier(0.16, 1, 0.3, 1)', + }, + keyframes: { + 'fade-in': { from: { opacity: '0' }, to: { opacity: '1' } }, + 'slide-up': { + from: { opacity: '0', transform: 'translateY(8px)' }, + to: { opacity: '1', transform: 'translateY(0)' }, + }, + }, + }, + }, + plugins: [], +} satisfies Config diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..7f1ee89 --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "types": ["vite/client"] + }, + "include": ["src"], + "exclude": ["src/sw.ts"] +} diff --git a/apps/web/tsconfig.sw.json b/apps/web/tsconfig.sw.json new file mode 100644 index 0000000..91a844c --- /dev/null +++ b/apps/web/tsconfig.sw.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "WebWorker"], + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true + }, + "include": ["src/sw.ts"] +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 0000000..00f24d3 --- /dev/null +++ b/apps/web/vite.config.ts @@ -0,0 +1,47 @@ +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' +import { VitePWA } from 'vite-plugin-pwa' + +export default defineConfig({ + plugins: [ + react(), + VitePWA({ + strategies: 'injectManifest', + srcDir: 'src', + filename: 'sw.ts', + injectRegister: 'auto', + manifest: { + name: 'Notizarr', + short_name: 'Notizarr', + description: 'Self-hosted notification hub', + start_url: '/?source=pwa', + scope: '/', + display: 'standalone', + background_color: '#0a0b0e', + theme_color: '#0a0b0e', + orientation: 'portrait', + categories: ['utilities'], + icons: [ + { src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' }, + { src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' }, + { + src: '/icons/maskable-512.png', + sizes: '512x512', + type: 'image/png', + purpose: 'maskable', + }, + ], + }, + injectManifest: { + globPatterns: ['**/*.{js,css,html,svg,png}'], + }, + devOptions: { + enabled: false, + }, + }), + ], + build: { + outDir: 'dist', + emptyOutDir: true, + }, +}) diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml new file mode 100644 index 0000000..99cce6c --- /dev/null +++ b/docker/docker-compose.prod.yml @@ -0,0 +1,68 @@ +# Production template for Unraid. Deployment root per the project spec is +# /mnt/tank/appdata/notizarr — this file is meant to be COPIED there as +# docker-compose.yml, alongside a real .env (see ../.env.example), so that +# ./postgres, ./app, ./backups, ./logs resolve relative to that directory. +# See docs/phase1/06-docker-unraid-design.md and 07-folder-structure.md. +# +# Both variable interpolation (${POSTGRES_USER}, ...) and the container's +# env_file expect a .env file in the SAME DIRECTORY as this compose file. +# +# Two ways to run this: +# 1. Local prod-like test from a repo checkout: copy ../.env.example to +# docker/.env, fill it in, then from docker/: +# docker compose -f docker-compose.prod.yml up -d --build +# 2. Real Unraid deployment: build+tag the image (or let step 1 build it), +# then copy this file AND a filled-in .env to +# /mnt/tank/appdata/notizarr/ (as docker-compose.yml + .env), and from +# that directory: docker compose up -d + +services: + notizarr-postgres: + image: postgres:16-alpine + container_name: notizarr-postgres + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - ./postgres:/var/lib/postgresql/data + networks: [notizarr_internal] + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 + + notizarr: + build: + context: .. + dockerfile: apps/server/Dockerfile + target: runtime + image: ${NOTIZARR_IMAGE:-notizarr:latest} + container_name: notizarr + restart: unless-stopped + depends_on: + notizarr-postgres: + condition: service_healthy + env_file: .env + environment: + DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@notizarr-postgres:5432/${POSTGRES_DB} + ports: + # BIND_ADDR_INGEST defaults to 127.0.0.1 on purpose — see .env.example + # and docs/phase1/02-threat-model.md. Set it to your LAN IP to let LAN + # devices publish; never 0.0.0.0 on a host with any WAN exposure. + - "${BIND_ADDR_WEB:-0.0.0.0}:${PORT_WEB:-8080}:8080" + - "${BIND_ADDR_INGEST:-127.0.0.1}:${PORT_INGEST:-8081}:8081" + volumes: + - ./app:/data + networks: [notizarr_internal] + healthcheck: + test: ["CMD", "node", "healthcheck.js"] + interval: 15s + timeout: 5s + retries: 3 + start_period: 20s + +networks: + notizarr_internal: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..6c30737 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,87 @@ +# Dev stack: hot-reload container + Postgres, for local development on this +# machine. NOT what gets deployed to Unraid — see docker-compose.prod.yml +# and docs/phase1/06-docker-unraid-design.md for that. +# +# Run from the repo root: +# docker compose -f docker/docker-compose.yml up -d +# +# Reads a .env file from the repo root (Compose's default project +# directory, since that's the CWD the command above is run from) — copy +# .env.example to .env there and fill in real values first. +# +# Host ports are overridable (Unraid boxes commonly already have something +# on 8080/5432) — e.g.: +# HOST_PORT_WEB=18080 HOST_PORT_INGEST=18081 HOST_PORT_PG=15432 \ +# docker compose -f docker/docker-compose.yml up -d +# or put those in the same .env file so you don't have to repeat them. +# +# IMPORTANT: whenever a package.json dependency changes, `--build` alone is +# not enough — Compose reuses the OLD container's anonymous node_modules +# volumes by default instead of repopulating them from the rebuilt image. +# Use: +# docker compose -f docker/docker-compose.yml up -d --build --force-recreate -V +# (-V / --renew-anon-volumes forces those volumes to be recreated from the +# new image; it does not touch the named notizarr_dev_pgdata volume below.) + +services: + postgres: + image: postgres:16-alpine + container_name: notizarr-postgres-dev + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-notizarr} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} + POSTGRES_DB: ${POSTGRES_DB:-notizarr} + ports: + - "127.0.0.1:${HOST_PORT_PG:-15432}:5432" + volumes: + - notizarr_dev_pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-notizarr} -d ${POSTGRES_DB:-notizarr}"] + interval: 5s + timeout: 5s + retries: 10 + + notizarr: + build: + context: .. + dockerfile: apps/server/Dockerfile + target: dev + container_name: notizarr-dev + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + environment: + NODE_ENV: development + PORT_WEB: "8080" + PORT_INGEST: "8081" + PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:?set PUBLIC_BASE_URL in .env} + DATABASE_URL: postgres://${POSTGRES_USER:-notizarr}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@postgres:5432/${POSTGRES_DB:-notizarr} + LOG_LEVEL: ${LOG_LEVEL:-debug} + AUTH_MODE: ${AUTH_MODE:-disabled} + INGESTION_ALLOWED_CIDRS: ${INGESTION_ALLOWED_CIDRS:-0.0.0.0/0} + # Apple's push service (web.push.apple.com) rejects VAPID JWTs whose + # `sub` claim isn't a real mailto:/https: value with a 403 — the + # config.ts fallback (mailto:admin@localhost) trips exactly that. + VAPID_SUBJECT: ${VAPID_SUBJECT:?set VAPID_SUBJECT in .env} + ports: + - "${HOST_PORT_WEB:-18080}:8080" + - "${HOST_PORT_INGEST:-18081}:8081" + volumes: + - ..:/workspace + # Anonymous volumes over node_modules so the bind mount above doesn't + # shadow the dependencies installed into the image at build time. + - /workspace/node_modules + - /workspace/apps/server/node_modules + - /workspace/apps/web/node_modules + - /workspace/packages/shared/node_modules + healthcheck: + test: ["CMD", "node", "apps/server/healthcheck.js"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 30s + +volumes: + notizarr_dev_pgdata: diff --git a/docs/home-assistant.md b/docs/home-assistant.md new file mode 100644 index 0000000..2422a7b --- /dev/null +++ b/docs/home-assistant.md @@ -0,0 +1,203 @@ +# Integration examples + +Concrete configuration for the services named in the original spec. All of +these publish through the LAN ingestion listener (`:8081`), never the +public URL — see +[docs/phase1/01-architecture.md](phase1/01-architecture.md) for why the two +are kept separate. + +Every example needs a token first: Settings → Admin → API Tokens → Create +token. Store the `nzr_...` value as a secret in whatever the integration +uses for secrets (Home Assistant's `secrets.yaml`, Seerr's environment +variables, etc.) — never commit it to a config repo in plain text. + +## Home Assistant + +### Replacing `ntfy.publish` + +If you're currently using the `ntfy` integration: + +```yaml +# Old +action: ntfy.publish +data: + title: "🏠 {{ trigger.to_state.attributes.friendly_name }}" + message: "Window opened" + target: homeassistant +``` + +Add a REST command pointing at Notizarr instead: + +```yaml +# configuration.yaml +rest_command: + notizarr: + url: "http://:8081/api/v1/notifications" + method: POST + headers: + Authorization: !secret notizarr_token + Content-Type: "application/json" + payload: > + { + "topic": "{{ topic }}", + "title": "{{ title }}", + "message": "{{ message }}" + } +``` + +```yaml +# secrets.yaml +# Note: the secret holds the FULL header value including "Bearer " — a +# YAML !secret tag must be the entire value of a key, it can't be embedded +# inside a larger string like "Bearer !secret notizarr_token" (that would +# be treated as the literal text "!secret notizarr_token", not resolved, +# and Notizarr would correctly reject it as an invalid token). +notizarr_token: "Bearer nzr_your_token_here" +``` + +Then call it wherever you used `ntfy.publish`: + +```yaml +action: rest_command.notizarr +data: + topic: homeassistant + title: "🏠 Home" + message: "Office window is open, don't forget to close it" +``` + +### As part of an automation + +```yaml +automation: + - alias: "Notify on window open" + trigger: + - platform: state + entity_id: binary_sensor.office_window + to: "on" + action: + - action: rest_command.notizarr + data: + topic: homeassistant + title: "🏠 Office window" + message: "The office window was just opened" +``` + +### Silencing specific automations (same topic, no push) + +If some automations are just for the log/history and shouldn't buzz your +phone, set `priority: min` (or `1`) on that call — Notizarr still stores it +and shows it in the topic like normal, it just skips the push step. Nothing +else needs to change, so it stays in the same topic as everything else: + +```yaml +automation: + - alias: "Log robot vacuum finished (no push)" + trigger: + - platform: state + entity_id: vacuum.living_room + to: "docked" + action: + - action: rest_command.notizarr + data: + topic: homeassistant + title: "🤖 Robot vacuum" + message: "Finished cleaning and docked" + priority: min +``` + +The accepted values are the same ntfy uses: `min`/`low`/`default`/`high`/`urgent`, +or the numbers `1`-`5` — only `min`/`1` currently changes behavior (no push); +the rest are accepted and stored but don't yet affect delivery. Add +`priority: "{{ priority }}"` to the shared `rest_command.notizarr` payload +(see above) if you want to set it per-call via a template variable instead +of hardcoding it in every automation. + +### Deduplicating repeated triggers + +If an automation can fire repeatedly for the same event within a short +window (flapping sensors, retried API calls), pass a `deduplication_key` so +repeats increment `occurrence_count` in Notizarr's history instead of +flooding the topic: + +```yaml +rest_command: + notizarr: + url: "http://:8081/api/v1/notifications" + method: POST + headers: + Authorization: !secret notizarr_token + Content-Type: "application/json" + payload: > + { + "topic": "{{ topic }}", + "title": "{{ title }}", + "message": "{{ message }}", + "deduplication_key": "{{ dedup_key }}" + } +``` + +## Seerr (Overseerr/Jellyseerr) + +Seerr supports generic webhook notification agents. In Settings → +Notifications → Webhook: + +- **Webhook URL**: `http://:8081/api/v1/webhook/seerr` +- **Authorization Header**: `Bearer nzr_your_token_here` +- **JSON Payload**: Seerr's default webhook payload doesn't match + Notizarr's webhook shape (`{topic, title, message, url?}`) out of the box. + Use Seerr's payload template editor to produce: + + ```json + { + "topic": "seerr", + "title": "{{event}}", + "message": "{{subject}} - {{message}}", + "url": "{{media.link}}" + } + ``` + + Adjust the field names to whatever Seerr's template variables actually + expose in your version — check Seerr's webhook test payload if the + variables above don't match. + +## Uptime Kuma + +Uptime Kuma's generic Webhook notification type works the same way: + +1. Settings → Notifications → Add New Notification → Webhook. +2. **Post URL**: `http://:8081/api/v1/webhook/uptimekuma` +3. **Content Type**: `application/json` +4. Uptime Kuma doesn't let you fully remap its payload shape in the UI, so + the simplest reliable option is Uptime Kuma's **ntfy** notification type + instead of its generic webhook — point it at Notizarr's ntfy-compat + endpoint: + - **Server URL**: `http://:8081` + - **Topic**: `uptimekuma` + - **Authorization**: Bearer token, `nzr_your_token_here` + - **Priority**: whatever Uptime Kuma offers — accepted and ignored, see + [docs/migration-ntfy.md](migration-ntfy.md#whats-different). + +## Any other service with a generic webhook + +The pattern is the same everywhere: + +```bash +curl -X POST http://:8081/api/v1/webhook/ \ + -H "Authorization: Bearer nzr_your_token_here" \ + -H "Content-Type: application/json" \ + -d '{ + "topic": "your-topic-slug", + "title": "Notification title", + "message": "Notification body", + "url": "https://optional-link.example.com" + }' +``` + +`` in the URL becomes the notification's `source` field — +pick something recognizable (`gitea`, `sonarr`, `radarr`, ...). If the +service can't be configured to send exactly this JSON shape, check whether +it has an "ntfy" notification type instead — Notizarr's ntfy-compat +endpoint (`POST /:topic`, see +[docs/migration-ntfy.md](migration-ntfy.md)) accepts a wider range of input +shapes (plain text, headers, query params) and is often the easier target +for apps that weren't built with Notizarr specifically in mind. diff --git a/docs/migration-ntfy.md b/docs/migration-ntfy.md new file mode 100644 index 0000000..b6eb6b5 --- /dev/null +++ b/docs/migration-ntfy.md @@ -0,0 +1,101 @@ +# Migrating from ntfy + +Notizarr's ingestion listener (`:8081`) accepts ntfy-style publish requests +at `POST /:topic`, so most existing ntfy integrations only need a URL and an +auth header changed — not a rewrite. This isn't a full ntfy protocol clone +(see [What's different](#whats-different) below); it covers the parts of +ntfy that are actually used for publishing a message. + +## Quick reference + +| ntfy | Notizarr | +|---|---| +| `https://ntfy.sh/mytopic` | `http://:8081/mytopic` | +| No auth, or ntfy access tokens | `Authorization: Bearer nzr_...` (create one in Admin → API Tokens) | +| `Title:` header | `Title:` header — same | +| `Priority:` header | Accepted, stored, `min`/`1` skips push — see [Priority](#priority) | +| `Tags:` header | Not mapped yet — see [What's different](#whats-different) | +| `Click:` header | `Click:` header — same, becomes the notification's link | +| `Attach:` header | `Attach:` header — same, treated as an image URL | +| `Filename:` header | `Filename:` header — stored as metadata | +| plain-text body | plain-text body — same, becomes the message | +| JSON publish (`{"topic", "message", ...}`) | JSON publish — same shape, topic comes from the URL path instead | +| Query params (`?title=...`) | Query params — same | + +## Before / after examples + +**Plain curl, ntfy:** +```bash +curl -H "Title: Backup finished" -d "Backup completed successfully" ntfy.sh/mytopic +``` + +**Plain curl, Notizarr:** +```bash +curl -H "Title: Backup finished" \ + -H "Authorization: Bearer nzr_your_token_here" \ + -d "Backup completed successfully" \ + http://:8081/mytopic +``` + +The only changes: the host/port, and adding the `Authorization` header. If +you were using ntfy's optional access tokens, this direction is the same — +you're just switching to a Notizarr-issued token with real per-service +scoping instead. + +**JSON publish, ntfy:** +```bash +curl ntfy.sh -d '{"topic":"mytopic","title":"Backup finished","message":"Backup completed successfully","click":"https://backups.example.com"}' +``` + +**JSON publish, Notizarr:** +```bash +curl -H "Authorization: Bearer nzr_your_token_here" \ + -d '{"topic":"mytopic","title":"Backup finished","message":"Backup completed successfully","click":"https://backups.example.com"}' \ + http://:8081/mytopic +``` + +Same JSON body. `topic` in the body is redundant with the URL path but +harmless if you leave it — Notizarr always takes the topic from the path on +this endpoint. + +## Getting a token + +1. Log into Notizarr as an admin. +2. Settings → Admin → API Tokens → Create token. +3. Give it a name matching the service (e.g. "Gitea", "Uptime Kuma"). +4. Copy the `nzr_...` value immediately — it's shown once. +5. Optionally scope it to specific topics under "Restrict topics" so a + leaked token from one integration can't publish everywhere. + +## What's different + +Deliberately **not** replicated, because the goal is easy migration, not a +full protocol clone (and some of these are explicit non-goals — see +[docs/phase1/00-README.md](phase1/00-README.md)): + +- **Priority.** ntfy's priority levels (min/low/default/high/urgent, or + 1-5) are accepted and stored on the notification. Only `min`/`1` changes + delivery: that notification is skipped during push fan-out (still stored + and shown in-app), so a single noisy sender can be silenced without a + separate topic. The other levels are accepted but don't yet change + anything further (no sound/vibration mapping, no urgent-bypasses-DND). + Whether a topic pushes at all otherwise is controlled by that topic's push + ON/OFF setting — see [docs/phase1/04-api-design.md](phase1/04-api-design.md). +- **Tags/emoji.** ntfy's `Tags:` header (e.g. `warning,skull`) isn't parsed + into emoji prefixes. If you want an emoji in the title, put it directly in + the `Title:` header or JSON `title` field, same as ntfy's own + emoji-in-title convention. +- **Attachments.** `Attach:` is treated as an image URL the browser loads + directly — there's no server-side download, proxying, or file upload. + This is a deliberate security choice (see the SSRF section of + [docs/phase1/02-threat-model.md](phase1/02-threat-model.md)), not a + temporary gap. +- **Auth is required by default.** Unlike a bare ntfy instance, publishing + always needs a valid `Authorization: Bearer` token — there's no + anonymous-publish mode. + +## Home Assistant, Seerr, Uptime Kuma, and other services + +See [docs/home-assistant.md](home-assistant.md) for a worked Home Assistant +example (replacing `ntfy.publish`) and equivalent webhook configurations for +Seerr, Uptime Kuma, and any service with a generic webhook option. diff --git a/docs/phase1/00-README.md b/docs/phase1/00-README.md new file mode 100644 index 0000000..977893f --- /dev/null +++ b/docs/phase1/00-README.md @@ -0,0 +1,34 @@ +# Notizarr — Phase 1 Design Package + +This is the Phase 1 deliverable: architecture, threat model, data model, API +design, PWA/Web Push design, Docker/Unraid design, folder structure, and the +phased implementation plan. **No application code is written in this phase.** +Per the project's own process rules, implementation (Phase 2+) does not start +until this package is reviewed and the open decisions below are confirmed. + +## Documents + +1. [01-architecture.md](01-architecture.md) — components, containers, data flow +2. [02-threat-model.md](02-threat-model.md) — trust boundaries, STRIDE notes, mitigations +3. [03-database-model.md](03-database-model.md) — full Postgres schema +4. [04-api-design.md](04-api-design.md) — public app API + LAN ingestion API +5. [05-pwa-web-push-design.md](05-pwa-web-push-design.md) — manifest, service worker, push flow, iOS notes +6. [06-docker-unraid-design.md](06-docker-unraid-design.md) — compose design, volumes, networking +7. [07-folder-structure.md](07-folder-structure.md) — repo layout vs. deployment layout +8. [08-implementation-plan.md](08-implementation-plan.md) — Phase 2–10 task breakdown, mapped to MVP acceptance criteria + +## Open decisions that need your sign-off before Phase 2 + +These are the "major implementation decisions" the process asks to pause on: + +| # | Decision | Recommendation | Why | +|---|---|---|---| +| 1 | Backend language/framework | **Node.js 22 + TypeScript, Fastify** | One language across front+back end for a solo-maintained homelab app; `web-push` and `openid-client` are the most mature libraries for the two hardest requirements (Web Push, OIDC); Fastify has first-class schema validation and low overhead. | +| 2 | ORM / migrations | **Drizzle ORM + drizzle-kit** | Typesafe, SQL-first, no native binary/engine (unlike Prisma) — simpler in a small Alpine container, plain-SQL migration files you can read and back up. | +| 3 | Realtime "instant in UI" delivery | **Server-Sent Events (SSE)**, in-process pub/sub, no Redis | Single-instance app; SSE is one-way (all we need), works over plain HTTP through a reverse proxy, avoids the complexity of a WebSocket + no new infra. | +| 4 | VAPID key storage | **Postgres `system_settings` table**, generated on first boot if absent | Already-durable volume; no separate key file to remember to back up — `pg_dump` covers it automatically. | +| 5 | Attachments / image handling | **No server-side fetch or proxy.** `image_url`/`external_url` are stored as plain URLs and loaded directly by the browser (``), never fetched by the server. | Eliminates SSRF risk entirely for this feature per the security requirements, at the cost of not supporting file uploads in v1 (can be added later as an explicit, scoped feature). | +| 6 | Webhook mapping | MVP ships a **fixed simple mapping** (`{topic, title, message, url?}` JSON body, or ntfy-style headers), not a configurable templating engine | Spec says "goal is easy migration, not full protocol cloning"; a mapping UI is called out as a later enhancement. | +| 7 | Topic access control | Topics are **visible to all authenticated users by default**; an admin can mark a topic `restricted` and grant per-user access | Matches "single/small household" simplicity principle while still satisfying "users can browse allowed topics." | + +If any of these should go differently, say so and I'll adjust the design before Phase 2 starts. Otherwise, confirming this package moves us into Phase 2 (repo bootstrap + Docker Compose skeleton). diff --git a/docs/phase1/01-architecture.md b/docs/phase1/01-architecture.md new file mode 100644 index 0000000..ac04448 --- /dev/null +++ b/docs/phase1/01-architecture.md @@ -0,0 +1,126 @@ +# 01 — Architecture + +## Guiding constraint + +Single-user / small-household homelab. Two containers, no queues, no +microservices. Everything that looks like it needs a worker is instead done +in-process, synchronously or via a lightweight interval timer, because volume +is low (dozens to low-thousands of notifications/day, not per-second traffic). + +## Components + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ notizarr (single Node.js/TypeScript container, modular monolith)│ +│ │ +│ Listener A 0.0.0.0:8080 "web" │ +│ - Serves the built React SPA (static assets + index.html) │ +│ - Session-cookie authenticated JSON API (/api/v1/*) │ +│ - OIDC login/callback, or proxy-header auth │ +│ - SSE stream for live UI updates │ +│ - Web Push subscribe/unsubscribe, diagnostics │ +│ - Admin API (role=admin) │ +│ - /docs (OpenAPI/Swagger UI) │ +│ - /health, /ready │ +│ │ +│ Listener B 0.0.0.0:8081 "ingest" │ +│ - Bearer-token authenticated only. No cookies, no static SPA. │ +│ - POST /api/v1/notifications, /api/v1/topics/:slug │ +│ - ntfy-compat: POST /:topic │ +│ - Webhook: POST /api/v1/webhook/:source │ +│ - /health, /metrics (Prometheus) │ +│ │ +│ Shared internal modules: │ +│ - Notification service (insert, dedupe, fan-out) │ +│ - Push service (VAPID sign + send via web-push lib) │ +│ - Rate limiter (in-memory token bucket, per-token & per-IP) │ +│ - Retention cleanup (interval timer, e.g. every 1h) │ +│ - Audit logger │ +│ │ +│ On boot: run pending DB migrations, load/generate VAPID keys │ +└─────────────────────────────────────────────────────────────────┘ + │ + │ single internal Docker network, + │ Postgres not published to host + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ notizarr-postgres (postgres:16-alpine) │ +│ Single source of truth: users, topics, notifications, │ +│ subscriptions, tokens, push subscriptions, delivery history, │ +│ audit log, VAPID keys, system settings. │ +└─────────────────────────────────────────────────────────────────┘ +``` + +Pangolin (external, not part of this compose stack) sits in front of listener +A only. Listener B is reachable only from the LAN (see §06 networking). + +## Why one process, two listeners, instead of two containers + +Splitting "public API" and "ingestion API" into two containers would mean two +copies of the same DB access layer, two deployments, two healthchecks, for no +real isolation benefit — a compromised process still has the same DB +credentials either way. The actual security boundary that matters is +*network reachability* (LAN-only port vs. reverse-proxied port) and +*auth method* (bearer token vs. session), both of which are enforced per +listener inside one process. This keeps the "prefer one app container + +Postgres" principle intact. + +## Data flow: LAN publish → history → push + +``` +LAN service (Home Assistant, Seerr, ...) + │ POST http://:8081/api/v1/notifications + │ Authorization: Bearer nzr_xxxxxxxx + ▼ +Ingestion listener + 1. Resolve token → validate hash, not revoked/expired + 2. Check token's allowed_topics / allowed_source / allowed_cidrs against request + 3. Rate limit check (per-token bucket, per-IP bucket) + 4. Validate payload (topic exists, sizes, required fields) + 5. Deduplication check (deduplication_key within window) → increment + occurrence_count on existing row instead of inserting, if matched + 6. Insert notification row (always — storage never depends on push) + 7. Write audit log entry (only for rejects/rate-limit events, to keep the + audit log meaningful rather than noisy) + 8. Publish SSE event to any connected web/PWA clients subscribed to that topic + 9. For each user with a user_topic_subscriptions row where push_enabled=true + for this topic (or no row = default push_enabled=true), and who has one + or more active push_subscriptions: + - build a small push payload (ids + short title/body + deep link) + - send via web-push (VAPID), record push_delivery_attempts + - on 404/410 from the push service, mark subscription disabled_at + 10. Return { id, status: "accepted" } +``` + +## Data flow: browser session + +``` +Browser (PWA or desktop) + │ HTTPS → Pangolin → :8080 + ▼ +notizarr web listener + - AUTH_MODE=oidc: Authorization Code + PKCE against Pocket ID (or any OIDC IdP) + - AUTH_MODE=proxy: trust identity headers only if request's peer IP is in + TRUSTED_PROXY_CIDRS (Pangolin already validated OIDC upstream) + - Sets httpOnly, Secure, SameSite=Lax session cookie + ▼ +React SPA fetches /api/v1/topics, /api/v1/notifications, opens /api/v1/stream (SSE) + - New notifications arrive over SSE while the tab is open → instant UI update + - Push notifications handle the case where the tab/app is closed +``` + +## Modular monolith layout (backend) + +Each module owns its DB access, routes, and types; only `db/schema.ts` is +shared. See [07-folder-structure.md](07-folder-structure.md) for the concrete +tree. + +- `auth` — OIDC flow, proxy-header trust, session management +- `users` — profile, role +- `topics` — CRUD, subscription toggle, access control +- `notifications` — ingestion, history, search, read/star state +- `tokens` — API token CRUD, hashing, scoping +- `push` — VAPID, subscribe/unsubscribe, fan-out, diagnostics +- `admin` — dashboard stats, noisy-source detection, settings +- `audit` — write-only log + admin read view +- `rate-limit` — shared in-memory limiter used by `notifications` ingestion diff --git a/docs/phase1/02-threat-model.md b/docs/phase1/02-threat-model.md new file mode 100644 index 0000000..a416271 --- /dev/null +++ b/docs/phase1/02-threat-model.md @@ -0,0 +1,130 @@ +# 02 — Threat Model + +## Trust domains + +| Domain | Trust level | Notes | +|---|---|---| +| A. Public internet (browsers) | Untrusted until OIDC-authenticated | Only reaches listener A, via Pangolin | +| B. Pangolin reverse proxy | Trusted **only** when its source IP is in `TRUSTED_PROXY_CIDRS` and `AUTH_MODE=proxy` | Otherwise treated as domain A | +| C. LAN services (Home Assistant, Seerr, etc.) | Semi-trusted network, but the real boundary is the API token | Reaches listener B only | +| D. notizarr app | Mediates every access | | +| E. Postgres | Fully trusted, but only reachable from the notizarr container on the internal Docker network | Never published to host | + +## Assets to protect + +- Notification history (may contain home automation state, personal info) +- API tokens (plaintext shown once, hash stored) +- OIDC client secret +- VAPID private key +- Push subscription endpoints (identify a user's device to a push service) +- Session cookies +- Admin functions (user/topic/token management) + +## Threats and mitigations + +### Spoofed identity via proxy headers +**Threat:** an attacker on the LAN or a compromised container sends +`X-Auth-Request-Email` directly to notizarr, bypassing Pangolin. +**Mitigation:** proxy-header auth is only trusted when the *direct TCP peer* +address (not a forwarded-for chain) is inside `TRUSTED_PROXY_CIDRS`. If +`AUTH_MODE=oidc`, proxy headers are never read at all. Document that Pangolin +must connect directly to notizarr (no additional untrusted hop in between). + +### Forged/leaked OIDC tokens +**Threat:** token replay, audience confusion, state/nonce fixation. +**Mitigation:** Authorization Code + PKCE (S256) via `openid-client`; +validate `iss`, `aud`, `nonce`, `exp`, signature against the issuer's JWKS; +state/nonce stored in a short-lived signed httpOnly cookie scoped to the auth +flow only. + +### Session hijacking / CSRF +**Threat:** stolen session cookie, or a malicious site making authenticated +requests on the user's behalf. +**Mitigation:** `httpOnly`, `Secure`, `SameSite=Lax` session cookie. All +mutating requests (`POST`/`PATCH`/`DELETE` on listener A) additionally +require a custom header (`X-Requested-With: notizarr`) and are rejected if +the `Origin` header doesn't match `PUBLIC_BASE_URL` — a cross-site form post +can't set custom headers, so this blocks CSRF without needing a token +handshake. + +### API token theft / abuse +**Threat:** a leaked LAN token is used to flood topics, read data it +shouldn't, or pivot into admin functions. +**Mitigation:** tokens are write-only credentials — they can **only** hit +listener B's publish endpoints, never any read/admin endpoint on listener A, +even if presented there. Tokens are scoped (topics, source name, source +CIDR, expiry). Only a SHA-256 hash is stored; plaintext is shown once at +creation/rotation. Per-token rate limiting caps blast radius. Revocation is +immediate (checked on every request, no caching of revoked status). + +### Ingestion listener exposed to the internet by misconfiguration +**Threat:** admin accidentally publishes 8081 to `0.0.0.0` on a +port-forwarded router, or Pangolin is misrouted to it. +**Mitigation:** defense in depth beyond "the admin will firewall it": +optional `INGESTION_ALLOWED_CIDRS` env var enforced in-app (default: RFC1918 +ranges) rejects any publish request from outside those CIDRs regardless of +token validity. Listener B never serves the SPA or session auth, so even a +fully exposed 8081 can't be used to browse notification history or reach +admin functions — worst case is unauthorized token-scoped publishing, which +is still gated by needing a valid token. + +### SSRF via notification content +**Threat:** a malicious/compromised publisher sets `image_url` or `url` to +an internal address (`http://169.254.169.254/...`, `http://postgres:5432`, +etc.), hoping the server fetches it. +**Mitigation:** the server **never fetches these URLs**. They are stored as +opaque strings and only ever rendered as `` / `` in the +*browser's* context, exactly like a normal webpage. This removes SSRF as a +viable vector for this field entirely, at the cost of not offering +server-side attachment proxying/caching (acceptable per the "attachment +metadata only" requirement). + +### XSS via notification content +**Threat:** `title`/`message` containing `