Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c8849c6cd | ||
|
|
b30aa23d64 | ||
|
|
fea09e3e23 | ||
|
|
4831c74acc | ||
|
|
7e5e0495cf | ||
|
|
188685e1b6 | ||
|
|
3271a5f3e6 | ||
|
|
ee3ed29316 | ||
|
|
a39f660a37 | ||
|
|
69818089a6 | ||
|
|
09062b8c82 | ||
|
|
d518710cc4 | ||
|
|
e2c15f5931 | ||
|
|
a50b968b95 | ||
|
|
023b1f7fec | ||
|
|
7e99fc6d70 | ||
|
|
12d6d30fb0 | ||
|
|
f9c14685b3 | ||
|
|
4a7009989c |
@@ -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:
|
||||
|
||||
50
Caddyfile
50
Caddyfile
@@ -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
|
||||
@@ -203,41 +204,11 @@
|
||||
}
|
||||
|
||||
# ── Tooling subdomains ────────────────────────────────────────────────────────
|
||||
feedback.libnovel.cc {
|
||||
import security_headers
|
||||
reverse_proxy fider:3000
|
||||
}
|
||||
|
||||
# ── GlitchTip: error tracking ─────────────────────────────────────────────────
|
||||
errors.libnovel.cc {
|
||||
import security_headers
|
||||
reverse_proxy glitchtip-web:8000
|
||||
}
|
||||
|
||||
# ── Umami: page analytics ─────────────────────────────────────────────────────
|
||||
analytics.libnovel.cc {
|
||||
import security_headers
|
||||
reverse_proxy umami:3000
|
||||
}
|
||||
|
||||
# ── Dozzle: Docker log viewer ─────────────────────────────────────────────────
|
||||
logs.libnovel.cc {
|
||||
import security_headers
|
||||
reverse_proxy dozzle:8080
|
||||
}
|
||||
|
||||
# ── Uptime Kuma: uptime monitoring ────────────────────────────────────────────
|
||||
uptime.libnovel.cc {
|
||||
import security_headers
|
||||
reverse_proxy uptime-kuma:3001
|
||||
}
|
||||
|
||||
# ── Gotify: push notifications ────────────────────────────────────────────────
|
||||
push.libnovel.cc {
|
||||
import security_headers
|
||||
reverse_proxy gotify:80
|
||||
}
|
||||
|
||||
# feedback.libnovel.cc, errors.libnovel.cc, analytics.libnovel.cc,
|
||||
# logs.libnovel.cc, uptime.libnovel.cc, push.libnovel.cc, grafana.libnovel.cc
|
||||
# are now routed via Cloudflare Tunnel directly to the homelab (192.168.0.109).
|
||||
# No Caddy rules needed here — Cloudflare handles TLS termination and routing.
|
||||
|
||||
# ── 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.
|
||||
@@ -254,3 +225,12 @@ storage.libnovel.cc {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,12 @@ COPY --from=builder /out/backend /backend
|
||||
ENTRYPOINT ["/backend"]
|
||||
|
||||
# ── runner service ───────────────────────────────────────────────────────────
|
||||
FROM gcr.io/distroless/static:nonroot AS runner
|
||||
# Uses Alpine (not distroless) so ffmpeg is available for WAV→MP3 transcoding
|
||||
# when pocket-tts voices are used.
|
||||
FROM alpine:3.21 AS runner
|
||||
RUN apk add --no-cache ffmpeg ca-certificates && \
|
||||
addgroup -S appgroup && adduser -S appuser -G appgroup
|
||||
COPY --from=builder /out/healthcheck /healthcheck
|
||||
COPY --from=builder /out/runner /runner
|
||||
USER appuser
|
||||
ENTRYPOINT ["/runner"]
|
||||
|
||||
BIN
backend/backend
Executable file
BIN
backend/backend
Executable file
Binary file not shown.
@@ -26,6 +26,7 @@ import (
|
||||
"github.com/libnovel/backend/internal/config"
|
||||
"github.com/libnovel/backend/internal/kokoro"
|
||||
"github.com/libnovel/backend/internal/meili"
|
||||
"github.com/libnovel/backend/internal/otelsetup"
|
||||
"github.com/libnovel/backend/internal/storage"
|
||||
)
|
||||
|
||||
@@ -70,6 +71,19 @@ func run() error {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
// ── OpenTelemetry tracing + logs ──────────────────────────────────────────
|
||||
otelShutdown, otelLog, err := otelsetup.Init(ctx, version)
|
||||
if err != nil {
|
||||
return fmt.Errorf("init otel: %w", err)
|
||||
}
|
||||
if otelShutdown != nil {
|
||||
defer otelShutdown()
|
||||
// Replace the plain slog logger with the OTel-bridged one so all
|
||||
// structured log lines are forwarded to Loki with trace IDs attached.
|
||||
log = otelLog
|
||||
log.Info("otel tracing + logs enabled", "endpoint", os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"))
|
||||
}
|
||||
|
||||
// ── Storage ──────────────────────────────────────────────────────────────
|
||||
store, err := storage.NewStore(ctx, cfg, log)
|
||||
if err != nil {
|
||||
|
||||
@@ -25,6 +25,8 @@ import (
|
||||
"github.com/libnovel/backend/internal/kokoro"
|
||||
"github.com/libnovel/backend/internal/meili"
|
||||
"github.com/libnovel/backend/internal/novelfire"
|
||||
"github.com/libnovel/backend/internal/otelsetup"
|
||||
"github.com/libnovel/backend/internal/pockettts"
|
||||
"github.com/libnovel/backend/internal/runner"
|
||||
"github.com/libnovel/backend/internal/storage"
|
||||
)
|
||||
@@ -70,6 +72,19 @@ func run() error {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
// ── OpenTelemetry tracing + logs ─────────────────────────────────────────
|
||||
otelShutdown, otelLog, err := otelsetup.Init(ctx, version)
|
||||
if err != nil {
|
||||
return fmt.Errorf("init otel: %w", err)
|
||||
}
|
||||
if otelShutdown != nil {
|
||||
defer otelShutdown()
|
||||
// Switch to the OTel-bridged logger so all structured log lines are
|
||||
// forwarded to Loki with trace IDs attached.
|
||||
log = otelLog
|
||||
log.Info("otel tracing + logs enabled", "endpoint", os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"))
|
||||
}
|
||||
|
||||
// ── Storage ─────────────────────────────────────────────────────────────
|
||||
store, err := storage.NewStore(ctx, cfg, log)
|
||||
if err != nil {
|
||||
@@ -98,10 +113,19 @@ func run() error {
|
||||
kokoroClient = kokoro.New(cfg.Kokoro.URL)
|
||||
log.Info("kokoro TTS enabled", "url", cfg.Kokoro.URL)
|
||||
} else {
|
||||
log.Warn("KOKORO_URL not set — audio tasks will fail")
|
||||
log.Warn("KOKORO_URL not set — kokoro voice tasks will fail")
|
||||
kokoroClient = &noopKokoro{}
|
||||
}
|
||||
|
||||
// ── pocket-tts ──────────────────────────────────────────────────────────
|
||||
var pocketTTSClient pockettts.Client
|
||||
if cfg.PocketTTS.URL != "" {
|
||||
pocketTTSClient = pockettts.New(cfg.PocketTTS.URL)
|
||||
log.Info("pocket-tts enabled", "url", cfg.PocketTTS.URL)
|
||||
} else {
|
||||
log.Warn("POCKET_TTS_URL not set — pocket-tts voice tasks will fail")
|
||||
}
|
||||
|
||||
// ── Meilisearch ─────────────────────────────────────────────────────────
|
||||
var searchIndex meili.Client
|
||||
if cfg.Meilisearch.URL != "" {
|
||||
@@ -137,6 +161,7 @@ func run() error {
|
||||
SearchIndex: searchIndex,
|
||||
Novel: novel,
|
||||
Kokoro: kokoroClient,
|
||||
PocketTTS: pocketTTSClient,
|
||||
Log: log,
|
||||
}
|
||||
r := runner.New(rCfg, deps)
|
||||
|
||||
@@ -9,14 +9,19 @@ require (
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.1.1 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/getsentry/sentry-go v0.43.0 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
||||
github.com/klauspost/compress v1.18.2 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
|
||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||
@@ -28,10 +33,27 @@ require (
|
||||
github.com/redis/go-redis/v9 v9.18.0 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/tinylib/msgp v1.6.1 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.17.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
|
||||
go.opentelemetry.io/otel v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/log v0.18.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/log v0.18.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.42.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect
|
||||
google.golang.org/grpc v1.79.2 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
||||
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
@@ -8,14 +10,23 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/getsentry/sentry-go v0.43.0 h1:XbXLpFicpo8HmBDaInk7dum18G9KSLcjZiyUKS+hLW4=
|
||||
github.com/getsentry/sentry-go v0.43.0/go.mod h1:XDotiNZbgf5U8bPDUAfvcFmOnMQQceESxyKaObSssW0=
|
||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
@@ -45,6 +56,32 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
|
||||
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.17.0 h1:NFIS6x7wyObQ7cR84x7bt1sr8nYBx89s3x3GwRjw40k=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.17.0/go.mod h1:39SaByOyDMRMe872AE7uelMuQZidIw7LLFAnQi0FWTE=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
|
||||
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
|
||||
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0 h1:icqq3Z34UrEFk2u+HMhTtRsvo7Ues+eiJVjaJt62njs=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0/go.mod h1:W2m8P+d5Wn5kipj4/xmbt9uMqezEKfBjzVJadfABSBE=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 h1:THuZiwpQZuHPul65w4WcwEnkX2QIuMT+UFoOrygtoJw=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0/go.mod h1:J2pvYM5NGHofZ2/Ru6zw/TNWnEQp5crgyDeSrYpXkAw=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 h1:uLXP+3mghfMf7XmV4PkGfFhFKuNWoCvvx5wP/wOXo0o=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0/go.mod h1:v0Tj04armyT59mnURNUJf7RCKcKzq+lgJs6QSjHjaTc=
|
||||
go.opentelemetry.io/otel/log v0.18.0 h1:XgeQIIBjZZrliksMEbcwMZefoOSMI1hdjiLEiiB0bAg=
|
||||
go.opentelemetry.io/otel/log v0.18.0/go.mod h1:KEV1kad0NofR3ycsiDH4Yjcoj0+8206I6Ox2QYFSNgI=
|
||||
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
|
||||
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
|
||||
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
|
||||
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
|
||||
go.opentelemetry.io/otel/sdk/log v0.18.0 h1:n8OyZr7t7otkeTnPTbDNom6rW16TBYGtvyy2Gk6buQw=
|
||||
go.opentelemetry.io/otel/sdk/log v0.18.0/go.mod h1:C0+wxkTwKpOCZLrlJ3pewPiiQwpzycPI/u6W0Z9fuYk=
|
||||
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
|
||||
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
@@ -57,6 +94,14 @@ golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
|
||||
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
|
||||
@@ -33,6 +33,7 @@ import (
|
||||
"github.com/libnovel/backend/internal/kokoro"
|
||||
"github.com/libnovel/backend/internal/meili"
|
||||
"github.com/libnovel/backend/internal/taskqueue"
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
)
|
||||
|
||||
// Dependencies holds all external services the backend server depends on.
|
||||
@@ -170,9 +171,17 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
mux.HandleFunc("POST /api/progress/{slug}", s.handleSetProgress)
|
||||
mux.HandleFunc("DELETE /api/progress/{slug}", s.handleDeleteProgress)
|
||||
|
||||
// Wrap mux with OTel tracing (no-op when no TracerProvider is set),
|
||||
// then with Sentry for panic recovery and error reporting.
|
||||
var handler http.Handler = mux
|
||||
handler = otelhttp.NewHandler(handler, "libnovel.backend",
|
||||
otelhttp.WithMessageEvents(otelhttp.ReadEvents, otelhttp.WriteEvents),
|
||||
)
|
||||
handler = sentryhttp.New(sentryhttp.Options{Repanic: true}).Handle(handler)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: s.cfg.Addr,
|
||||
Handler: sentryhttp.New(sentryhttp.Options{Repanic: true}).Handle(mux),
|
||||
Handler: handler,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
|
||||
@@ -50,13 +50,20 @@ type MinIO struct {
|
||||
|
||||
// Kokoro holds connection settings for the Kokoro-FastAPI TTS service.
|
||||
type Kokoro struct {
|
||||
// URL is the base URL of the Kokoro service, e.g. https://kokoro.libnovel.cc
|
||||
// An empty string disables TTS generation.
|
||||
// URL is the base URL of the Kokoro service, e.g. https://tts.libnovel.cc
|
||||
// An empty string disables Kokoro TTS generation.
|
||||
URL string
|
||||
// DefaultVoice is the voice used when none is specified.
|
||||
DefaultVoice string
|
||||
}
|
||||
|
||||
// PocketTTS holds connection settings for the kyutai-labs/pocket-tts service.
|
||||
type PocketTTS struct {
|
||||
// URL is the base URL of the pocket-tts service, e.g. https://pocket-tts.libnovel.cc
|
||||
// An empty string disables pocket-tts generation.
|
||||
URL string
|
||||
}
|
||||
|
||||
// HTTP holds settings for the HTTP server (backend only).
|
||||
type HTTP struct {
|
||||
// Addr is the listen address, e.g. ":8080"
|
||||
@@ -113,6 +120,7 @@ type Config struct {
|
||||
PocketBase PocketBase
|
||||
MinIO MinIO
|
||||
Kokoro Kokoro
|
||||
PocketTTS PocketTTS
|
||||
HTTP HTTP
|
||||
Runner Runner
|
||||
Meilisearch Meilisearch
|
||||
@@ -156,6 +164,10 @@ func Load() Config {
|
||||
DefaultVoice: envOr("KOKORO_VOICE", "af_bella"),
|
||||
},
|
||||
|
||||
PocketTTS: PocketTTS{
|
||||
URL: envOr("POCKET_TTS_URL", ""),
|
||||
},
|
||||
|
||||
HTTP: HTTP{
|
||||
Addr: envOr("BACKEND_HTTP_ADDR", ":8080"),
|
||||
},
|
||||
|
||||
120
backend/internal/otelsetup/otelsetup.go
Normal file
120
backend/internal/otelsetup/otelsetup.go
Normal file
@@ -0,0 +1,120 @@
|
||||
// Package otelsetup initialises the OpenTelemetry SDK for the LibNovel backend.
|
||||
//
|
||||
// It reads two environment variables:
|
||||
//
|
||||
// OTEL_EXPORTER_OTLP_ENDPOINT — OTLP/HTTP endpoint; accepts either a full
|
||||
// URL ("https://otel.example.com") or a bare
|
||||
// host[:port] ("otel-collector:4318").
|
||||
// TLS is used when the value starts with "https://".
|
||||
// OTEL_SERVICE_NAME — service name reported in traces (default: "backend")
|
||||
//
|
||||
// When OTEL_EXPORTER_OTLP_ENDPOINT is empty the function is a no-op: it
|
||||
// returns a nil shutdown func and the default slog.Logger, so callers never
|
||||
// need to branch on it.
|
||||
//
|
||||
// Usage in main.go:
|
||||
//
|
||||
// shutdown, log, err := otelsetup.Init(ctx, version)
|
||||
// if err != nil { return err }
|
||||
// if shutdown != nil { defer shutdown() }
|
||||
package otelsetup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/contrib/bridges/otelslog"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
|
||||
otellog "go.opentelemetry.io/otel/log/global"
|
||||
"go.opentelemetry.io/otel/sdk/log"
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
|
||||
)
|
||||
|
||||
// Init sets up TracerProvider and LoggerProvider that export via OTLP/HTTP.
|
||||
//
|
||||
// Returns:
|
||||
// - shutdown: flushes and stops both providers (nil when OTel is disabled).
|
||||
// - logger: an slog.Logger bridged to OTel logs (falls back to default when disabled).
|
||||
// - err: non-nil only on SDK initialisation failure.
|
||||
func Init(ctx context.Context, version string) (shutdown func(), logger *slog.Logger, err error) {
|
||||
rawEndpoint := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
|
||||
if rawEndpoint == "" {
|
||||
return nil, slog.Default(), nil // OTel disabled — not an error
|
||||
}
|
||||
|
||||
// WithEndpoint expects a host[:port] value — no scheme.
|
||||
// Support both "https://otel.example.com" and "otel-collector:4318".
|
||||
useTLS := strings.HasPrefix(rawEndpoint, "https://")
|
||||
endpoint := strings.TrimPrefix(rawEndpoint, "https://")
|
||||
endpoint = strings.TrimPrefix(endpoint, "http://")
|
||||
|
||||
serviceName := os.Getenv("OTEL_SERVICE_NAME")
|
||||
if serviceName == "" {
|
||||
serviceName = "backend"
|
||||
}
|
||||
|
||||
// ── Shared resource ───────────────────────────────────────────────────────
|
||||
res, err := resource.New(ctx,
|
||||
resource.WithAttributes(
|
||||
semconv.ServiceName(serviceName),
|
||||
semconv.ServiceVersion(version),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, slog.Default(), fmt.Errorf("otelsetup: create resource: %w", err)
|
||||
}
|
||||
|
||||
// ── Trace provider ────────────────────────────────────────────────────────
|
||||
traceOpts := []otlptracehttp.Option{otlptracehttp.WithEndpoint(endpoint)}
|
||||
if !useTLS {
|
||||
traceOpts = append(traceOpts, otlptracehttp.WithInsecure())
|
||||
}
|
||||
traceExp, err := otlptracehttp.New(ctx, traceOpts...)
|
||||
if err != nil {
|
||||
return nil, slog.Default(), fmt.Errorf("otelsetup: create OTLP trace exporter: %w", err)
|
||||
}
|
||||
|
||||
tp := sdktrace.NewTracerProvider(
|
||||
sdktrace.WithBatcher(traceExp),
|
||||
sdktrace.WithResource(res),
|
||||
sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(0.2))),
|
||||
)
|
||||
otel.SetTracerProvider(tp)
|
||||
|
||||
// ── Log provider ──────────────────────────────────────────────────────────
|
||||
logOpts := []otlploghttp.Option{otlploghttp.WithEndpoint(endpoint)}
|
||||
if !useTLS {
|
||||
logOpts = append(logOpts, otlploghttp.WithInsecure())
|
||||
}
|
||||
logExp, err := otlploghttp.New(ctx, logOpts...)
|
||||
if err != nil {
|
||||
return nil, slog.Default(), fmt.Errorf("otelsetup: create OTLP log exporter: %w", err)
|
||||
}
|
||||
|
||||
lp := log.NewLoggerProvider(
|
||||
log.WithProcessor(log.NewBatchProcessor(logExp)),
|
||||
log.WithResource(res),
|
||||
)
|
||||
otellog.SetLoggerProvider(lp)
|
||||
|
||||
// Bridge slog → OTel logs. Structured fields and trace IDs are forwarded
|
||||
// automatically; Grafana can correlate log lines with Tempo traces.
|
||||
otelLogger := otelslog.NewLogger(serviceName)
|
||||
|
||||
shutdown = func() {
|
||||
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = tp.Shutdown(shutCtx)
|
||||
_ = lp.Shutdown(shutCtx)
|
||||
}
|
||||
|
||||
return shutdown, otelLogger, nil
|
||||
}
|
||||
159
backend/internal/pockettts/client.go
Normal file
159
backend/internal/pockettts/client.go
Normal file
@@ -0,0 +1,159 @@
|
||||
// Package pockettts provides a client for the kyutai-labs/pocket-tts TTS service.
|
||||
//
|
||||
// pocket-tts exposes a non-OpenAI API:
|
||||
//
|
||||
// POST /tts (multipart form: text, voice_url) → streaming WAV
|
||||
// GET /health → {"status":"healthy"}
|
||||
//
|
||||
// GenerateAudio streams the WAV response and transcodes it to MP3 using ffmpeg,
|
||||
// so callers receive MP3 bytes — the same format as the kokoro client — and the
|
||||
// rest of the pipeline does not need to care which TTS engine was used.
|
||||
//
|
||||
// Predefined voices (pass the bare name as the voice parameter):
|
||||
//
|
||||
// alba, marius, javert, jean, fantine, cosette, eponine, azelma,
|
||||
// anna, vera, charles, paul, george, mary, jane, michael, eve,
|
||||
// bill_boerst, peter_yearsley, stuart_bell
|
||||
package pockettts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PredefinedVoices is the set of voice names built into pocket-tts.
|
||||
// The runner uses this to decide which TTS engine to route a task to.
|
||||
var PredefinedVoices = map[string]struct{}{
|
||||
"alba": {}, "marius": {}, "javert": {}, "jean": {},
|
||||
"fantine": {}, "cosette": {}, "eponine": {}, "azelma": {},
|
||||
"anna": {}, "vera": {}, "charles": {}, "paul": {},
|
||||
"george": {}, "mary": {}, "jane": {}, "michael": {},
|
||||
"eve": {}, "bill_boerst": {}, "peter_yearsley": {}, "stuart_bell": {},
|
||||
}
|
||||
|
||||
// IsPocketTTSVoice reports whether voice is served by pocket-tts.
|
||||
func IsPocketTTSVoice(voice string) bool {
|
||||
_, ok := PredefinedVoices[voice]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Client is the interface for interacting with the pocket-tts service.
|
||||
type Client interface {
|
||||
// GenerateAudio synthesises text using the given voice and returns MP3 bytes.
|
||||
// Voice must be one of the predefined pocket-tts voice names.
|
||||
GenerateAudio(ctx context.Context, text, voice string) ([]byte, error)
|
||||
|
||||
// ListVoices returns the available predefined voice names.
|
||||
ListVoices(ctx context.Context) ([]string, error)
|
||||
}
|
||||
|
||||
// httpClient is the concrete pocket-tts HTTP client.
|
||||
type httpClient struct {
|
||||
baseURL string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// New returns a Client targeting baseURL (e.g. "https://pocket-tts.libnovel.cc").
|
||||
func New(baseURL string) Client {
|
||||
return &httpClient{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
http: &http.Client{Timeout: 10 * time.Minute},
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateAudio posts to POST /tts and transcodes the WAV response to MP3
|
||||
// using the system ffmpeg binary. Requires ffmpeg to be on PATH (available in
|
||||
// the runner Docker image via Alpine's ffmpeg package).
|
||||
func (c *httpClient) GenerateAudio(ctx context.Context, text, voice string) ([]byte, error) {
|
||||
if text == "" {
|
||||
return nil, fmt.Errorf("pockettts: empty text")
|
||||
}
|
||||
if voice == "" {
|
||||
voice = "alba"
|
||||
}
|
||||
|
||||
// ── Build multipart form ──────────────────────────────────────────────────
|
||||
var body bytes.Buffer
|
||||
mw := multipart.NewWriter(&body)
|
||||
|
||||
if err := mw.WriteField("text", text); err != nil {
|
||||
return nil, fmt.Errorf("pockettts: write text field: %w", err)
|
||||
}
|
||||
// pocket-tts accepts a predefined voice name as voice_url.
|
||||
if err := mw.WriteField("voice_url", voice); err != nil {
|
||||
return nil, fmt.Errorf("pockettts: write voice_url field: %w", err)
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("pockettts: close multipart writer: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
c.baseURL+"/tts", &body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pockettts: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pockettts: request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return nil, fmt.Errorf("pockettts: server returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
wavData, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pockettts: read response body: %w", err)
|
||||
}
|
||||
|
||||
// ── Transcode WAV → MP3 via ffmpeg ────────────────────────────────────────
|
||||
mp3Data, err := wavToMP3(ctx, wavData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pockettts: transcode to mp3: %w", err)
|
||||
}
|
||||
return mp3Data, nil
|
||||
}
|
||||
|
||||
// ListVoices returns the statically known predefined voice names.
|
||||
// pocket-tts has no REST endpoint for listing voices.
|
||||
func (c *httpClient) ListVoices(_ context.Context) ([]string, error) {
|
||||
voices := make([]string, 0, len(PredefinedVoices))
|
||||
for v := range PredefinedVoices {
|
||||
voices = append(voices, v)
|
||||
}
|
||||
return voices, nil
|
||||
}
|
||||
|
||||
// wavToMP3 converts raw WAV bytes to MP3 using ffmpeg.
|
||||
// ffmpeg reads from stdin (pipe:0) and writes to stdout (pipe:1).
|
||||
func wavToMP3(ctx context.Context, wav []byte) ([]byte, error) {
|
||||
cmd := exec.CommandContext(ctx,
|
||||
"ffmpeg",
|
||||
"-hide_banner", "-loglevel", "error",
|
||||
"-i", "pipe:0", // read WAV from stdin
|
||||
"-f", "mp3", // output format
|
||||
"-q:a", "2", // VBR quality ~190 kbps
|
||||
"pipe:1", // write MP3 to stdout
|
||||
)
|
||||
cmd.Stdin = bytes.NewReader(wav)
|
||||
|
||||
var out, stderr bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("ffmpeg: %w (stderr: %s)", err, stderr.String())
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
@@ -22,11 +22,16 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
|
||||
"github.com/libnovel/backend/internal/bookstore"
|
||||
"github.com/libnovel/backend/internal/domain"
|
||||
"github.com/libnovel/backend/internal/kokoro"
|
||||
"github.com/libnovel/backend/internal/meili"
|
||||
"github.com/libnovel/backend/internal/orchestrator"
|
||||
"github.com/libnovel/backend/internal/pockettts"
|
||||
"github.com/libnovel/backend/internal/scraper"
|
||||
"github.com/libnovel/backend/internal/taskqueue"
|
||||
)
|
||||
@@ -80,8 +85,11 @@ type Dependencies struct {
|
||||
SearchIndex meili.Client
|
||||
// Novel is the scraper implementation.
|
||||
Novel scraper.NovelScraper
|
||||
// Kokoro is the TTS client.
|
||||
// Kokoro is the Kokoro-FastAPI TTS client (GPU, OpenAI-compatible voices).
|
||||
Kokoro kokoro.Client
|
||||
// PocketTTS is the pocket-tts client (CPU, kyutai voices: alba, marius, etc.).
|
||||
// If nil, pocket-tts voice tasks will fail with a clear error.
|
||||
PocketTTS pockettts.Client
|
||||
// Log is the structured logger.
|
||||
Log *slog.Logger
|
||||
}
|
||||
@@ -248,23 +256,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)
|
||||
@@ -294,6 +309,14 @@ func (r *Runner) newOrchestrator() *orchestrator.Orchestrator {
|
||||
|
||||
// runScrapeTask executes one scrape task end-to-end and reports the result.
|
||||
func (r *Runner) runScrapeTask(ctx context.Context, task domain.ScrapeTask) {
|
||||
ctx, span := otel.Tracer("runner").Start(ctx, "runner.scrape_task")
|
||||
defer span.End()
|
||||
span.SetAttributes(
|
||||
attribute.String("task.id", task.ID),
|
||||
attribute.String("task.kind", task.Kind),
|
||||
attribute.String("task.url", task.TargetURL),
|
||||
)
|
||||
|
||||
log := r.deps.Log.With("task_id", task.ID, "kind", task.Kind, "url", task.TargetURL)
|
||||
log.Info("runner: scrape task starting")
|
||||
|
||||
@@ -333,8 +356,10 @@ func (r *Runner) runScrapeTask(ctx context.Context, task domain.ScrapeTask) {
|
||||
|
||||
if result.ErrorMessage != "" {
|
||||
r.tasksFailed.Add(1)
|
||||
span.SetStatus(codes.Error, result.ErrorMessage)
|
||||
} else {
|
||||
r.tasksCompleted.Add(1)
|
||||
span.SetStatus(codes.Ok, "")
|
||||
}
|
||||
|
||||
log.Info("runner: scrape task finished",
|
||||
@@ -377,6 +402,15 @@ func (r *Runner) runCatalogueTask(ctx context.Context, task domain.ScrapeTask, o
|
||||
|
||||
// runAudioTask executes one audio-generation task.
|
||||
func (r *Runner) runAudioTask(ctx context.Context, task domain.AudioTask) {
|
||||
ctx, span := otel.Tracer("runner").Start(ctx, "runner.audio_task")
|
||||
defer span.End()
|
||||
span.SetAttributes(
|
||||
attribute.String("task.id", task.ID),
|
||||
attribute.String("book.slug", task.Slug),
|
||||
attribute.Int("chapter.number", task.Chapter),
|
||||
attribute.String("audio.voice", task.Voice),
|
||||
)
|
||||
|
||||
log := r.deps.Log.With("task_id", task.ID, "slug", task.Slug, "chapter", task.Chapter, "voice", task.Voice)
|
||||
log.Info("runner: audio task starting")
|
||||
|
||||
@@ -400,6 +434,7 @@ func (r *Runner) runAudioTask(ctx context.Context, task domain.AudioTask) {
|
||||
fail := func(msg string) {
|
||||
log.Error("runner: audio task failed", "reason", msg)
|
||||
r.tasksFailed.Add(1)
|
||||
span.SetStatus(codes.Error, msg)
|
||||
result := domain.AudioResult{ErrorMessage: msg}
|
||||
if err := r.deps.Consumer.FinishAudioTask(ctx, task.ID, result); err != nil {
|
||||
log.Error("runner: FinishAudioTask failed", "err", err)
|
||||
@@ -417,14 +452,31 @@ func (r *Runner) runAudioTask(ctx context.Context, task domain.AudioTask) {
|
||||
return
|
||||
}
|
||||
|
||||
if r.deps.Kokoro == nil {
|
||||
fail("kokoro client not configured")
|
||||
return
|
||||
}
|
||||
audioData, err := r.deps.Kokoro.GenerateAudio(ctx, text, task.Voice)
|
||||
if err != nil {
|
||||
fail(fmt.Sprintf("kokoro generate: %v", err))
|
||||
return
|
||||
var audioData []byte
|
||||
if pockettts.IsPocketTTSVoice(task.Voice) {
|
||||
if r.deps.PocketTTS == nil {
|
||||
fail("pocket-tts client not configured (POCKET_TTS_URL is empty)")
|
||||
return
|
||||
}
|
||||
var genErr error
|
||||
audioData, genErr = r.deps.PocketTTS.GenerateAudio(ctx, text, task.Voice)
|
||||
if genErr != nil {
|
||||
fail(fmt.Sprintf("pocket-tts generate: %v", genErr))
|
||||
return
|
||||
}
|
||||
log.Info("runner: audio generated via pocket-tts", "voice", task.Voice)
|
||||
} else {
|
||||
if r.deps.Kokoro == nil {
|
||||
fail("kokoro client not configured (KOKORO_URL is empty)")
|
||||
return
|
||||
}
|
||||
var genErr error
|
||||
audioData, genErr = r.deps.Kokoro.GenerateAudio(ctx, text, task.Voice)
|
||||
if genErr != nil {
|
||||
fail(fmt.Sprintf("kokoro generate: %v", genErr))
|
||||
return
|
||||
}
|
||||
log.Info("runner: audio generated via kokoro-fastapi", "voice", task.Voice)
|
||||
}
|
||||
|
||||
key := r.deps.AudioStore.AudioObjectKey(task.Slug, task.Chapter, task.Voice)
|
||||
@@ -434,6 +486,7 @@ func (r *Runner) runAudioTask(ctx context.Context, task domain.AudioTask) {
|
||||
}
|
||||
|
||||
r.tasksCompleted.Add(1)
|
||||
span.SetStatus(codes.Ok, "")
|
||||
result := domain.AudioResult{ObjectKey: key}
|
||||
if err := r.deps.Consumer.FinishAudioTask(ctx, task.ID, result); err != nil {
|
||||
log.Error("runner: FinishAudioTask failed", "err", err)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -161,6 +161,8 @@ services:
|
||||
KOKORO_URL: "${KOKORO_URL}"
|
||||
KOKORO_VOICE: "${KOKORO_VOICE}"
|
||||
GLITCHTIP_DSN: "${GLITCHTIP_DSN}"
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: "${OTEL_EXPORTER_OTLP_ENDPOINT}"
|
||||
OTEL_SERVICE_NAME: "backend"
|
||||
healthcheck:
|
||||
test: ["CMD", "/healthcheck", "http://localhost:8080/health"]
|
||||
interval: 15s
|
||||
@@ -217,8 +219,9 @@ services:
|
||||
KOKORO_URL: "${KOKORO_URL}"
|
||||
KOKORO_VOICE: "${KOKORO_VOICE}"
|
||||
GLITCHTIP_DSN: "${GLITCHTIP_DSN}"
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: "${OTEL_EXPORTER_OTLP_ENDPOINT}"
|
||||
OTEL_SERVICE_NAME: "runner"
|
||||
healthcheck:
|
||||
# The runner writes /tmp/runner.alive on every poll.
|
||||
# 120s = 2× the default 30s poll interval with generous headroom.
|
||||
test: ["CMD", "/healthcheck", "file", "/tmp/runner.alive", "120"]
|
||||
interval: 60s
|
||||
@@ -267,13 +270,14 @@ services:
|
||||
PUBLIC_UMAMI_SCRIPT_URL: "${PUBLIC_UMAMI_SCRIPT_URL}"
|
||||
# GlitchTip client + server-side error tracking
|
||||
PUBLIC_GLITCHTIP_DSN: "${PUBLIC_GLITCHTIP_DSN}"
|
||||
# Email verification (Resend SMTP — shared with Fider/GlitchTip)
|
||||
SMTP_HOST: "${FIDER_SMTP_HOST}"
|
||||
SMTP_PORT: "${FIDER_SMTP_PORT}"
|
||||
SMTP_USER: "${FIDER_SMTP_USER}"
|
||||
SMTP_PASSWORD: "${FIDER_SMTP_PASSWORD}"
|
||||
SMTP_FROM: "noreply@libnovel.cc"
|
||||
APP_URL: "${ORIGIN}"
|
||||
# OpenTelemetry tracing
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: "${OTEL_EXPORTER_OTLP_ENDPOINT}"
|
||||
OTEL_SERVICE_NAME: "ui"
|
||||
# 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
|
||||
@@ -301,6 +305,19 @@ services:
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
|
||||
# ─── Dozzle agent ────────────────────────────────────────────────────────────
|
||||
# Exposes prod container logs to the Dozzle instance on the homelab.
|
||||
# The homelab Dozzle connects here via DOZZLE_REMOTE_AGENT.
|
||||
# Port 7007 is bound to localhost only — not reachable from the internet.
|
||||
dozzle-agent:
|
||||
image: amir20/dozzle:latest
|
||||
restart: unless-stopped
|
||||
command: agent
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
ports:
|
||||
- "127.0.0.1:7007:7007"
|
||||
|
||||
# ─── CrowdSec bouncer registration ───────────────────────────────────────────
|
||||
# One-shot: registers the Caddy bouncer with the CrowdSec LAPI and writes the
|
||||
# generated API key to crowdsec/.crowdsec.env, which Caddy reads via env_file.
|
||||
@@ -382,203 +399,6 @@ services:
|
||||
WATCHTOWER_NOTIFICATION_URL: "${WATCHTOWER_NOTIFICATION_URL}"
|
||||
DOCKER_API_VERSION: "1.44"
|
||||
|
||||
# ─── Shared PostgreSQL (Fider + GlitchTip + Umami) ───────────────────────────
|
||||
# A single Postgres instance hosting three separate databases.
|
||||
# PocketBase uses its own embedded SQLite; this postgres is only for the
|
||||
# three new services below.
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: "${POSTGRES_USER}"
|
||||
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
|
||||
POSTGRES_DB: postgres
|
||||
expose:
|
||||
- "5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD", "pg_isready", "-U", "${POSTGRES_USER}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ─── Postgres database initialisation ────────────────────────────────────────
|
||||
# One-shot: creates the fider, glitchtip, and umami databases if missing.
|
||||
postgres-init:
|
||||
image: postgres:16-alpine
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
PGPASSWORD: "${POSTGRES_PASSWORD}"
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
psql -h postgres -U ${POSTGRES_USER} -d postgres -tc \"SELECT 1 FROM pg_database WHERE datname='fider'\" | grep -q 1 ||
|
||||
psql -h postgres -U ${POSTGRES_USER} -d postgres -c \"CREATE DATABASE fider\";
|
||||
psql -h postgres -U ${POSTGRES_USER} -d postgres -tc \"SELECT 1 FROM pg_database WHERE datname='glitchtip'\" | grep -q 1 ||
|
||||
psql -h postgres -U ${POSTGRES_USER} -d postgres -c \"CREATE DATABASE glitchtip\";
|
||||
psql -h postgres -U ${POSTGRES_USER} -d postgres -tc \"SELECT 1 FROM pg_database WHERE datname='umami'\" | grep -q 1 ||
|
||||
psql -h postgres -U ${POSTGRES_USER} -d postgres -c \"CREATE DATABASE umami\";
|
||||
echo 'postgres-init: databases ready';
|
||||
"
|
||||
restart: "no"
|
||||
|
||||
# ─── Fider (user feedback & feature requests) ─────────────────────────────────
|
||||
fider:
|
||||
image: getfider/fider:stable
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres-init:
|
||||
condition: service_completed_successfully
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
expose:
|
||||
- "3000"
|
||||
environment:
|
||||
BASE_URL: "${FIDER_BASE_URL}"
|
||||
DATABASE_URL: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/fider?sslmode=disable"
|
||||
JWT_SECRET: "${FIDER_JWT_SECRET}"
|
||||
# Email: Resend SMTP
|
||||
EMAIL_NOREPLY: "noreply@libnovel.cc"
|
||||
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:
|
||||
image: glitchtip/glitchtip:latest
|
||||
depends_on:
|
||||
postgres-init:
|
||||
condition: service_completed_successfully
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DATABASE_URL: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/glitchtip"
|
||||
SECRET_KEY: "${GLITCHTIP_SECRET_KEY}"
|
||||
GLITCHTIP_DOMAIN: "${GLITCHTIP_DOMAIN}"
|
||||
EMAIL_URL: "${GLITCHTIP_EMAIL_URL}"
|
||||
DEFAULT_FROM_EMAIL: "noreply@libnovel.cc"
|
||||
VALKEY_URL: "redis://valkey:6379/1"
|
||||
command: "./manage.py migrate"
|
||||
restart: "no"
|
||||
|
||||
# ─── GlitchTip web (error tracking UI + API) ─────────────────────────────────
|
||||
glitchtip-web:
|
||||
image: glitchtip/glitchtip:latest
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
glitchtip-migrate:
|
||||
condition: service_completed_successfully
|
||||
valkey:
|
||||
condition: service_healthy
|
||||
expose:
|
||||
- "8000"
|
||||
environment:
|
||||
DATABASE_URL: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/glitchtip"
|
||||
SECRET_KEY: "${GLITCHTIP_SECRET_KEY}"
|
||||
GLITCHTIP_DOMAIN: "${GLITCHTIP_DOMAIN}"
|
||||
EMAIL_URL: "${GLITCHTIP_EMAIL_URL}"
|
||||
DEFAULT_FROM_EMAIL: "noreply@libnovel.cc"
|
||||
VALKEY_URL: "redis://valkey:6379/1"
|
||||
PORT: "8000"
|
||||
ENABLE_USER_REGISTRATION: "false"
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/0/')"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ─── GlitchTip worker (background task processor) ─────────────────────────────
|
||||
glitchtip-worker:
|
||||
image: glitchtip/glitchtip:latest
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
glitchtip-migrate:
|
||||
condition: service_completed_successfully
|
||||
valkey:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DATABASE_URL: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/glitchtip"
|
||||
SECRET_KEY: "${GLITCHTIP_SECRET_KEY}"
|
||||
GLITCHTIP_DOMAIN: "${GLITCHTIP_DOMAIN}"
|
||||
EMAIL_URL: "${GLITCHTIP_EMAIL_URL}"
|
||||
DEFAULT_FROM_EMAIL: "noreply@libnovel.cc"
|
||||
VALKEY_URL: "redis://valkey:6379/1"
|
||||
SERVER_ROLE: "worker"
|
||||
|
||||
# ─── Umami (page analytics) ───────────────────────────────────────────────────
|
||||
umami:
|
||||
image: ghcr.io/umami-software/umami:postgresql-latest
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres-init:
|
||||
condition: service_completed_successfully
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
expose:
|
||||
- "3000"
|
||||
environment:
|
||||
DATABASE_URL: "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/umami"
|
||||
APP_SECRET: "${UMAMI_APP_SECRET}"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:3000/api/heartbeat"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ─── Dozzle (Docker log viewer) ───────────────────────────────────────────────
|
||||
dozzle:
|
||||
image: amir20/dozzle:latest
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ./dozzle/users.yml:/data/users.yml:ro
|
||||
expose:
|
||||
- "8080"
|
||||
environment:
|
||||
DOZZLE_AUTH_PROVIDER: simple
|
||||
DOZZLE_HOSTNAME: "logs.libnovel.cc"
|
||||
healthcheck:
|
||||
test: ["CMD", "/dozzle", "healthcheck"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ─── Uptime Kuma (uptime monitoring) ──────────────────────────────────────────
|
||||
uptime-kuma:
|
||||
image: louislam/uptime-kuma:1
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- uptime_kuma_data:/app/data
|
||||
expose:
|
||||
- "3001"
|
||||
healthcheck:
|
||||
test: ["CMD", "extra/healthcheck"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ─── Gotify (push notifications) ──────────────────────────────────────────────
|
||||
gotify:
|
||||
image: gotify/server:latest
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- gotify_data:/app/data
|
||||
expose:
|
||||
- "80"
|
||||
environment:
|
||||
GOTIFY_DEFAULTUSER_NAME: "${GOTIFY_ADMIN_USER}"
|
||||
GOTIFY_DEFAULTUSER_PASS: "${GOTIFY_ADMIN_PASS}"
|
||||
GOTIFY_SERVER_PORT: "80"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:80/health"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
minio_data:
|
||||
pb_data:
|
||||
@@ -588,6 +408,3 @@ volumes:
|
||||
caddy_config:
|
||||
caddy_logs:
|
||||
crowdsec_data:
|
||||
postgres_data:
|
||||
uptime_kuma_data:
|
||||
gotify_data:
|
||||
|
||||
463
homelab/docker-compose.yml
Normal file
463
homelab/docker-compose.yml
Normal file
@@ -0,0 +1,463 @@
|
||||
# LibNovel homelab
|
||||
#
|
||||
# Runs on 192.168.0.109. Hosts:
|
||||
# - libnovel runner (background task worker)
|
||||
# - tooling: GlitchTip, Umami, Fider, Dozzle, Uptime Kuma, Gotify
|
||||
# - observability: OTel Collector, Tempo, Loki, Prometheus, Grafana
|
||||
# - cloudflared tunnel (public subdomains via Cloudflare Zero Trust)
|
||||
# - shared Postgres for tooling DBs
|
||||
#
|
||||
# All secrets come from Doppler (project=libnovel, config=prd_homelab).
|
||||
# Run with: doppler run -- docker compose up -d
|
||||
#
|
||||
# Public subdomains (via Cloudflare Tunnel — no ports exposed to internet):
|
||||
# errors.libnovel.cc → glitchtip-web:8000
|
||||
# analytics.libnovel.cc → umami:3000
|
||||
# feedback.libnovel.cc → fider:3000
|
||||
# logs.libnovel.cc → dozzle:8080
|
||||
# uptime.libnovel.cc → uptime-kuma:3001
|
||||
# push.libnovel.cc → gotify:80
|
||||
# grafana.libnovel.cc → grafana:3000
|
||||
|
||||
services:
|
||||
|
||||
# ── Cloudflare Tunnel ───────────────────────────────────────────────────────
|
||||
# Outbound-only encrypted tunnel to Cloudflare.
|
||||
# Routes all public subdomains to their respective containers on this network.
|
||||
# No inbound ports needed — cloudflared initiates all connections outward.
|
||||
cloudflared:
|
||||
image: cloudflare/cloudflared:latest
|
||||
restart: unless-stopped
|
||||
command: tunnel --no-autoupdate run --token ${CLOUDFLARE_TUNNEL_TOKEN}
|
||||
environment:
|
||||
CLOUDFLARE_TUNNEL_TOKEN: "${CLOUDFLARE_TUNNEL_TOKEN}"
|
||||
|
||||
# ── LibNovel Runner ─────────────────────────────────────────────────────────
|
||||
# Background task worker. Connects to prod PocketBase, MinIO, Meilisearch
|
||||
# via their public subdomains (pb.libnovel.cc, storage.libnovel.cc, etc.)
|
||||
runner:
|
||||
image: kalekber/libnovel-runner:latest
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 135s
|
||||
labels:
|
||||
com.centurylinklabs.watchtower.enable: "true"
|
||||
environment:
|
||||
POCKETBASE_URL: "https://pb.libnovel.cc"
|
||||
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL}"
|
||||
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD}"
|
||||
|
||||
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}"
|
||||
|
||||
MEILI_URL: "${MEILI_URL}"
|
||||
MEILI_API_KEY: "${MEILI_API_KEY}"
|
||||
VALKEY_ADDR: ""
|
||||
GODEBUG: "preferIPv4=1"
|
||||
|
||||
KOKORO_URL: "http://kokoro-fastapi:8880"
|
||||
KOKORO_VOICE: "${KOKORO_VOICE}"
|
||||
|
||||
POCKET_TTS_URL: "http://pocket-tts:8000"
|
||||
|
||||
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"
|
||||
|
||||
LOG_LEVEL: "${LOG_LEVEL}"
|
||||
GLITCHTIP_DSN: "${GLITCHTIP_DSN}"
|
||||
|
||||
# OTel — send runner traces/metrics to the local collector (HTTP)
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-collector:4318"
|
||||
OTEL_SERVICE_NAME: "runner"
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "/healthcheck", "file", "/tmp/runner.alive", "120"]
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
# ── Shared Postgres ─────────────────────────────────────────────────────────
|
||||
# Hosts glitchtip, umami, and fider databases.
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: "${POSTGRES_USER}"
|
||||
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
|
||||
POSTGRES_DB: postgres
|
||||
expose:
|
||||
- "5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD", "pg_isready", "-U", "${POSTGRES_USER}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ── Postgres database initialisation ────────────────────────────────────────
|
||||
postgres-init:
|
||||
image: postgres:16-alpine
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
PGPASSWORD: "${POSTGRES_PASSWORD}"
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
psql -h postgres -U ${POSTGRES_USER} -d postgres -tc \"SELECT 1 FROM pg_database WHERE datname='fider'\" | grep -q 1 ||
|
||||
psql -h postgres -U ${POSTGRES_USER} -d postgres -c \"CREATE DATABASE fider\";
|
||||
psql -h postgres -U ${POSTGRES_USER} -d postgres -tc \"SELECT 1 FROM pg_database WHERE datname='glitchtip'\" | grep -q 1 ||
|
||||
psql -h postgres -U ${POSTGRES_USER} -d postgres -c \"CREATE DATABASE glitchtip\";
|
||||
psql -h postgres -U ${POSTGRES_USER} -d postgres -tc \"SELECT 1 FROM pg_database WHERE datname='umami'\" | grep -q 1 ||
|
||||
psql -h postgres -U ${POSTGRES_USER} -d postgres -c \"CREATE DATABASE umami\";
|
||||
echo 'postgres-init: databases ready';
|
||||
"
|
||||
restart: "no"
|
||||
|
||||
# ── GlitchTip DB migration ──────────────────────────────────────────────────
|
||||
glitchtip-migrate:
|
||||
image: glitchtip/glitchtip:latest
|
||||
depends_on:
|
||||
postgres-init:
|
||||
condition: service_completed_successfully
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DATABASE_URL: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/glitchtip"
|
||||
SECRET_KEY: "${GLITCHTIP_SECRET_KEY}"
|
||||
GLITCHTIP_DOMAIN: "${GLITCHTIP_DOMAIN}"
|
||||
EMAIL_URL: "${GLITCHTIP_EMAIL_URL}"
|
||||
DEFAULT_FROM_EMAIL: "noreply@libnovel.cc"
|
||||
VALKEY_URL: "redis://valkey:6379/1"
|
||||
command: "./manage.py migrate"
|
||||
restart: "no"
|
||||
|
||||
# ── GlitchTip web ───────────────────────────────────────────────────────────
|
||||
glitchtip-web:
|
||||
image: glitchtip/glitchtip:latest
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
glitchtip-migrate:
|
||||
condition: service_completed_successfully
|
||||
expose:
|
||||
- "8000"
|
||||
environment:
|
||||
DATABASE_URL: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/glitchtip"
|
||||
SECRET_KEY: "${GLITCHTIP_SECRET_KEY}"
|
||||
GLITCHTIP_DOMAIN: "${GLITCHTIP_DOMAIN}"
|
||||
EMAIL_URL: "${GLITCHTIP_EMAIL_URL}"
|
||||
DEFAULT_FROM_EMAIL: "noreply@libnovel.cc"
|
||||
VALKEY_URL: "redis://valkey:6379/1"
|
||||
PORT: "8000"
|
||||
ENABLE_USER_REGISTRATION: "false"
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/0/')"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ── GlitchTip worker ────────────────────────────────────────────────────────
|
||||
glitchtip-worker:
|
||||
image: glitchtip/glitchtip:latest
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
glitchtip-migrate:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
DATABASE_URL: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/glitchtip"
|
||||
SECRET_KEY: "${GLITCHTIP_SECRET_KEY}"
|
||||
GLITCHTIP_DOMAIN: "${GLITCHTIP_DOMAIN}"
|
||||
EMAIL_URL: "${GLITCHTIP_EMAIL_URL}"
|
||||
DEFAULT_FROM_EMAIL: "noreply@libnovel.cc"
|
||||
VALKEY_URL: "redis://valkey:6379/1"
|
||||
SERVER_ROLE: "worker"
|
||||
|
||||
# ── Umami ───────────────────────────────────────────────────────────────────
|
||||
umami:
|
||||
image: ghcr.io/umami-software/umami:postgresql-latest
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres-init:
|
||||
condition: service_completed_successfully
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
expose:
|
||||
- "3000"
|
||||
environment:
|
||||
DATABASE_URL: "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/umami"
|
||||
APP_SECRET: "${UMAMI_APP_SECRET}"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:3000/api/heartbeat"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ── Fider ───────────────────────────────────────────────────────────────────
|
||||
fider:
|
||||
image: getfider/fider:stable
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres-init:
|
||||
condition: service_completed_successfully
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
expose:
|
||||
- "3000"
|
||||
environment:
|
||||
BASE_URL: "${FIDER_BASE_URL}"
|
||||
DATABASE_URL: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/fider?sslmode=disable"
|
||||
JWT_SECRET: "${FIDER_JWT_SECRET}"
|
||||
EMAIL_NOREPLY: "noreply@libnovel.cc"
|
||||
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"
|
||||
|
||||
# ── Dozzle ──────────────────────────────────────────────────────────────────
|
||||
# Watches both homelab and prod containers.
|
||||
# Prod agent runs on 165.22.70.138:7007 (added separately to prod compose).
|
||||
dozzle:
|
||||
image: amir20/dozzle:latest
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ./dozzle/users.yml:/data/users.yml:ro
|
||||
expose:
|
||||
- "8080"
|
||||
environment:
|
||||
DOZZLE_AUTH_PROVIDER: simple
|
||||
DOZZLE_HOSTNAME: "logs.libnovel.cc"
|
||||
DOZZLE_REMOTE_AGENT: "prod@165.22.70.138:7007"
|
||||
healthcheck:
|
||||
test: ["CMD", "/dozzle", "healthcheck"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ── Uptime Kuma ─────────────────────────────────────────────────────────────
|
||||
uptime-kuma:
|
||||
image: louislam/uptime-kuma:1
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- uptime_kuma_data:/app/data
|
||||
expose:
|
||||
- "3001"
|
||||
healthcheck:
|
||||
test: ["CMD", "extra/healthcheck"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ── Gotify ──────────────────────────────────────────────────────────────────
|
||||
gotify:
|
||||
image: gotify/server:latest
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- gotify_data:/app/data
|
||||
expose:
|
||||
- "80"
|
||||
environment:
|
||||
GOTIFY_DEFAULTUSER_NAME: "${GOTIFY_ADMIN_USER}"
|
||||
GOTIFY_DEFAULTUSER_PASS: "${GOTIFY_ADMIN_PASS}"
|
||||
GOTIFY_SERVER_PORT: "80"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:80/health"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ── Valkey ──────────────────────────────────────────────────────────────────
|
||||
# Used by GlitchTip for task queuing.
|
||||
valkey:
|
||||
image: valkey/valkey:7-alpine
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "6379"
|
||||
volumes:
|
||||
- valkey_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "valkey-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ── OTel Collector ──────────────────────────────────────────────────────────
|
||||
# Receives OTLP from backend/ui/runner, fans out to Tempo + Prometheus + Loki.
|
||||
otel-collector:
|
||||
image: otel/opentelemetry-collector-contrib:latest
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./otel/collector.yaml:/etc/otelcol-contrib/config.yaml:ro
|
||||
expose:
|
||||
- "4317" # OTLP gRPC
|
||||
- "4318" # OTLP HTTP
|
||||
- "8888" # Collector self-metrics (scraped by Prometheus)
|
||||
depends_on:
|
||||
- tempo
|
||||
- prometheus
|
||||
- loki
|
||||
# No healthcheck — distroless image has no shell or curl
|
||||
|
||||
# ── Tempo ───────────────────────────────────────────────────────────────────
|
||||
# Distributed trace storage. Receives OTLP from the collector.
|
||||
tempo:
|
||||
image: grafana/tempo:2.6.1
|
||||
restart: unless-stopped
|
||||
command: ["-config.file=/etc/tempo.yaml"]
|
||||
volumes:
|
||||
- ./otel/tempo.yaml:/etc/tempo.yaml:ro
|
||||
- tempo_data:/var/tempo
|
||||
expose:
|
||||
- "3200" # Tempo query API (queried by Grafana)
|
||||
- "4317" # OTLP gRPC ingest (collector → tempo)
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3200/ready"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ── Prometheus ──────────────────────────────────────────────────────────────
|
||||
# Scrapes metrics from backend (via prod), runner, and otel-collector.
|
||||
prometheus:
|
||||
image: prom/prometheus:latest
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- "--config.file=/etc/prometheus/prometheus.yaml"
|
||||
- "--storage.tsdb.path=/prometheus"
|
||||
- "--storage.tsdb.retention.time=30d"
|
||||
- "--web.enable-remote-write-receiver"
|
||||
volumes:
|
||||
- ./otel/prometheus.yaml:/etc/prometheus/prometheus.yaml:ro
|
||||
- prometheus_data:/prometheus
|
||||
expose:
|
||||
- "9090"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:9090/-/healthy"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ── Loki ────────────────────────────────────────────────────────────────────
|
||||
# Log aggregation. Receives logs from OTel collector. Replaces manual Dozzle
|
||||
# tailing for structured log search.
|
||||
loki:
|
||||
image: grafana/loki:latest
|
||||
restart: unless-stopped
|
||||
command: ["-config.file=/etc/loki/loki.yaml"]
|
||||
volumes:
|
||||
- ./otel/loki.yaml:/etc/loki/loki.yaml:ro
|
||||
- loki_data:/loki
|
||||
expose:
|
||||
- "3100"
|
||||
# No healthcheck — distroless image has no shell or curl
|
||||
|
||||
# ── Grafana ─────────────────────────────────────────────────────────────────
|
||||
# Single UI for traces (Tempo), metrics (Prometheus), and logs (Loki).
|
||||
# Accessible at grafana.libnovel.cc via Cloudflare Tunnel.
|
||||
grafana:
|
||||
image: grafana/grafana:latest
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- tempo
|
||||
- prometheus
|
||||
- loki
|
||||
expose:
|
||||
- "3000"
|
||||
volumes:
|
||||
- grafana_data:/var/lib/grafana
|
||||
- ./otel/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||
environment:
|
||||
GF_SERVER_ROOT_URL: "https://grafana.libnovel.cc"
|
||||
GF_SECURITY_ADMIN_USER: "${GRAFANA_ADMIN_USER}"
|
||||
GF_SECURITY_ADMIN_PASSWORD: "${GRAFANA_ADMIN_PASSWORD}"
|
||||
GF_AUTH_ANONYMOUS_ENABLED: "false"
|
||||
GF_FEATURE_TOGGLES_ENABLE: "traceqlEditor"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ── Kokoro-FastAPI (GPU TTS) ────────────────────────────────────────────────
|
||||
# OpenAI-compatible TTS service backed by the Kokoro model, running on the
|
||||
# homelab RTX 3050 (8 GB VRAM). Replaces the broken kokoro.kalekber.cc DNS.
|
||||
# Voices match existing IDs: af_bella, af_sky, af_heart, etc.
|
||||
# The runner reaches it at http://kokoro-fastapi:8880 via the Docker network.
|
||||
kokoro-fastapi:
|
||||
image: ghcr.io/remsky/kokoro-fastapi-gpu:latest
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
expose:
|
||||
- "8880"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:8880/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
|
||||
# ── pocket-tts (CPU TTS) ────────────────────────────────────────────────────
|
||||
# Lightweight CPU-only TTS using kyutai-labs/pocket-tts.
|
||||
# Image is built locally on homelab from https://github.com/kyutai-labs/pocket-tts
|
||||
# (no prebuilt image published): cd /tmp && git clone --depth=1 https://github.com/kyutai-labs/pocket-tts.git && docker build -t pocket-tts:latest /tmp/pocket-tts
|
||||
# OpenAI-compatible: POST /tts (multipart form) on port 8000.
|
||||
# Voices: alba, marius, javert, jean, fantine, cosette, eponine, azelma, etc.
|
||||
# Not currently used by the runner (runner uses kokoro-fastapi), but available
|
||||
# for experimentation / fallback.
|
||||
pocket-tts:
|
||||
image: pocket-tts:latest
|
||||
restart: unless-stopped
|
||||
command: ["uv", "run", "pocket-tts", "serve", "--host", "0.0.0.0"]
|
||||
expose:
|
||||
- "8000"
|
||||
volumes:
|
||||
- pocket_tts_cache:/root/.cache/pocket_tts
|
||||
- hf_cache:/root/.cache/huggingface
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:8000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 120s
|
||||
|
||||
# ── Watchtower ──────────────────────────────────────────────────────────────
|
||||
# Auto-updates runner image when CI pushes a new tag.
|
||||
# Only watches services with the watchtower label.
|
||||
watchtower:
|
||||
image: containrrr/watchtower:latest
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
command: --label-enable --interval 300 --cleanup
|
||||
environment:
|
||||
WATCHTOWER_NOTIFICATIONS: "${WATCHTOWER_NOTIFICATIONS}"
|
||||
WATCHTOWER_NOTIFICATION_URL: "${WATCHTOWER_NOTIFICATION_URL}"
|
||||
DOCKER_API_VERSION: "1.44"
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
valkey_data:
|
||||
uptime_kuma_data:
|
||||
gotify_data:
|
||||
tempo_data:
|
||||
prometheus_data:
|
||||
loki_data:
|
||||
grafana_data:
|
||||
pocket_tts_cache:
|
||||
hf_cache:
|
||||
5
homelab/dozzle/users.yml
Normal file
5
homelab/dozzle/users.yml
Normal file
@@ -0,0 +1,5 @@
|
||||
users:
|
||||
admin:
|
||||
name: admin
|
||||
email: admin@libnovel.cc
|
||||
password: "$2y$10$4jqLza2grpxnQn0EGux2C.UmlSxRmOvH/J1ySzOBxMZgW6cA2TnmK"
|
||||
68
homelab/otel/collector.yaml
Normal file
68
homelab/otel/collector.yaml
Normal file
@@ -0,0 +1,68 @@
|
||||
# OTel Collector config
|
||||
#
|
||||
# Receivers: OTLP (gRPC + HTTP) from backend, ui, runner
|
||||
# Processors: batch for efficiency, resource detection for host metadata
|
||||
# Exporters: Tempo (traces), Prometheus (metrics), Loki (logs)
|
||||
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
grpc:
|
||||
endpoint: 0.0.0.0:4317
|
||||
http:
|
||||
endpoint: 0.0.0.0:4318
|
||||
|
||||
processors:
|
||||
batch:
|
||||
timeout: 5s
|
||||
send_batch_size: 512
|
||||
|
||||
# Attach host metadata to all telemetry
|
||||
resourcedetection:
|
||||
detectors: [env, system]
|
||||
timeout: 5s
|
||||
|
||||
exporters:
|
||||
# Traces → Tempo
|
||||
otlp/tempo:
|
||||
endpoint: tempo:4317
|
||||
tls:
|
||||
insecure: true
|
||||
|
||||
# Metrics → Prometheus (remote write)
|
||||
prometheusremotewrite:
|
||||
endpoint: "http://prometheus:9090/api/v1/write"
|
||||
tls:
|
||||
insecure_skip_verify: true
|
||||
|
||||
# Logs → Loki (via OTLP HTTP endpoint)
|
||||
otlphttp/loki:
|
||||
endpoint: "http://loki:3100/otlp"
|
||||
tls:
|
||||
insecure: true
|
||||
|
||||
# Collector self-observability (optional debug)
|
||||
debug:
|
||||
verbosity: basic
|
||||
|
||||
extensions:
|
||||
health_check:
|
||||
endpoint: 0.0.0.0:13133
|
||||
pprof:
|
||||
endpoint: 0.0.0.0:1777
|
||||
|
||||
service:
|
||||
extensions: [health_check, pprof]
|
||||
pipelines:
|
||||
traces:
|
||||
receivers: [otlp]
|
||||
processors: [resourcedetection, batch]
|
||||
exporters: [otlp/tempo]
|
||||
metrics:
|
||||
receivers: [otlp]
|
||||
processors: [resourcedetection, batch]
|
||||
exporters: [prometheusremotewrite]
|
||||
logs:
|
||||
receivers: [otlp]
|
||||
processors: [resourcedetection, batch]
|
||||
exporters: [otlphttp/loki]
|
||||
13
homelab/otel/grafana/provisioning/dashboards/dashboards.yaml
Normal file
13
homelab/otel/grafana/provisioning/dashboards/dashboards.yaml
Normal file
@@ -0,0 +1,13 @@
|
||||
# Grafana dashboard provisioning
|
||||
# Points Grafana at the local dashboards directory.
|
||||
# Drop any .json dashboard file into homelab/otel/grafana/provisioning/dashboards/
|
||||
# and it will appear in Grafana automatically on restart.
|
||||
|
||||
apiVersion: 1
|
||||
|
||||
providers:
|
||||
- name: libnovel
|
||||
folder: LibNovel
|
||||
type: file
|
||||
options:
|
||||
path: /etc/grafana/provisioning/dashboards
|
||||
@@ -0,0 +1,53 @@
|
||||
# Grafana datasource provisioning
|
||||
# Auto-configures Tempo, Prometheus, and Loki on first start.
|
||||
# No manual setup needed in the UI.
|
||||
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: Tempo
|
||||
type: tempo
|
||||
uid: tempo
|
||||
url: http://tempo:3200
|
||||
access: proxy
|
||||
isDefault: false
|
||||
jsonData:
|
||||
httpMethod: GET
|
||||
serviceMap:
|
||||
datasourceUid: prometheus
|
||||
nodeGraph:
|
||||
enabled: true
|
||||
traceQuery:
|
||||
timeShiftEnabled: true
|
||||
spanStartTimeShift: "1h"
|
||||
spanEndTimeShift: "-1h"
|
||||
spanBar:
|
||||
type: "Tag"
|
||||
tag: "http.url"
|
||||
lokiSearch:
|
||||
datasourceUid: loki
|
||||
|
||||
- name: Prometheus
|
||||
type: prometheus
|
||||
uid: prometheus
|
||||
url: http://prometheus:9090
|
||||
access: proxy
|
||||
isDefault: true
|
||||
jsonData:
|
||||
httpMethod: POST
|
||||
exemplarTraceIdDestinations:
|
||||
- name: traceID
|
||||
datasourceUid: tempo
|
||||
|
||||
- name: Loki
|
||||
type: loki
|
||||
uid: loki
|
||||
url: http://loki:3100
|
||||
access: proxy
|
||||
isDefault: false
|
||||
jsonData:
|
||||
derivedFields:
|
||||
- datasourceUid: tempo
|
||||
matcherRegex: '"traceID":"(\w+)"'
|
||||
name: TraceID
|
||||
url: "$${__value.raw}"
|
||||
38
homelab/otel/loki.yaml
Normal file
38
homelab/otel/loki.yaml
Normal file
@@ -0,0 +1,38 @@
|
||||
# Loki config — minimal single-node setup
|
||||
# Receives logs from OTel Collector. 30-day retention.
|
||||
|
||||
auth_enabled: false
|
||||
|
||||
server:
|
||||
http_listen_port: 3100
|
||||
grpc_listen_port: 9096
|
||||
|
||||
common:
|
||||
instance_addr: 127.0.0.1
|
||||
path_prefix: /loki
|
||||
storage:
|
||||
filesystem:
|
||||
chunks_directory: /loki/chunks
|
||||
rules_directory: /loki/rules
|
||||
replication_factor: 1
|
||||
ring:
|
||||
kvstore:
|
||||
store: inmemory
|
||||
|
||||
schema_config:
|
||||
configs:
|
||||
- from: 2024-01-01
|
||||
store: tsdb
|
||||
object_store: filesystem
|
||||
schema: v13
|
||||
index:
|
||||
prefix: index_
|
||||
period: 24h
|
||||
|
||||
limits_config:
|
||||
retention_period: 720h # 30 days
|
||||
|
||||
compactor:
|
||||
working_directory: /loki/compactor
|
||||
delete_request_store: filesystem
|
||||
retention_enabled: true
|
||||
22
homelab/otel/prometheus.yaml
Normal file
22
homelab/otel/prometheus.yaml
Normal file
@@ -0,0 +1,22 @@
|
||||
# Prometheus config
|
||||
# Scrapes OTel collector self-metrics and runner metrics endpoint.
|
||||
# Backend metrics come in via OTel remote-write — no direct scrape needed.
|
||||
|
||||
global:
|
||||
scrape_interval: 15s
|
||||
evaluation_interval: 15s
|
||||
external_labels:
|
||||
environment: production
|
||||
|
||||
scrape_configs:
|
||||
# OTel Collector self-metrics
|
||||
- job_name: otel-collector
|
||||
static_configs:
|
||||
- targets: ["otel-collector:8888"]
|
||||
|
||||
# Runner JSON metrics endpoint (native format, no Prometheus client yet)
|
||||
# Will be replaced by OTLP once runner is instrumented with OTel SDK.
|
||||
- job_name: libnovel-runner
|
||||
metrics_path: /metrics
|
||||
static_configs:
|
||||
- targets: ["runner:9091"]
|
||||
45
homelab/otel/tempo.yaml
Normal file
45
homelab/otel/tempo.yaml
Normal file
@@ -0,0 +1,45 @@
|
||||
# Tempo config — minimal single-node setup
|
||||
# Stores traces locally. Grafana queries via the HTTP API on port 3200.
|
||||
|
||||
server:
|
||||
http_listen_port: 3200
|
||||
|
||||
distributor:
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
grpc:
|
||||
endpoint: 0.0.0.0:4317
|
||||
|
||||
ingester:
|
||||
trace_idle_period: 10s
|
||||
max_block_bytes: 104857600 # 100MB
|
||||
max_block_duration: 30m
|
||||
|
||||
compactor:
|
||||
compaction:
|
||||
block_retention: 720h # 30 days
|
||||
|
||||
storage:
|
||||
trace:
|
||||
backend: local
|
||||
local:
|
||||
path: /var/tempo/blocks
|
||||
wal:
|
||||
path: /var/tempo/wal
|
||||
|
||||
metrics_generator:
|
||||
registry:
|
||||
external_labels:
|
||||
source: tempo
|
||||
storage:
|
||||
path: /var/tempo/generator/wal
|
||||
remote_write:
|
||||
- url: http://prometheus:9090/api/v1/write
|
||||
send_exemplars: true
|
||||
|
||||
overrides:
|
||||
defaults:
|
||||
metrics_generator:
|
||||
processors: [service-graphs, span-metrics]
|
||||
generate_native_histograms: both
|
||||
@@ -8,7 +8,8 @@
|
||||
# - 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)
|
||||
# - MEILI_URL → https://search.libnovel.cc (Caddy-proxied)
|
||||
# - VALKEY_ADDR → unset (not exposed publicly)
|
||||
# - RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH=true
|
||||
|
||||
services:
|
||||
@@ -30,9 +31,12 @@ services:
|
||||
MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT}"
|
||||
MINIO_PUBLIC_USE_SSL: "${MINIO_PUBLIC_USE_SSL}"
|
||||
|
||||
# ── Meilisearch / Valkey — not exposed, disabled ────────────────────────
|
||||
MEILI_URL: ""
|
||||
# ── 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}"
|
||||
|
||||
@@ -185,7 +185,9 @@ create "app_users" '{
|
||||
{"name":"email", "type":"text"},
|
||||
{"name":"email_verified", "type":"bool"},
|
||||
{"name":"verification_token", "type":"text"},
|
||||
{"name":"verification_token_exp","type":"text"}
|
||||
{"name":"verification_token_exp","type":"text"},
|
||||
{"name":"oauth_provider", "type":"text"},
|
||||
{"name":"oauth_id", "type":"text"}
|
||||
]}'
|
||||
|
||||
create "user_sessions" '{
|
||||
@@ -254,5 +256,7 @@ 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"
|
||||
|
||||
1185
ui/package-lock.json
generated
1185
ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
@@ -29,6 +30,11 @@
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1005.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1005.0",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
|
||||
"@opentelemetry/resources": "^2.6.1",
|
||||
"@opentelemetry/sdk-node": "^0.214.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.40.0",
|
||||
"@sentry/sveltekit": "^10.45.0",
|
||||
"cropperjs": "^1.6.2",
|
||||
"ioredis": "^5.3.2",
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -7,13 +7,43 @@ import { env as pubEnv } from '$env/dynamic/public';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { createUserSession, touchUserSession, isSessionRevoked } from '$lib/server/pocketbase';
|
||||
import { drain as drainPresignCache } from '$lib/server/presignCache';
|
||||
import { NodeSDK } from '@opentelemetry/sdk-node';
|
||||
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
|
||||
import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http';
|
||||
import { BatchLogRecordProcessor } from '@opentelemetry/sdk-logs';
|
||||
import { resourceFromAttributes } from '@opentelemetry/resources';
|
||||
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
|
||||
|
||||
// ─── OpenTelemetry server-side tracing + logs ─────────────────────────────────
|
||||
// No-op when OTEL_EXPORTER_OTLP_ENDPOINT is unset (e.g. local dev).
|
||||
const otlpEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
|
||||
if (otlpEndpoint) {
|
||||
const sdk = new NodeSDK({
|
||||
resource: resourceFromAttributes({
|
||||
[ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME ?? 'ui',
|
||||
[ATTR_SERVICE_VERSION]: pubEnv.PUBLIC_BUILD_VERSION ?? 'dev'
|
||||
}),
|
||||
traceExporter: new OTLPTraceExporter({ url: `${otlpEndpoint}/v1/traces` }),
|
||||
logRecordProcessors: [
|
||||
new BatchLogRecordProcessor(
|
||||
new OTLPLogExporter({ url: `${otlpEndpoint}/v1/logs` })
|
||||
)
|
||||
]
|
||||
});
|
||||
sdk.start();
|
||||
process.once('SIGTERM', () => sdk.shutdown().catch(() => {}));
|
||||
process.once('SIGINT', () => sdk.shutdown().catch(() => {}));
|
||||
}
|
||||
|
||||
// ─── Sentry / GlitchTip server-side error tracking ────────────────────────────
|
||||
// No-op when PUBLIC_GLITCHTIP_DSN is unset (e.g. local dev).
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
72
ui/src/lib/server/cache.ts
Normal file
72
ui/src/lib/server/cache.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Generic Valkey (Redis-compatible) cache.
|
||||
*
|
||||
* Reuses the same ioredis singleton from presignCache.ts but exposes a
|
||||
* simple typed get/set/invalidate API for arbitrary JSON values.
|
||||
*
|
||||
* Usage:
|
||||
* const books = await cache.get<Book[]>('books:all');
|
||||
* await cache.set('books:all', books, 5 * 60);
|
||||
* await cache.invalidate('books:all');
|
||||
*/
|
||||
|
||||
import Redis from 'ioredis';
|
||||
|
||||
let _client: Redis | null = null;
|
||||
|
||||
function client(): Redis {
|
||||
if (!_client) {
|
||||
const url = process.env.VALKEY_URL ?? 'redis://valkey:6379';
|
||||
_client = new Redis(url, {
|
||||
lazyConnect: false,
|
||||
enableOfflineQueue: true,
|
||||
maxRetriesPerRequest: 2
|
||||
});
|
||||
_client.on('error', (err: Error) => {
|
||||
console.error('[cache] Valkey error:', err.message);
|
||||
});
|
||||
}
|
||||
return _client;
|
||||
}
|
||||
|
||||
/** Return the cached value for key, or null if absent / expired / error. */
|
||||
export async function get<T>(key: string): Promise<T | null> {
|
||||
try {
|
||||
const raw = await client().get(key);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a value under key for ttlSeconds seconds.
|
||||
* Silently no-ops on Valkey errors so callers never crash.
|
||||
*/
|
||||
export async function set<T>(key: string, value: T, ttlSeconds: number): Promise<void> {
|
||||
try {
|
||||
await client().set(key, JSON.stringify(value), 'EX', ttlSeconds);
|
||||
} catch {
|
||||
// non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete a key immediately (e.g. after a write that invalidates it). */
|
||||
export async function invalidate(key: string): Promise<void> {
|
||||
try {
|
||||
await client().del(key);
|
||||
} catch {
|
||||
// non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
/** Invalidate all keys matching a glob pattern (e.g. 'books:*'). */
|
||||
export async function invalidatePattern(pattern: string): Promise<void> {
|
||||
try {
|
||||
const keys = await client().keys(pattern);
|
||||
if (keys.length > 0) await client().del(...keys);
|
||||
} catch {
|
||||
// non-fatal
|
||||
}
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { log } from '$lib/server/logger';
|
||||
import * as cache from '$lib/server/cache';
|
||||
|
||||
const PB_URL = env.POCKETBASE_URL ?? 'http://localhost:8090';
|
||||
const PB_EMAIL = env.POCKETBASE_ADMIN_EMAIL ?? 'admin@libnovel.local';
|
||||
@@ -67,6 +68,8 @@ export interface User {
|
||||
email_verified?: boolean;
|
||||
verification_token?: string;
|
||||
verification_token_exp?: string;
|
||||
oauth_provider?: string;
|
||||
oauth_id?: string;
|
||||
}
|
||||
|
||||
// ─── Auth token cache ─────────────────────────────────────────────────────────
|
||||
@@ -198,16 +201,65 @@ async function listOne<T>(collection: string, filter: string): Promise<T | null>
|
||||
|
||||
// ─── Books ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const BOOKS_CACHE_KEY = 'books:all';
|
||||
const BOOKS_CACHE_TTL = 5 * 60; // 5 minutes
|
||||
|
||||
export async function listBooks(): Promise<Book[]> {
|
||||
const cached = await cache.get<Book[]>(BOOKS_CACHE_KEY);
|
||||
if (cached) {
|
||||
log.debug('pocketbase', 'listBooks cache hit', { total: cached.length });
|
||||
return cached;
|
||||
}
|
||||
const books = await listAll<Book>('books', '', '+title');
|
||||
const nullTitles = books.filter((b) => b.title == null).length;
|
||||
if (nullTitles > 0) {
|
||||
log.warn('pocketbase', 'listBooks: books with null title', { count: nullTitles, total: books.length });
|
||||
}
|
||||
log.debug('pocketbase', 'listBooks', { total: books.length, nullTitles });
|
||||
log.debug('pocketbase', 'listBooks cache miss', { total: books.length, nullTitles });
|
||||
await cache.set(BOOKS_CACHE_KEY, books, BOOKS_CACHE_TTL);
|
||||
return books;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch only the books whose slugs are in the given set.
|
||||
* Uses PocketBase filter `slug IN (...)` — a single request regardless of how
|
||||
* many slugs are requested. Falls back to empty array on error.
|
||||
*
|
||||
* Use this instead of listBooks() whenever you only need a small subset of
|
||||
* books (e.g. the user's reading list or saved shelf).
|
||||
*
|
||||
* PocketBase filter syntax for IN: slug='a' || slug='b' || ...
|
||||
* Limited to 200 slugs to keep the filter URL sane; callers with larger sets
|
||||
* should fall back to listBooks().
|
||||
*/
|
||||
export async function getBooksBySlugs(slugs: Iterable<string>): Promise<Book[]> {
|
||||
const slugArr = [...new Set(slugs)].slice(0, 200);
|
||||
if (slugArr.length === 0) return [];
|
||||
|
||||
// Check cache for each slug individually (populated by prior listBooks calls).
|
||||
// If all slugs hit, skip the network round-trip entirely.
|
||||
const cached = await cache.get<Book[]>(BOOKS_CACHE_KEY);
|
||||
if (cached) {
|
||||
const slugSet = new Set(slugArr);
|
||||
const found = cached.filter((b) => slugSet.has(b.slug));
|
||||
if (found.length === slugArr.length) {
|
||||
log.debug('pocketbase', 'getBooksBySlugs cache hit', { count: found.length });
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
// Build filter: slug='a' || slug='b' || ...
|
||||
const filter = slugArr.map((s) => `slug='${s.replace(/'/g, "\\'")}'`).join(' || ');
|
||||
const books = await listAll<Book>('books', filter, '+title');
|
||||
log.debug('pocketbase', 'getBooksBySlugs', { requested: slugArr.length, found: books.length });
|
||||
return books;
|
||||
}
|
||||
|
||||
/** Invalidate the books cache (call after a book is created/updated/deleted). */
|
||||
export async function invalidateBooksCache(): Promise<void> {
|
||||
await cache.invalidate(BOOKS_CACHE_KEY);
|
||||
}
|
||||
|
||||
export async function getBook(slug: string): Promise<Book | null> {
|
||||
return listOne<Book>('books', `slug="${slug}"`);
|
||||
}
|
||||
@@ -496,8 +548,75 @@ 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, '\\"')}"`);
|
||||
@@ -608,7 +727,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.
|
||||
* 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 });
|
||||
@@ -617,15 +736,15 @@ 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 });
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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', '/verify-email']);
|
||||
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`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import {
|
||||
listBooks,
|
||||
getBooksBySlugs,
|
||||
recentlyAddedBooks,
|
||||
allProgress,
|
||||
getHomeStats,
|
||||
@@ -10,14 +10,15 @@ import { log } from '$lib/server/logger';
|
||||
import type { Book, Progress } from '$lib/server/pocketbase';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
let allBooks: Book[] = [];
|
||||
// Step 1: fetch progress + recent books + stats in parallel.
|
||||
// We intentionally do NOT call listBooks() here — we only need books that
|
||||
// appear in the user's progress list, which is a tiny subset of 15k books.
|
||||
let recentBooks: Book[] = [];
|
||||
let progressList: Progress[] = [];
|
||||
let stats = { totalBooks: 0, totalChapters: 0 };
|
||||
|
||||
try {
|
||||
[allBooks, recentBooks, progressList, stats] = await Promise.all([
|
||||
listBooks(),
|
||||
[recentBooks, progressList, stats] = await Promise.all([
|
||||
recentlyAddedBooks(8),
|
||||
allProgress(locals.sessionId, locals.user?.id),
|
||||
getHomeStats()
|
||||
@@ -26,8 +27,14 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
log.error('home', 'failed to load home data', { err: String(e) });
|
||||
}
|
||||
|
||||
// Build slug → book lookup
|
||||
const bookMap = new Map<string, Book>(allBooks.map((b) => [b.slug, b]));
|
||||
// Step 2: fetch only the books we actually need for continue-reading.
|
||||
// This is O(progress entries) instead of O(15k books).
|
||||
const progressSlugs = progressList.map((p) => p.slug);
|
||||
const progressBooks = progressSlugs.length > 0
|
||||
? await getBooksBySlugs(progressSlugs).catch(() => [] as Book[])
|
||||
: [];
|
||||
|
||||
const bookMap = new Map<string, Book>(progressBooks.map((b) => [b.slug, b]));
|
||||
|
||||
// Continue reading: progress entries joined with book data, most recent first
|
||||
const continueReading = progressList
|
||||
|
||||
@@ -1,66 +1,12 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { createUser } from '$lib/server/pocketbase';
|
||||
import { sendVerificationEmail } from '$lib/server/email';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
* POST /api/auth/register
|
||||
* Body: { username: string, email: string, password: string }
|
||||
* Returns: { pending_verification: true, email: string }
|
||||
*
|
||||
* 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.
|
||||
* Username/password registration has been replaced by OAuth2 (Google & GitHub).
|
||||
* This endpoint is no longer supported.
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
let body: { username?: string; email?: string; password?: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
error(400, 'Invalid JSON body');
|
||||
}
|
||||
|
||||
const username = (body.username ?? '').trim();
|
||||
const email = (body.email ?? '').trim().toLowerCase();
|
||||
const password = body.password ?? '';
|
||||
|
||||
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');
|
||||
}
|
||||
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, 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.');
|
||||
}
|
||||
|
||||
// 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) });
|
||||
}
|
||||
|
||||
return json({ pending_verification: true, email });
|
||||
export const POST: RequestHandler = async () => {
|
||||
error(410, 'Username/password registration is no longer supported. Please sign in with Google or GitHub.');
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import {
|
||||
listBooks,
|
||||
getBooksBySlugs,
|
||||
recentlyAddedBooks,
|
||||
allProgress,
|
||||
getHomeStats,
|
||||
@@ -17,14 +17,12 @@ import type { Book, Progress } from '$lib/server/pocketbase';
|
||||
* Requires authentication (enforced by layout guard).
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
let allBooks: Book[] = [];
|
||||
let recentBooks: Book[] = [];
|
||||
let progressList: Progress[] = [];
|
||||
let stats = { totalBooks: 0, totalChapters: 0 };
|
||||
|
||||
try {
|
||||
[allBooks, recentBooks, progressList, stats] = await Promise.all([
|
||||
listBooks(),
|
||||
[recentBooks, progressList, stats] = await Promise.all([
|
||||
recentlyAddedBooks(8),
|
||||
allProgress(locals.sessionId, locals.user?.id),
|
||||
getHomeStats()
|
||||
@@ -33,7 +31,13 @@ export const GET: RequestHandler = async ({ locals }) => {
|
||||
log.error('api/home', 'failed to load home data', { err: String(e) });
|
||||
}
|
||||
|
||||
const bookMap = new Map<string, Book>(allBooks.map((b) => [b.slug, b]));
|
||||
// Fetch only the books we actually need for continue-reading.
|
||||
const progressSlugs = progressList.map((p) => p.slug);
|
||||
const progressBooks = progressSlugs.length > 0
|
||||
? await getBooksBySlugs(progressSlugs).catch(() => [] as Book[])
|
||||
: [];
|
||||
|
||||
const bookMap = new Map<string, Book>(progressBooks.map((b) => [b.slug, b]));
|
||||
|
||||
const continueReading = progressList
|
||||
.filter((p) => bookMap.has(p.slug))
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { listBooks, allProgress, getSavedSlugs } from '$lib/server/pocketbase';
|
||||
import { getBooksBySlugs, allProgress, getSavedSlugs } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
import type { Book } from '$lib/server/pocketbase';
|
||||
|
||||
/**
|
||||
* GET /api/library
|
||||
@@ -11,23 +12,25 @@ import { log } from '$lib/server/logger';
|
||||
* Response shape mirrors LibraryItem in the iOS APIClient.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
let allBooks: Awaited<ReturnType<typeof listBooks>>;
|
||||
let progressList: Awaited<ReturnType<typeof allProgress>>;
|
||||
let savedSlugs: Set<string>;
|
||||
let progressList: Awaited<ReturnType<typeof allProgress>> = [];
|
||||
let savedSlugs: Set<string> = new Set();
|
||||
|
||||
try {
|
||||
[allBooks, progressList, savedSlugs] = await Promise.all([
|
||||
listBooks(),
|
||||
[progressList, savedSlugs] = await Promise.all([
|
||||
allProgress(locals.sessionId, locals.user?.id),
|
||||
getSavedSlugs(locals.sessionId, locals.user?.id)
|
||||
]);
|
||||
} catch (e) {
|
||||
log.error('api/library', 'failed to load library data', { err: String(e) });
|
||||
allBooks = [];
|
||||
progressList = [];
|
||||
savedSlugs = new Set();
|
||||
}
|
||||
|
||||
// Fetch only the books the user actually has in their library.
|
||||
const progressSlugs = new Set(progressList.map((p) => p.slug));
|
||||
const allNeededSlugs = new Set([...progressSlugs, ...savedSlugs]);
|
||||
const books = allNeededSlugs.size > 0
|
||||
? await getBooksBySlugs(allNeededSlugs).catch(() => [] as Book[])
|
||||
: [];
|
||||
|
||||
const progressMap: Record<string, number> = {};
|
||||
const progressUpdatedMap: Record<string, string> = {};
|
||||
for (const p of progressList) {
|
||||
@@ -35,9 +38,6 @@ export const GET: RequestHandler = async ({ locals }) => {
|
||||
progressUpdatedMap[p.slug] = p.updated;
|
||||
}
|
||||
|
||||
const progressSlugs = new Set(progressList.map((p) => p.slug));
|
||||
const books = allBooks.filter((b) => progressSlugs.has(b.slug) || savedSlugs.has(b.slug));
|
||||
|
||||
const withProgress = books.filter((b) => progressSlugs.has(b.slug));
|
||||
const savedOnly = books
|
||||
.filter((b) => !progressSlugs.has(b.slug))
|
||||
|
||||
79
ui/src/routes/auth/[provider]/+server.ts
Normal file
79
ui/src/routes/auth/[provider]/+server.ts
Normal 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()}`);
|
||||
};
|
||||
246
ui/src/routes/auth/[provider]/callback/+server.ts
Normal file
246
ui/src/routes/auth/[provider]/callback/+server.ts
Normal 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);
|
||||
};
|
||||
@@ -1,48 +1,42 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { listBooks, allProgress, getSavedSlugs } from '$lib/server/pocketbase';
|
||||
import { getBooksBySlugs, allProgress, getSavedSlugs } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
import type { Book } from '$lib/server/pocketbase';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
let allBooks: Awaited<ReturnType<typeof listBooks>>;
|
||||
let progressList: Awaited<ReturnType<typeof allProgress>>;
|
||||
let savedSlugs: Set<string>;
|
||||
let progressList: Awaited<ReturnType<typeof allProgress>> = [];
|
||||
let savedSlugs: Set<string> = new Set();
|
||||
|
||||
try {
|
||||
[allBooks, progressList, savedSlugs] = await Promise.all([
|
||||
listBooks(),
|
||||
[progressList, savedSlugs] = await Promise.all([
|
||||
allProgress(locals.sessionId, locals.user?.id),
|
||||
getSavedSlugs(locals.sessionId, locals.user?.id)
|
||||
]);
|
||||
} catch (e) {
|
||||
log.error('books', 'failed to load library data', { err: String(e) });
|
||||
allBooks = [];
|
||||
progressList = [];
|
||||
savedSlugs = new Set();
|
||||
}
|
||||
|
||||
// Fetch only the books the user actually has in their library.
|
||||
const progressSlugs = new Set(progressList.map((p) => p.slug));
|
||||
const allNeededSlugs = new Set([...progressSlugs, ...savedSlugs]);
|
||||
const books = allNeededSlugs.size > 0
|
||||
? await getBooksBySlugs(allNeededSlugs).catch(() => [] as Book[])
|
||||
: [];
|
||||
|
||||
// Build a quick lookup: slug → last chapter read
|
||||
const progressMap: Record<string, number> = {};
|
||||
const progressUpdatedMap: Record<string, string> = {};
|
||||
for (const p of progressList) {
|
||||
progressMap[p.slug] = p.chapter;
|
||||
progressUpdatedMap[p.slug] = p.updated;
|
||||
}
|
||||
|
||||
// Library = books the user has started reading OR explicitly saved
|
||||
const progressSlugs = new Set(progressList.map((p) => p.slug));
|
||||
const books = allBooks.filter((b) => progressSlugs.has(b.slug) || savedSlugs.has(b.slug));
|
||||
|
||||
// Sort: books with progress first (most-recently-read order is implicit via progressList),
|
||||
// then saved-only books alphabetically.
|
||||
// Sort: books with progress first (most-recently-read), then saved-only alphabetically.
|
||||
const withProgress = books.filter((b) => progressSlugs.has(b.slug));
|
||||
const savedOnly = books
|
||||
.filter((b) => !progressSlugs.has(b.slug))
|
||||
.sort((a, b) => (a.title ?? '').localeCompare(b.title ?? ''));
|
||||
|
||||
// Re-sort withProgress by most recent progress update
|
||||
const progressUpdatedMap: Record<string, string> = {};
|
||||
for (const p of progressList) {
|
||||
progressUpdatedMap[p.slug] = p.updated;
|
||||
}
|
||||
withProgress.sort((a, b) => {
|
||||
const ta = progressUpdatedMap[a.slug] ?? '';
|
||||
const tb = progressUpdatedMap[b.slug] ?? '';
|
||||
|
||||
@@ -1,140 +1,12 @@
|
||||
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';
|
||||
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) {
|
||||
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.' });
|
||||
}
|
||||
|
||||
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 }) => {
|
||||
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 || !email || !password) {
|
||||
return fail(400, { action: 'register', error: 'All fields 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 (!/^[^\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',
|
||||
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, 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.' });
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Return success state — do NOT log the user in yet
|
||||
return { action: 'register', registered: true, email };
|
||||
}
|
||||
// Surface provider error codes to the page (oauth_state, oauth_failed, etc.)
|
||||
const error = url.searchParams.get('error') ?? undefined;
|
||||
return { error };
|
||||
};
|
||||
|
||||
@@ -1,12 +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();
|
||||
|
||||
// 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');
|
||||
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>
|
||||
@@ -16,155 +17,71 @@
|
||||
<div class="flex items-center justify-center min-h-[60vh]">
|
||||
<div class="w-full max-w-sm">
|
||||
|
||||
<!-- 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>
|
||||
{:else}
|
||||
<!-- 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>
|
||||
<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}
|
||||
</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">3–32 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 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}
|
||||
|
||||
<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"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
</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"
|
||||
/>
|
||||
</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>
|
||||
|
||||
51
ui/src/routes/terms/+page.svelte
Normal file
51
ui/src/routes/terms/+page.svelte
Normal 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>
|
||||
@@ -1,72 +0,0 @@
|
||||
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, '/');
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
<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>
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user