From 920ac0d41b7476b80218ba4ed10d6ade2e720e68 Mon Sep 17 00:00:00 2001 From: Admin Date: Tue, 24 Mar 2026 20:18:24 +0500 Subject: [PATCH] feat(auth): add email verification to registration flow - Add email/email_verified/verification_token/verification_token_exp fields to app_users PocketBase schema (pb-init-v3.sh) - Add SMTP env vars to UI service in docker-compose.yml - New email.ts: raw TLS SMTP mailer via Node tls module, sendVerificationEmail() - createUser() now takes email param, stores verification token (24h TTL) - loginUser() throws 'Email not verified' when email_verified is false - New /verify-email route: validates token, verifies user, auto-logs in - Login page: email field in register form, check-inbox state after register - /api/auth/register (iOS): returns { pending_verification, email } instead of token - Add pb.libnovel.cc and storage.libnovel.cc Caddy virtual hosts for homelab runner - Add homelab runner docker-compose and libnovel.sh helper script --- Caddyfile | 20 +- docker-compose.yml | 31 ++- homelab/runner/docker-compose.yml | 58 +++++ scripts/libnovel.sh | 100 ++++++++ scripts/pb-init-v3.sh | 30 ++- ui/src/lib/server/email.ts | 195 ++++++++++++++++ ui/src/lib/server/pocketbase.ts | 63 +++++- ui/src/routes/+layout.server.ts | 2 +- ui/src/routes/api/auth/register/+server.ts | 68 ++---- ui/src/routes/login/+page.server.ts | 58 +++-- ui/src/routes/login/+page.svelte | 252 ++++++++++++--------- ui/src/routes/verify-email/+page.server.ts | 72 ++++++ ui/src/routes/verify-email/+page.svelte | 21 ++ 13 files changed, 759 insertions(+), 211 deletions(-) create mode 100644 homelab/runner/docker-compose.yml create mode 100644 scripts/libnovel.sh create mode 100644 ui/src/lib/server/email.ts create mode 100644 ui/src/routes/verify-email/+page.server.ts create mode 100644 ui/src/routes/verify-email/+page.svelte diff --git a/Caddyfile b/Caddyfile index 1dfb9b4..8d81b5a 100644 --- a/Caddyfile +++ b/Caddyfile @@ -43,8 +43,8 @@ { # Email for Let's Encrypt ACME account registration. # When CADDY_ACME_EMAIL is set this expands to e.g. "email you@example.com". - # When unset it expands to an empty string and is silently ignored. - {$CADDY_ACME_EMAIL:} + # When unset the variable expands to an empty string and Caddy ignores it. + email {$CADDY_ACME_EMAIL:} # CrowdSec bouncer — streams decisions from the CrowdSec LAPI every 15s. # CROWDSEC_API_KEY is injected at runtime via crowdsec/.crowdsec.env. @@ -238,3 +238,19 @@ push.libnovel.cc { import security_headers reverse_proxy gotify:80 } + +# ── PocketBase: exposed for homelab runner task polling ─────────────────────── +# Allows the homelab runner to claim tasks and write results via the PB API. +# Admin UI is also accessible here for convenience. +pb.libnovel.cc { + import security_headers + reverse_proxy pocketbase:8090 +} + +# ── MinIO S3 API: exposed for homelab runner object writes ──────────────────── +# The homelab runner connects here as MINIO_ENDPOINT to PutObject audio/chapters. +# Also used as MINIO_PUBLIC_ENDPOINT for presigned URL generation. +storage.libnovel.cc { + import security_headers + reverse_proxy minio:9000 +} diff --git a/docker-compose.yml b/docker-compose.yml index a69a403..1e232ee 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -267,6 +267,13 @@ services: PUBLIC_UMAMI_SCRIPT_URL: "${PUBLIC_UMAMI_SCRIPT_URL}" # GlitchTip client + server-side error tracking PUBLIC_GLITCHTIP_DSN: "${PUBLIC_GLITCHTIP_DSN}" + # Email verification (Resend SMTP — shared with Fider/GlitchTip) + SMTP_HOST: "${FIDER_SMTP_HOST}" + SMTP_PORT: "${FIDER_SMTP_PORT}" + SMTP_USER: "${FIDER_SMTP_USER}" + SMTP_PASSWORD: "${FIDER_SMTP_PASSWORD}" + SMTP_FROM: "noreply@libnovel.cc" + APP_URL: "${ORIGIN}" healthcheck: test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"] interval: 15s @@ -432,13 +439,13 @@ services: BASE_URL: "${FIDER_BASE_URL}" DATABASE_URL: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/fider?sslmode=disable" JWT_SECRET: "${FIDER_JWT_SECRET}" - # Email: noreply mode — emails are suppressed (logged to stdout). - # Fider still requires SMTP vars to be non-empty even in noreply mode. + # Email: Resend SMTP EMAIL_NOREPLY: "noreply@libnovel.cc" - EMAIL_SMTP_HOST: "localhost" - EMAIL_SMTP_PORT: "25" - # Disable outbound email — set real SMTP values to enable. - EMAIL_NOREPLY_MODE: "true" + EMAIL_SMTP_HOST: "${FIDER_SMTP_HOST}" + EMAIL_SMTP_PORT: "${FIDER_SMTP_PORT}" + EMAIL_SMTP_USERNAME: "${FIDER_SMTP_USER}" + EMAIL_SMTP_PASSWORD: "${FIDER_SMTP_PASSWORD}" + EMAIL_SMTP_ENABLE_STARTTLS: "false" # ─── GlitchTip DB migration (one-shot) ─────────────────────────────────────── glitchtip-migrate: @@ -452,8 +459,8 @@ services: DATABASE_URL: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/glitchtip" SECRET_KEY: "${GLITCHTIP_SECRET_KEY}" GLITCHTIP_DOMAIN: "${GLITCHTIP_DOMAIN}" - EMAIL_URL: "consolemail://" - DEFAULT_FROM_EMAIL: "errors@libnovel.cc" + EMAIL_URL: "${GLITCHTIP_EMAIL_URL}" + DEFAULT_FROM_EMAIL: "noreply@libnovel.cc" VALKEY_URL: "redis://valkey:6379/1" command: "./manage.py migrate" restart: "no" @@ -473,8 +480,8 @@ services: DATABASE_URL: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/glitchtip" SECRET_KEY: "${GLITCHTIP_SECRET_KEY}" GLITCHTIP_DOMAIN: "${GLITCHTIP_DOMAIN}" - EMAIL_URL: "consolemail://" - DEFAULT_FROM_EMAIL: "errors@libnovel.cc" + EMAIL_URL: "${GLITCHTIP_EMAIL_URL}" + DEFAULT_FROM_EMAIL: "noreply@libnovel.cc" VALKEY_URL: "redis://valkey:6379/1" PORT: "8000" ENABLE_USER_REGISTRATION: "false" @@ -497,8 +504,8 @@ services: DATABASE_URL: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/glitchtip" SECRET_KEY: "${GLITCHTIP_SECRET_KEY}" GLITCHTIP_DOMAIN: "${GLITCHTIP_DOMAIN}" - EMAIL_URL: "consolemail://" - DEFAULT_FROM_EMAIL: "errors@libnovel.cc" + EMAIL_URL: "${GLITCHTIP_EMAIL_URL}" + DEFAULT_FROM_EMAIL: "noreply@libnovel.cc" VALKEY_URL: "redis://valkey:6379/1" SERVER_ROLE: "worker" diff --git a/homelab/runner/docker-compose.yml b/homelab/runner/docker-compose.yml new file mode 100644 index 0000000..60e6299 --- /dev/null +++ b/homelab/runner/docker-compose.yml @@ -0,0 +1,58 @@ +# LibNovel homelab runner +# +# Connects to production PocketBase and MinIO via public subdomains. +# All secrets come from Doppler (project=libnovel, config=prd). +# Run with: doppler run -- docker compose up -d +# +# Differs from prod runner: +# - RUNNER_WORKER_ID=homelab-runner-1 (unique, avoids task claiming conflicts) +# - MINIO_ENDPOINT/USE_SSL → storage.libnovel.cc over HTTPS +# - POCKETBASE_URL → https://pb.libnovel.cc +# - MEILI_URL/VALKEY_ADDR → unset (not exposed publicly; not needed by runner) +# - RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH=true + +services: + runner: + image: kalekber/libnovel-runner:latest + restart: unless-stopped + stop_grace_period: 135s + environment: + # ── PocketBase ────────────────────────────────────────────────────────── + POCKETBASE_URL: "https://pb.libnovel.cc" + POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL}" + POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD}" + + # ── MinIO (S3 API via public subdomain) ───────────────────────────────── + MINIO_ENDPOINT: "storage.libnovel.cc" + MINIO_ACCESS_KEY: "${MINIO_ROOT_USER}" + MINIO_SECRET_KEY: "${MINIO_ROOT_PASSWORD}" + MINIO_USE_SSL: "true" + MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT}" + MINIO_PUBLIC_USE_SSL: "${MINIO_PUBLIC_USE_SSL}" + + # ── Meilisearch / Valkey — not exposed, disabled ──────────────────────── + MEILI_URL: "" + VALKEY_ADDR: "" + + # ── Kokoro TTS ────────────────────────────────────────────────────────── + KOKORO_URL: "${KOKORO_URL}" + KOKORO_VOICE: "${KOKORO_VOICE}" + + # ── Runner tuning ─────────────────────────────────────────────────────── + RUNNER_WORKER_ID: "${RUNNER_WORKER_ID}" + RUNNER_POLL_INTERVAL: "${RUNNER_POLL_INTERVAL}" + RUNNER_MAX_CONCURRENT_SCRAPE: "${RUNNER_MAX_CONCURRENT_SCRAPE}" + RUNNER_MAX_CONCURRENT_AUDIO: "${RUNNER_MAX_CONCURRENT_AUDIO}" + RUNNER_TIMEOUT: "${RUNNER_TIMEOUT}" + RUNNER_METRICS_ADDR: "${RUNNER_METRICS_ADDR}" + RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH: "true" + + # ── Observability ─────────────────────────────────────────────────────── + LOG_LEVEL: "${LOG_LEVEL}" + GLITCHTIP_DSN: "${GLITCHTIP_DSN}" + + healthcheck: + test: ["CMD", "/healthcheck", "file", "/tmp/runner.alive", "120"] + interval: 60s + timeout: 5s + retries: 3 diff --git a/scripts/libnovel.sh b/scripts/libnovel.sh new file mode 100644 index 0000000..2b76f98 --- /dev/null +++ b/scripts/libnovel.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ── LibNovel — production management script ─────────────────────────────────── +# Prerequisites on the server: +# - docker + docker compose plugin +# - doppler CLI authenticated (doppler setup run once in the repo directory) +# - docker-compose.yml present in the same directory as this script +# +# Usage: ./libnovel.sh [service] +# +# up Start all services (detached) +# down Stop all services +# restart Stop then start all services +# restart Restart a single service +# pull Pull latest images from Docker Hub (uses GIT_TAG from Doppler) +# update Pull images then recreate containers +# logs Tail all logs +# logs Tail a specific service +# ps Show running containers +# shell Open a shell in a running container +# init Run one-shot init containers +# secrets Print all Doppler secrets (debug) +# ────────────────────────────────────────────────────────────────────────────── + +DC="doppler run -- docker compose" +CMD="${1:-help}" +SVC="${2:-}" + +case "$CMD" in + up) + $DC up -d + ;; + + down) + $DC down + ;; + + restart) + if [[ -n "$SVC" ]]; then + $DC restart "$SVC" + else + $DC down + $DC up -d + fi + ;; + + pull) + $DC pull backend runner ui caddy + ;; + + update) + $DC pull backend runner ui caddy + $DC up -d + ;; + + logs) + if [[ -n "$SVC" ]]; then + $DC logs -f --tail=100 "$SVC" + else + $DC logs -f --tail=50 + fi + ;; + + ps) + $DC ps + ;; + + shell) + [[ -z "$SVC" ]] && { echo "Usage: $0 shell "; exit 1; } + $DC exec "$SVC" sh + ;; + + init) + $DC run --rm minio-init + $DC run --rm pb-init + $DC run --rm postgres-init + ;; + + secrets) + doppler secrets --project libnovel --config prd + ;; + + help|*) + echo "Usage: $0 [service]" + echo "" + echo " up Start all services" + echo " down Stop all services" + echo " restart Full restart" + echo " restart Restart one service" + echo " pull Pull latest images from Docker Hub" + echo " update Pull + recreate containers" + echo " logs Tail all logs" + echo " logs Tail one service" + echo " ps Show running containers" + echo " shell Shell into a container" + echo " init Run init containers (first-time setup)" + echo " secrets Print Doppler secrets" + ;; +esac diff --git a/scripts/pb-init-v3.sh b/scripts/pb-init-v3.sh index 0abdf85..29aa8d8 100755 --- a/scripts/pb-init-v3.sh +++ b/scripts/pb-init-v3.sh @@ -177,11 +177,15 @@ create "audio_jobs" '{ create "app_users" '{ "name":"app_users","type":"base","fields":[ - {"name":"username", "type":"text","required":true}, - {"name":"password_hash","type":"text"}, - {"name":"role", "type":"text"}, - {"name":"avatar_url", "type":"text"}, - {"name":"created", "type":"text"} + {"name":"username", "type":"text","required":true}, + {"name":"password_hash", "type":"text"}, + {"name":"role", "type":"text"}, + {"name":"avatar_url", "type":"text"}, + {"name":"created", "type":"text"}, + {"name":"email", "type":"text"}, + {"name":"email_verified", "type":"bool"}, + {"name":"verification_token", "type":"text"}, + {"name":"verification_token_exp","type":"text"} ]}' create "user_sessions" '{ @@ -240,11 +244,15 @@ create "comment_votes" '{ ]}' # ── 5. Field migrations (idempotent — adds fields missing from older installs) ─ -add_field "scraping_tasks" "heartbeat_at" "date" -add_field "audio_jobs" "heartbeat_at" "date" -add_field "progress" "user_id" "text" -add_field "progress" "audio_time" "number" -add_field "progress" "updated" "text" -add_field "books" "meta_updated" "text" +add_field "scraping_tasks" "heartbeat_at" "date" +add_field "audio_jobs" "heartbeat_at" "date" +add_field "progress" "user_id" "text" +add_field "progress" "audio_time" "number" +add_field "progress" "updated" "text" +add_field "books" "meta_updated" "text" +add_field "app_users" "email" "text" +add_field "app_users" "email_verified" "bool" +add_field "app_users" "verification_token" "text" +add_field "app_users" "verification_token_exp" "text" log "done" diff --git a/ui/src/lib/server/email.ts b/ui/src/lib/server/email.ts new file mode 100644 index 0000000..6f5d761 --- /dev/null +++ b/ui/src/lib/server/email.ts @@ -0,0 +1,195 @@ +/** + * Minimal SMTP mailer for email verification. + * + * Uses Node's built-in `tls` module to connect to smtp.resend.com:465 + * (implicit TLS / SMTPS) — no external dependencies required. + * + * Env vars (injected by docker-compose via Doppler): + * SMTP_HOST smtp.resend.com + * SMTP_PORT 465 + * SMTP_USER resend + * SMTP_PASSWORD re_... + * SMTP_FROM noreply@libnovel.cc + * APP_URL https://libnovel.cc (used to build verification links) + */ + +import { env } from '$env/dynamic/private'; +import { log } from '$lib/server/logger'; +import * as tls from 'node:tls'; + +const SMTP_HOST = env.SMTP_HOST ?? 'smtp.resend.com'; +const SMTP_PORT = parseInt(env.SMTP_PORT ?? '465', 10); +const SMTP_USER = env.SMTP_USER ?? ''; +const SMTP_PASSWORD = env.SMTP_PASSWORD ?? ''; +const SMTP_FROM = env.SMTP_FROM ?? 'noreply@libnovel.cc'; +export const APP_URL = (env.APP_URL ?? 'https://libnovel.cc').replace(/\/$/, ''); + +// ─── Low-level SMTP over implicit TLS ──────────────────────────────────────── + +function smtpEncode(s: string): string { + return Buffer.from(s).toString('base64'); +} + +/** + * Send a raw email via SMTP over implicit TLS (port 465). + * Returns true on success, throws on failure. + */ +async function sendSmtp(opts: { + to: string; + subject: string; + html: string; + text: string; +}): Promise { + return new Promise((resolve, reject) => { + const socket = tls.connect( + { host: SMTP_HOST, port: SMTP_PORT, rejectUnauthorized: true }, + () => { + // TLS handshake complete — SMTP conversation begins + } + ); + + socket.setEncoding('utf8'); + socket.setTimeout(15_000); + socket.on('timeout', () => { + socket.destroy(new Error('SMTP connection timed out')); + }); + + let buf = ''; + let step = 0; + + const send = (cmd: string) => socket.write(cmd + '\r\n'); + + const boundary = `----=_Part_${Date.now()}`; + const multipart = [ + `--${boundary}`, + 'Content-Type: text/plain; charset=UTF-8', + '', + opts.text, + `--${boundary}`, + 'Content-Type: text/html; charset=UTF-8', + '', + opts.html, + `--${boundary}--` + ].join('\r\n'); + + const message = [ + `From: LibNovel <${SMTP_FROM}>`, + `To: ${opts.to}`, + `Subject: ${opts.subject}`, + 'MIME-Version: 1.0', + `Content-Type: multipart/alternative; boundary="${boundary}"`, + '', + multipart + ].join('\r\n'); + + socket.on('data', (chunk: string) => { + buf += chunk; + // Process complete lines + const lines = buf.split('\r\n'); + buf = lines.pop() ?? ''; + + for (const line of lines) { + if (!line) continue; + const code = parseInt(line.slice(0, 3), 10); + // Only act on the final response line (no continuation dash) + if (line[3] === '-') continue; + + if (code >= 400) { + socket.destroy(new Error(`SMTP error: ${line}`)); + return; + } + + switch (step) { + case 0: // 220 banner + send(`EHLO libnovel.cc`); + step++; + break; + case 1: // 250 EHLO + send('AUTH LOGIN'); + step++; + break; + case 2: // 334 Username prompt + send(smtpEncode(SMTP_USER)); + step++; + break; + case 3: // 334 Password prompt + send(smtpEncode(SMTP_PASSWORD)); + step++; + break; + case 4: // 235 Auth success + send(`MAIL FROM:<${SMTP_FROM}>`); + step++; + break; + case 5: // 250 MAIL FROM ok + send(`RCPT TO:<${opts.to}>`); + step++; + break; + case 6: // 250 RCPT TO ok + send('DATA'); + step++; + break; + case 7: // 354 Start data + send(message + '\r\n.'); + step++; + break; + case 8: // 250 Message accepted + send('QUIT'); + step++; + break; + case 9: // 221 Bye + socket.destroy(); + resolve(); + break; + } + } + }); + + socket.on('error', (err) => reject(err)); + socket.on('close', () => { + if (step < 9) reject(new Error('SMTP connection closed unexpectedly')); + }); + }); +} + +// ─── Email templates ────────────────────────────────────────────────────────── + +export async function sendVerificationEmail(to: string, token: string): Promise { + const link = `${APP_URL}/verify-email?token=${token}`; + + const html = ` + + + + +
+

Verify your email

+

+ Thanks for signing up to LibNovel. Click the button below to verify your email address. + The link expires in 24 hours. +

+ + Verify email + +

+ Or copy this link:
+ ${link} +

+

+ If you didn't create a LibNovel account, you can safely ignore this email. +

+
+ +`; + + const text = `Verify your LibNovel email address\n\nClick this link to verify your account (expires in 24 hours):\n${link}\n\nIf you didn't sign up, ignore this email.`; + + try { + await sendSmtp({ to, subject: 'Verify your LibNovel email', html, text }); + log.info('email', 'verification email sent', { to }); + } catch (err) { + log.error('email', 'failed to send verification email', { to, err: String(err) }); + throw err; + } +} diff --git a/ui/src/lib/server/pocketbase.ts b/ui/src/lib/server/pocketbase.ts index 89bdf05..e1db3d0 100644 --- a/ui/src/lib/server/pocketbase.ts +++ b/ui/src/lib/server/pocketbase.ts @@ -63,6 +63,10 @@ export interface User { role: string; created: string; avatar_url?: string; + email?: string; + email_verified?: boolean; + verification_token?: string; + verification_token_exp?: string; } // ─── Auth token cache ───────────────────────────────────────────────────────── @@ -486,21 +490,52 @@ export async function getUserByUsername(username: string): Promise } /** - * Create a new user with a hashed password. Throws if username already exists. + * Look up a user by email. Returns null if not found. */ -export async function createUser(username: string, password: string, role = 'user'): Promise { +export async function getUserByEmail(email: string): Promise { + return listOne('app_users', `email="${email.replace(/"/g, '\\"')}"`); +} + +/** + * Look up a user by verification token. Returns null if not found. + */ +export async function getUserByVerificationToken(token: string): Promise { + return listOne('app_users', `verification_token="${token.replace(/"/g, '\\"')}"`); +} + +/** + * Create a new user with a hashed password. Throws if username already exists. + * Stores email + verification token but does NOT log the user in. + */ +export async function createUser( + username: string, + password: string, + email: string, + role = 'user' +): Promise { log.info('pocketbase', 'createUser: checking for existing username', { username }); const existing = await getUserByUsername(username); if (existing) { log.warn('pocketbase', 'createUser: username already taken', { username }); throw new Error('Username already taken'); } + const existingEmail = await getUserByEmail(email); + if (existingEmail) { + log.warn('pocketbase', 'createUser: email already in use', { email }); + throw new Error('Email already in use'); + } const password_hash = hashPassword(password); - log.info('pocketbase', 'createUser: inserting new user', { username, role }); + const verification_token = randomBytes(32).toString('hex'); + const verification_token_exp = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); + log.info('pocketbase', 'createUser: inserting new user', { username, email, role }); const res = await pbPost('/api/collections/app_users/records', { username, password_hash, role, + email, + email_verified: false, + verification_token, + verification_token_exp, created: new Date().toISOString() }); if (!res.ok) { @@ -516,6 +551,23 @@ export async function createUser(username: string, password: string, role = 'use return res.json() as Promise; } +/** + * Mark a user's email as verified and clear the verification token. + */ +export async function verifyUserEmail(userId: string): Promise { + const res = await pbPatch(`/api/collections/app_users/records/${userId}`, { + email_verified: true, + verification_token: '', + verification_token_exp: '' + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + log.error('pocketbase', 'verifyUserEmail: PATCH failed', { userId, status: res.status, body }); + throw new Error(`Failed to verify email: ${res.status}`); + } + log.info('pocketbase', 'verifyUserEmail: success', { userId }); +} + /** * Change a user's password. Verifies the current password first. * Returns true on success, false if currentPassword is wrong. @@ -556,6 +608,7 @@ export async function changePassword( /** * Verify username + password. Returns the user on success, null on failure. + * Throws with message 'Email not verified' if the account exists but hasn't been verified. */ export async function loginUser(username: string, password: string): Promise { log.debug('pocketbase', 'loginUser: lookup', { username }); @@ -569,6 +622,10 @@ export async function loginUser(username: string, password: string): Promise { if (!PUBLIC_ROUTES.has(url.pathname) && !locals.user) { diff --git a/ui/src/routes/api/auth/register/+server.ts b/ui/src/routes/api/auth/register/+server.ts index 58d0be7..a55cf90 100644 --- a/ui/src/routes/api/auth/register/+server.ts +++ b/ui/src/routes/api/auth/register/+server.ts @@ -1,23 +1,19 @@ import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { createUser, mergeSessionProgress, createUserSession } from '$lib/server/pocketbase'; -import { createAuthToken } from '../../../../hooks.server'; +import { createUser } from '$lib/server/pocketbase'; +import { sendVerificationEmail } from '$lib/server/email'; import { log } from '$lib/server/logger'; -import { randomBytes } from 'node:crypto'; - -const AUTH_COOKIE = 'libnovel_auth'; -const ONE_YEAR = 60 * 60 * 24 * 365; /** * POST /api/auth/register - * Body: { username: string, password: string } - * Returns: { token: string, user: { id, username, role } } + * Body: { username: string, email: string, password: string } + * Returns: { pending_verification: true, email: string } * - * Sets the libnovel_auth cookie and returns the raw token value so the - * iOS app can persist it for subsequent requests. + * Account is created but NOT activated until the user clicks the verification + * link sent to their email. The iOS app should show a "check your inbox" screen. */ -export const POST: RequestHandler = async ({ request, cookies, locals }) => { - let body: { username?: string; password?: string }; +export const POST: RequestHandler = async ({ request }) => { + let body: { username?: string; email?: string; password?: string }; try { body = await request.json(); } catch { @@ -25,10 +21,11 @@ export const POST: RequestHandler = async ({ request, cookies, locals }) => { } const username = (body.username ?? '').trim(); + const email = (body.email ?? '').trim().toLowerCase(); const password = body.password ?? ''; - if (!username || !password) { - error(400, 'Username and password are required'); + if (!username || !email || !password) { + error(400, 'Username, email and password are required'); } if (username.length < 3 || username.length > 32) { error(400, 'Username must be between 3 and 32 characters'); @@ -36,49 +33,34 @@ export const POST: RequestHandler = async ({ request, cookies, locals }) => { if (!/^[a-zA-Z0-9_-]+$/.test(username)) { error(400, 'Username may only contain letters, numbers, underscores and hyphens'); } + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + error(400, 'Please enter a valid email address'); + } if (password.length < 8) { error(400, 'Password must be at least 8 characters'); } let user; try { - user = await createUser(username, password); + user = await createUser(username, password, email); } catch (e: unknown) { const msg = e instanceof Error ? e.message : 'Registration failed.'; if (msg.includes('Username already taken')) { error(409, 'That username is already taken'); } + if (msg.includes('Email already in use')) { + error(409, 'That email address is already registered'); + } log.error('api/auth/register', 'unexpected error', { username, err: String(e) }); error(500, 'An error occurred. Please try again.'); } - // Merge anonymous session progress (non-fatal) - mergeSessionProgress(locals.sessionId, user.id).catch((e) => - log.warn('api/auth/register', 'mergeSessionProgress failed (non-fatal)', { err: String(e) }) - ); + // Send verification email (non-fatal) + try { + await sendVerificationEmail(email, user.verification_token!); + } catch (e) { + log.error('api/auth/register', 'failed to send verification email', { username, email, err: String(e) }); + } - const authSessionId = randomBytes(16).toString('hex'); - - const userAgent = request.headers.get('user-agent') ?? ''; - const ip = - request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? - request.headers.get('x-real-ip') ?? - ''; - createUserSession(user.id, authSessionId, userAgent, ip).catch((e) => - log.warn('api/auth/register', 'createUserSession failed (non-fatal)', { err: String(e) }) - ); - - const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId); - - cookies.set(AUTH_COOKIE, token, { - path: '/', - httpOnly: true, - sameSite: 'lax', - maxAge: ONE_YEAR - }); - - return json({ - token, - user: { id: user.id, username: user.username, role: user.role ?? 'user' } - }); + return json({ pending_verification: true, email }); }; diff --git a/ui/src/routes/login/+page.server.ts b/ui/src/routes/login/+page.server.ts index 69e6211..21e3827 100644 --- a/ui/src/routes/login/+page.server.ts +++ b/ui/src/routes/login/+page.server.ts @@ -1,6 +1,7 @@ import { fail, redirect } from '@sveltejs/kit'; import type { Actions, PageServerLoad } from './$types'; import { loginUser, createUser, mergeSessionProgress, createUserSession } from '$lib/server/pocketbase'; +import { sendVerificationEmail } from '$lib/server/email'; import { createAuthToken } from '../../hooks.server'; import { log } from '$lib/server/logger'; import { randomBytes } from 'node:crypto'; @@ -30,6 +31,13 @@ export const actions: Actions = { try { user = await loginUser(username, password); } catch (err) { + const msg = err instanceof Error ? err.message : ''; + if (msg === 'Email not verified') { + return fail(403, { + action: 'login', + error: 'Please verify your email before signing in. Check your inbox for the verification link.' + }); + } log.error('auth', 'login unexpected error', { username, err: String(err) }); return fail(500, { action: 'login', error: 'An error occurred. Please try again.' }); } @@ -68,14 +76,15 @@ export const actions: Actions = { redirect(302, '/'); }, - register: async ({ request, cookies, locals }) => { + register: async ({ request }) => { const data = await request.formData(); const username = (data.get('username') as string | null)?.trim() ?? ''; + const email = (data.get('email') as string | null)?.trim().toLowerCase() ?? ''; const password = (data.get('password') as string | null) ?? ''; const confirm = (data.get('confirm') as string | null) ?? ''; - if (!username || !password) { - return fail(400, { action: 'register', error: 'Username and password are required.' }); + if (!username || !email || !password) { + return fail(400, { action: 'register', error: 'All fields are required.' }); } if (username.length < 3 || username.length > 32) { return fail(400, { @@ -89,6 +98,9 @@ export const actions: Actions = { error: 'Username may only contain letters, numbers, underscores and hyphens.' }); } + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + return fail(400, { action: 'register', error: 'Please enter a valid email address.' }); + } if (password.length < 8) { return fail(400, { action: 'register', @@ -101,42 +113,28 @@ export const actions: Actions = { let user; try { - user = await createUser(username, password); + user = await createUser(username, password, email); } catch (err: unknown) { const msg = err instanceof Error ? err.message : 'Registration failed.'; if (msg.includes('Username already taken')) { return fail(409, { action: 'register', error: 'That username is already taken.' }); } + if (msg.includes('Email already in use')) { + return fail(409, { action: 'register', error: 'That email address is already registered.' }); + } log.error('auth', 'register unexpected error', { username, err: String(err) }); return fail(500, { action: 'register', error: 'An error occurred. Please try again.' }); } - // Merge any anonymous session progress into the newly created account. - mergeSessionProgress(locals.sessionId, user.id).catch((err) => - log.warn('auth', 'register: mergeSessionProgress failed (non-fatal)', { err: String(err) }) - ); + // Send verification email (non-fatal — user can re-request later) + try { + await sendVerificationEmail(email, user.verification_token!); + } catch (err) { + log.error('auth', 'register: failed to send verification email', { username, email, err: String(err) }); + // Don't fail registration if email fails — user sees the pending screen + } - // Create a unique auth session ID for this registration - const authSessionId = randomBytes(16).toString('hex'); - - // Record the session in PocketBase (best-effort, non-fatal) - const userAgent = request.headers.get('user-agent') ?? ''; - const ip = - request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? - request.headers.get('x-real-ip') ?? - ''; - createUserSession(user.id, authSessionId, userAgent, ip).catch((err) => - log.warn('auth', 'register: createUserSession failed (non-fatal)', { err: String(err) }) - ); - - const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId); - cookies.set(AUTH_COOKIE, token, { - path: '/', - httpOnly: true, - sameSite: 'lax', - maxAge: ONE_YEAR - }); - - redirect(302, '/'); + // Return success state — do NOT log the user in yet + return { action: 'register', registered: true, email }; } }; diff --git a/ui/src/routes/login/+page.svelte b/ui/src/routes/login/+page.svelte index 77a5a13..d797a1b 100644 --- a/ui/src/routes/login/+page.svelte +++ b/ui/src/routes/login/+page.svelte @@ -3,6 +3,9 @@ let { form }: { form: ActionData } = $props(); + // Cast to access union members that TypeScript can't narrow statically + const f = $derived(form as (typeof form) & { registered?: boolean; email?: string } | null); + let mode: 'login' | 'register' = $state('login'); @@ -12,125 +15,156 @@
- -
- - -
- {#if form?.error && (form?.action === mode || !form?.action)} -
- {form.error} + + {#if f?.registered} +
+
✉️
+

Check your inbox

+

+ We sent a verification link to {f?.email}. + Click it to activate your account. +

+

+ Didn't receive it? Check your spam folder, or + try again. +

- {/if} - - {#if mode === 'login'} -
-
- - -
-
- - -
+ {:else} + +
- - {:else} -
-
- - -

3–32 characters: letters, numbers, _ or -

-
-
- - -

At least 8 characters

-
-
- - -
-
+
+ + {#if form?.error && (form?.action === mode || !form?.action)} +
+ {form.error} +
+ {/if} + + {#if mode === 'login'} +
+
+ + +
+
+ + +
+ +
+ {:else} +
+
+ + +

3–32 characters: letters, numbers, _ or -

+
+
+ + +

Used to verify your account — not shown publicly

+
+
+ + +

At least 8 characters

+
+
+ + +
+ +
+ {/if} {/if}
diff --git a/ui/src/routes/verify-email/+page.server.ts b/ui/src/routes/verify-email/+page.server.ts new file mode 100644 index 0000000..0038769 --- /dev/null +++ b/ui/src/routes/verify-email/+page.server.ts @@ -0,0 +1,72 @@ +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; +import { + getUserByVerificationToken, + verifyUserEmail, + createUserSession +} from '$lib/server/pocketbase'; +import { createAuthToken } from '../../hooks.server'; +import { log } from '$lib/server/logger'; +import { randomBytes } from 'node:crypto'; + +const AUTH_COOKIE = 'libnovel_auth'; +const ONE_YEAR = 60 * 60 * 24 * 365; + +export const load: PageServerLoad = async ({ url, cookies, request }) => { + const token = url.searchParams.get('token') ?? ''; + + if (!token) { + return { success: false, error: 'Missing verification token.' }; + } + + let user; + try { + user = await getUserByVerificationToken(token); + } catch (e) { + log.error('verify-email', 'lookup failed', { err: String(e) }); + return { success: false, error: 'An error occurred. Please try again.' }; + } + + if (!user) { + return { success: false, error: 'Invalid or expired verification link.' }; + } + + // Check expiry + if (user.verification_token_exp) { + const exp = new Date(user.verification_token_exp).getTime(); + if (Date.now() > exp) { + return { success: false, error: 'This verification link has expired. Please register again.' }; + } + } + + // Mark email as verified + try { + await verifyUserEmail(user.id); + } catch (e) { + log.error('verify-email', 'verifyUserEmail failed', { userId: user.id, err: String(e) }); + return { success: false, error: 'Failed to verify email. Please try again.' }; + } + + // Log the user in automatically + const authSessionId = randomBytes(16).toString('hex'); + const userAgent = request.headers.get('user-agent') ?? ''; + const ip = + request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? + request.headers.get('x-real-ip') ?? + ''; + + createUserSession(user.id, authSessionId, userAgent, ip).catch((e) => + log.warn('verify-email', 'createUserSession failed (non-fatal)', { err: String(e) }) + ); + + const authToken = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId); + cookies.set(AUTH_COOKIE, authToken, { + path: '/', + httpOnly: true, + sameSite: 'lax', + maxAge: ONE_YEAR + }); + + log.info('verify-email', 'email verified, user logged in', { userId: user.id, username: user.username }); + redirect(302, '/'); +}; diff --git a/ui/src/routes/verify-email/+page.svelte b/ui/src/routes/verify-email/+page.svelte new file mode 100644 index 0000000..fcbe853 --- /dev/null +++ b/ui/src/routes/verify-email/+page.svelte @@ -0,0 +1,21 @@ + + + + Verify email — libnovel + + +
+
+ {#if data.error} +
+ {data.error} +
+ + Back to sign in + + {/if} +
+