Compare commits

...

4 Commits

Author SHA1 Message Date
Admin
920ac0d41b feat(auth): add email verification to registration flow
Some checks failed
CI / Test backend (pull_request) Failing after 11s
CI / Check ui (pull_request) Failing after 11s
CI / Docker / backend (pull_request) Has been skipped
CI / Docker / runner (pull_request) Has been skipped
CI / Docker / ui (pull_request) Has been skipped
CI / Docker / caddy (pull_request) Successful in 2m53s
Release / Test backend (push) Successful in 19s
Release / Check ui (push) Successful in 33s
Release / Docker / caddy (push) Failing after 1m26s
Release / Docker / ui (push) Failing after 11s
Release / Docker / backend (push) Successful in 2m2s
Release / Docker / runner (push) Successful in 2m24s
Release / Gitea Release (push) Has been skipped
- 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
2026-03-24 20:18:24 +05:00
Admin
424f2c5e16 chore: remove GlitchTip test page after successful verification 2026-03-24 15:25:23 +05:00
Admin
8a0f5b6cde feat: add GlitchTip test page and PUBLIC_UMAMI_SCRIPT_URL to ui env
Some checks failed
CI / Test backend (pull_request) Successful in 19s
CI / Check ui (pull_request) Successful in 40s
CI / Docker / caddy (pull_request) Failing after 43s
CI / Docker / ui (pull_request) Successful in 1m17s
CI / Docker / backend (pull_request) Successful in 1m58s
CI / Docker / runner (pull_request) Successful in 2m12s
2026-03-24 15:06:01 +05:00
Admin
5fea8f67d0 chore: rename workflows from ci-v3/release-v3 to ci/release
All checks were successful
CI / Check ui (pull_request) Successful in 32s
CI / Test backend (pull_request) Successful in 32s
CI / Docker / backend (pull_request) Successful in 1m30s
CI / Docker / runner (pull_request) Successful in 2m24s
CI / Docker / ui (pull_request) Successful in 1m10s
CI / Docker / caddy (pull_request) Successful in 6m26s
2026-03-23 19:00:53 +05:00
17 changed files with 897 additions and 406 deletions

View File

@@ -1,191 +0,0 @@
name: CI / v3
on:
push:
branches: ["main", "master"]
paths:
- "backend/**"
- "ui/**"
- "caddy/**"
- "docker-compose.yml"
- ".gitea/workflows/ci-v3.yaml"
pull_request:
branches: ["main", "master"]
paths:
- "backend/**"
- "ui/**"
- "caddy/**"
- "docker-compose.yml"
- ".gitea/workflows/ci-v3.yaml"
concurrency:
group: ${{ gitea.workflow }}-${{ gitea.ref }}
cancel-in-progress: true
jobs:
# ── backend: vet & test ───────────────────────────────────────────────────────
test-backend:
name: Test backend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: backend/go.mod
cache-dependency-path: backend/go.sum
- name: go vet
working-directory: backend
run: go vet ./...
- name: Run tests
working-directory: backend
run: go test -short -race -count=1 -timeout=60s ./...
# ── ui: type-check & build ────────────────────────────────────────────────────
check-ui:
name: Check ui
runs-on: ubuntu-latest
defaults:
run:
working-directory: ui
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: ui/package-lock.json
- name: Install dependencies
run: npm ci
- name: Type check
run: npm run check
- name: Build
run: npm run build
# ── docker: backend ───────────────────────────────────────────────────────────
docker-backend:
name: Docker / backend
runs-on: ubuntu-latest
needs: [test-backend]
if: gitea.event_name == 'push'
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: backend
target: backend
push: true
tags: |
${{ secrets.DOCKER_USER }}/libnovel-backend:latest
${{ secrets.DOCKER_USER }}/libnovel-backend:${{ gitea.sha }}
build-args: |
VERSION=${{ gitea.sha }}
COMMIT=${{ gitea.sha }}
cache-from: type=registry,ref=${{ secrets.DOCKER_USER }}/libnovel-backend:latest
cache-to: type=inline
# ── docker: runner ────────────────────────────────────────────────────────────
docker-runner:
name: Docker / runner
runs-on: ubuntu-latest
needs: [test-backend]
if: gitea.event_name == 'push'
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: backend
target: runner
push: true
tags: |
${{ secrets.DOCKER_USER }}/libnovel-runner:latest
${{ secrets.DOCKER_USER }}/libnovel-runner:${{ gitea.sha }}
build-args: |
VERSION=${{ gitea.sha }}
COMMIT=${{ gitea.sha }}
cache-from: type=registry,ref=${{ secrets.DOCKER_USER }}/libnovel-runner:latest
cache-to: type=inline
# ── docker: ui ────────────────────────────────────────────────────────────────
docker-ui:
name: Docker / ui
runs-on: ubuntu-latest
needs: [check-ui]
if: gitea.event_name == 'push'
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: ui
push: true
tags: |
${{ secrets.DOCKER_USER }}/libnovel-ui:latest
${{ secrets.DOCKER_USER }}/libnovel-ui:${{ gitea.sha }}
build-args: |
BUILD_VERSION=${{ gitea.sha }}
BUILD_COMMIT=${{ gitea.sha }}
cache-from: type=registry,ref=${{ secrets.DOCKER_USER }}/libnovel-ui:latest
cache-to: type=inline
# ── docker: caddy ─────────────────────────────────────────────────────────────
docker-caddy:
name: Docker / caddy
runs-on: ubuntu-latest
if: gitea.event_name == 'push'
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: caddy
push: true
tags: |
${{ secrets.DOCKER_USER }}/libnovel-caddy:latest
${{ secrets.DOCKER_USER }}/libnovel-caddy:${{ gitea.sha }}
cache-from: type=registry,ref=${{ secrets.DOCKER_USER }}/libnovel-caddy:latest
cache-to: type=inline

123
.gitea/workflows/ci.yaml Normal file
View File

@@ -0,0 +1,123 @@
name: CI
on:
push:
branches: ["main", "master"]
paths:
- "backend/**"
- "ui/**"
- "caddy/**"
- "docker-compose.yml"
- ".gitea/workflows/ci.yaml"
pull_request:
branches: ["main", "master"]
paths:
- "backend/**"
- "ui/**"
- "caddy/**"
- "docker-compose.yml"
- ".gitea/workflows/ci.yaml"
concurrency:
group: ${{ gitea.workflow }}-${{ gitea.ref }}
cancel-in-progress: true
jobs:
# ── backend: vet & test ───────────────────────────────────────────────────────
test-backend:
name: Test backend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: backend/go.mod
cache-dependency-path: backend/go.sum
- name: go vet
working-directory: backend
run: go vet ./...
- name: Run tests
working-directory: backend
run: go test -short -race -count=1 -timeout=60s ./...
# ── ui: type-check & build ────────────────────────────────────────────────────
check-ui:
name: Check ui
runs-on: ubuntu-latest
defaults:
run:
working-directory: ui
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: ui/package-lock.json
- name: Install dependencies
run: npm ci
- name: Type check
run: npm run check
- name: Build
run: npm run build
# ── docker: validate Dockerfiles build (no push) ──────────────────────────────
docker-backend:
name: Docker / backend
runs-on: ubuntu-latest
needs: [test-backend]
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Build
uses: docker/build-push-action@v6
with:
context: backend
target: backend
push: false
docker-runner:
name: Docker / runner
runs-on: ubuntu-latest
needs: [test-backend]
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Build
uses: docker/build-push-action@v6
with:
context: backend
target: runner
push: false
docker-ui:
name: Docker / ui
runs-on: ubuntu-latest
needs: [check-ui]
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Build
uses: docker/build-push-action@v6
with:
context: ui
push: false
docker-caddy:
name: Docker / caddy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Build
uses: docker/build-push-action@v6
with:
context: caddy
push: false

View File

@@ -1,4 +1,4 @@
name: Release / v3
name: Release
on:
push:

View File

@@ -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 the variable expands to an empty string and Caddy ignores it.
{$CADDY_ACME_EMAIL:}
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.
# The default "disabled" placeholder makes the bouncer fail-open (warn,
@@ -238,3 +238,19 @@ push.libnovel.cc {
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
}
}

View File

@@ -160,6 +160,7 @@ services:
LOG_LEVEL: "${LOG_LEVEL}"
KOKORO_URL: "${KOKORO_URL}"
KOKORO_VOICE: "${KOKORO_VOICE}"
GLITCHTIP_DSN: "${GLITCHTIP_DSN}"
healthcheck:
test: ["CMD", "/healthcheck", "http://localhost:8080/health"]
interval: 15s
@@ -167,7 +168,11 @@ services:
retries: 3
# ─── Runner (background task worker) ─────────────────────────────────────────
# profiles: [runner] prevents accidental restart on `docker compose up -d`.
# The homelab runner (192.168.0.109) is the sole worker in production.
# To start explicitly: doppler run -- docker compose --profile runner up -d runner
runner:
profiles: [runner]
image: kalekber/libnovel-runner:${GIT_TAG:-latest}
build:
context: ./backend
@@ -211,6 +216,7 @@ services:
# Kokoro-FastAPI TTS endpoint
KOKORO_URL: "${KOKORO_URL}"
KOKORO_VOICE: "${KOKORO_VOICE}"
GLITCHTIP_DSN: "${GLITCHTIP_DSN}"
healthcheck:
# The runner writes /tmp/runner.alive on every poll.
# 120s = 2× the default 30s poll interval with generous headroom.
@@ -256,6 +262,18 @@ services:
PUBLIC_MINIO_PUBLIC_URL: "${MINIO_PUBLIC_ENDPOINT}"
# Valkey
VALKEY_ADDR: "valkey:6379"
# Umami analytics
PUBLIC_UMAMI_WEBSITE_ID: "${PUBLIC_UMAMI_WEBSITE_ID}"
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
@@ -421,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:
@@ -441,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"
@@ -462,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"
@@ -486,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"
@@ -556,7 +574,7 @@ services:
GOTIFY_DEFAULTUSER_PASS: "${GOTIFY_ADMIN_PASS}"
GOTIFY_SERVER_PORT: "80"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:80/health"]
test: ["CMD", "curl", "-sf", "http://localhost:80/health"]
interval: 15s
timeout: 5s
retries: 5

View File

@@ -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

100
scripts/libnovel.sh Normal file
View File

@@ -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 <command> [service]
#
# up Start all services (detached)
# down Stop all services
# restart Stop then start all services
# restart <svc> 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 <svc> Tail a specific service
# ps Show running containers
# shell <svc> 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 <service>"; 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 <command> [service]"
echo ""
echo " up Start all services"
echo " down Stop all services"
echo " restart Full restart"
echo " restart <svc> Restart one service"
echo " pull Pull latest images from Docker Hub"
echo " update Pull + recreate containers"
echo " logs Tail all logs"
echo " logs <svc> Tail one service"
echo " ps Show running containers"
echo " shell <svc> Shell into a container"
echo " init Run init containers (first-time setup)"
echo " secrets Print Doppler secrets"
;;
esac

View File

@@ -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"

195
ui/src/lib/server/email.ts Normal file
View File

@@ -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<void> {
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<void> {
const link = `${APP_URL}/verify-email?token=${token}`;
const html = `
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"></head>
<body style="font-family:sans-serif;background:#18181b;color:#f4f4f5;padding:32px;">
<div style="max-width:480px;margin:0 auto;">
<h1 style="color:#f59e0b;font-size:24px;margin-bottom:8px;">Verify your email</h1>
<p style="color:#a1a1aa;margin-bottom:24px;">
Thanks for signing up to LibNovel. Click the button below to verify your email address.
The link expires in 24 hours.
</p>
<a href="${link}"
style="display:inline-block;background:#f59e0b;color:#18181b;font-weight:600;
padding:12px 24px;border-radius:6px;text-decoration:none;font-size:15px;">
Verify email
</a>
<p style="margin-top:24px;color:#71717a;font-size:13px;">
Or copy this link:<br>
<a href="${link}" style="color:#f59e0b;word-break:break-all;">${link}</a>
</p>
<p style="margin-top:32px;color:#52525b;font-size:12px;">
If you didn't create a LibNovel account, you can safely ignore this email.
</p>
</div>
</body>
</html>`;
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;
}
}

View File

@@ -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<User | null>
}
/**
* 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<User> {
export async function getUserByEmail(email: string): Promise<User | null> {
return listOne<User>('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<User | null> {
return listOne<User>('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<User> {
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<User>;
}
/**
* Mark a user's email as verified and clear the verification token.
*/
export async function verifyUserEmail(userId: string): Promise<void> {
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<User | null> {
log.debug('pocketbase', 'loginUser: lookup', { username });
@@ -569,6 +622,10 @@ export async function loginUser(username: string, password: string): Promise<Use
log.warn('pocketbase', 'loginUser: wrong password', { username });
return null;
}
if (!user.email_verified) {
log.warn('pocketbase', 'loginUser: email not verified', { username });
throw new Error('Email not verified');
}
log.info('pocketbase', 'loginUser: success', { username, role: user.role });
return user;
}

View File

@@ -4,7 +4,7 @@ import { getSettings } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
// Routes that are accessible without being logged in
const PUBLIC_ROUTES = new Set(['/login']);
const PUBLIC_ROUTES = new Set(['/login', '/verify-email']);
export const load: LayoutServerLoad = async ({ locals, url }) => {
if (!PUBLIC_ROUTES.has(url.pathname) && !locals.user) {

View File

@@ -171,10 +171,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>libnovel</title>
<!-- Umami analytics — no-op when PUBLIC_UMAMI_WEBSITE_ID is unset -->
{#if env.PUBLIC_UMAMI_WEBSITE_ID}
{#if env.PUBLIC_UMAMI_WEBSITE_ID && env.PUBLIC_UMAMI_SCRIPT_URL}
<script
defer
src="https://analytics.libnovel.cc/script.js"
src={env.PUBLIC_UMAMI_SCRIPT_URL}
data-website-id={env.PUBLIC_UMAMI_WEBSITE_ID}
></script>
{/if}

View File

@@ -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 });
};

View File

@@ -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 };
}
};

View File

@@ -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');
</script>
@@ -12,125 +15,156 @@
<div class="flex items-center justify-center min-h-[60vh]">
<div class="w-full max-w-sm">
<!-- Tab switcher -->
<div class="flex mb-6 border-b border-zinc-700">
<button
type="button"
onclick={() => (mode = 'login')}
class="flex-1 pb-3 text-sm font-medium transition-colors
{mode === 'login'
? 'text-amber-400 border-b-2 border-amber-400 -mb-px'
: 'text-zinc-400 hover:text-zinc-100'}"
>
Sign in
</button>
<button
type="button"
onclick={() => (mode = 'register')}
class="flex-1 pb-3 text-sm font-medium transition-colors
{mode === 'register'
? 'text-amber-400 border-b-2 border-amber-400 -mb-px'
: 'text-zinc-400 hover:text-zinc-100'}"
>
Create account
</button>
</div>
{#if form?.error && (form?.action === mode || !form?.action)}
<div class="mb-4 rounded bg-red-900/40 border border-red-700 px-4 py-3 text-sm text-red-300">
{form.error}
<!-- Post-registration: check inbox -->
{#if f?.registered}
<div class="text-center">
<div class="mb-4 text-4xl">✉️</div>
<h2 class="text-lg font-semibold text-zinc-100 mb-2">Check your inbox</h2>
<p class="text-sm text-zinc-400 mb-6">
We sent a verification link to <span class="text-zinc-200 font-medium">{f?.email}</span>.
Click it to activate your account.
</p>
<p class="text-xs text-zinc-500">
Didn't receive it? Check your spam folder, or
<a href="/login" class="text-amber-400 hover:text-amber-300 transition-colors">try again</a>.
</p>
</div>
{/if}
{#if mode === 'login'}
<form method="POST" action="?/login" class="flex flex-col gap-4">
<div>
<label for="login-username" class="block text-xs text-zinc-400 mb-1">Username</label>
<input
id="login-username"
name="username"
type="text"
autocomplete="username"
required
class="w-full rounded bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100
placeholder-zinc-500 focus:outline-none focus:border-amber-400 focus:ring-1 focus:ring-amber-400"
placeholder="your_username"
/>
</div>
<div>
<label for="login-password" class="block text-xs text-zinc-400 mb-1">Password</label>
<input
id="login-password"
name="password"
type="password"
autocomplete="current-password"
required
class="w-full rounded bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100
placeholder-zinc-500 focus:outline-none focus:border-amber-400 focus:ring-1 focus:ring-amber-400"
placeholder="••••••••"
/>
</div>
{:else}
<!-- Tab switcher -->
<div class="flex mb-6 border-b border-zinc-700">
<button
type="submit"
class="w-full py-2 rounded bg-amber-400 text-zinc-900 font-semibold text-sm hover:bg-amber-300 transition-colors"
type="button"
onclick={() => (mode = 'login')}
class="flex-1 pb-3 text-sm font-medium transition-colors
{mode === 'login'
? 'text-amber-400 border-b-2 border-amber-400 -mb-px'
: 'text-zinc-400 hover:text-zinc-100'}"
>
Sign in
</button>
</form>
{:else}
<form method="POST" action="?/register" class="flex flex-col gap-4">
<div>
<label for="reg-username" class="block text-xs text-zinc-400 mb-1">Username</label>
<input
id="reg-username"
name="username"
type="text"
autocomplete="username"
required
minlength="3"
maxlength="32"
pattern="[a-zA-Z0-9_\-]+"
class="w-full rounded bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100
placeholder-zinc-500 focus:outline-none focus:border-amber-400 focus:ring-1 focus:ring-amber-400"
placeholder="your_username"
/>
<p class="mt-1 text-xs text-zinc-500">332 characters: letters, numbers, _ or -</p>
</div>
<div>
<label for="reg-password" class="block text-xs text-zinc-400 mb-1">Password</label>
<input
id="reg-password"
name="password"
type="password"
autocomplete="new-password"
required
minlength="8"
class="w-full rounded bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100
placeholder-zinc-500 focus:outline-none focus:border-amber-400 focus:ring-1 focus:ring-amber-400"
placeholder="••••••••"
/>
<p class="mt-1 text-xs text-zinc-500">At least 8 characters</p>
</div>
<div>
<label for="reg-confirm" class="block text-xs text-zinc-400 mb-1">Confirm password</label>
<input
id="reg-confirm"
name="confirm"
type="password"
autocomplete="new-password"
required
class="w-full rounded bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100
placeholder-zinc-500 focus:outline-none focus:border-amber-400 focus:ring-1 focus:ring-amber-400"
placeholder="••••••••"
/>
</div>
<button
type="submit"
class="w-full py-2 rounded bg-amber-400 text-zinc-900 font-semibold text-sm hover:bg-amber-300 transition-colors"
type="button"
onclick={() => (mode = 'register')}
class="flex-1 pb-3 text-sm font-medium transition-colors
{mode === 'register'
? 'text-amber-400 border-b-2 border-amber-400 -mb-px'
: 'text-zinc-400 hover:text-zinc-100'}"
>
Create account
</button>
</form>
</div>
{#if form?.error && (form?.action === mode || !form?.action)}
<div class="mb-4 rounded bg-red-900/40 border border-red-700 px-4 py-3 text-sm text-red-300">
{form.error}
</div>
{/if}
{#if mode === 'login'}
<form method="POST" action="?/login" class="flex flex-col gap-4">
<div>
<label for="login-username" class="block text-xs text-zinc-400 mb-1">Username</label>
<input
id="login-username"
name="username"
type="text"
autocomplete="username"
required
class="w-full rounded bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100
placeholder-zinc-500 focus:outline-none focus:border-amber-400 focus:ring-1 focus:ring-amber-400"
placeholder="your_username"
/>
</div>
<div>
<label for="login-password" class="block text-xs text-zinc-400 mb-1">Password</label>
<input
id="login-password"
name="password"
type="password"
autocomplete="current-password"
required
class="w-full rounded bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100
placeholder-zinc-500 focus:outline-none focus:border-amber-400 focus:ring-1 focus:ring-amber-400"
placeholder="••••••••"
/>
</div>
<button
type="submit"
class="w-full py-2 rounded bg-amber-400 text-zinc-900 font-semibold text-sm hover:bg-amber-300 transition-colors"
>
Sign in
</button>
</form>
{:else}
<form method="POST" action="?/register" class="flex flex-col gap-4">
<div>
<label for="reg-username" class="block text-xs text-zinc-400 mb-1">Username</label>
<input
id="reg-username"
name="username"
type="text"
autocomplete="username"
required
minlength="3"
maxlength="32"
pattern="[a-zA-Z0-9_\-]+"
class="w-full rounded bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100
placeholder-zinc-500 focus:outline-none focus:border-amber-400 focus:ring-1 focus:ring-amber-400"
placeholder="your_username"
/>
<p class="mt-1 text-xs text-zinc-500">332 characters: letters, numbers, _ or -</p>
</div>
<div>
<label for="reg-email" class="block text-xs text-zinc-400 mb-1">Email</label>
<input
id="reg-email"
name="email"
type="email"
autocomplete="email"
required
class="w-full rounded bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100
placeholder-zinc-500 focus:outline-none focus:border-amber-400 focus:ring-1 focus:ring-amber-400"
placeholder="you@example.com"
/>
<p class="mt-1 text-xs text-zinc-500">Used to verify your account — not shown publicly</p>
</div>
<div>
<label for="reg-password" class="block text-xs text-zinc-400 mb-1">Password</label>
<input
id="reg-password"
name="password"
type="password"
autocomplete="new-password"
required
minlength="8"
class="w-full rounded bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100
placeholder-zinc-500 focus:outline-none focus:border-amber-400 focus:ring-1 focus:ring-amber-400"
placeholder="••••••••"
/>
<p class="mt-1 text-xs text-zinc-500">At least 8 characters</p>
</div>
<div>
<label for="reg-confirm" class="block text-xs text-zinc-400 mb-1">Confirm password</label>
<input
id="reg-confirm"
name="confirm"
type="password"
autocomplete="new-password"
required
class="w-full rounded bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100
placeholder-zinc-500 focus:outline-none focus:border-amber-400 focus:ring-1 focus:ring-amber-400"
placeholder="••••••••"
/>
</div>
<button
type="submit"
class="w-full py-2 rounded bg-amber-400 text-zinc-900 font-semibold text-sm hover:bg-amber-300 transition-colors"
>
Create account
</button>
</form>
{/if}
{/if}
</div>
</div>

View File

@@ -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, '/');
};

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<svelte:head>
<title>Verify email — libnovel</title>
</svelte:head>
<div class="flex items-center justify-center min-h-[60vh]">
<div class="w-full max-w-sm text-center">
{#if data.error}
<div class="mb-6 rounded bg-red-900/40 border border-red-700 px-4 py-3 text-sm text-red-300">
{data.error}
</div>
<a href="/login" class="text-sm text-amber-400 hover:text-amber-300 transition-colors">
Back to sign in
</a>
{/if}
</div>
</div>