siliconsolder 079013730b Add notification image proxy, expand README, fix compose env-file handling
- Proxy notification image_url server-side (GET /api/v1/notifications/:id/image)
  instead of loading it directly in the browser, so LAN-only image sources
  (e.g. Frigate snapshots) still load for clients off that network
- Tighten CSP img-src to 'self' now that images are same-origin
- Update threat model / architecture docs to match the new image-fetch design
- Expand README: Home Assistant quick-start, Pocket ID/OIDC walkthrough,
  Pangolin note, backup automation, new troubleshooting entries
- Fix docker-compose.yml: document that --env-file .env must be passed
  explicitly (Compose's implicit .env lookup was unreliable with -f from
  a different directory)
- Fix image-proxy tests to build a real session cookie (AUTH_MODE=oidc
  applies inside the test container too, so requireAuth needs one)
2026-09-07 18:49:45 +00:00
2026-09-07 09:54:30 +00:00
2026-09-07 09:54:30 +00:00
2026-09-07 09:54:30 +00:00
2026-09-07 09:54:30 +00:00
2026-09-07 09:54:30 +00:00
2026-09-07 09:54:30 +00:00
2026-09-07 09:54:30 +00:00
2026-09-07 09:54:30 +00:00
2026-09-07 09:54:30 +00:00
2026-09-07 09:54:30 +00:00

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/.

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 for the phase breakdown and 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.

Auth modes

Set via AUTH_MODE (.env / docker-compose.yml environment): disabled, proxy, or oidc — see 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 confirmed working end-to-end against a real Pocket ID instance (Authorization Code + PKCE via openid-client v5). Once AUTH_MODE=oidc is set and the server restarted, the actual provider details (issuer URL, client ID/secret, redirect URL, scopes) are editable any time from Admin → Auth — no further env file edits or restarts needed for those.

Setting up OIDC with Pocket ID

Notizarr handles the OIDC login flow itself (Authorization Code + PKCE) — it does not delegate auth to the reverse proxy via trusted headers, so Pangolin (or whatever sits in front of it) only needs to forward HTTP, no auth injection.

  1. In Pocket ID, create a new OIDC client:
    • Name: Notizarr
    • Client's start URL: your public Notizarr URL (e.g. https://notify.example.com)
    • Callback URLs: https://notify.example.com/api/v1/auth/oidc/callback — this exact path is hardcoded server-side (apps/server/src/modules/auth/routes.ts), it has to match exactly
    • Public Client: off — Notizarr is a confidential/server-side client and needs a client secret. Leaving this on means Pocket ID never issues one.
    • PKCE: on — Notizarr always sends a PKCE challenge regardless, so this just means Pocket ID requires it too
    • Everything else (logout callback URLs, skip consent, logo) is optional/cosmetic
  2. Save, then open the client back up and find its Referenser / Credentials tab — client secrets in Pocket ID live there, not on the creation form, and are only shown once when generated.
  3. In Notizarr, go to Admin → Auth and fill in the Issuer URL, Client ID, Client Secret, Redirect URL (same value as the Callback URL above), and Scopes (openid profile email). Save.
  4. Set AUTH_MODE=oidc in .env and restart the container — this one setting is the only part that still needs a restart, since it changes which routes the server exposes at boot. Everything from step 3 takes effect immediately without one.
  5. Log in once. If you'd previously been running AUTH_MODE=disabled, its implicit "Local Admin" user already claimed the bootstrap-admin slot (the very first user ever created), so your real login likely comes back as a normal user — see the "First-ever login should be an admin but isn't" entry under Troubleshooting to fix that.

Pangolin: point it at the web listener only (notizarr:8080 on the same Docker network, or the host's PUBLIC_BASE_URL) — never at :8081, the LAN-only ingestion listener. See Unraid deployment below.

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.

Local development

Requires Node.js 22+ and pnpm (corepack enable will fetch the pinned version automatically).

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

docker compose --env-file .env -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):

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:

docker compose --env-file .env -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:

HOST_PORT_WEB=28080 HOST_PORT_INGEST=28081 HOST_PORT_PG=25432 \
  docker compose --env-file .env -f docker/docker-compose.yml up -d

Check health:

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):

docker compose --env-file .env -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.

  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):
    docker build -f apps/server/Dockerfile --target runtime -t notizarr:0.1.0 .
    
  2. On the Unraid host, create the deployment directory structure:
    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:
    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), so backing up the database is a complete backup.

Automatic (no setup needed): the app backs itself up once a day and keeps the last 14, no Unraid User Scripts cron entry required. Trigger one manually or download an existing one from Admin → Backups.

Manual / scripted, e.g. for an off-box copy:

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 — the same directory and naming the in-app job uses, so both coexist. Restore with:

./scripts/restore.sh backups/notizarr-db-<timestamp>.sql.gz

This is destructive (replaces all current data) and asks for confirmation.

Home Assistant

Add a rest_command pointing at the LAN ingestion listener (:8081, never the public URL):

# configuration.yaml
rest_command:
  notizarr:
    url: "http://<lan-ip>:8081/api/v1/notifications"
    method: POST
    headers:
      Authorization: !secret notizarr_token
      Content-Type: "application/json"
    payload: >
      {
        "topic": "{{ topic }}",
        "title": "{{ title }}",
        "message": "{{ message }}",
        "source": "{{ source | default('home-assistant') }}",
        "priority": "{{ priority | default('default') }}",
        "image_url": "{{ image_url | default('') }}"
      }
# secrets.yaml — the value is the FULL header including "Bearer ", since a
# YAML !secret must be a key's entire value, not embedded in a larger string
notizarr_token: "Bearer nzr_your_token_here"

Then call it from any automation:

action: rest_command.notizarr
data:
  topic: homeassistant
  title: "🏠 Home"
  message: "Office window is open, don't forget to close it"

Get the token from Admin → API Tokens → Create token. Two extras worth knowing:

  • priority: min stores and shows the notification as usual but skips the push — useful for automations that should log something without buzzing anyone's phone, without needing a separate "quiet" topic.
  • Quiet hours (Admin → System) and per-topic snooze (the alarm-clock icon on any topic page) both suppress push independently of what any individual automation sends, if you'd rather configure "don't wake me up" once instead of per-automation.

Full field reference, dedup keys, and examples for Seerr/Uptime Kuma/generic webhooks: docs/home-assistant.md.

Migrating from ntfy

docs/migration-ntfy.md — endpoint mapping, before/after examples, what's intentionally not replicated. Short version: point existing ntfy.sh/<topic> publishers at http://<lan-ip>:8081/<topic> with an Authorization: Bearer nzr_... header instead — headers, JSON body, and query-param publishing all still work the same way.

Troubleshooting

  • docker compose ... exec/logs/etc fails with required variable POSTGRES_PASSWORD is missing a value. Compose's implicit .env lookup when the compose file is passed via -f docker/docker-compose.yml from the repo root doesn't reliably resolve to the repo root's .env — it's ambiguous/version-dependent, and got this wrong in practice. Always pass --env-file .env explicitly, on every invocation against this file, not just up (see the comment at the top of docker/docker-compose.yml).
  • 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).
  • 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.
  • A third-party app's built-in "Ntfy" notification type won't send ("test failed", 404). Sonarr/Radarr/Prowlarr-style apps typically append each topic in their own "Topics" field to the Server URL ({Server URL}/{topic}), so the topic must not already be in the Server URL — just the bare host+port. Overseerr/Jellyseerr-style apps do the opposite: they expect the topic already in the Server URL and don't append anything, so putting a topic in a separate "Topic" field there is redundant but harmless. When in doubt, curl the ingestion listener directly with the same topic path the app would use — Notizarr always expects exactly one path segment (POST /:topic), and comparing that against what actually gets sent explains almost every case of this.

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

S
Description
No description provided
Readme
360 KiB
0 Stars 1 Watchers 0 Forks
Languages
TypeScript 96.4%
Shell 1.1%
JavaScript 0.9%
Dockerfile 0.9%
CSS 0.5%
Other 0.2%