Initial commit
No files matched your search
@@ -0,0 +1,11 @@
|
|||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
docs
|
||||||
|
docker/postgres
|
||||||
|
docker/app
|
||||||
|
docker/backups
|
||||||
|
docker/logs
|
||||||
|
*.md
|
||||||
@@ -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
|
||||||
@@ -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/
|
||||||
@@ -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-<timestamp>.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://<lan-ip>: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
|
||||||
@@ -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"]
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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<boolean> {
|
||||||
|
try {
|
||||||
|
await pool.query('SELECT 1')
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
// 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "user_topic_subscriptions" ADD COLUMN "muted_until" timestamp with time zone;
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import { db } from './client.js'
|
||||||
|
import { systemSettings } from './schema.js'
|
||||||
|
|
||||||
|
export async function getSetting<T>(key: string): Promise<T | null> {
|
||||||
|
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<T>(key: string, value: T): Promise<void> {
|
||||||
|
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<T>(key: string, generate: () => T): Promise<T> {
|
||||||
|
const existing = await getSetting<T>(key)
|
||||||
|
if (existing !== null) return existing
|
||||||
|
|
||||||
|
const value = generate()
|
||||||
|
await setSetting(key, value)
|
||||||
|
return value
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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<T>(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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<string, number> = {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<string, Bucket>()
|
||||||
|
|
||||||
|
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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<FastifyInstance> {
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
'<!doctype html><title>Notizarr</title><body>Building web assets, refresh shortly…</body>',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<FastifyInstance> {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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<BackupResult> {
|
||||||
|
const dir = backupsDir()
|
||||||
|
const filename = `notizarr-db-${timestamp()}.sql.gz`
|
||||||
|
const fullPath = path.join(dir, filename)
|
||||||
|
|
||||||
|
await new Promise<void>((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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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<string, unknown>,
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<number>`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 }))
|
||||||
|
}
|
||||||
@@ -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<number>`count(*) filter (where ${notifications.createdAt} >= ${startOfToday})::int`,
|
||||||
|
last24h: sql<number>`count(*) filter (where ${notifications.createdAt} >= ${last24h})::int`,
|
||||||
|
})
|
||||||
|
.from(notifications),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
attempts: sql<number>`count(*) filter (where ${pushDeliveryAttempts.attemptedAt} >= ${last24h})::int`,
|
||||||
|
failures: sql<number>`count(*) filter (where ${pushDeliveryAttempts.attemptedAt} >= ${last24h} and ${pushDeliveryAttempts.status} in ${FAILURE_STATUSES})::int`,
|
||||||
|
})
|
||||||
|
.from(pushDeliveryAttempts),
|
||||||
|
db.select({ c: sql<number>`count(*)::int` }).from(users),
|
||||||
|
db.select({ c: sql<number>`count(*)::int` }).from(topics),
|
||||||
|
db
|
||||||
|
.select({ c: sql<number>`count(*)::int` })
|
||||||
|
.from(pushSubscriptions)
|
||||||
|
.where(sql`${pushSubscriptions.disabledAt} is null`),
|
||||||
|
])
|
||||||
|
|
||||||
|
const topTopics = await db
|
||||||
|
.select({
|
||||||
|
slug: topics.slug,
|
||||||
|
displayName: topics.displayName,
|
||||||
|
total: sql<number>`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<number>`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<TopicStats | null> {
|
||||||
|
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<number>`count(*)::int`,
|
||||||
|
})
|
||||||
|
.from(notifications)
|
||||||
|
.where(eq(notifications.topicId, topic.id))
|
||||||
|
.groupBy(notifications.message)
|
||||||
|
.orderBy(desc(sql`count(*)`))
|
||||||
|
.limit(15)
|
||||||
|
|
||||||
|
return { dailyCounts: dailyResult.rows, topMessages }
|
||||||
|
}
|
||||||
@@ -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<StoredSystemSettings | null> {
|
||||||
|
return getSetting<StoredSystemSettings>(SYSTEM_SETTINGS_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setStoredSystemSettings(settings: StoredSystemSettings): Promise<void> {
|
||||||
|
await setSetting(SYSTEM_SETTINGS_KEY, settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveRetentionDefaultDays(): Promise<number> {
|
||||||
|
const stored = await getStoredSystemSettings()
|
||||||
|
return stored?.retentionDefaultDays ?? config.retentionDefaultDays
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveVapidSubject(): Promise<string> {
|
||||||
|
const stored = await getStoredSystemSettings()
|
||||||
|
return stored?.vapidSubject || config.vapidSubject
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveQuietHours(): Promise<QuietHoursSettings> {
|
||||||
|
const stored = await getStoredSystemSettings()
|
||||||
|
return stored?.quietHours ?? DEFAULT_QUIET_HOURS
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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<string, unknown> | 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<void> {
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 <form> 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' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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<User | null> {
|
||||||
|
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' })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<StoredOidcSettings | null> {
|
||||||
|
return getSetting<StoredOidcSettings>(OIDC_SETTINGS_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setStoredOidcSettings(settings: StoredOidcSettings): Promise<void> {
|
||||||
|
await setSetting(OIDC_SETTINGS_KEY, settings)
|
||||||
|
}
|
||||||
@@ -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<Client> | 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<EffectiveOidcConfig> {
|
||||||
|
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<Client> {
|
||||||
|
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 }
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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<string, unknown>
|
||||||
|
dirty = false
|
||||||
|
cleared = false
|
||||||
|
|
||||||
|
constructor(initial: Record<string, unknown>) {
|
||||||
|
this.data = initial
|
||||||
|
}
|
||||||
|
|
||||||
|
get<T = unknown>(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<string, unknown> {
|
||||||
|
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<void> {
|
||||||
|
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<Record<string, unknown>>(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<string>('userId')
|
||||||
|
if (!userId) return null
|
||||||
|
return getUserById(userId)
|
||||||
|
}
|
||||||
@@ -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<boolean> {
|
||||||
|
const [row] = await db.select({ count: sql<number>`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!
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<string, string | undefined>
|
||||||
|
|
||||||
|
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<string, unknown>
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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<CreateNotificationInput, 'senderTokenId'>,
|
||||||
|
): Promise<void> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<CreateNotificationResult> {
|
||||||
|
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<NotificationWithState[]> {
|
||||||
|
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<typeof c> => 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<NotificationWithState | null> {
|
||||||
|
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<number> {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<number> {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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' }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<string, (typeof pushDeliveryAttempts.$inferSelect) | null> = {}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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<VapidKeyPair> | 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<VapidKeyPair> {
|
||||||
|
if (!keysPromise) {
|
||||||
|
keysPromise = getOrCreateSetting<VapidKeyPair>(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<string> {
|
||||||
|
const keys = await ensureVapidConfigured(subject)
|
||||||
|
return keys.publicKey
|
||||||
|
}
|
||||||
|
|
||||||
|
export { webpush }
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
@@ -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<string, string | undefined>).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)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<DeleteTokenResult> {
|
||||||
|
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<string[] | null> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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' }
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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<TopicWithStats> {
|
||||||
|
const [counts] = await db
|
||||||
|
.select({
|
||||||
|
totalCount: sql<number>`count(*)::int`,
|
||||||
|
latestAt: sql<Date | null>`max(${notifications.createdAt})`,
|
||||||
|
})
|
||||||
|
.from(notifications)
|
||||||
|
.where(eq(notifications.topicId, topic.id))
|
||||||
|
|
||||||
|
const [unread] = await db
|
||||||
|
.select({ unreadCount: sql<number>`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<TopicWithStats[]> {
|
||||||
|
const topicRows = await listTopics()
|
||||||
|
return Promise.all(topicRows.map((topic) => attachStats(userId, topic)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTopicWithStatsBySlug(
|
||||||
|
userId: string,
|
||||||
|
slug: string,
|
||||||
|
): Promise<TopicWithStats | null> {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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<typeof updateTopic>[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<string, unknown>,
|
||||||
|
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' }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#0a0b0e" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Notizarr" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/icons/favicon.svg" />
|
||||||
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||||
|
<title>Notizarr</title>
|
||||||
|
</head>
|
||||||
|
<body class="bg-base-bg text-slate-100 antialiased overflow-x-hidden">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,17 @@
|
|||||||
|
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#6366f1" />
|
||||||
|
<stop offset="1" stop-color="#4338ca" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="512" height="512" rx="112" fill="url(#bg)" />
|
||||||
|
<path
|
||||||
|
d="M176 336 L176 176 L336 336 L336 176"
|
||||||
|
fill="none"
|
||||||
|
stroke="#ffffff"
|
||||||
|
stroke-width="44"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 554 B |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -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)
|
||||||
|
})
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#6366f1" />
|
||||||
|
<stop offset="1" stop-color="#4338ca" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<!-- Full-bleed, unrounded background: the OS applies its own mask shape.
|
||||||
|
Glyph is sized to stay within the ~80% "safe zone" circle so it
|
||||||
|
survives circular/squircle/rounded-square masking on any platform. -->
|
||||||
|
<rect width="512" height="512" fill="url(#bg)" />
|
||||||
|
<path
|
||||||
|
d="M206 316 L206 196 L306 316 L306 196"
|
||||||
|
fill="none"
|
||||||
|
stroke="#ffffff"
|
||||||
|
stroke-width="30"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 770 B |
@@ -0,0 +1,17 @@
|
|||||||
|
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#6366f1" />
|
||||||
|
<stop offset="1" stop-color="#4338ca" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="512" height="512" rx="112" fill="url(#bg)" />
|
||||||
|
<path
|
||||||
|
d="M176 336 L176 176 L336 336 L336 176"
|
||||||
|
fill="none"
|
||||||
|
stroke="#ffffff"
|
||||||
|
stroke-width="44"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 554 B |
@@ -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 (
|
||||||
|
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-base-bg px-6 text-center text-slate-100">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-accent/15 text-accent-bright">
|
||||||
|
<span className="text-lg font-bold">N</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold tracking-tight">Notizarr</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">Sign in to see your notifications.</p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="/api/v1/auth/oidc/login"
|
||||||
|
className="rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-accent-dim"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { data: session, isLoading } = useSession()
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="min-h-screen bg-base-bg" />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
if (session?.authMode === 'oidc') return <SignInScreen />
|
||||||
|
// proxy mode with no trusted headers reaching us, or a config problem —
|
||||||
|
// there's nothing a client-side redirect can do about either.
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-base-bg px-6 text-center text-sm text-slate-500">
|
||||||
|
Not authenticated. Check AUTH_MODE / trusted proxy configuration.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = session.user.role === 'admin'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route element={<AppShell />}>
|
||||||
|
<Route index element={<InboxPage />} />
|
||||||
|
<Route path="topics" element={<TopicsPage />} />
|
||||||
|
<Route path="topics/:slug" element={<TopicDetailPage />} />
|
||||||
|
<Route path="topics/:slug/messages/:id" element={<NotificationDetailPage />} />
|
||||||
|
<Route path="search" element={<SearchPage />} />
|
||||||
|
<Route path="starred" element={<StarredPage />} />
|
||||||
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
|
<Route path="settings/diagnostics" element={<DiagnosticsPage />} />
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<Route path="admin" element={<AdminLayout />}>
|
||||||
|
<Route index element={<DashboardPage />} />
|
||||||
|
<Route path="topics" element={<TopicsAdminPage />} />
|
||||||
|
<Route path="topics/:slug/stats" element={<TopicStatsAdminPage />} />
|
||||||
|
<Route path="tokens" element={<TokensAdminPage />} />
|
||||||
|
<Route path="users" element={<UsersAdminPage />} />
|
||||||
|
<Route path="devices" element={<DevicesAdminPage />} />
|
||||||
|
<Route path="sources" element={<SourcesAdminPage />} />
|
||||||
|
<Route path="auth" element={<AuthSettingsAdminPage />} />
|
||||||
|
<Route path="system" element={<SystemSettingsAdminPage />} />
|
||||||
|
<Route path="backups" element={<BackupsAdminPage />} />
|
||||||
|
<Route path="audit-log" element={<AuditLogPage />} />
|
||||||
|
</Route>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex h-32 items-end gap-1">
|
||||||
|
{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 (
|
||||||
|
<div key={d.day} className="group relative flex h-full flex-1 items-end">
|
||||||
|
<div
|
||||||
|
className="w-full rounded-t bg-accent/70 transition-colors group-hover:bg-accent"
|
||||||
|
style={{ height: `${Math.max(heightPct, d.count > 0 ? 4 : 1)}%` }}
|
||||||
|
/>
|
||||||
|
<div className="pointer-events-none absolute bottom-full left-1/2 z-10 mb-1.5 -translate-x-1/2 whitespace-nowrap rounded-md bg-base-raised px-2 py-1 text-[11px] text-slate-200 opacity-0 shadow-raised transition-opacity group-hover:opacity-100">
|
||||||
|
{label}: {d.count}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex items-center gap-5">
|
||||||
|
<div className="relative shrink-0" style={{ width: size, height: size }}>
|
||||||
|
<div className="h-full w-full rounded-full" style={{ background: gradient }} />
|
||||||
|
<div className="absolute inset-[18%] flex items-center justify-center rounded-full bg-base-surface">
|
||||||
|
<span className="text-sm font-semibold text-slate-200">{total.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{data.map((d) => (
|
||||||
|
<li key={d.label} className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: d.color }} />
|
||||||
|
<span className="text-slate-300">{d.label}</span>
|
||||||
|
<span className="text-slate-500">
|
||||||
|
{total > 0 ? Math.round((d.value / total) * 100) : 0}%
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 <main>, 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 (
|
||||||
|
<div className="flex h-[100dvh] bg-base-bg text-slate-100">
|
||||||
|
<Sidebar />
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
{/* 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. */}
|
||||||
|
<main className="safe-top flex-1 overflow-x-hidden overflow-y-auto">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
<BottomNav />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { NavLink } from 'react-router-dom'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
import { navItems } from './nav-items'
|
||||||
|
|
||||||
|
export function BottomNav() {
|
||||||
|
return (
|
||||||
|
<nav className="safe-bottom shrink-0 border-t border-base-border bg-base-surface/95 backdrop-blur md:hidden">
|
||||||
|
<ul className="flex items-stretch justify-around">
|
||||||
|
{navItems.map(({ to, label, icon: Icon, end }) => (
|
||||||
|
// min-w-0 is load-bearing: flex items default to min-width: auto,
|
||||||
|
// which blocks shrinking below their content's natural width —
|
||||||
|
// with 5 labeled items that's enough to push the row wider than
|
||||||
|
// narrow phone screens, cutting the last item off entirely.
|
||||||
|
<li key={to} className="min-w-0 flex-1">
|
||||||
|
<NavLink
|
||||||
|
to={to}
|
||||||
|
end={end}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
'flex flex-col items-center gap-1 py-2.5 text-[11px] font-medium transition-colors',
|
||||||
|
isActive ? 'text-accent-bright' : 'text-slate-500 hover:text-slate-300',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ isActive }) => (
|
||||||
|
<>
|
||||||
|
<Icon className="h-5 w-5" strokeWidth={isActive ? 2.25 : 1.75} />
|
||||||
|
<span className="truncate">{label}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { NavLink } from 'react-router-dom'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
import { navItems } from './nav-items'
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
return (
|
||||||
|
<aside className="hidden w-60 shrink-0 flex-col border-r border-base-border bg-base-surface md:flex">
|
||||||
|
<div className="flex items-center gap-2.5 px-5 py-5">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-accent/15 text-accent-bright">
|
||||||
|
<span className="text-sm font-bold">N</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[15px] font-semibold tracking-tight text-slate-100">Notizarr</span>
|
||||||
|
</div>
|
||||||
|
<nav className="flex-1 px-3 py-2">
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{navItems.map(({ to, label, icon: Icon, end }) => (
|
||||||
|
<li key={to}>
|
||||||
|
<NavLink
|
||||||
|
to={to}
|
||||||
|
end={end}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-accent/10 text-accent-bright'
|
||||||
|
: 'text-slate-400 hover:bg-base-hover hover:text-slate-200',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon className="h-[18px] w-[18px]" strokeWidth={1.75} />
|
||||||
|
{label}
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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<number | null>(null)
|
||||||
|
const startY = useRef<number | null>(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 (
|
||||||
|
<div className="relative overflow-hidden">
|
||||||
|
{revealingRead && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none absolute inset-0 flex items-center px-5 text-xs font-medium',
|
||||||
|
dragX > 0
|
||||||
|
? 'justify-start bg-emerald-500/15 text-emerald-400'
|
||||||
|
: 'justify-end bg-emerald-500/15 text-emerald-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
{notification.read ? 'Mark unread' : 'Mark read'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
to={`/topics/${notification.topicSlug}/messages/${notification.id}`}
|
||||||
|
onClick={handleClick}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={endDrag}
|
||||||
|
onPointerCancel={endDrag}
|
||||||
|
style={{ transform: `translateX(${dragX}px)`, transition: dragging ? 'none' : 'transform 0.2s' }}
|
||||||
|
className="group relative flex items-start gap-3 bg-base-bg px-4 py-3.5 transition-colors hover:bg-base-hover"
|
||||||
|
>
|
||||||
|
<div className="relative mt-0.5 shrink-0">
|
||||||
|
<div
|
||||||
|
className="flex h-9 w-9 items-center justify-center overflow-hidden rounded-full"
|
||||||
|
style={{ backgroundColor: avatarColor.bg }}
|
||||||
|
>
|
||||||
|
{notification.imageUrl ? (
|
||||||
|
<img src={notification.imageUrl} alt="" className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<span className="text-xs font-semibold uppercase" style={{ color: avatarColor.fg }}>
|
||||||
|
{notification.source.slice(0, 2)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!notification.read && (
|
||||||
|
<span className="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full bg-accent ring-2 ring-base-bg" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'truncate text-sm',
|
||||||
|
notification.read ? 'font-medium text-slate-300' : 'font-semibold text-slate-100',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{notification.title}
|
||||||
|
</p>
|
||||||
|
{notification.starred && (
|
||||||
|
<Star className="h-3.5 w-3.5 shrink-0 fill-amber-400 text-amber-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 line-clamp-2 text-sm text-slate-400">{notification.message}</p>
|
||||||
|
<p className="mt-1 flex items-center gap-1.5 text-xs text-slate-500">
|
||||||
|
{showTopic && (
|
||||||
|
<>
|
||||||
|
<span className="rounded bg-base-raised px-1.5 py-0.5 font-medium text-slate-400">
|
||||||
|
{notification.topicDisplayName}
|
||||||
|
</span>
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span>{formatRelativeTime(notification.createdAt)}</span>
|
||||||
|
{notification.occurrenceCount > 1 && (
|
||||||
|
<>
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<span>×{notification.occurrenceCount}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isQuietPriority(notification.rawPriority) && (
|
||||||
|
<>
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<span className="flex items-center gap-1" title="Sent without a push notification">
|
||||||
|
<BellOff className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<string, string> = {
|
||||||
|
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 (
|
||||||
|
<span className={`rounded px-1.5 py-0.5 text-xs font-medium ${styles[permission] ?? styles.default}`}>
|
||||||
|
{permission}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PushSection() {
|
||||||
|
const { permission, subscription, isBusy, error, enable, disable, supported } = usePushSubscription()
|
||||||
|
const { data: devices } = useDevices()
|
||||||
|
const deleteDevice = useDeleteDevice()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-xl border border-base-border bg-base-surface p-4 shadow-card">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-slate-200">Push notifications</h2>
|
||||||
|
<PermissionBadge permission={permission} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!supported && (
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{supported && (
|
||||||
|
<>
|
||||||
|
<p className="mb-3 text-sm text-slate-500">
|
||||||
|
{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.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={isBusy || permission === 'denied'}
|
||||||
|
onClick={() => (subscription ? disable() : enable())}
|
||||||
|
className="rounded-lg bg-accent px-3.5 py-2 text-sm font-semibold text-white transition-colors hover:bg-accent-dim disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{subscription ? 'Disable on this device' : 'Enable notifications'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{permission === 'denied' && (
|
||||||
|
<p className="mt-2 text-xs text-red-400">
|
||||||
|
Notifications are blocked for this site — re-enable them in your browser's site settings.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{error && <p className="mt-2 text-xs text-red-400">{error}</p>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{devices && devices.length > 0 && (
|
||||||
|
<div className="mt-4 space-y-2 border-t border-base-border pt-3">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||||
|
Devices ({devices.length})
|
||||||
|
</p>
|
||||||
|
{devices.map((device) => (
|
||||||
|
<div key={device.id} className="flex items-center gap-2.5 py-1 text-sm">
|
||||||
|
<Smartphone className="h-4 w-4 shrink-0 text-slate-500" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-slate-300">
|
||||||
|
{device.deviceLabel ?? friendlyDeviceName(device.userAgent)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{device.disabledAt ? 'Disabled' : `Last seen ${formatRelativeTime(device.lastSeenAt)}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => deleteDevice.mutate(device.id)}
|
||||||
|
className="rounded p-1 text-slate-500 transition-colors hover:bg-base-hover hover:text-red-400"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to="/settings/diagnostics"
|
||||||
|
className="mt-4 block text-xs font-medium text-accent-bright hover:underline"
|
||||||
|
>
|
||||||
|
Push diagnostics →
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Link
|
||||||
|
to={`/topics/${topic.slug}`}
|
||||||
|
className="group flex items-center gap-4 rounded-xl border border-base-border bg-base-surface p-4 shadow-card transition-all hover:-translate-y-0.5 hover:border-base-border hover:bg-base-hover hover:shadow-raised"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-lg"
|
||||||
|
style={{ backgroundColor: `${topic.accentColor ?? '#818cf8'}1a` }}
|
||||||
|
>
|
||||||
|
{topic.icon ? (
|
||||||
|
<span aria-hidden>{topic.icon}</span>
|
||||||
|
) : (
|
||||||
|
<Hash className="h-[18px] w-[18px]" style={{ color: topic.accentColor ?? '#818cf8' }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="truncate text-sm font-medium text-slate-100">{topic.displayName}</p>
|
||||||
|
{topic.unreadCount > 0 && (
|
||||||
|
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-accent px-1.5 text-[11px] font-semibold text-white">
|
||||||
|
{topic.unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 truncate text-xs text-slate-500">
|
||||||
|
{topic.totalCount.toLocaleString()} total
|
||||||
|
{topic.latestAt && <> · latest {formatRelativeTime(topic.latestAt)}</>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{topic.pushEnabled ? (
|
||||||
|
<Bell className="h-[18px] w-[18px] shrink-0 text-slate-500" strokeWidth={1.75} />
|
||||||
|
) : (
|
||||||
|
<BellOff className="h-[18px] w-[18px] shrink-0 text-slate-600" strokeWidth={1.75} />
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={checked}
|
||||||
|
aria-label={label}
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
className={cn(
|
||||||
|
'flex h-5 w-5 shrink-0 items-center justify-center rounded-md border transition-colors',
|
||||||
|
checked
|
||||||
|
? 'border-accent bg-accent'
|
||||||
|
: 'border-base-border bg-base-raised hover:border-slate-500',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{checked && <Check className="h-3.5 w-3.5 text-white" strokeWidth={3} />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3 px-6 py-16 text-center animate-fade-in">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-base-raised">
|
||||||
|
<Icon className="h-6 w-6 text-slate-500" strokeWidth={1.75} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium text-slate-200">{title}</p>
|
||||||
|
{description && <p className="max-w-xs text-sm text-slate-500">{description}</p>}
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
export function PageHeader({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
action,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
subtitle?: string
|
||||||
|
action?: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="safe-top flex items-center justify-between border-b border-base-border px-4 py-4 md:px-6 md:py-5">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold tracking-tight text-slate-100">{title}</h1>
|
||||||
|
{subtitle && <p className="mt-0.5 text-sm text-slate-500">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||