diff --git a/.gitea/workflows/ci-v3.yaml b/.gitea/workflows/ci-v3.yaml new file mode 100644 index 0000000..9ea014f --- /dev/null +++ b/.gitea/workflows/ci-v3.yaml @@ -0,0 +1,146 @@ +name: CI / v3 + +on: + push: + branches: ["main", "master"] + paths: + - "v3/**" + - ".gitea/workflows/ci-v3.yaml" + pull_request: + branches: ["main", "master"] + paths: + - "v3/**" + - ".gitea/workflows/ci-v3.yaml" + +concurrency: + group: ${{ gitea.workflow }}-${{ gitea.ref }} + cancel-in-progress: true + +jobs: + # ── backend: lint & test ───────────────────────────────────────────────────── + test-backend: + name: Test backend + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: v3/backend/go.mod + cache-dependency-path: v3/backend/go.sum + + - name: go vet + working-directory: v3/backend + run: go vet ./... + + - name: Run tests + working-directory: v3/backend + run: go test -short -race -count=1 -timeout=60s ./... + + # ── ui: type-check ─────────────────────────────────────────────────────────── + check-ui: + name: Check ui + runs-on: ubuntu-latest + defaults: + run: + working-directory: v3/ui + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: v3/ui/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Type check + run: npm run check + + - name: Build + run: npm run build + + # ── docker: backend (push to Docker Hub on branch push only) ───────────────── + docker-backend: + name: Docker / backend + runs-on: ubuntu-latest + needs: [test-backend] + if: gitea.event_name == 'push' + steps: + - uses: actions/checkout@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USER }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: v3/backend + target: backend + push: true + tags: | + ${{ secrets.DOCKER_USER }}/libnovel-v3-backend:latest + ${{ secrets.DOCKER_USER }}/libnovel-v3-backend:${{ gitea.sha }} + build-args: | + VERSION=${{ gitea.sha }} + COMMIT=${{ gitea.sha }} + + # ── docker: runner ──────────────────────────────────────────────────────────── + docker-runner: + name: Docker / runner + runs-on: ubuntu-latest + needs: [test-backend] + if: gitea.event_name == 'push' + steps: + - uses: actions/checkout@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USER }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: v3/backend + target: runner + push: true + tags: | + ${{ secrets.DOCKER_USER }}/libnovel-v3-runner:latest + ${{ secrets.DOCKER_USER }}/libnovel-v3-runner:${{ gitea.sha }} + build-args: | + VERSION=${{ gitea.sha }} + COMMIT=${{ gitea.sha }} + + # ── docker: ui ──────────────────────────────────────────────────────────────── + docker-ui: + name: Docker / ui + runs-on: ubuntu-latest + needs: [check-ui] + if: gitea.event_name == 'push' + steps: + - uses: actions/checkout@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USER }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: v3/ui + push: true + tags: | + ${{ secrets.DOCKER_USER }}/libnovel-v3-ui:latest + ${{ secrets.DOCKER_USER }}/libnovel-v3-ui:${{ gitea.sha }} + build-args: | + BUILD_VERSION=${{ gitea.sha }} + BUILD_COMMIT=${{ gitea.sha }} diff --git a/.gitea/workflows/release-v3.yaml b/.gitea/workflows/release-v3.yaml new file mode 100644 index 0000000..5e733b0 --- /dev/null +++ b/.gitea/workflows/release-v3.yaml @@ -0,0 +1,177 @@ +name: Release / v3 + +on: + push: + tags: + - "v3/*" # e.g. v3/1.0.0, v3/1.2.3 + - "v3-*" # e.g. v3-1.0.0 (alternative convention) + +concurrency: + group: ${{ gitea.workflow }}-${{ gitea.ref }} + cancel-in-progress: true + +jobs: + # ── backend: lint & test ───────────────────────────────────────────────────── + test-backend: + name: Test backend + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: v3/backend/go.mod + cache-dependency-path: v3/backend/go.sum + + - name: go vet + working-directory: v3/backend + run: go vet ./... + + - name: Run tests + working-directory: v3/backend + run: go test -short -race -count=1 -timeout=60s ./... + + # ── ui: type-check & build ─────────────────────────────────────────────────── + check-ui: + name: Check ui + runs-on: ubuntu-latest + defaults: + run: + working-directory: v3/ui + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: v3/ui/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Type check + run: npm run check + + - name: Build + run: npm run build + + # ── docker: backend ─────────────────────────────────────────────────────────── + docker-backend: + name: Docker / backend + runs-on: ubuntu-latest + needs: [test-backend] + steps: + - uses: actions/checkout@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USER }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ secrets.DOCKER_USER }}/libnovel-v3-backend + tags: | + type=match,pattern=v3/(.*),group=1 + type=raw,value=latest + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: v3/backend + target: backend + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + VERSION=${{ steps.meta.outputs.version }} + COMMIT=${{ gitea.sha }} + + # ── docker: runner ──────────────────────────────────────────────────────────── + docker-runner: + name: Docker / runner + runs-on: ubuntu-latest + needs: [test-backend] + steps: + - uses: actions/checkout@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USER }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ secrets.DOCKER_USER }}/libnovel-v3-runner + tags: | + type=match,pattern=v3/(.*),group=1 + type=raw,value=latest + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: v3/backend + target: runner + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + VERSION=${{ steps.meta.outputs.version }} + COMMIT=${{ gitea.sha }} + + # ── docker: ui ──────────────────────────────────────────────────────────────── + docker-ui: + name: Docker / ui + runs-on: ubuntu-latest + needs: [check-ui] + steps: + - uses: actions/checkout@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USER }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ secrets.DOCKER_USER }}/libnovel-v3-ui + tags: | + type=match,pattern=v3/(.*),group=1 + type=raw,value=latest + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: v3/ui + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + BUILD_VERSION=${{ steps.meta.outputs.version }} + BUILD_COMMIT=${{ gitea.sha }} + + # ── Gitea release ───────────────────────────────────────────────────────────── + release: + name: Gitea Release + runs-on: ubuntu-latest + needs: [docker-backend, docker-runner, docker-ui] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Create release + uses: actions/gitea-release-action@v1 + with: + token: ${{ secrets.GITEA_TOKEN }} + generate_release_notes: true diff --git a/backend/bin/runner b/backend/bin/runner new file mode 100755 index 0000000..6de49f4 Binary files /dev/null and b/backend/bin/runner differ diff --git a/v3/Caddyfile b/v3/Caddyfile index 2a72753..b538844 100644 --- a/v3/Caddyfile +++ b/v3/Caddyfile @@ -1,6 +1,7 @@ # v3/Caddyfile # # Caddy reverse proxy for LibNovel v3. +# Custom build includes github.com/mholt/caddy-ratelimit. # # Environment variables consumed (set in docker-compose.yml): # DOMAIN — public hostname, e.g. libnovel.example.com @@ -9,51 +10,106 @@ # # Routing rules: # /health → backend:8080 (liveness probe) -# /scrape* → backend:8080 (scrape task creation) +# /scrape* → backend:8080 (Go admin scrape endpoints) # /api/browse → backend:8080 (MinIO-cached browse pages) # /api/book-preview/* → backend:8080 (live scrape, no store write) -# /api/chapter-text-preview/*/* → backend:8080 (live chapter, no store write) -# /api/chapter-text/*/* → backend:8080 (chapter markdown from MinIO) +# /api/chapter-text/* → backend:8080 (chapter markdown from MinIO) # /api/reindex/* → backend:8080 (rebuild chapter index) # /api/cover/* → backend:8080 (proxy cover image) -# /api/audio-proxy/*/* → backend:8080 (proxy generated audio) -# /api/scrape/* → backend:8080 (scrape job status/tasks) +# /api/audio-proxy/* → backend:8080 (proxy generated audio) +# /avatars/* → minio:9000 (presigned avatar GETs) # /* (everything else) → ui:3000 (SvelteKit — handles all -# remaining /api/* routes too) +# remaining /api/* routes) # -# The SvelteKit UI itself proxies to the backend for: ranking, voices, search, -# browse-page, presign, audio, progress, and the Go /api/progress endpoint. -# MinIO and PocketBase are NOT exposed publicly. +# Routes intentionally removed from direct-to-backend: +# /api/scrape/* — SvelteKit has /api/scrape/ counterparts +# that enforce auth; routing directly would +# bypass SK middleware. +# /api/chapter-text-preview/* — Same: SvelteKit owns +# /api/chapter-text-preview/[slug]/[n]. { # Email for Let's Encrypt ACME account registration. - # Optional — omit to use an anonymous ACME account. + # When CADDY_ACME_EMAIL is set this expands to e.g. "email you@example.com". + # When unset it expands to an empty string and is silently ignored. {$CADDY_ACME_EMAIL:} } +(security_headers) { + header { + # Prevent clickjacking + X-Frame-Options "SAMEORIGIN" + # Prevent MIME-type sniffing + X-Content-Type-Options "nosniff" + # Minimal referrer info for cross-origin requests + Referrer-Policy "strict-origin-when-cross-origin" + # Restrict powerful browser features + Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" + # Enforce HTTPS for 1 year (includeSubDomains) + Strict-Transport-Security "max-age=31536000; includeSubDomains" + # Enable XSS filter in older browsers + X-XSS-Protection "1; mode=block" + # Remove server identity header + -Server + } +} + {$DOMAIN:localhost} { - # ── Liveness probe ─────────────────────────────────────────────────────── + import security_headers + + # ── Rate limiting ───────────────────────────────────────────────────────── + # Auth endpoints: strict — 10 req/min per IP + rate_limit { + zone auth_zone { + match { + path /api/auth/login /api/auth/register /api/auth/change-password + } + key {remote_host} + window 1m + events 10 + } + } + + # Admin scrape endpoints: moderate — 20 req/min per IP + rate_limit { + zone scrape_zone { + match { + path /scrape* + } + key {remote_host} + window 1m + events 20 + } + } + + # Global: 300 req/min per IP (covers everything) + rate_limit { + zone global_zone { + key {remote_host} + window 1m + events 300 + } + } + + # ── Liveness probe ──────────────────────────────────────────────────────── handle /health { reverse_proxy backend:8080 } - # ── Scrape task creation (Go backend only) ─────────────────────────────── + # ── Scrape task creation (Go backend only) ──────────────────────────────── handle /scrape* { reverse_proxy backend:8080 } # ── Backend-only API paths ──────────────────────────────────────────────── - # These paths are served exclusively by the Go scraper and are not - # implemented in the SvelteKit UI. + # These paths are served exclusively by the Go backend and have no + # SvelteKit counterpart. Routing them here skips SK intentionally. handle /api/browse { reverse_proxy backend:8080 } handle /api/book-preview/* { reverse_proxy backend:8080 } - handle /api/chapter-text-preview/* { - reverse_proxy backend:8080 - } handle /api/chapter-text/* { reverse_proxy backend:8080 } @@ -66,8 +122,10 @@ handle /api/audio-proxy/* { reverse_proxy backend:8080 } - handle /api/scrape/* { - reverse_proxy backend:8080 + + # ── MinIO avatars bucket (presigned GET only) ───────────────────────────── + handle /avatars/* { + reverse_proxy minio:9000 } # ── SvelteKit UI (catch-all — includes all remaining /api/* routes) ─────── @@ -75,7 +133,26 @@ reverse_proxy ui:3000 } - # ── Logging ────────────────────────────────────────────────────────────── + # ── Caddy-level error pages ─────────────────────────────────────────────── + # These fire when the upstream (backend or ui) is completely unreachable. + # SvelteKit's own +error.svelte handles application-level errors (404, 500). + handle_errors 502 { + root * /srv/errors + rewrite * /502.html + file_server + } + handle_errors 503 { + root * /srv/errors + rewrite * /503.html + file_server + } + handle_errors 504 { + root * /srv/errors + rewrite * /504.html + file_server + } + + # ── Logging ─────────────────────────────────────────────────────────────── log { output stdout format json diff --git a/v3/backend/bin/runner b/v3/backend/bin/runner new file mode 100755 index 0000000..6de49f4 Binary files /dev/null and b/v3/backend/bin/runner differ diff --git a/v3/backend/cmd/runner/main.go b/v3/backend/cmd/runner/main.go index 01a230c..f534d68 100644 --- a/v3/backend/cmd/runner/main.go +++ b/v3/backend/cmd/runner/main.go @@ -111,7 +111,8 @@ func run() error { MaxConcurrentAudio: cfg.Runner.MaxConcurrentAudio, OrchestratorWorkers: workers, MetricsAddr: cfg.Runner.MetricsAddr, - CatalogueRefreshInterval: cfg.Runner.CatalogueRefreshInterval, + CatalogueRefreshInterval: cfg.Runner.CatalogueRefreshInterval, + SkipInitialCatalogueRefresh: cfg.Runner.SkipInitialCatalogueRefresh, } deps := runner.Dependencies{ Consumer: store, diff --git a/v3/backend/healthcheck b/v3/backend/healthcheck new file mode 100755 index 0000000..9d0e8ec Binary files /dev/null and b/v3/backend/healthcheck differ diff --git a/v3/backend/internal/backend/handlers.go b/v3/backend/internal/backend/handlers.go index 8d5a835..eda8eec 100644 --- a/v3/backend/internal/backend/handlers.go +++ b/v3/backend/internal/backend/handlers.go @@ -7,8 +7,7 @@ package backend // handleScrapeStatus, handleScrapeTasks // handleBrowse, handleSearch // handleGetRanking, handleGetCover -// handleBookPreview, handleChapterText, handleReindex -// handleChapterText, handleReindex +// handleBookPreview, handleChapterText, handleChapterTextPreview, handleChapterMarkdown, handleReindex // handleAudioGenerate, handleAudioStatus, handleAudioProxy // handleVoices // handlePresignChapter, handlePresignAudio, handlePresignVoiceSample @@ -29,6 +28,8 @@ package backend // by the runner after each catalogue scrape). // - GET /api/book-preview returns stored data when in library, or enqueues a // scrape task and returns 202 when not. The backend never scrapes directly. +// - GET /api/chapter-text-preview scrapes a chapter live from novelfire.net +// directly (no runner task, no store writes). Used for unscraped books. import ( "context" @@ -45,6 +46,8 @@ import ( "github.com/libnovel/backend/internal/domain" "github.com/libnovel/backend/internal/kokoro" "github.com/libnovel/backend/internal/meili" + "github.com/libnovel/backend/internal/novelfire/htmlutil" + "github.com/libnovel/backend/internal/scraper" ) const ( @@ -502,6 +505,117 @@ func (s *Server) handleChapterMarkdown(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, raw) } +// handleChapterTextPreview handles GET /api/chapter-text-preview/{slug}/{n}. +// +// Fetches a chapter live from novelfire.net and returns its plain text without +// writing anything to PocketBase or MinIO. This is the preview path used when +// a chapter has not yet been scraped into the library. +// +// Optional query params: +// +// chapter_url — the canonical chapter URL (preferred over constructing one) +// title — hint for the chapter title (used when the page title is empty) +// +// Response: {"slug":string,"number":int,"title":string,"text":string,"url":string} +func (s *Server) handleChapterTextPreview(w http.ResponseWriter, r *http.Request) { + slug := r.PathValue("slug") + n, err := strconv.Atoi(r.PathValue("n")) + if err != nil || n < 1 || slug == "" { + jsonError(w, http.StatusBadRequest, "invalid slug or chapter number") + return + } + + // Determine the chapter URL to fetch. + chapterURL := r.URL.Query().Get("chapter_url") + if chapterURL == "" { + // Best-effort: novelfire chapter URLs follow /book/{slug}/chapter-{n} + chapterURL = fmt.Sprintf("%s/book/%s/chapter-%d", novelFireBase, slug, n) + } + + titleHint := r.URL.Query().Get("title") + + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + // Fetch the chapter page. + req, err := http.NewRequestWithContext(ctx, http.MethodGet, chapterURL, nil) + if err != nil { + s.deps.Log.Error("chapter-text-preview: build request failed", "url", chapterURL, "err", err) + jsonError(w, http.StatusInternalServerError, "failed to build request") + return + } + req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; libnovel-backend/2)") + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + s.deps.Log.Warn("chapter-text-preview: fetch failed", "url", chapterURL, "err", err) + jsonError(w, http.StatusBadGateway, "failed to fetch chapter") + return + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + jsonError(w, http.StatusNotFound, "chapter not found") + return + } + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + s.deps.Log.Warn("chapter-text-preview: upstream error", + "url", chapterURL, "status", resp.StatusCode, "body_snippet", string(body)) + jsonError(w, http.StatusBadGateway, fmt.Sprintf("upstream returned %d", resp.StatusCode)) + return + } + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + s.deps.Log.Error("chapter-text-preview: read body failed", "err", err) + jsonError(w, http.StatusInternalServerError, "failed to read response") + return + } + + // Parse HTML and extract the #content node. + root, err := htmlutil.ParseHTML(string(bodyBytes)) + if err != nil { + s.deps.Log.Error("chapter-text-preview: html parse failed", "err", err) + jsonError(w, http.StatusInternalServerError, "failed to parse chapter HTML") + return + } + + container := htmlutil.FindFirst(root, scraper.Selector{ID: "content"}) + if container == nil { + s.deps.Log.Warn("chapter-text-preview: #content not found", "url", chapterURL) + jsonError(w, http.StatusNotFound, "chapter content not found on page") + return + } + + markdownText := htmlutil.NodeToMarkdown(container) + plainText := stripMarkdown(markdownText) + + // Extract the chapter title from the page
The server is temporarily unreachable. Please try again in a moment.
+ Go home + + diff --git a/v3/caddy/errors/503.html b/v3/caddy/errors/503.html new file mode 100644 index 0000000..1080cf9 --- /dev/null +++ b/v3/caddy/errors/503.html @@ -0,0 +1,51 @@ + + + + + +LibNovel is briefly offline for maintenance. We’ll be back shortly.
+ Try again + + diff --git a/v3/caddy/errors/504.html b/v3/caddy/errors/504.html new file mode 100644 index 0000000..53faf9c --- /dev/null +++ b/v3/caddy/errors/504.html @@ -0,0 +1,51 @@ + + + + + +The request took too long to complete. Please refresh and try again.
+ Go home + + diff --git a/v3/docker-compose.yml b/v3/docker-compose.yml index de6d60e..c08f14d 100644 --- a/v3/docker-compose.yml +++ b/v3/docker-compose.yml @@ -8,8 +8,8 @@ x-infra-env: &infra-env MINIO_ACCESS_KEY: "${MINIO_ROOT_USER:-admin}" MINIO_SECRET_KEY: "${MINIO_ROOT_PASSWORD:-changeme123}" MINIO_USE_SSL: "false" - MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT:-}" - MINIO_PUBLIC_USE_SSL: "${MINIO_PUBLIC_USE_SSL:-false}" + MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT:-localhost}" + MINIO_PUBLIC_USE_SSL: "${MINIO_PUBLIC_USE_SSL:-true}" # PocketBase POCKETBASE_URL: "http://pocketbase:8090" POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}" @@ -53,7 +53,7 @@ services: mc alias set local http://minio:9000 $${MINIO_ROOT_USER:-admin} $${MINIO_ROOT_PASSWORD:-changeme123}; mc mb --ignore-existing local/libnovel-chapters; mc mb --ignore-existing local/libnovel-audio; - mc mb --ignore-existing local/libnovel-avatars; + mc mb --ignore-existing local/avatars; mc mb --ignore-existing local/libnovel-browse; echo 'buckets ready'; " @@ -135,6 +135,8 @@ services: args: VERSION: "${GIT_TAG:-dev}" COMMIT: "${GIT_COMMIT:-unknown}" + labels: + com.centurylinklabs.watchtower.enable: "true" restart: unless-stopped stop_grace_period: 35s depends_on: @@ -170,6 +172,8 @@ services: args: VERSION: "${GIT_TAG:-dev}" COMMIT: "${GIT_COMMIT:-unknown}" + labels: + com.centurylinklabs.watchtower.enable: "true" restart: unless-stopped stop_grace_period: 135s depends_on: @@ -215,6 +219,8 @@ services: args: BUILD_VERSION: "${GIT_TAG:-dev}" BUILD_COMMIT: "${GIT_COMMIT:-unknown}" + labels: + com.centurylinklabs.watchtower.enable: "true" restart: unless-stopped stop_grace_period: 35s depends_on: @@ -238,7 +244,7 @@ services: POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}" POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD:-changeme123}" AUTH_SECRET: "${AUTH_SECRET:-dev_secret_change_in_production}" - PUBLIC_MINIO_PUBLIC_URL: "${MINIO_PUBLIC_ENDPOINT:-http://localhost:9000}" + PUBLIC_MINIO_PUBLIC_URL: "${MINIO_PUBLIC_ENDPOINT:-https://localhost}" # Valkey VALKEY_ADDR: "valkey:6379" healthcheck: @@ -248,8 +254,11 @@ services: retries: 3 # ─── Caddy (reverse proxy + automatic HTTPS) ────────────────────────────────── + # Custom build includes github.com/mholt/caddy-ratelimit. caddy: - image: caddy:2-alpine + build: + context: ./caddy + dockerfile: Dockerfile restart: unless-stopped depends_on: backend: @@ -265,9 +274,24 @@ services: CADDY_ACME_EMAIL: "${CADDY_ACME_EMAIL:-}" volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro + - ./caddy/errors:/srv/errors:ro - caddy_data:/data - caddy_config:/config + # ─── Watchtower (auto-redeploy custom services on new images) ──────────────── + # Only watches services labelled com.centurylinklabs.watchtower.enable=true. + # Third-party infra images (minio, pocketbase, meilisearch, etc.) are excluded. + 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: minio_data: pb_data: diff --git a/v3/docs/api-endpoints.md b/v3/docs/api-endpoints.md index dbba6b4..01019bb 100644 --- a/v3/docs/api-endpoints.md +++ b/v3/docs/api-endpoints.md @@ -1,8 +1,8 @@ # API Endpoint Reference -All endpoints served by the Go **backend** binary on `:8080`. In production all -traffic is routed through Caddy — `/api/*` and `/health` are proxied to the -backend; everything else goes to the SvelteKit UI. +> **Routing ownership map**: see [`docs/d2/api-routing.svg`](d2/api-routing.svg) (source: [`docs/d2/api-routing.d2`](d2/api-routing.d2)) for a visual overview of which paths Caddy sends to the backend directly vs. through SvelteKit, with auth levels colour-coded. + +All traffic enters through **Caddy :443**. Caddy routes a subset of paths directly to the Go backend (bypassing SvelteKit); everything else goes to SvelteKit, which enforces auth before proxying onward. ## Health / Version diff --git a/v3/docs/architecture.svg b/v3/docs/architecture.svg deleted file mode 100644 index 55082b5..0000000 --- a/v3/docs/architecture.svg +++ /dev/null @@ -1,125 +0,0 @@ - diff --git a/v3/docs/d2/api-routing.d2 b/v3/docs/d2/api-routing.d2 new file mode 100644 index 0000000..10966d9 --- /dev/null +++ b/v3/docs/d2/api-routing.d2 @@ -0,0 +1,201 @@ +direction: right + +# ─── Legend ─────────────────────────────────────────────────────────────────── + +legend: Legend { + style.fill: "#fafafa" + style.stroke: "#d4d4d8" + + pub: public { + style.fill: "#f0fdf4" + style.font-color: "#15803d" + style.stroke: "#86efac" + } + user: user auth { + style.fill: "#eff6ff" + style.font-color: "#1d4ed8" + style.stroke: "#93c5fd" + } + adm: admin only { + style.fill: "#fff7ed" + style.font-color: "#c2410c" + style.stroke: "#fdba74" + } +} + +# ─── Client ─────────────────────────────────────────────────────────────────── + +client: Browser / iOS App { + shape: person + style.fill: "#fff9e6" +} + +# ─── Caddy ──────────────────────────────────────────────────────────────────── + +caddy: Caddy :443 { + shape: rectangle + style.fill: "#f1f5f9" + label: "Caddy :443\ncustom build · caddy-ratelimit\nsecurity headers · rate limiting\nstatic error pages" +} + +# ─── SvelteKit UI ───────────────────────────────────────────────────────────── +# Handles: auth enforcement, session, all /api/* routes that have SK counterparts + +sk: SvelteKit UI :3000 { + style.fill: "#fef3c7" + + auth: Auth { + style.fill: "#fde68a" + style.stroke: "#f59e0b" + label: "POST /api/auth/login\nPOST /api/auth/register\nPOST /api/auth/change-password\nGET /api/auth/session" + } + + catalogue_sk: Catalogue { + style.fill: "#f0fdf4" + style.stroke: "#86efac" + label: "GET /api/catalogue-page\nGET /api/search" + } + + book_sk: Book { + style.fill: "#f0fdf4" + style.stroke: "#86efac" + label: "GET /api/book/{slug}\nGET /api/chapter/{slug}/{n}\nGET /api/chapter-text-preview/{slug}/{n}" + } + + scrape_sk: Scrape (admin) { + style.fill: "#fff7ed" + style.stroke: "#fdba74" + label: "GET /api/scrape/status\nGET /api/scrape/tasks\nPOST /api/scrape\nPOST /api/scrape/range\nPOST /api/scrape/cancel/{id}" + } + + audio_sk: Audio { + style.fill: "#f0fdf4" + style.stroke: "#86efac" + label: "POST /api/audio/{slug}/{n}\nGET /api/audio/status/{slug}/{n}\nGET /api/voices" + } + + presign_sk: Presigned URLs { + style.fill: "#f0fdf4" + style.stroke: "#86efac" + label: "GET /api/presign/chapter/{slug}/{n}\nGET /api/presign/audio/{slug}/{n}\nGET /api/presign/voice-sample/{voice}" + } + + presign_user: Presigned URLs (user) { + style.fill: "#eff6ff" + style.stroke: "#93c5fd" + label: "GET /api/presign/avatar-upload/{userId}\nGET /api/presign/avatar/{userId}" + } + + progress_sk: Progress { + style.fill: "#f0fdf4" + style.stroke: "#86efac" + label: "GET /api/progress\nPOST /api/progress/{slug}\nDELETE /api/progress/{slug}" + } + + library_sk: Library { + style.fill: "#f0fdf4" + style.stroke: "#86efac" + label: "GET /api/library\nPOST /api/library/{slug}\nDELETE /api/library/{slug}" + } + + comments_sk: Comments { + style.fill: "#f0fdf4" + style.stroke: "#86efac" + label: "GET /api/comments/{slug}\nPOST /api/comments/{slug}" + } +} + +# ─── Go Backend ─────────────────────────────────────────────────────────────── +# Caddy proxies these paths directly — no SvelteKit auth layer + +be: Backend API :8080 { + style.fill: "#eef3ff" + + health_be: Health { + style.fill: "#f0fdf4" + style.stroke: "#86efac" + label: "GET /health\nGET /api/version" + } + + scrape_be: Scrape admin (direct) { + style.fill: "#fff7ed" + style.stroke: "#fdba74" + label: "POST /scrape\nPOST /scrape/book\nPOST /scrape/book/range" + } + + catalogue_be: Catalogue { + style.fill: "#f0fdf4" + style.stroke: "#86efac" + label: "GET /api/browse\nGET /api/catalogue\nGET /api/ranking\nGET /api/cover/{domain}/{slug}" + } + + book_be: Book / Chapter { + style.fill: "#f0fdf4" + style.stroke: "#86efac" + label: "GET /api/book-preview/{slug}\nGET /api/chapter-text/{slug}/{n}\nGET /api/chapter-markdown/{slug}/{n}\nPOST /api/reindex/{slug} ⚠ admin" + } + + audio_be: Audio { + style.fill: "#f0fdf4" + style.stroke: "#86efac" + label: "GET /api/audio-proxy/{slug}/{n}\nGET /api/voices" + } +} + +# ─── Storage ────────────────────────────────────────────────────────────────── + +storage: Storage { + style.fill: "#eaf7ea" + + pb: PocketBase :8090 { + shape: cylinder + label: "auth · books · progress\ncomments · library\nscrape_jobs · audio_cache" + } + mn: MinIO :9000 { + shape: cylinder + label: "chapters · audio\navatars · browse" + } + ms: Meilisearch :7700 { + shape: cylinder + label: "index: books" + } + vk: Valkey :6379 { + shape: cylinder + label: "presign URL cache" + } +} + +# ─── Caddy routing ──────────────────────────────────────────────────────────── + +client -> caddy: HTTPS :443 + +caddy -> sk: "/* (catch-all)\n→ SvelteKit handles auth" +caddy -> be: "/health /scrape*\n/api/browse /api/book-preview/*\n/api/chapter-text/* /api/chapter-markdown/*\n/api/reindex/* /api/cover/*\n/api/audio-proxy/* /api/catalogue /api/ranking" +caddy -> storage.mn: "/avatars/*\n(presigned GETs)" + +# ─── SvelteKit → Backend (server-side proxy) ────────────────────────────────── + +sk.catalogue_sk -> be.catalogue_be: internal proxy +sk.book_sk -> be.book_be: internal proxy +sk.audio_sk -> be.audio_be: internal proxy +sk.presign_sk -> storage.vk: check cache +sk.presign_sk -> storage.mn: generate presign +sk.presign_user -> storage.mn: generate presign + +# ─── SvelteKit → Storage (direct) ──────────────────────────────────────────── + +sk.auth -> storage.pb: sessions / users +sk.scrape_sk -> storage.pb: scrape job records +sk.progress_sk -> storage.pb +sk.library_sk -> storage.pb +sk.comments_sk -> storage.pb + +# ─── Backend → Storage ──────────────────────────────────────────────────────── + +be.catalogue_be -> storage.ms: full-text search +be.catalogue_be -> storage.pb: ranking records +be.catalogue_be -> storage.mn: cover presign +be.book_be -> storage.mn: chapter objects +be.book_be -> storage.pb: book metadata +be.audio_be -> storage.mn: audio presign +be.audio_be -> storage.vk: presign cache diff --git a/v3/docs/d2/api-routing.svg b/v3/docs/d2/api-routing.svg new file mode 100644 index 0000000..ae42f95 --- /dev/null +++ b/v3/docs/d2/api-routing.svg @@ -0,0 +1,127 @@ +- Log in + Log in to leave a comment.
{/if} diff --git a/v3/ui/src/lib/server/catalogue.ts b/v3/ui/src/lib/server/catalogue.ts new file mode 100644 index 0000000..7a1b644 --- /dev/null +++ b/v3/ui/src/lib/server/catalogue.ts @@ -0,0 +1,71 @@ +/** + * Shared types and helpers for the /api/catalogue backend response. + * + * Imported by both: + * - src/routes/catalogue/+page.server.ts (SSR page load) + * - src/routes/api/catalogue-page/+server.ts (infinite-scroll proxy) + */ + +/** Shape of a single book as returned by GET /api/catalogue on the Go backend. */ +export interface CatalogueBook { + slug: string; + title: string; + author: string; + cover: string; + status: string; + genres: string[]; + summary: string; + total_chapters: number; + source_url: string; + ranking: number; + rating: number; +} + +/** Facets returned alongside catalogue results for dynamic filter options. */ +export interface CatalogueFacets { + genres: string[]; + statuses: string[]; +} + +/** Full response shape from GET /api/catalogue. */ +export interface CatalogueResponse { + books: CatalogueBook[]; + page: number; + limit: number; + total: number; + has_next: boolean; + facets?: CatalogueFacets; +} + +/** Normalised book shape consumed by the catalogue UI. */ +export interface NovelListing { + slug: string; + title: string; + cover: string; + rank: string; + rating: string; + chapters: string; + url: string; + // enriched fields + author?: string; + status?: string; + genres?: string[]; + source_url?: string; +} + +/** Convert a raw CatalogueBook into the UI NovelListing shape. */ +export function bookToListing(book: CatalogueBook): NovelListing { + return { + slug: book.slug, + title: book.title, + cover: book.cover, + rank: book.ranking > 0 ? `#${book.ranking}` : '', + rating: book.rating > 0 ? String(book.rating) : '', + chapters: book.total_chapters > 0 ? `${book.total_chapters} chapters` : '', + url: book.source_url ?? '', + author: book.author, + status: book.status, + genres: book.genres ?? [], + source_url: book.source_url + }; +} diff --git a/v3/ui/src/lib/server/minio.ts b/v3/ui/src/lib/server/minio.ts index 0dccca9..149573e 100644 --- a/v3/ui/src/lib/server/minio.ts +++ b/v3/ui/src/lib/server/minio.ts @@ -51,7 +51,7 @@ export async function presignAvatarUrl(userId: string): Promise