Compare commits

...

13 Commits

Author SHA1 Message Date
Admin
09062b8c82 fix(ci): use correct glitchtip-cli download URL for linux-x86_64
Some checks failed
Release / Test backend (push) Successful in 35s
Release / Check ui (push) Successful in 38s
Release / Docker / caddy (push) Successful in 48s
CI / Check ui (pull_request) Successful in 31s
CI / Test backend (pull_request) Successful in 36s
CI / Docker / caddy (pull_request) Successful in 4m38s
Release / Docker / ui (push) Successful in 1m45s
Release / Upload source maps (push) Successful in 2m16s
CI / Docker / ui (pull_request) Failing after 30s
CI / Docker / backend (pull_request) Failing after 42s
CI / Docker / runner (pull_request) Failing after 32s
Release / Docker / runner (push) Successful in 2m2s
Release / Docker / backend (push) Successful in 1m36s
Release / Gitea Release (push) Failing after 1s
2026-03-26 11:55:17 +05:00
Admin
d518710cc4 fix(observability): switch source map upload to glitchtip-cli
Some checks failed
Release / Test backend (push) Successful in 18s
CI / Test backend (pull_request) Successful in 18s
Release / Check ui (push) Successful in 47s
Release / Docker / caddy (push) Successful in 55s
CI / Check ui (pull_request) Successful in 21s
Release / Docker / backend (push) Failing after 1m13s
CI / Docker / backend (pull_request) Failing after 20s
Release / Docker / runner (push) Successful in 1m46s
CI / Docker / runner (pull_request) Failing after 1m17s
Release / Docker / ui (push) Failing after 39s
CI / Docker / ui (pull_request) Successful in 1m11s
CI / Docker / caddy (pull_request) Successful in 7m6s
Release / Upload source maps (push) Failing after 41s
Release / Gitea Release (push) Has been skipped
@sentry/vite-plugin uses sentry-cli which creates release entries but
doesn't upload files to GlitchTip's API correctly. Switch to the native
glitchtip-cli which uses the debug ID inject+upload approach that
GlitchTip actually supports.
2026-03-25 21:10:10 +05:00
Admin
e2c15f5931 fix(observability): correct sentryVitePlugin sourcemaps option key
Some checks failed
Release / Test backend (push) Successful in 18s
CI / Test backend (pull_request) Successful in 17s
Release / Docker / caddy (push) Successful in 42s
Release / Check ui (push) Successful in 45s
CI / Check ui (pull_request) Successful in 24s
CI / Docker / caddy (pull_request) Failing after 38s
Release / Docker / runner (push) Successful in 2m16s
Release / Docker / backend (push) Successful in 2m33s
CI / Docker / backend (pull_request) Successful in 2m14s
Release / Upload source maps (push) Successful in 30s
CI / Docker / runner (pull_request) Successful in 1m26s
CI / Docker / ui (pull_request) Successful in 1m14s
Release / Docker / ui (push) Successful in 2m6s
Release / Gitea Release (push) Failing after 2s
2026-03-25 20:39:26 +05:00
Admin
a50b968b95 fix(infra): expose Meilisearch via search.libnovel.cc for homelab runner indexing
Some checks failed
CI / Test backend (pull_request) Has been cancelled
CI / Check ui (pull_request) Has been cancelled
CI / Docker / backend (pull_request) Has been cancelled
CI / Docker / runner (pull_request) Has been cancelled
CI / Docker / ui (pull_request) Has been cancelled
CI / Docker / caddy (pull_request) Has been cancelled
Release / Check ui (push) Failing after 22s
Release / Upload source maps (push) Has been skipped
Release / Docker / ui (push) Has been skipped
Release / Test backend (push) Successful in 36s
Release / Docker / caddy (push) Successful in 1m16s
Release / Docker / backend (push) Successful in 2m49s
Release / Docker / runner (push) Successful in 3m29s
Release / Gitea Release (push) Has been skipped
- Add search.libnovel.cc Caddy vhost proxying to meilisearch:7700
- Pass MEILI_URL + MEILI_API_KEY from Doppler into homelab runner
- Set GODEBUG=preferIPv4=1 to work around missing IPv6 route on homelab
- Update comments to reflect runner now indexes books into Meilisearch
2026-03-25 20:27:50 +05:00
Admin
023b1f7fec feat(observability): add GlitchTip source map uploads for un-minified stack traces
Some checks failed
CI / Check ui (pull_request) Failing after 11s
CI / Docker / caddy (pull_request) Failing after 11s
CI / Docker / ui (pull_request) Has been skipped
CI / Test backend (pull_request) Successful in 30s
Release / Check ui (push) Failing after 38s
Release / Upload source maps (push) Has been skipped
Release / Docker / ui (push) Has been skipped
Release / Test backend (push) Successful in 48s
Release / Docker / caddy (push) Successful in 45s
CI / Docker / backend (pull_request) Has been cancelled
CI / Docker / runner (pull_request) Has been cancelled
Release / Docker / runner (push) Has been cancelled
Release / Gitea Release (push) Has been cancelled
Release / Docker / backend (push) Has been cancelled
- Enable sourcemap:true in vite.config.ts
- Add sentryVitePlugin: uploads maps to errors.libnovel.cc, deletes them post-upload so they never ship in the Docker image
- Wire release: PUBLIC_BUILD_VERSION in both hooks.client.ts and hooks.server.ts so events correlate to the correct artifact
- Add upload-sourcemaps CI job in release.yaml (parallel to docker-ui, uses GLITCHTIP_AUTH_TOKEN secret)
2026-03-25 20:26:19 +05:00
Admin
7e99fc6d70 fix(runner): fix audio task infinite loop and semaphore race
Some checks failed
Release / Check ui (push) Successful in 22s
Release / Test backend (push) Successful in 33s
Release / Docker / backend (push) Failing after 30s
Release / Docker / caddy (push) Successful in 1m9s
Release / Docker / ui (push) Successful in 1m34s
Release / Docker / runner (push) Failing after 1m15s
Release / Gitea Release (push) Has been skipped
Two bugs caused audio tasks to loop endlessly:

1. claimRecord never set heartbeat_at — newly claimed tasks had
   heartbeat_at=null, which matched the reaper's stale filter
   (heartbeat_at=null || heartbeat_at<threshold). Tasks were reaped
   and reset to pending within seconds of being claimed, before the
   30s heartbeat goroutine had a chance to write a timestamp.
   Fix: set heartbeat_at=now() in claimRecord alongside status=running.

2. Audio semaphore was checked AFTER claiming the task. When the
   semaphore was full the select/break only broke the inner select,
   not the for loop — the code fell through and launched an uncapped
   goroutine that blocked forever on <-audioSem drain. The task also
   stayed status=running with no heartbeat, feeding bug #1.
   Fix: pre-acquire a semaphore slot BEFORE claiming the task; release
   it immediately if the queue is empty or claim fails.
2026-03-25 15:09:52 +05:00
Admin
12d6d30fb0 feat: add /terms page and make disclaimer/privacy/dmca/terms public routes
Some checks failed
CI / Check ui (pull_request) Successful in 20s
Release / Test backend (push) Successful in 42s
Release / Check ui (push) Successful in 38s
CI / Docker / caddy (pull_request) Successful in 3m2s
Release / Docker / caddy (push) Successful in 1m36s
CI / Docker / ui (pull_request) Successful in 1m19s
Release / Docker / backend (push) Successful in 1m42s
CI / Test backend (pull_request) Successful in 6m54s
Release / Docker / ui (push) Successful in 1m38s
Release / Docker / runner (push) Successful in 2m28s
Release / Gitea Release (push) Failing after 2s
CI / Docker / backend (pull_request) Failing after 43s
CI / Docker / runner (pull_request) Successful in 1m20s
2026-03-25 13:55:27 +05:00
Admin
f9c14685b3 feat(auth): make /disclaimer, /privacy, /dmca public routes
Some checks failed
CI / Test backend (pull_request) Successful in 28s
CI / Check ui (pull_request) Successful in 33s
Release / Test backend (push) Successful in 18s
Release / Check ui (push) Successful in 29s
CI / Docker / backend (pull_request) Failing after 20s
Release / Docker / caddy (push) Successful in 1m31s
CI / Docker / ui (pull_request) Successful in 1m8s
CI / Docker / runner (pull_request) Successful in 2m17s
Release / Docker / backend (push) Successful in 1m31s
Release / Docker / runner (push) Successful in 2m24s
Release / Docker / ui (push) Successful in 1m25s
CI / Docker / caddy (pull_request) Successful in 6m47s
Release / Gitea Release (push) Failing after 1s
2026-03-25 13:51:36 +05:00
Admin
4a7009989c feat(auth): replace email/password registration with OAuth2 (Google + GitHub)
Some checks failed
CI / Test backend (pull_request) Successful in 19s
Release / Test backend (push) Successful in 18s
CI / Check ui (pull_request) Successful in 41s
Release / Check ui (push) Successful in 21s
CI / Docker / backend (pull_request) Successful in 1m43s
CI / Docker / runner (pull_request) Successful in 1m28s
Release / Docker / backend (push) Successful in 1m40s
CI / Docker / caddy (pull_request) Successful in 6m45s
Release / Docker / runner (push) Successful in 1m48s
Release / Docker / caddy (push) Successful in 7m12s
CI / Docker / ui (pull_request) Successful in 1m20s
Release / Docker / ui (push) Successful in 1m19s
Release / Gitea Release (push) Failing after 2s
- New /auth/[provider] route: generates state cookie, redirects to provider
- New /auth/[provider]/callback: exchanges code, fetches profile, auto-creates
  or links account, sets auth cookie
- pocketbase.ts: add oauth_provider/oauth_id to User; new getUserByOAuth(),
  createOAuthUser(), linkOAuthToUser() helpers; loginUser() drops email_verified gate
- pb-init-v3.sh: add oauth_provider + oauth_id fields (schema + migration)
- docker-compose.yml: GOOGLE/GITHUB client ID/secret env vars (replaces SMTP vars)
- Login page: two OAuth buttons (Google, GitHub) — register form removed
- /verify-email route and email.ts removed (provider handles email verification)
- /api/auth/register returns 410 (OAuth-only from now on)
2026-03-24 22:01:51 +05:00
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
24 changed files with 1033 additions and 563 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:
@@ -135,6 +135,54 @@ jobs:
cache-from: type=registry,ref=${{ secrets.DOCKER_USER }}/libnovel-runner:latest
cache-to: type=inline
# ── ui: source map upload ─────────────────────────────────────────────────────
# Builds the UI with source maps and uploads them to GlitchTip so that error
# stack traces resolve to original .svelte/.ts file names and line numbers.
# Runs in parallel with docker-ui (both need check-ui to pass first).
upload-sourcemaps:
name: Upload source maps
runs-on: ubuntu-latest
needs: [check-ui]
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: Build with source maps
run: npm run build
- name: Download glitchtip-cli
run: |
curl -L "https://gitlab.com/glitchtip/glitchtip-cli/-/jobs/artifacts/v0.1.0/raw/artifacts/glitchtip-cli-linux-x86_64?job=build-linux-x86_64" \
-o /usr/local/bin/glitchtip-cli
chmod +x /usr/local/bin/glitchtip-cli
- name: Inject debug IDs into build artifacts
run: glitchtip-cli sourcemaps inject ./build
env:
SENTRY_URL: https://errors.libnovel.cc/
SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
SENTRY_ORG: libnovel
SENTRY_PROJECT: libnovel-ui
- name: Upload source maps to GlitchTip
run: glitchtip-cli sourcemaps upload ./build --release ${{ gitea.ref_name }}
env:
SENTRY_URL: https://errors.libnovel.cc/
SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
SENTRY_ORG: libnovel
SENTRY_PROJECT: libnovel-ui
# ── docker: ui ────────────────────────────────────────────────────────────────
docker-ui:
name: Docker / ui
@@ -213,7 +261,7 @@ jobs:
release:
name: Gitea Release
runs-on: ubuntu-latest
needs: [docker-backend, docker-runner, docker-ui, docker-caddy]
needs: [docker-backend, docker-runner, docker-ui, docker-caddy, upload-sourcemaps]
steps:
- uses: actions/checkout@v4
with:

View File

@@ -30,6 +30,7 @@
# logs.libnovel.cc → dozzle:8080 (Docker log viewer)
# uptime.libnovel.cc → uptime-kuma:3001 (uptime monitoring)
# push.libnovel.cc → gotify:80 (push notifications)
# search.libnovel.cc → meilisearch:7700 (search index — homelab runner)
#
# Routes intentionally removed from direct-to-backend:
# /api/scrape/* — SvelteKit has /api/scrape/ counterparts
@@ -43,8 +44,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 +239,28 @@ 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
}
# ── Meilisearch: exposed for homelab runner search indexing ──────────────────
# The homelab runner connects here as MEILI_URL to index books after scraping.
# Protected by MEILI_MASTER_KEY bearer token — Meilisearch enforces auth on
# every request; Caddy just terminates TLS.
search.libnovel.cc {
import security_headers
reverse_proxy meilisearch:7700
}
}

View File

@@ -248,23 +248,30 @@ func (r *Runner) poll(ctx context.Context, scrapeSem, audioSem chan struct{}, wg
}
// ── Audio tasks ───────────────────────────────────────────────────────
// Only claim tasks when there is a free slot in the semaphore.
// This avoids the old bug where we claimed (status→running) a task and
// then couldn't dispatch it, leaving it orphaned until the reaper fired.
audioLoop:
for {
if ctx.Err() != nil {
return
}
// Check capacity before claiming to avoid orphaning tasks.
select {
case audioSem <- struct{}{}:
// Slot acquired — proceed to claim a task.
default:
// All slots busy; leave remaining pending tasks for next tick.
break audioLoop
}
task, ok, err := r.deps.Consumer.ClaimNextAudioTask(ctx, r.cfg.WorkerID)
if err != nil {
<-audioSem // release the pre-acquired slot
r.deps.Log.Error("runner: ClaimNextAudioTask failed", "err", err)
break
}
if !ok {
break
}
select {
case audioSem <- struct{}{}:
default:
r.deps.Log.Warn("runner: audio semaphore full, will retry next tick",
"task_id", task.ID)
<-audioSem // release the pre-acquired slot; queue empty
break
}
r.tasksRunning.Add(1)

View File

@@ -247,8 +247,9 @@ func (c *pbClient) claimRecord(ctx context.Context, collection, workerID string,
}
claim := map[string]any{
"status": string(domain.TaskStatusRunning),
"worker_id": workerID,
"status": string(domain.TaskStatusRunning),
"worker_id": workerID,
"heartbeat_at": time.Now().UTC().Format(time.RFC3339),
}
for k, v := range extraClaim {
claim[k] = v

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,16 @@ 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}"
# OAuth2 providers
GOOGLE_CLIENT_ID: "${GOOGLE_CLIENT_ID}"
GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET}"
GITHUB_CLIENT_ID: "${GITHUB_CLIENT_ID}"
GITHUB_CLIENT_SECRET: "${GITHUB_CLIENT_SECRET}"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
interval: 15s
@@ -421,13 +437,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 +457,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 +478,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 +502,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 +572,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,62 @@
# 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 → https://search.libnovel.cc (Caddy-proxied)
# - VALKEY_ADDR → unset (not exposed publicly)
# - 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 (via search.libnovel.cc Caddy proxy) ────────────────────
MEILI_URL: "${MEILI_URL}"
MEILI_API_KEY: "${MEILI_API_KEY}"
VALKEY_ADDR: ""
# Force IPv4 DNS resolution — homelab has no IPv6 route to search.libnovel.cc
GODEBUG: "preferIPv4=1"
# ── 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,17 @@ 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"},
{"name":"oauth_provider", "type":"text"},
{"name":"oauth_id", "type":"text"}
]}'
create "user_sessions" '{
@@ -240,11 +246,17 @@ 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"
add_field "app_users" "oauth_provider" "text"
add_field "app_users" "oauth_id" "text"
log "done"

1
ui/package-lock.json generated
View File

@@ -17,6 +17,7 @@
"pocketbase": "^0.26.8"
},
"devDependencies": {
"@sentry/vite-plugin": "^5.1.1",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/kit": "^2.50.2",

View File

@@ -12,6 +12,7 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@sentry/vite-plugin": "^5.1.1",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/kit": "^2.50.2",

View File

@@ -6,7 +6,10 @@ import { env } from '$env/dynamic/public';
if (env.PUBLIC_GLITCHTIP_DSN) {
Sentry.init({
dsn: env.PUBLIC_GLITCHTIP_DSN,
tracesSampleRate: 0.1
tracesSampleRate: 0.1,
// Must match the release name used when uploading source maps in CI
// (BUILD_VERSION injected by Dockerfile as PUBLIC_BUILD_VERSION).
release: env.PUBLIC_BUILD_VERSION || undefined
});
}

View File

@@ -13,7 +13,10 @@ import { drain as drainPresignCache } from '$lib/server/presignCache';
if (pubEnv.PUBLIC_GLITCHTIP_DSN) {
Sentry.init({
dsn: pubEnv.PUBLIC_GLITCHTIP_DSN,
tracesSampleRate: 0.1
tracesSampleRate: 0.1,
// Must match the release name used when uploading source maps in CI
// (BUILD_VERSION injected by Dockerfile as PUBLIC_BUILD_VERSION).
release: pubEnv.PUBLIC_BUILD_VERSION || undefined
});
}

View File

@@ -63,6 +63,12 @@ export interface User {
role: string;
created: string;
avatar_url?: string;
email?: string;
email_verified?: boolean;
verification_token?: string;
verification_token_exp?: string;
oauth_provider?: string;
oauth_id?: string;
}
// ─── Auth token cache ─────────────────────────────────────────────────────────
@@ -486,21 +492,119 @@ 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 OAuth provider + provider user ID. Returns null if not found.
*/
export async function getUserByOAuth(provider: string, oauthId: string): Promise<User | null> {
return listOne<User>(
'app_users',
`oauth_provider="${provider.replace(/"/g, '\\"')}"&&oauth_id="${oauthId.replace(/"/g, '\\"')}"`
);
}
/**
* Create a new user via OAuth (no password). email_verified is true since the
* provider already verified it. Throws on DB errors.
*/
export async function createOAuthUser(
username: string,
email: string,
provider: string,
oauthId: string,
avatarUrl?: string,
role = 'user'
): Promise<User> {
log.info('pocketbase', 'createOAuthUser', { username, email, provider });
const res = await pbPost('/api/collections/app_users/records', {
username,
password_hash: '',
role,
email,
email_verified: true,
oauth_provider: provider,
oauth_id: oauthId,
avatar_url: avatarUrl ?? '',
created: new Date().toISOString()
});
if (!res.ok) {
const body = await res.text().catch(() => '');
log.error('pocketbase', 'createOAuthUser: PocketBase rejected record', {
username,
status: res.status,
body
});
throw new Error(`Failed to create OAuth user: ${res.status} ${body}`);
}
return res.json() as Promise<User>;
}
/**
* Link an OAuth provider to an existing user account.
*/
export async function linkOAuthToUser(
userId: string,
provider: string,
oauthId: string
): Promise<void> {
const res = await pbPatch(`/api/collections/app_users/records/${userId}`, {
oauth_provider: provider,
oauth_id: oauthId,
email_verified: true
});
if (!res.ok) {
const body = await res.text().catch(() => '');
log.error('pocketbase', 'linkOAuthToUser: PATCH failed', { userId, status: res.status, body });
throw new Error(`Failed to link OAuth: ${res.status}`);
}
}
/**
* Look up a user by verification token. Returns null if not found.
* @deprecated Email verification removed — kept only for migration safety.
*/
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 +620,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 +677,7 @@ export async function changePassword(
/**
* Verify username + password. Returns the user on success, null on failure.
* Only used for legacy accounts that still have a password_hash.
*/
export async function loginUser(username: string, password: string): Promise<User | null> {
log.debug('pocketbase', 'loginUser: lookup', { username });
@@ -564,6 +686,10 @@ export async function loginUser(username: string, password: string): Promise<Use
log.warn('pocketbase', 'loginUser: username not found', { username });
return null;
}
if (!user.password_hash) {
log.warn('pocketbase', 'loginUser: account has no password (OAuth-only)', { username });
return null;
}
const ok = verifyPassword(password, user.password_hash);
if (!ok) {
log.warn('pocketbase', 'loginUser: wrong password', { username });

View File

@@ -4,10 +4,12 @@ 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', '/disclaimer', '/privacy', '/dmca', '/terms']);
export const load: LayoutServerLoad = async ({ locals, url }) => {
if (!PUBLIC_ROUTES.has(url.pathname) && !locals.user) {
// Allow /auth/* (OAuth initiation + callbacks) without login
const isPublic = PUBLIC_ROUTES.has(url.pathname) || url.pathname.startsWith('/auth/');
if (!isPublic && !locals.user) {
redirect(302, `/login`);
}

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,84 +1,12 @@
import { json, error } from '@sveltejs/kit';
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { createUser, mergeSessionProgress, 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;
/**
* POST /api/auth/register
* Body: { username: string, password: string }
* Returns: { token: string, user: { id, username, role } }
*
* Sets the libnovel_auth cookie and returns the raw token value so the
* iOS app can persist it for subsequent requests.
* Username/password registration has been replaced by OAuth2 (Google & GitHub).
* This endpoint is no longer supported.
*/
export const POST: RequestHandler = async ({ request, cookies, locals }) => {
let body: { username?: string; password?: string };
try {
body = await request.json();
} catch {
error(400, 'Invalid JSON body');
}
const username = (body.username ?? '').trim();
const password = body.password ?? '';
if (!username || !password) {
error(400, 'Username and password are required');
}
if (username.length < 3 || username.length > 32) {
error(400, 'Username must be between 3 and 32 characters');
}
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
error(400, 'Username may only contain letters, numbers, underscores and hyphens');
}
if (password.length < 8) {
error(400, 'Password must be at least 8 characters');
}
let user;
try {
user = await createUser(username, password);
} 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');
}
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) })
);
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' }
});
export const POST: RequestHandler = async () => {
error(410, 'Username/password registration is no longer supported. Please sign in with Google or GitHub.');
};

View File

@@ -0,0 +1,79 @@
/**
* GET /auth/[provider]
*
* Initiates the OAuth2 authorization code flow.
* Generates a random `state` param (stored in a short-lived cookie) to
* prevent CSRF, then redirects the browser to the provider's auth URL.
*
* Supported providers: google, github
*/
import { redirect, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/private';
import { randomBytes } from 'node:crypto';
const PROVIDERS = {
google: {
authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
scopes: 'openid email profile'
},
github: {
authUrl: 'https://github.com/login/oauth/authorize',
scopes: 'read:user user:email'
}
} as const;
type Provider = keyof typeof PROVIDERS;
function clientId(provider: Provider): string {
if (provider === 'google') return env.GOOGLE_CLIENT_ID ?? '';
if (provider === 'github') return env.GITHUB_CLIENT_ID ?? '';
return '';
}
function redirectUri(provider: Provider, origin: string): string {
return `${origin}/auth/${provider}/callback`;
}
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const provider = params.provider as Provider;
if (!(provider in PROVIDERS)) {
error(404, 'Unknown OAuth provider');
}
const id = clientId(provider);
if (!id) {
error(500, `OAuth provider "${provider}" is not configured`);
}
// Generate state token — stored in a 10-minute cookie
const state = randomBytes(16).toString('hex');
cookies.set(`oauth_state_${provider}`, state, {
path: '/',
httpOnly: true,
sameSite: 'lax',
maxAge: 60 * 10 // 10 minutes
});
// Where to send the user after successful auth (default: home)
const next = url.searchParams.get('next') ?? '/';
cookies.set(`oauth_next_${provider}`, next, {
path: '/',
httpOnly: true,
sameSite: 'lax',
maxAge: 60 * 10
});
const origin = url.origin;
const cfg = PROVIDERS[provider];
const params2 = new URLSearchParams({
client_id: id,
redirect_uri: redirectUri(provider, origin),
response_type: 'code',
scope: cfg.scopes,
state
});
redirect(302, `${cfg.authUrl}?${params2.toString()}`);
};

View File

@@ -0,0 +1,246 @@
/**
* GET /auth/[provider]/callback
*
* Handles the OAuth2 authorization code callback.
*
* Flow:
* 1. Validate state cookie (CSRF check).
* 2. Exchange code for access token with the provider.
* 3. Fetch the user's profile (email, name, avatar) from the provider.
* 4. Look up app_users by (oauth_provider, oauth_id).
* - If found: log in.
* - If not found but email matches an existing user: link the account.
* - If not found at all: auto-create a new account.
* 5. Set auth cookie, redirect to `next` (default: '/').
*/
import { redirect, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/private';
import { randomBytes } from 'node:crypto';
import {
getUserByOAuth,
getUserByEmail,
createOAuthUser,
linkOAuthToUser
} from '$lib/server/pocketbase';
import { createAuthToken } from '../../../../hooks.server';
import { createUserSession, mergeSessionProgress } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
type Provider = 'google' | 'github';
const AUTH_COOKIE = 'libnovel_auth';
const ONE_YEAR = 60 * 60 * 24 * 365;
// ─── Token exchange ───────────────────────────────────────────────────────────
interface TokenResponse {
access_token: string;
token_type: string;
error?: string;
}
async function exchangeCode(
provider: Provider,
code: string,
redirectUri: string
): Promise<string> {
const clientId = provider === 'google' ? env.GOOGLE_CLIENT_ID : env.GITHUB_CLIENT_ID;
const clientSecret =
provider === 'google' ? env.GOOGLE_CLIENT_SECRET : env.GITHUB_CLIENT_SECRET;
const tokenUrl =
provider === 'google'
? 'https://oauth2.googleapis.com/token'
: 'https://github.com/login/oauth/access_token';
const res = await fetch(tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json'
},
body: new URLSearchParams({
code,
client_id: clientId ?? '',
client_secret: clientSecret ?? '',
redirect_uri: redirectUri,
grant_type: 'authorization_code'
}).toString()
});
if (!res.ok) {
const body = await res.text().catch(() => '');
log.error('oauth', 'token exchange failed', { provider, status: res.status, body });
throw new Error(`Token exchange failed: ${res.status}`);
}
const data = (await res.json()) as TokenResponse;
if (data.error || !data.access_token) {
log.error('oauth', 'token response error', { provider, error: data.error });
throw new Error(data.error ?? 'No access_token in response');
}
return data.access_token;
}
// ─── Profile fetching ─────────────────────────────────────────────────────────
interface OAuthProfile {
id: string; // provider's user ID (as string)
email: string;
name: string;
avatarUrl?: string;
}
async function fetchGoogleProfile(accessToken: string): Promise<OAuthProfile> {
const res = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
headers: { Authorization: `Bearer ${accessToken}` }
});
if (!res.ok) throw new Error(`Google userinfo failed: ${res.status}`);
const d = await res.json();
return {
id: String(d.id),
email: d.email ?? '',
name: d.name ?? d.email ?? '',
avatarUrl: d.picture
};
}
async function fetchGitHubProfile(accessToken: string): Promise<OAuthProfile> {
const [userRes, emailRes] = await Promise.all([
fetch('https://api.github.com/user', {
headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/vnd.github+json' }
}),
fetch('https://api.github.com/user/emails', {
headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/vnd.github+json' }
})
]);
if (!userRes.ok) throw new Error(`GitHub user API failed: ${userRes.status}`);
const user = await userRes.json();
// Primary verified email — required for account linking
let email = user.email ?? '';
if (emailRes.ok) {
const emails = (await emailRes.json()) as Array<{
email: string;
primary: boolean;
verified: boolean;
}>;
const primary = emails.find((e) => e.primary && e.verified);
if (primary) email = primary.email;
}
if (!email) throw new Error('GitHub account has no verified primary email');
return {
id: String(user.id),
email,
name: user.name ?? user.login ?? email,
avatarUrl: user.avatar_url
};
}
// ─── Username derivation ──────────────────────────────────────────────────────
/** Derive a valid username from name/email. Sanitises to [a-zA-Z0-9_-], max 32 chars. */
function deriveUsername(name: string, email: string): string {
// Prefer the part before @ in the email for predictability
const base = (email.split('@')[0] ?? name)
.toLowerCase()
.replace(/[^a-z0-9_-]/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 28);
// Append 4 random hex chars to avoid collisions without needing a DB round-trip
const suffix = randomBytes(2).toString('hex');
return `${base || 'user'}_${suffix}`;
}
// ─── Handler ──────────────────────────────────────────────────────────────────
export const GET: RequestHandler = async ({ params, url, cookies, locals }) => {
const provider = params.provider as Provider;
if (provider !== 'google' && provider !== 'github') {
error(404, 'Unknown OAuth provider');
}
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const storedState = cookies.get(`oauth_state_${provider}`);
const next = cookies.get(`oauth_next_${provider}`) ?? '/';
// Clear short-lived cookies
cookies.delete(`oauth_state_${provider}`, { path: '/' });
cookies.delete(`oauth_next_${provider}`, { path: '/' });
if (!code || !state || state !== storedState) {
log.warn('oauth', 'state mismatch or missing code', { provider });
redirect(302, '/login?error=oauth_state');
}
const redirectUri = `${url.origin}/auth/${provider}/callback`;
let profile: OAuthProfile;
try {
const accessToken = await exchangeCode(provider, code, redirectUri);
profile =
provider === 'google'
? await fetchGoogleProfile(accessToken)
: await fetchGitHubProfile(accessToken);
} catch (err) {
log.error('oauth', 'profile fetch failed', { provider, err: String(err) });
redirect(302, '/login?error=oauth_failed');
}
if (!profile.email) {
log.warn('oauth', 'no email in profile', { provider, id: profile.id });
redirect(302, '/login?error=oauth_no_email');
}
// ── Find or create user ────────────────────────────────────────────────────
let user = await getUserByOAuth(provider, profile.id);
if (!user) {
// Try to link by email (user may have registered via the other provider)
const existing = await getUserByEmail(profile.email);
if (existing) {
// Link this provider to the existing account
await linkOAuthToUser(existing.id, provider, profile.id);
user = existing;
log.info('oauth', 'linked provider to existing account', {
provider,
userId: existing.id
});
} else {
// Auto-create a new account
const username = deriveUsername(profile.name, profile.email);
user = await createOAuthUser(username, profile.email, provider, profile.id, profile.avatarUrl);
log.info('oauth', 'created new account via oauth', { provider, username });
}
}
// ── Merge anonymous session progress ───────────────────────────────────────
mergeSessionProgress(locals.sessionId, user.id).catch((err) =>
log.warn('oauth', 'mergeSessionProgress failed (non-fatal)', { err: String(err) })
);
// ── Create session + auth cookie ──────────────────────────────────────────
const authSessionId = randomBytes(16).toString('hex');
const userAgent = '' ; // not available in RequestHandler — omit
const ip = '';
createUserSession(user.id, authSessionId, userAgent, ip).catch((err) =>
log.warn('oauth', '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, next);
};

View File

@@ -1,142 +1,12 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { loginUser, createUser, mergeSessionProgress, createUserSession } from '$lib/server/pocketbase';
import { createAuthToken } from '../../hooks.server';
import { log } from '$lib/server/logger';
import { randomBytes } from 'node:crypto';
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
const AUTH_COOKIE = 'libnovel_auth';
const ONE_YEAR = 60 * 60 * 24 * 365;
export const load: PageServerLoad = async ({ locals }) => {
export const load: PageServerLoad = async ({ locals, url }) => {
// Already logged in — send to home
if (locals.user) {
redirect(302, '/');
}
return {};
};
export const actions: Actions = {
login: async ({ request, cookies, locals }) => {
const data = await request.formData();
const username = (data.get('username') as string | null)?.trim() ?? '';
const password = (data.get('password') as string | null) ?? '';
if (!username || !password) {
return fail(400, { action: 'login', error: 'Username and password are required.' });
}
let user;
try {
user = await loginUser(username, password);
} catch (err) {
log.error('auth', 'login unexpected error', { username, err: String(err) });
return fail(500, { action: 'login', error: 'An error occurred. Please try again.' });
}
if (!user) {
return fail(401, { action: 'login', error: 'Invalid username or password.' });
}
// Merge any anonymous session progress into the user's account so that
// chapters read before logging in are preserved and portable across devices.
mergeSessionProgress(locals.sessionId, user.id).catch((err) =>
log.warn('auth', 'login: mergeSessionProgress failed (non-fatal)', { err: String(err) })
);
// Create a unique auth session ID for this login
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', 'login: 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, '/');
},
register: async ({ request, cookies, locals }) => {
const data = await request.formData();
const username = (data.get('username') as string | null)?.trim() ?? '';
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.length < 3 || username.length > 32) {
return fail(400, {
action: 'register',
error: 'Username must be between 3 and 32 characters.'
});
}
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
return fail(400, {
action: 'register',
error: 'Username may only contain letters, numbers, underscores and hyphens.'
});
}
if (password.length < 8) {
return fail(400, {
action: 'register',
error: 'Password must be at least 8 characters.'
});
}
if (password !== confirm) {
return fail(400, { action: 'register', error: 'Passwords do not match.' });
}
let user;
try {
user = await createUser(username, password);
} 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.' });
}
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) })
);
// 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, '/');
}
// Surface provider error codes to the page (oauth_state, oauth_failed, etc.)
const error = url.searchParams.get('error') ?? undefined;
return { error };
};

View File

@@ -1,9 +1,13 @@
<script lang="ts">
import type { ActionData } from './$types';
import type { PageServerLoad } from './$types';
let { form }: { form: ActionData } = $props();
let { data }: { data: { error?: string } } = $props();
let mode: 'login' | 'register' = $state('login');
const errorMessages: Record<string, string> = {
oauth_state: 'Sign-in was cancelled or expired. Please try again.',
oauth_failed: 'Could not connect to the provider. Please try again.',
oauth_no_email: 'Your account has no verified email address. Please add one and retry.'
};
</script>
<svelte:head>
@@ -12,125 +16,72 @@
<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 class="text-center mb-8">
<h1 class="text-2xl font-bold text-zinc-100 mb-2">Sign in to libnovel</h1>
<p class="text-sm text-zinc-400">Choose a provider to continue</p>
</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}
{#if data.error && errorMessages[data.error]}
<div class="mb-6 rounded bg-red-900/40 border border-red-700 px-4 py-3 text-sm text-red-300">
{errorMessages[data.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 class="flex flex-col gap-3">
<!-- Google -->
<a
href="/auth/google"
class="flex items-center justify-center gap-3 w-full py-3 px-4 rounded-lg
bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm font-medium
hover:bg-zinc-700 hover:border-zinc-600 transition-colors"
>
<svg class="w-5 h-5 shrink-0" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
</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="••••••••"
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
</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"
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<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="••••••••"
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
<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="••••••••"
</svg>
Continue with Google
</a>
<!-- GitHub -->
<a
href="/auth/github"
class="flex items-center justify-center gap-3 w-full py-3 px-4 rounded-lg
bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm font-medium
hover:bg-zinc-700 hover:border-zinc-600 transition-colors"
>
<svg class="w-5 h-5 shrink-0 fill-zinc-100" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483
0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466
-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832
.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688
-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 12 6.844a9.59 9.59 0
0 1 2.504.337c1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028
1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012
2.419-.012 2.747 0 .268.18.58.688.482A10.02 10.02 0 0 0 22 12.017C22 6.484 17.522 2 12 2z"
/>
</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}
</svg>
Continue with GitHub
</a>
</div>
<p class="mt-8 text-center text-xs text-zinc-500">
By signing in you agree to our terms of service.
</p>
</div>
</div>

View File

@@ -0,0 +1,51 @@
<svelte:head>
<title>Terms of Service — libnovel</title>
</svelte:head>
<div class="max-w-2xl mx-auto py-10 px-4">
<h1 class="text-2xl font-bold text-zinc-100 mb-6">Terms of Service</h1>
<div class="space-y-5 text-sm text-zinc-400 leading-relaxed">
<p>
By using libnovel you agree to these terms. If you do not agree, please do not use the service.
</p>
<h2 class="text-base font-semibold text-zinc-200 mt-6">Use of the service</h2>
<ul class="list-disc list-inside space-y-2 pl-1">
<li>libnovel is provided for personal, non-commercial reading use only.</li>
<li>You may not scrape, crawl, or systematically download content from the site.</li>
<li>You may not use the service for any unlawful purpose.</li>
<li>Accounts may be suspended or terminated for abuse.</li>
</ul>
<h2 class="text-base font-semibold text-zinc-200 mt-6">Content</h2>
<p>
libnovel aggregates publicly available web novel content from third-party sources for
personal reading convenience. We do not claim ownership of any novel content displayed on
the site. If you are a rights holder and wish to have content removed, please see our
<a href="/dmca" class="text-amber-400 hover:text-amber-300 transition-colors">DMCA policy</a>.
</p>
<h2 class="text-base font-semibold text-zinc-200 mt-6">Accounts</h2>
<p>
You are responsible for maintaining the security of your account. libnovel is not liable
for any loss or damage resulting from unauthorised access to your account.
</p>
<h2 class="text-base font-semibold text-zinc-200 mt-6">Disclaimer of warranties</h2>
<p>
The service is provided "as is" without warranty of any kind. We do not guarantee
availability, accuracy, or completeness of any content. See our full
<a href="/disclaimer" class="text-amber-400 hover:text-amber-300 transition-colors">disclaimer</a>
for details.
</p>
<h2 class="text-base font-semibold text-zinc-200 mt-6">Changes to these terms</h2>
<p>
We may update these terms at any time. Continued use of the service after changes are
posted constitutes acceptance of the revised terms.
</p>
<p class="text-zinc-600 text-xs mt-8">Last updated: {new Date().getFullYear()}</p>
</div>
</div>

View File

@@ -2,7 +2,12 @@ import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
// Source maps are always generated so that the CI pipeline can upload them to
// GlitchTip via glitchtip-cli after a release build.
export default defineConfig({
build: {
sourcemap: true
},
plugins: [tailwindcss(), sveltekit()],
ssr: {
// Force these packages to be bundled into the server output rather than