Initial commit
This commit is contained in:
commit
38c24fc9dc
166 files changed
+22002
No files matched your search
@@ -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;
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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"]
|
||||
}
|
||||
Reference in new issue
Block a user