feat: enforce bearer token auth on all /api/admin/* endpoints
Adds BACKEND_ADMIN_TOKEN env var (set in Doppler) as a required Bearer token for every admin route. Also fixes PocketBase filter injection in notification queries and wires BACKEND_ADMIN_TOKEN through docker-compose to both backend and ui services. Includes CLAUDE.md for AI assistant guidance. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
86
CLAUDE.md
Normal file
86
CLAUDE.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### Docker (via `just` — the primary way to run services)
|
||||||
|
All services use Doppler for secrets injection. The `just` commands handle this automatically.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just up # Start all services in background
|
||||||
|
just up-fg # Start all services, stream logs
|
||||||
|
just down # Stop all services
|
||||||
|
just down-volumes # Full reset (destructive — removes all volumes)
|
||||||
|
just build # Rebuild all Docker images
|
||||||
|
just build-svc backend # Rebuild a specific service
|
||||||
|
just restart # Stop + rebuild + start
|
||||||
|
just logs # Tail all logs
|
||||||
|
just log backend # Tail a specific service
|
||||||
|
just shell backend # Open shell in running container
|
||||||
|
just init # One-shot init: MinIO buckets, PocketBase collections, Postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend (Go)
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
go vet ./...
|
||||||
|
go test -short -race -count=1 -timeout=60s ./...
|
||||||
|
go test -short -race -count=1 -run TestFoo ./internal/somepackage/
|
||||||
|
go build ./cmd/backend
|
||||||
|
go build ./cmd/runner
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend (SvelteKit)
|
||||||
|
```bash
|
||||||
|
cd ui
|
||||||
|
npm run dev # Dev server at localhost:5173
|
||||||
|
npm run build # Production build
|
||||||
|
npm run check # svelte-check (type-check)
|
||||||
|
npm run paraglide # Regenerate i18n messages (run after editing messages/*.json)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Three services communicate via PocketBase records and a Redis/Valkey task queue:
|
||||||
|
|
||||||
|
**Backend** (`backend/cmd/backend`) — HTTP REST API. Handles reads, enqueues tasks to Redis via Asynq, returns presigned MinIO URLs. Minimal processing; delegates heavy work to the runner.
|
||||||
|
|
||||||
|
**Runner** (`backend/cmd/runner`) — Asynq task worker. Processes scraping, TTS audio generation, AI text/image generation. Reads/writes PocketBase and MinIO directly.
|
||||||
|
|
||||||
|
**UI** (`ui/`) — SvelteKit 2 + Svelte 5 SSR app. Consumes the backend API. Uses Paraglide JS for i18n (5 locales).
|
||||||
|
|
||||||
|
### Data layer
|
||||||
|
| Service | Role |
|
||||||
|
|---------|------|
|
||||||
|
| **PocketBase** (SQLite) | Auth, structured records (books, chapters, tasks, subscriptions) |
|
||||||
|
| **MinIO** (S3-compatible) | Object storage — chapter text, audio files, images |
|
||||||
|
| **Meilisearch** | Full-text search (runner indexes, backend reads) |
|
||||||
|
| **Redis/Valkey** | Asynq task queue + presigned URL cache |
|
||||||
|
|
||||||
|
### Key backend packages
|
||||||
|
- `internal/backend/` — HTTP handlers and server setup
|
||||||
|
- `internal/runner/` — Task processor implementations
|
||||||
|
- `internal/storage/` — Unified MinIO + PocketBase interface (all data access goes through here)
|
||||||
|
- `internal/orchestrator/` — Task orchestration across services
|
||||||
|
- `internal/taskqueue/` — Enqueue helpers (backend side)
|
||||||
|
- `internal/asynqqueue/` — Asynq queue setup (runner side)
|
||||||
|
- `internal/config/` — Environment variable loading (Doppler-injected at runtime, no .env files)
|
||||||
|
- `internal/presigncache/` — Redis cache for MinIO presigned URLs
|
||||||
|
|
||||||
|
### UI routing conventions (SvelteKit)
|
||||||
|
- `+page.svelte` / `+page.server.ts` — Page + server-side load
|
||||||
|
- `+layout.svelte` / `+layout.server.ts` — Layouts
|
||||||
|
- `routes/api/` — API routes (`+server.ts`)
|
||||||
|
- `lib/audio.svelte.ts` — Client-side audio playback store (Svelte 5 runes)
|
||||||
|
|
||||||
|
## Key Conventions
|
||||||
|
|
||||||
|
- **Svelte 5 runes only** — use `$state`, `$derived`, `$effect`; do not use Svelte 4 stores or reactive statements.
|
||||||
|
- **Modern Go idioms** — structured logging via `log/slog`, OpenTelemetry tracing throughout.
|
||||||
|
- **No direct MinIO/PocketBase client calls** outside the `internal/storage/` package.
|
||||||
|
- **Secrets via Doppler** — never use `.env` files. All secrets are injected by Doppler CLI.
|
||||||
|
- **CI/CD is Gitea Actions** (`.gitea/workflows/`), not GitHub Actions. Use `gitea.ref_name`/`gitea.sha` variables.
|
||||||
|
- **Git hooks** in `.githooks/` — enable with `just setup`.
|
||||||
|
- **i18n**: translation files live in `ui/messages/{en,es,fr,de,pt}.json`; run `npm run paraglide` after editing them.
|
||||||
|
- **Error tracking**: GlitchTip with per-service DSNs (backend id/2, runner id/3, UI id/1) stored in Doppler.
|
||||||
@@ -177,6 +177,7 @@ func run() error {
|
|||||||
DefaultVoice: cfg.Kokoro.DefaultVoice,
|
DefaultVoice: cfg.Kokoro.DefaultVoice,
|
||||||
Version: version,
|
Version: version,
|
||||||
Commit: commit,
|
Commit: commit,
|
||||||
|
AdminToken: cfg.HTTP.AdminToken,
|
||||||
},
|
},
|
||||||
backend.Dependencies{
|
backend.Dependencies{
|
||||||
BookReader: store,
|
BookReader: store,
|
||||||
|
|||||||
@@ -111,6 +111,9 @@ type Config struct {
|
|||||||
// Version and Commit are embedded in /health and /api/version responses.
|
// Version and Commit are embedded in /health and /api/version responses.
|
||||||
Version string
|
Version string
|
||||||
Commit string
|
Commit string
|
||||||
|
// AdminToken is the bearer token required for all /api/admin/* endpoints.
|
||||||
|
// When empty a startup warning is logged and admin routes are unprotected.
|
||||||
|
AdminToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server is the HTTP API server.
|
// Server is the HTTP API server.
|
||||||
@@ -137,9 +140,30 @@ func New(cfg Config, deps Dependencies) *Server {
|
|||||||
return &Server{cfg: cfg, deps: deps}
|
return &Server{cfg: cfg, deps: deps}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// requireAdmin returns a handler that enforces Bearer token authentication.
|
||||||
|
// When AdminToken is empty all requests are allowed through (with a warning logged
|
||||||
|
// once at startup via ListenAndServe).
|
||||||
|
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.cfg.AdminToken == "" {
|
||||||
|
next(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Header.Get("Authorization") != "Bearer "+s.cfg.AdminToken {
|
||||||
|
jsonError(w, http.StatusUnauthorized, "unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ListenAndServe registers all routes and starts the HTTP server.
|
// ListenAndServe registers all routes and starts the HTTP server.
|
||||||
// It blocks until ctx is cancelled, then performs a graceful shutdown.
|
// It blocks until ctx is cancelled, then performs a graceful shutdown.
|
||||||
func (s *Server) ListenAndServe(ctx context.Context) error {
|
func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||||
|
if s.cfg.AdminToken == "" {
|
||||||
|
s.deps.Log.Warn("backend: BACKEND_ADMIN_TOKEN is not set — /api/admin/* endpoints are unprotected")
|
||||||
|
}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
// Health / version
|
// Health / version
|
||||||
@@ -204,68 +228,73 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
|||||||
mux.HandleFunc("GET /api/translation/status/{slug}/{n}", s.handleTranslationStatus)
|
mux.HandleFunc("GET /api/translation/status/{slug}/{n}", s.handleTranslationStatus)
|
||||||
mux.HandleFunc("GET /api/translation/{slug}/{n}", s.handleTranslationRead)
|
mux.HandleFunc("GET /api/translation/{slug}/{n}", s.handleTranslationRead)
|
||||||
|
|
||||||
|
// admin is a shorthand that wraps every /api/admin/* handler with bearer-token auth.
|
||||||
|
admin := func(pattern string, h http.HandlerFunc) {
|
||||||
|
mux.HandleFunc(pattern, s.requireAdmin(h))
|
||||||
|
}
|
||||||
|
|
||||||
// Admin translation endpoints
|
// Admin translation endpoints
|
||||||
mux.HandleFunc("GET /api/admin/translation/jobs", s.handleAdminTranslationJobs)
|
admin("GET /api/admin/translation/jobs", s.handleAdminTranslationJobs)
|
||||||
mux.HandleFunc("POST /api/admin/translation/bulk", s.handleAdminTranslationBulk)
|
admin("POST /api/admin/translation/bulk", s.handleAdminTranslationBulk)
|
||||||
|
|
||||||
// Admin audio endpoints
|
// Admin audio endpoints
|
||||||
mux.HandleFunc("GET /api/admin/audio/jobs", s.handleAdminAudioJobs)
|
admin("GET /api/admin/audio/jobs", s.handleAdminAudioJobs)
|
||||||
mux.HandleFunc("POST /api/admin/audio/bulk", s.handleAdminAudioBulk)
|
admin("POST /api/admin/audio/bulk", s.handleAdminAudioBulk)
|
||||||
mux.HandleFunc("POST /api/admin/audio/cancel-bulk", s.handleAdminAudioCancelBulk)
|
admin("POST /api/admin/audio/cancel-bulk", s.handleAdminAudioCancelBulk)
|
||||||
|
|
||||||
// Admin image generation endpoints
|
// Admin image generation endpoints
|
||||||
mux.HandleFunc("GET /api/admin/image-gen/models", s.handleAdminImageGenModels)
|
admin("GET /api/admin/image-gen/models", s.handleAdminImageGenModels)
|
||||||
mux.HandleFunc("POST /api/admin/image-gen", s.handleAdminImageGen)
|
admin("POST /api/admin/image-gen", s.handleAdminImageGen)
|
||||||
mux.HandleFunc("POST /api/admin/image-gen/async", s.handleAdminImageGenAsync)
|
admin("POST /api/admin/image-gen/async", s.handleAdminImageGenAsync)
|
||||||
mux.HandleFunc("POST /api/admin/image-gen/save-cover", s.handleAdminImageGenSaveCover)
|
admin("POST /api/admin/image-gen/save-cover", s.handleAdminImageGenSaveCover)
|
||||||
mux.HandleFunc("POST /api/admin/image-gen/save-chapter-image", s.handleAdminImageGenSaveChapterImage)
|
admin("POST /api/admin/image-gen/save-chapter-image", s.handleAdminImageGenSaveChapterImage)
|
||||||
|
|
||||||
// Chapter image serving
|
// Chapter image serving
|
||||||
mux.HandleFunc("GET /api/chapter-image/{domain}/{slug}/{n}", s.handleGetChapterImage)
|
mux.HandleFunc("GET /api/chapter-image/{domain}/{slug}/{n}", s.handleGetChapterImage)
|
||||||
mux.HandleFunc("HEAD /api/chapter-image/{domain}/{slug}/{n}", s.handleHeadChapterImage)
|
mux.HandleFunc("HEAD /api/chapter-image/{domain}/{slug}/{n}", s.handleHeadChapterImage)
|
||||||
|
|
||||||
// Admin text generation endpoints (chapter names + book description)
|
// Admin text generation endpoints (chapter names + book description)
|
||||||
mux.HandleFunc("GET /api/admin/text-gen/models", s.handleAdminTextGenModels)
|
admin("GET /api/admin/text-gen/models", s.handleAdminTextGenModels)
|
||||||
mux.HandleFunc("POST /api/admin/text-gen/chapter-names", s.handleAdminTextGenChapterNames)
|
admin("POST /api/admin/text-gen/chapter-names", s.handleAdminTextGenChapterNames)
|
||||||
mux.HandleFunc("POST /api/admin/text-gen/chapter-names/async", s.handleAdminTextGenChapterNamesAsync)
|
admin("POST /api/admin/text-gen/chapter-names/async", s.handleAdminTextGenChapterNamesAsync)
|
||||||
mux.HandleFunc("POST /api/admin/text-gen/chapter-names/apply", s.handleAdminTextGenApplyChapterNames)
|
admin("POST /api/admin/text-gen/chapter-names/apply", s.handleAdminTextGenApplyChapterNames)
|
||||||
mux.HandleFunc("POST /api/admin/text-gen/description", s.handleAdminTextGenDescription)
|
admin("POST /api/admin/text-gen/description", s.handleAdminTextGenDescription)
|
||||||
mux.HandleFunc("POST /api/admin/text-gen/description/async", s.handleAdminTextGenDescriptionAsync)
|
admin("POST /api/admin/text-gen/description/async", s.handleAdminTextGenDescriptionAsync)
|
||||||
mux.HandleFunc("POST /api/admin/text-gen/description/apply", s.handleAdminTextGenApplyDescription)
|
admin("POST /api/admin/text-gen/description/apply", s.handleAdminTextGenApplyDescription)
|
||||||
|
|
||||||
// Admin catalogue enrichment endpoints
|
// Admin catalogue enrichment endpoints
|
||||||
mux.HandleFunc("POST /api/admin/text-gen/tagline", s.handleAdminTextGenTagline)
|
admin("POST /api/admin/text-gen/tagline", s.handleAdminTextGenTagline)
|
||||||
mux.HandleFunc("POST /api/admin/text-gen/genres", s.handleAdminTextGenGenres)
|
admin("POST /api/admin/text-gen/genres", s.handleAdminTextGenGenres)
|
||||||
mux.HandleFunc("POST /api/admin/text-gen/genres/apply", s.handleAdminTextGenApplyGenres)
|
admin("POST /api/admin/text-gen/genres/apply", s.handleAdminTextGenApplyGenres)
|
||||||
mux.HandleFunc("POST /api/admin/text-gen/content-warnings", s.handleAdminTextGenContentWarnings)
|
admin("POST /api/admin/text-gen/content-warnings", s.handleAdminTextGenContentWarnings)
|
||||||
mux.HandleFunc("POST /api/admin/text-gen/quality-score", s.handleAdminTextGenQualityScore)
|
admin("POST /api/admin/text-gen/quality-score", s.handleAdminTextGenQualityScore)
|
||||||
mux.HandleFunc("POST /api/admin/catalogue/batch-covers", s.handleAdminBatchCovers)
|
admin("POST /api/admin/catalogue/batch-covers", s.handleAdminBatchCovers)
|
||||||
mux.HandleFunc("POST /api/admin/catalogue/batch-covers/cancel", s.handleAdminBatchCoversCancel)
|
admin("POST /api/admin/catalogue/batch-covers/cancel", s.handleAdminBatchCoversCancel)
|
||||||
mux.HandleFunc("POST /api/admin/catalogue/refresh-metadata/{slug}", s.handleAdminRefreshMetadata)
|
admin("POST /api/admin/catalogue/refresh-metadata/{slug}", s.handleAdminRefreshMetadata)
|
||||||
|
|
||||||
// Admin AI job tracking endpoints
|
// Admin AI job tracking endpoints
|
||||||
mux.HandleFunc("GET /api/admin/ai-jobs", s.handleAdminListAIJobs)
|
admin("GET /api/admin/ai-jobs", s.handleAdminListAIJobs)
|
||||||
mux.HandleFunc("GET /api/admin/ai-jobs/{id}", s.handleAdminGetAIJob)
|
admin("GET /api/admin/ai-jobs/{id}", s.handleAdminGetAIJob)
|
||||||
mux.HandleFunc("POST /api/admin/ai-jobs/{id}/cancel", s.handleAdminCancelAIJob)
|
admin("POST /api/admin/ai-jobs/{id}/cancel", s.handleAdminCancelAIJob)
|
||||||
|
|
||||||
// Auto-prompt generation from book/chapter content
|
// Auto-prompt generation from book/chapter content
|
||||||
mux.HandleFunc("POST /api/admin/image-gen/auto-prompt", s.handleAdminImageGenAutoPrompt)
|
admin("POST /api/admin/image-gen/auto-prompt", s.handleAdminImageGenAutoPrompt)
|
||||||
|
|
||||||
// Admin data repair endpoints
|
// Admin data repair endpoints
|
||||||
mux.HandleFunc("POST /api/admin/dedup-chapters/{slug}", s.handleDedupChapters)
|
admin("POST /api/admin/dedup-chapters/{slug}", s.handleDedupChapters)
|
||||||
|
|
||||||
// Admin book management (soft-delete / hard-delete)
|
// Admin book management (soft-delete / hard-delete)
|
||||||
mux.HandleFunc("PATCH /api/admin/books/{slug}/archive", s.handleAdminArchiveBook)
|
admin("PATCH /api/admin/books/{slug}/archive", s.handleAdminArchiveBook)
|
||||||
mux.HandleFunc("PATCH /api/admin/books/{slug}/unarchive", s.handleAdminUnarchiveBook)
|
admin("PATCH /api/admin/books/{slug}/unarchive", s.handleAdminUnarchiveBook)
|
||||||
mux.HandleFunc("DELETE /api/admin/books/{slug}", s.handleAdminDeleteBook)
|
admin("DELETE /api/admin/books/{slug}", s.handleAdminDeleteBook)
|
||||||
|
|
||||||
// Admin chapter split (imported books)
|
// Admin chapter split (imported books)
|
||||||
mux.HandleFunc("POST /api/admin/books/{slug}/split-chapters", s.handleAdminSplitChapters)
|
admin("POST /api/admin/books/{slug}/split-chapters", s.handleAdminSplitChapters)
|
||||||
|
|
||||||
// Import (PDF/EPUB)
|
// Import (PDF/EPUB)
|
||||||
mux.HandleFunc("POST /api/admin/import", s.handleAdminImport)
|
admin("POST /api/admin/import", s.handleAdminImport)
|
||||||
mux.HandleFunc("GET /api/admin/import", s.handleAdminImportList)
|
admin("GET /api/admin/import", s.handleAdminImportList)
|
||||||
mux.HandleFunc("GET /api/admin/import/{id}", s.handleAdminImportStatus)
|
admin("GET /api/admin/import/{id}", s.handleAdminImportStatus)
|
||||||
|
|
||||||
// Notifications
|
// Notifications
|
||||||
mux.HandleFunc("GET /api/notifications", s.handleListNotifications)
|
mux.HandleFunc("GET /api/notifications", s.handleListNotifications)
|
||||||
|
|||||||
@@ -92,6 +92,10 @@ type LibreTranslate struct {
|
|||||||
type HTTP struct {
|
type HTTP struct {
|
||||||
// Addr is the listen address, e.g. ":8080"
|
// Addr is the listen address, e.g. ":8080"
|
||||||
Addr string
|
Addr string
|
||||||
|
// AdminToken is the bearer token required for all /api/admin/* endpoints.
|
||||||
|
// Set via BACKEND_ADMIN_TOKEN. When empty, admin endpoints are unprotected —
|
||||||
|
// only acceptable when the backend is unreachable from the public internet.
|
||||||
|
AdminToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Meilisearch holds connection settings for the Meilisearch full-text search service.
|
// Meilisearch holds connection settings for the Meilisearch full-text search service.
|
||||||
@@ -242,7 +246,8 @@ func Load() Config {
|
|||||||
},
|
},
|
||||||
|
|
||||||
HTTP: HTTP{
|
HTTP: HTTP{
|
||||||
Addr: envOr("BACKEND_HTTP_ADDR", ":8080"),
|
Addr: envOr("BACKEND_HTTP_ADDR", ":8080"),
|
||||||
|
AdminToken: envOr("BACKEND_ADMIN_TOKEN", ""),
|
||||||
},
|
},
|
||||||
|
|
||||||
Runner: Runner{
|
Runner: Runner{
|
||||||
|
|||||||
@@ -773,7 +773,7 @@ func (s *Store) CreateNotification(ctx context.Context, userID, title, message,
|
|||||||
|
|
||||||
// ListNotifications returns notifications for a user.
|
// ListNotifications returns notifications for a user.
|
||||||
func (s *Store) ListNotifications(ctx context.Context, userID string, limit int) ([]map[string]any, error) {
|
func (s *Store) ListNotifications(ctx context.Context, userID string, limit int) ([]map[string]any, error) {
|
||||||
filter := fmt.Sprintf(`user_id="%s"`, userID)
|
filter := fmt.Sprintf(`user_id=%q`, userID)
|
||||||
items, err := s.pb.listAll(ctx, "notifications", filter, "-created")
|
items, err := s.pb.listAll(ctx, "notifications", filter, "-created")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -805,7 +805,7 @@ func (s *Store) DeleteNotification(ctx context.Context, id string) error {
|
|||||||
|
|
||||||
// ClearAllNotifications deletes all notifications for a user.
|
// ClearAllNotifications deletes all notifications for a user.
|
||||||
func (s *Store) ClearAllNotifications(ctx context.Context, userID string) error {
|
func (s *Store) ClearAllNotifications(ctx context.Context, userID string) error {
|
||||||
filter := fmt.Sprintf(`user_id="%s"`, userID)
|
filter := fmt.Sprintf(`user_id=%q`, userID)
|
||||||
items, err := s.pb.listAll(ctx, "notifications", filter, "")
|
items, err := s.pb.listAll(ctx, "notifications", filter, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("ClearAllNotifications list: %w", err)
|
return fmt.Errorf("ClearAllNotifications list: %w", err)
|
||||||
@@ -823,7 +823,7 @@ func (s *Store) ClearAllNotifications(ctx context.Context, userID string) error
|
|||||||
|
|
||||||
// MarkAllNotificationsRead marks all notifications for a user as read.
|
// MarkAllNotificationsRead marks all notifications for a user as read.
|
||||||
func (s *Store) MarkAllNotificationsRead(ctx context.Context, userID string) error {
|
func (s *Store) MarkAllNotificationsRead(ctx context.Context, userID string) error {
|
||||||
filter := fmt.Sprintf(`user_id="%s"&&read=false`, userID)
|
filter := fmt.Sprintf(`user_id=%q&&read=false`, userID)
|
||||||
items, err := s.pb.listAll(ctx, "notifications", filter, "")
|
items, err := s.pb.listAll(ctx, "notifications", filter, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("MarkAllNotificationsRead list: %w", err)
|
return fmt.Errorf("MarkAllNotificationsRead list: %w", err)
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
<<: *infra-env
|
<<: *infra-env
|
||||||
BACKEND_HTTP_ADDR: ":8080"
|
BACKEND_HTTP_ADDR: ":8080"
|
||||||
|
BACKEND_ADMIN_TOKEN: "${BACKEND_ADMIN_TOKEN}"
|
||||||
LOG_LEVEL: "${LOG_LEVEL}"
|
LOG_LEVEL: "${LOG_LEVEL}"
|
||||||
KOKORO_URL: "${KOKORO_URL}"
|
KOKORO_URL: "${KOKORO_URL}"
|
||||||
KOKORO_VOICE: "${KOKORO_VOICE}"
|
KOKORO_VOICE: "${KOKORO_VOICE}"
|
||||||
@@ -295,6 +296,7 @@ services:
|
|||||||
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL}"
|
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL}"
|
||||||
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD}"
|
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD}"
|
||||||
AUTH_SECRET: "${AUTH_SECRET}"
|
AUTH_SECRET: "${AUTH_SECRET}"
|
||||||
|
BACKEND_ADMIN_TOKEN: "${BACKEND_ADMIN_TOKEN}"
|
||||||
DEBUG_LOGIN_TOKEN: "${DEBUG_LOGIN_TOKEN}"
|
DEBUG_LOGIN_TOKEN: "${DEBUG_LOGIN_TOKEN}"
|
||||||
PUBLIC_MINIO_PUBLIC_URL: "${MINIO_PUBLIC_ENDPOINT}"
|
PUBLIC_MINIO_PUBLIC_URL: "${MINIO_PUBLIC_ENDPOINT}"
|
||||||
# Valkey
|
# Valkey
|
||||||
|
|||||||
@@ -15,18 +15,31 @@ import { env } from '$env/dynamic/private';
|
|||||||
import * as cache from '$lib/server/cache';
|
import * as cache from '$lib/server/cache';
|
||||||
|
|
||||||
export const BACKEND_URL = env.BACKEND_API_URL ?? 'http://localhost:8080';
|
export const BACKEND_URL = env.BACKEND_API_URL ?? 'http://localhost:8080';
|
||||||
|
const ADMIN_TOKEN = env.BACKEND_ADMIN_TOKEN ?? '';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch a path on the backend, throwing a 502 on network failures.
|
* Fetch a path on the backend, throwing a 502 on network failures.
|
||||||
*
|
*
|
||||||
* The `path` must start with `/` (e.g. `/api/voices`).
|
* The `path` must start with `/` (e.g. `/api/voices`).
|
||||||
|
* Requests to `/api/admin/*` automatically include the Bearer token from
|
||||||
|
* the BACKEND_ADMIN_TOKEN environment variable.
|
||||||
*
|
*
|
||||||
* SvelteKit `error()` exceptions are always re-thrown so callers can
|
* SvelteKit `error()` exceptions are always re-thrown so callers can
|
||||||
* short-circuit correctly inside their own catch blocks.
|
* short-circuit correctly inside their own catch blocks.
|
||||||
*/
|
*/
|
||||||
export async function backendFetch(path: string, init?: RequestInit): Promise<Response> {
|
export async function backendFetch(path: string, init?: RequestInit): Promise<Response> {
|
||||||
|
let finalInit = init;
|
||||||
|
if (ADMIN_TOKEN && path.startsWith('/api/admin')) {
|
||||||
|
finalInit = {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${ADMIN_TOKEN}`,
|
||||||
|
...((init?.headers ?? {}) as Record<string, string>)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
return await fetch(`${BACKEND_URL}${path}`, init);
|
return await fetch(`${BACKEND_URL}${path}`, finalInit);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Re-throw SvelteKit HTTP errors so they propagate to the framework.
|
// Re-throw SvelteKit HTTP errors so they propagate to the framework.
|
||||||
if (e instanceof Error && 'status' in e) throw e;
|
if (e instanceof Error && 'status' in e) throw e;
|
||||||
|
|||||||
Reference in New Issue
Block a user