Compare commits

...

8 Commits

Author SHA1 Message Date
Admin
8c47aa3a11 fix: cover proxy routing, session filtering, library tab deep-link, profile UX
Some checks failed
Release / Test backend (push) Successful in 1m3s
Release / Test UI (push) Successful in 58s
Release / Build and push images (push) Successful in 5m55s
Release / Deploy to prod (push) Failing after 48s
Release / Deploy to homelab (push) Successful in 21s
Release / Gitea Release (push) Successful in 29s
- Catalogue/cover: rewrite raw scraped cover URLs to /api/cover/{domain}/{slug}
  in handleCatalogue so all covers route through the backend proxy; fix broken
  cdn.novelfire.net fallback in handleGetCover to read stored URL from PocketBase
- Catalogue/profile: add Svelte 5 onerror handlers on cover <img> tags to show
  letter-initial placeholder when image fails to load
- Library page: read ?status URL param to initialise activeShelf tab on load so
  /books?status=reading correctly pre-selects the Reading tab
- Sessions: filter bot/tool user-agents (curl, python, wget, etc.) and debug-IP
  sessions from listUserSessions display; also purge them in pruneStaleUserSessions
- Profile: show email under username, quick stats chips (streak/chapters/completed)
  in header, reading count on Library row, dedicated Sign out row, history covers
  routed through /api/cover proxy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 13:32:48 +05:00
Admin
1f987be75a feat: optimize prod deployment to avoid unnecessary container restarts
Some checks failed
Release / Test backend (push) Successful in 1m7s
Release / Test UI (push) Successful in 1m29s
Release / Build and push images (push) Successful in 4m30s
Release / Deploy to prod (push) Failing after 19s
Release / Deploy to homelab (push) Successful in 13s
Release / Gitea Release (push) Successful in 27s
Previously: 'docker compose up -d' recreated all services with changed images,
causing dependent services (pocketbase, minio, redis, etc.) to restart and
wait for healthchecks, leading to longer downtime.

Now: Use '--no-deps' flag to restart ONLY the services with updated images
(backend, runner, ui, caddy, pocketbase) without touching their dependencies.

Benefits:
- Faster deployments (~15-20s vs ~60s)
- No unnecessary restarts of infrastructure services
- Reduced downtime for the application

The final 'docker compose up -d --remove-orphans' ensures any orphaned
containers are cleaned up and all services are in the desired state.
2026-04-16 21:51:42 +05:00
Admin
7a4008bd9c chore: improve workflow job names for clarity
All checks were successful
Release / Test backend (push) Successful in 1m2s
Release / Test UI (push) Successful in 58s
Release / Build and push images (push) Successful in 4m34s
Release / Deploy to prod (push) Successful in 2m24s
Release / Deploy to homelab (push) Successful in 15s
Release / Gitea Release (push) Successful in 20s
- 'Check ui' → 'Test UI' (consistent with 'Test backend')
- 'Docker' → 'Build and push images' (more descriptive of what it does)

Job IDs remain unchanged (test-backend, check-ui, docker) for stability.
2026-04-16 21:34:23 +05:00
Admin
f4834f968a fix: disable strict host key checking for homelab SSH
Some checks failed
Release / Test backend (push) Successful in 55s
Release / Check ui (push) Successful in 1m0s
Release / Docker (push) Failing after 2m52s
Release / Deploy to prod (push) Has been skipped
Release / Deploy to homelab (push) Has been skipped
Release / Gitea Release (push) Has been skipped
Homelab is on private network (192.168.0.109), so we can safely disable
strict host key checking. This avoids the complexity of managing known_hosts
entries in Gitea secrets.

Changes:
- Remove HOMELAB_SSH_KNOWN_HOSTS requirement
- Add -o StrictHostKeyChecking=no to scp/ssh commands
- Add -o UserKnownHostsFile=/dev/null to avoid host key persistence
2026-04-16 21:23:59 +05:00
Admin
32ee3c302d chore: add .opencode/ to gitignore
Local OpenCode agent state (memory, node_modules) shouldn't be committed.
2026-04-16 20:34:05 +05:00
Admin
f5650a98ec chore: remove unused homelab/runner directory
We use homelab/docker-compose.yml (full stack) for the homelab deployment,
not homelab/runner/docker-compose.yml (runner-only subset). Removing the
unused directory to prevent confusion.
2026-04-16 20:25:37 +05:00
Admin
9c3b235382 fix: copy full homelab compose file, not runner-only subset
Some checks failed
Release / Test backend (push) Successful in 1m1s
Release / Check ui (push) Successful in 1m2s
Release / Docker (push) Successful in 9m22s
Release / Deploy to prod (push) Successful in 2m32s
Release / Gitea Release (push) Successful in 1m37s
Release / Deploy to homelab (push) Failing after 5s
CRITICAL FIX: The homelab server runs the full stack (runner + GlitchTip +
observability tools), not just the runner. Copying homelab/runner/docker-compose.yml
would have destroyed all other services.

Changed: homelab/runner/docker-compose.yml → homelab/docker-compose.yml
2026-04-16 20:22:10 +05:00
Admin
da37b1be88 feat: option A — visibility gating + author submission system
Some checks failed
Release / Test backend (push) Successful in 1m1s
Release / Check ui (push) Successful in 1m0s
Release / Docker (push) Successful in 11m19s
Release / Deploy to prod (push) Successful in 2m10s
Release / Deploy to homelab (push) Failing after 7s
Release / Gitea Release (push) Successful in 2m25s
Content visibility:
- Add `visibility` field to books ("public" | "admin_only"); new migration
  backfills all existing scraped books to admin_only
- Meilisearch: add visibility as filterable attribute; catalogue/search
  endpoints filter to public-only for non-admin requests
- Admin users identified by bearer token bypass the filter and see all books
- All PocketBase discovery queries (trending, recommended, recently-updated,
  audio shelf, discover, subscription feed) now filter to visibility=public
- New scraped books default to admin_only; WriteMetadata preserves existing
  visibility on PATCH (never overwrites)

Author submission:
- POST /api/admin/books/submit — creates a public book with submitted_by
- PATCH /api/admin/books/{slug}/publish / unpublish — toggle visibility
- SvelteKit proxies: /api/admin/books/[slug]/publish|unpublish
- /api/books/[slug] endpoint for admin book lookup

Frontend:
- backendFetchAdmin() helper sends admin token on any path
- Catalogue server load uses admin fetch when user is admin
- /submit page: author submission form with genre picker and rights assertion
- "Publish" nav link shown to all logged-in users
- Admin catalogue-tools: visibility management panel (load book by slug, toggle)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 20:20:16 +05:00
24 changed files with 924 additions and 161 deletions

View File

@@ -32,7 +32,7 @@ jobs:
# ── ui: type-check & build ────────────────────────────────────────────────────
check-ui:
name: Check ui
name: Test UI
runs-on: ubuntu-latest
defaults:
run:
@@ -57,7 +57,7 @@ jobs:
# ── docker: build + push all images via docker bake ──────────────────────────
docker:
name: Docker
name: Build and push images
runs-on: ubuntu-latest
needs: [test-backend, check-ui]
steps:
@@ -130,6 +130,8 @@ jobs:
'set -euo pipefail
cd /opt/libnovel
doppler run -- docker compose pull backend runner ui caddy pocketbase
# Restart only the services with new images, without waiting for dependencies
doppler run -- docker compose up -d --no-deps backend runner ui caddy pocketbase
doppler run -- docker compose up -d --remove-orphans'
# ── deploy homelab runner ─────────────────────────────────────────────────────
@@ -152,17 +154,20 @@ jobs:
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.HOMELAB_SSH_KEY }}" > ~/.ssh/homelab_key
chmod 600 ~/.ssh/homelab_key
printf '%s\n' "${{ secrets.HOMELAB_SSH_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts
- name: Copy docker-compose.yml to homelab
run: |
scp -i ~/.ssh/homelab_key \
homelab/runner/docker-compose.yml \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
homelab/docker-compose.yml \
"${{ secrets.HOMELAB_USER }}@${{ secrets.HOMELAB_HOST }}:/opt/libnovel-runner/docker-compose.yml"
- name: Pull new runner image and restart
run: |
ssh -i ~/.ssh/homelab_key \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
"${{ secrets.HOMELAB_USER }}@${{ secrets.HOMELAB_HOST }}" \
'set -euo pipefail
cd /opt/libnovel-runner

1
.gitignore vendored
View File

@@ -28,3 +28,4 @@ Thumbs.db
*.swp
*.swo
*~
.opencode/

View File

@@ -293,7 +293,8 @@ func (s *Server) handleGetRanking(w http.ResponseWriter, r *http.Request) {
// handleGetCover handles GET /api/cover/{domain}/{slug}.
// Serves the cover image directly from MinIO when available; falls back to a
// redirect to the novelfire CDN when the cover has not yet been downloaded.
// redirect to the stored cover URL from PocketBase when the cover has not yet
// been downloaded to MinIO.
func (s *Server) handleGetCover(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if slug == "" {
@@ -318,10 +319,20 @@ func (s *Server) handleGetCover(w http.ResponseWriter, r *http.Request) {
}
}
// Fallback: redirect to the CDN. The caller sees a working image; the
// cover will be populated on the next catalogue refresh run.
coverURL := fmt.Sprintf("https://cdn.novelfire.net/covers/%s.jpg", slug)
http.Redirect(w, r, coverURL, http.StatusFound)
// Fallback: read the stored cover URL from PocketBase and redirect to it.
// This avoids the broken cdn.novelfire.net domain and uses the actual URL
// scraped from the source. If the book is not found, return 404.
meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), slug)
if err != nil {
s.deps.Log.Warn("handleGetCover: ReadMetadata error", "slug", slug, "err", err)
http.Error(w, "cover not found", http.StatusNotFound)
return
}
if !ok || meta.Cover == "" || strings.HasPrefix(meta.Cover, "/api/cover/") {
http.Error(w, "cover not found", http.StatusNotFound)
return
}
http.Redirect(w, r, meta.Cover, http.StatusFound)
}
// ── Preview (live scrape, no store writes) ─────────────────────────────────────
@@ -1869,13 +1880,19 @@ func (s *Server) handleCatalogue(w http.ResponseWriter, r *http.Request) {
limit = 100
}
// Admin users (identified by bearer token) see all non-archived books
// including those marked admin_only.
isAdmin := s.cfg.AdminToken != "" &&
r.Header.Get("Authorization") == "Bearer "+s.cfg.AdminToken
cq := meili.CatalogueQuery{
Q: q.Get("q"),
Genre: genre,
Status: status,
Sort: sort,
Page: page,
Limit: limit,
Q: q.Get("q"),
Genre: genre,
Status: status,
Sort: sort,
Page: page,
Limit: limit,
AdminAll: isAdmin,
}
books, total, facets, err := s.deps.SearchIndex.Catalogue(r.Context(), cq)
@@ -1885,6 +1902,16 @@ func (s *Server) handleCatalogue(w http.ResponseWriter, r *http.Request) {
return
}
// Rewrite raw scraped cover URLs to go through the backend cover proxy.
// /api/cover/{domain}/{slug} serves from MinIO when available, otherwise
// redirects to the CDN. This avoids ERR_BLOCKED_BY_ORB when the source
// site returns HTML error pages instead of images.
for i := range books {
if !strings.HasPrefix(books[i].Cover, "/api/cover/") {
books[i].Cover = fmt.Sprintf("/api/cover/novelfire.net/%s", books[i].Slug)
}
}
hasNext := int64(page*limit) < total
w.Header().Set("Cache-Control", "public, max-age=60")

View File

@@ -0,0 +1,161 @@
package backend
import (
"encoding/json"
"errors"
"net/http"
"regexp"
"strings"
"unicode"
"github.com/libnovel/backend/internal/domain"
"github.com/libnovel/backend/internal/storage"
)
// handleAdminPublishBook handles PATCH /api/admin/books/{slug}/publish.
// Sets visibility=public so the book is visible to all users.
func (s *Server) handleAdminPublishBook(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if slug == "" {
jsonError(w, http.StatusBadRequest, "missing slug")
return
}
if s.deps.BookAdminStore == nil {
jsonError(w, http.StatusServiceUnavailable, "book admin store not configured")
return
}
if err := s.deps.BookAdminStore.PublishBook(r.Context(), slug); err != nil {
if errors.Is(err, storage.ErrNotFound) {
jsonError(w, http.StatusNotFound, "book not found")
return
}
s.deps.Log.Error("publish book failed", "slug", slug, "err", err)
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
// Sync the visibility change to Meilisearch immediately.
if meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), slug); err == nil && ok {
if upsertErr := s.deps.SearchIndex.UpsertBook(r.Context(), meta); upsertErr != nil {
s.deps.Log.Warn("publish book: meili upsert failed", "slug", slug, "err", upsertErr)
}
}
s.deps.Log.Info("book published", "slug", slug)
writeJSON(w, http.StatusOK, map[string]string{"slug": slug, "visibility": domain.VisibilityPublic})
}
// handleAdminUnpublishBook handles PATCH /api/admin/books/{slug}/unpublish.
// Sets visibility=admin_only, hiding the book from regular users.
func (s *Server) handleAdminUnpublishBook(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if slug == "" {
jsonError(w, http.StatusBadRequest, "missing slug")
return
}
if s.deps.BookAdminStore == nil {
jsonError(w, http.StatusServiceUnavailable, "book admin store not configured")
return
}
if err := s.deps.BookAdminStore.UnpublishBook(r.Context(), slug); err != nil {
if errors.Is(err, storage.ErrNotFound) {
jsonError(w, http.StatusNotFound, "book not found")
return
}
s.deps.Log.Error("unpublish book failed", "slug", slug, "err", err)
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
// Sync to Meilisearch.
if meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), slug); err == nil && ok {
if upsertErr := s.deps.SearchIndex.UpsertBook(r.Context(), meta); upsertErr != nil {
s.deps.Log.Warn("unpublish book: meili upsert failed", "slug", slug, "err", upsertErr)
}
}
s.deps.Log.Info("book unpublished", "slug", slug)
writeJSON(w, http.StatusOK, map[string]string{"slug": slug, "visibility": domain.VisibilityAdminOnly})
}
// handleAdminSubmitBook handles POST /api/admin/books/submit.
// Creates a new author-submitted book with visibility=public.
// The book starts with zero chapters; chapters are added via the import pipeline.
func (s *Server) handleAdminSubmitBook(w http.ResponseWriter, r *http.Request) {
var req struct {
Title string `json:"title"`
Author string `json:"author"`
Cover string `json:"cover"`
Summary string `json:"summary"`
Genres []string `json:"genres"`
Status string `json:"status"`
SubmittedBy string `json:"submitted_by"` // app_users ID of submitting author
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "invalid request body")
return
}
req.Title = strings.TrimSpace(req.Title)
if req.Title == "" {
jsonError(w, http.StatusBadRequest, "title is required")
return
}
if req.Status == "" {
req.Status = "ongoing"
}
slug := slugifyTitle(req.Title)
if slug == "" {
jsonError(w, http.StatusBadRequest, "could not derive a slug from title")
return
}
if s.deps.BookAdminStore == nil {
jsonError(w, http.StatusServiceUnavailable, "book admin store not configured")
return
}
meta := domain.BookMeta{
Slug: slug,
Title: req.Title,
Author: req.Author,
Cover: req.Cover,
Summary: req.Summary,
Genres: req.Genres,
Status: req.Status,
Visibility: domain.VisibilityPublic,
SubmittedBy: req.SubmittedBy,
}
if err := s.deps.BookAdminStore.CreateSubmittedBook(r.Context(), meta); err != nil {
s.deps.Log.Error("submit book: create failed", "slug", slug, "err", err)
jsonError(w, http.StatusInternalServerError, "failed to create book")
return
}
// Index in Meilisearch immediately so it appears in search/catalogue.
if upsertErr := s.deps.SearchIndex.UpsertBook(r.Context(), meta); upsertErr != nil {
s.deps.Log.Warn("submit book: meili upsert failed", "slug", slug, "err", upsertErr)
}
s.deps.Log.Info("book submitted", "slug", slug, "title", req.Title, "by", req.SubmittedBy)
writeJSON(w, http.StatusCreated, map[string]string{"slug": slug})
}
// slugifyTitle converts a book title into a URL-safe slug.
// e.g. "The Wandering Sword" → "the-wandering-sword"
var nonAlnum = regexp.MustCompile(`[^a-z0-9]+`)
func slugifyTitle(title string) string {
// Fold to lower-case ASCII, replace non-alphanum runs with hyphens.
var b strings.Builder
for _, r := range strings.ToLower(title) {
if r <= unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r)) {
b.WriteRune(r)
} else {
b.WriteRune('-')
}
}
slug := nonAlnum.ReplaceAllString(b.String(), "-")
slug = strings.Trim(slug, "-")
if len(slug) > 80 {
slug = slug[:80]
slug = strings.TrimRight(slug, "-")
}
return slug
}

View File

@@ -283,10 +283,15 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
// Admin data repair endpoints
admin("POST /api/admin/dedup-chapters/{slug}", s.handleDedupChapters)
// Admin book management (soft-delete / hard-delete)
// Admin book management (soft-delete / hard-delete / publish visibility)
admin("PATCH /api/admin/books/{slug}/archive", s.handleAdminArchiveBook)
admin("PATCH /api/admin/books/{slug}/unarchive", s.handleAdminUnarchiveBook)
admin("DELETE /api/admin/books/{slug}", s.handleAdminDeleteBook)
admin("PATCH /api/admin/books/{slug}/publish", s.handleAdminPublishBook)
admin("PATCH /api/admin/books/{slug}/unpublish", s.handleAdminUnpublishBook)
// Author book submission (creates a public book with no scraped content)
admin("POST /api/admin/books/submit", s.handleAdminSubmitBook)
// Admin chapter split (imported books)
admin("POST /api/admin/books/{slug}/split-chapters", s.handleAdminSplitChapters)

View File

@@ -235,6 +235,15 @@ type BookAdminStore interface {
// - MinIO cover image (covers/{slug}.jpg)
// The caller is responsible for also deleting the Meilisearch document.
DeleteBook(ctx context.Context, slug string) error
// PublishBook sets visibility=public, making the book visible to all users.
PublishBook(ctx context.Context, slug string) error
// UnpublishBook sets visibility=admin_only, hiding the book from regular users.
UnpublishBook(ctx context.Context, slug string) error
// CreateSubmittedBook creates a new author-submitted book with visibility=public.
CreateSubmittedBook(ctx context.Context, meta domain.BookMeta) error
}
// ImportFileStore uploads raw import files to object storage.

View File

@@ -7,6 +7,12 @@ import "time"
// ── Book types ────────────────────────────────────────────────────────────────
// Visibility values for BookMeta.Visibility.
const (
VisibilityPublic = "public" // visible to all users
VisibilityAdminOnly = "admin_only" // visible only to admin users (e.g. scraped content)
)
// BookMeta carries all bibliographic information about a novel.
type BookMeta struct {
Slug string `json:"slug"`
@@ -27,6 +33,12 @@ type BookMeta struct {
// Archived is true when the book has been soft-deleted by an admin.
// Archived books are excluded from all public search and catalogue responses.
Archived bool `json:"archived,omitempty"`
// Visibility controls who can see this book.
// "public" = all users; "admin_only" = admin only (default for scraped content).
Visibility string `json:"visibility,omitempty"`
// SubmittedBy is the app_users ID of the author who submitted this book,
// or empty for scraped books.
SubmittedBy string `json:"submitted_by,omitempty"`
}
// CatalogueEntry is a lightweight book reference returned by catalogue pages.

View File

@@ -52,6 +52,9 @@ type CatalogueQuery struct {
Sort string // sort field: "popular", "new", "update", "top-rated", "rank", ""
Page int // 1-indexed
Limit int // items per page, default 20
// AdminAll disables the visibility filter so admin users see all non-archived
// books including those marked admin_only.
AdminAll bool
}
// FacetResult holds the available filter values discovered from the index.
@@ -103,7 +106,7 @@ func Configure(host, apiKey string) error {
return fmt.Errorf("meili: update searchable attributes: %w", err)
}
filterable := []interface{}{"status", "genres", "archived"}
filterable := []interface{}{"status", "genres", "archived", "visibility"}
if _, err := idx.UpdateFilterableAttributes(&filterable); err != nil {
return fmt.Errorf("meili: update filterable attributes: %w", err)
}
@@ -135,6 +138,9 @@ type bookDoc struct {
// Archived is true when the book has been soft-deleted by an admin.
// Used as a filter to exclude archived books from all search results.
Archived bool `json:"archived"`
// Visibility is "public" or "admin_only". Only public books are shown to
// non-admin users. Empty string is treated as admin_only for safety.
Visibility string `json:"visibility"`
}
func toDoc(b domain.BookMeta) bookDoc {
@@ -152,6 +158,7 @@ func toDoc(b domain.BookMeta) bookDoc {
Rating: b.Rating,
MetaUpdated: b.MetaUpdated,
Archived: b.Archived,
Visibility: b.Visibility,
}
}
@@ -170,6 +177,7 @@ func fromDoc(d bookDoc) domain.BookMeta {
Rating: d.Rating,
MetaUpdated: d.MetaUpdated,
Archived: d.Archived,
Visibility: d.Visibility,
}
}
@@ -210,7 +218,7 @@ func (c *MeiliClient) Search(_ context.Context, query string, limit int) ([]doma
}
res, err := c.idx.Search(query, &meilisearch.SearchRequest{
Limit: int64(limit),
Filter: "archived = false",
Filter: `archived = false AND visibility = "public"`,
})
if err != nil {
return nil, fmt.Errorf("meili: search %q: %w", query, err)
@@ -251,8 +259,11 @@ func (c *MeiliClient) Catalogue(_ context.Context, q CatalogueQuery) ([]domain.B
Facets: []string{"genres", "status"},
}
// Build filter — always exclude archived books
// Build filter — always exclude archived books; restrict to public unless admin.
filters := []string{"archived = false"}
if !q.AdminAll {
filters = append(filters, `visibility = "public"`)
}
if q.Genre != "" && q.Genre != "all" {
filters = append(filters, fmt.Sprintf("genres = %q", q.Genre))
}

View File

@@ -63,7 +63,8 @@ var _ taskqueue.Reader = (*Store)(nil)
// ── BookWriter ────────────────────────────────────────────────────────────────
func (s *Store) WriteMetadata(ctx context.Context, meta domain.BookMeta) error {
payload := map[string]any{
// patchPayload does NOT include visibility or submitted_by — preserve existing values.
patchPayload := map[string]any{
"slug": meta.Slug,
"title": meta.Title,
"author": meta.Author,
@@ -85,7 +86,13 @@ func (s *Store) WriteMetadata(ctx context.Context, meta domain.BookMeta) error {
return fmt.Errorf("WriteMetadata: %w", err)
}
if err == ErrNotFound {
postErr := s.pb.post(ctx, "/api/collections/books/records", payload, nil)
// New scraped book — default to admin_only visibility.
postPayload := make(map[string]any, len(patchPayload)+1)
for k, v := range patchPayload {
postPayload[k] = v
}
postPayload["visibility"] = domain.VisibilityAdminOnly
postErr := s.pb.post(ctx, "/api/collections/books/records", postPayload, nil)
if postErr == nil {
return nil
}
@@ -96,7 +103,28 @@ func (s *Store) WriteMetadata(ctx context.Context, meta domain.BookMeta) error {
return postErr // original POST error is more informative
}
}
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/books/records/%s", existing.ID), payload)
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/books/records/%s", existing.ID), patchPayload)
}
// CreateSubmittedBook creates a new author-submitted book with visibility=public.
// Unlike WriteMetadata this always POSTs (no upsert) and sets the submitted_by field.
func (s *Store) CreateSubmittedBook(ctx context.Context, meta domain.BookMeta) error {
payload := map[string]any{
"slug": meta.Slug,
"title": meta.Title,
"author": meta.Author,
"cover": meta.Cover,
"status": meta.Status,
"genres": meta.Genres,
"summary": meta.Summary,
"total_chapters": 0,
"source_url": "",
"ranking": 0,
"rating": 0,
"visibility": domain.VisibilityPublic,
"submitted_by": meta.SubmittedBy,
}
return s.pb.post(ctx, "/api/collections/books/records", payload, nil)
}
func (s *Store) WriteChapter(ctx context.Context, slug string, chapter domain.Chapter) error {
@@ -228,6 +256,8 @@ type pbBook struct {
Rating float64 `json:"rating"`
Updated string `json:"updated"`
Archived bool `json:"archived"`
Visibility string `json:"visibility"`
SubmittedBy string `json:"submitted_by"`
}
func (b pbBook) toDomain() domain.BookMeta {
@@ -249,6 +279,8 @@ func (b pbBook) toDomain() domain.BookMeta {
Rating: b.Rating,
MetaUpdated: metaUpdated,
Archived: b.Archived,
Visibility: b.Visibility,
SubmittedBy: b.SubmittedBy,
}
}
@@ -407,6 +439,32 @@ func (s *Store) UnarchiveBook(ctx context.Context, slug string) error {
map[string]any{"archived": false})
}
// PublishBook sets visibility=public on the book record for slug.
func (s *Store) PublishBook(ctx context.Context, slug string) error {
book, err := s.getBookBySlug(ctx, slug)
if err == ErrNotFound {
return ErrNotFound
}
if err != nil {
return fmt.Errorf("PublishBook: %w", err)
}
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/books/records/%s", book.ID),
map[string]any{"visibility": domain.VisibilityPublic})
}
// UnpublishBook sets visibility=admin_only on the book record for slug.
func (s *Store) UnpublishBook(ctx context.Context, slug string) error {
book, err := s.getBookBySlug(ctx, slug)
if err == ErrNotFound {
return ErrNotFound
}
if err != nil {
return fmt.Errorf("UnpublishBook: %w", err)
}
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/books/records/%s", book.ID),
map[string]any{"visibility": domain.VisibilityAdminOnly})
}
// DeleteBook permanently removes all data for a book:
// - PocketBase books record
// - All PocketBase chapters_idx records for the slug

View File

@@ -0,0 +1,75 @@
// Migration 3 — add visibility + submitted_by fields to books.
//
// visibility: "public" | "admin_only"
// All existing (scraped) books are backfilled to "admin_only".
// New author-submitted books are created with "public".
//
// submitted_by: optional app_users ID for books submitted by a registered author.
// Empty for scraped books.
//
// The backfill iterates books in pages of 200. It is idempotent: books whose
// visibility is already set are skipped.
package migrations
import (
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
coll, err := app.FindCollectionByNameOrId("books")
if err != nil {
return err
}
changed := false
if coll.Fields.GetByName("visibility") == nil {
coll.Fields.Add(&core.TextField{Name: "visibility"})
changed = true
}
if coll.Fields.GetByName("submitted_by") == nil {
coll.Fields.Add(&core.TextField{Name: "submitted_by"})
changed = true
}
if changed {
if err := app.Save(coll); err != nil {
return err
}
}
// Backfill: mark all existing books as admin_only where visibility is empty.
// These are scraped books that pre-date this migration.
const perPage = 200
for page := 1; ; page++ {
records, err := app.FindRecordsByFilter(
"books", `visibility=""`, "+id", perPage, (page-1)*perPage, nil,
)
if err != nil || len(records) == 0 {
break
}
for _, rec := range records {
rec.Set("visibility", "admin_only")
// Best-effort: ignore individual save errors (don't abort migration).
_ = app.Save(rec)
}
if len(records) < perPage {
break
}
}
return nil
}, func(app core.App) error {
coll, err := app.FindCollectionByNameOrId("books")
if err != nil {
return nil
}
for _, name := range []string{"visibility", "submitted_by"} {
f := coll.Fields.GetByName(name)
if f == nil {
continue
}
coll.Fields.RemoveById(f.GetId())
}
return app.Save(coll)
})
}

View File

@@ -1,115 +0,0 @@
# LibNovel homelab runner
#
# Connects to production PocketBase and MinIO via public subdomains.
# All secrets come from Doppler (project=libnovel, config=prd_homelab).
# Run with: doppler run -- docker compose up -d
#
# Differs from prod runner:
# - RUNNER_WORKER_ID=homelab-runner-1 (unique, avoids task claiming conflicts)
# - MINIO_ENDPOINT/USE_SSL → storage.libnovel.cc over HTTPS
# - POCKETBASE_URL → https://pb.libnovel.cc
# - MEILI_URL → https://search.libnovel.cc (Caddy-proxied)
# - VALKEY_ADDR → unset (not exposed publicly)
# - RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH=true
# - REDIS_ADDR → rediss://redis.libnovel.cc:6380 (prod Redis via Caddy TLS proxy)
# - LibreTranslate service for machine translation (internal network only)
#
# extra_hosts pins storage.libnovel.cc and pb.libnovel.cc to the prod server IP
# (165.22.70.138) so that large PutObject uploads and PocketBase writes bypass
# Cloudflare's 100-second proxy timeout entirely. TLS still terminates at Caddy
# on prod; the TLS certificate is valid for the domain names so SNI works fine.
services:
libretranslate:
image: libretranslate/libretranslate:latest
restart: unless-stopped
environment:
LT_API_KEYS: "true"
LT_API_KEYS_DB_PATH: "/app/db/api_keys.db"
# Limit to source→target pairs the runner actually uses
LT_LOAD_ONLY: "en,ru,id,pt,fr"
LT_DISABLE_WEB_UI: "true"
LT_UPDATE_MODELS: "false"
volumes:
- libretranslate_models:/home/libretranslate/.local/share/argos-translate
- libretranslate_db:/app/db
runner:
image: kalekber/libnovel-runner:latest
restart: unless-stopped
stop_grace_period: 135s
labels:
- "com.centurylinklabs.watchtower.enable=true"
depends_on:
- libretranslate
# Pin prod subdomains to the prod server IP to bypass Cloudflare's 100s
# proxy timeout. Large MP3 PutObject uploads and PocketBase writes go
# directly to Caddy on prod; TLS and SNI still work normally.
extra_hosts:
- "storage.libnovel.cc:165.22.70.138"
- "pb.libnovel.cc:165.22.70.138"
environment:
# ── PocketBase ──────────────────────────────────────────────────────────
POCKETBASE_URL: "https://pb.libnovel.cc"
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL}"
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD}"
# ── MinIO (S3 API via public subdomain) ─────────────────────────────────
MINIO_ENDPOINT: "storage.libnovel.cc"
MINIO_ACCESS_KEY: "${MINIO_ROOT_USER}"
MINIO_SECRET_KEY: "${MINIO_ROOT_PASSWORD}"
MINIO_USE_SSL: "true"
MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT}"
MINIO_PUBLIC_USE_SSL: "${MINIO_PUBLIC_USE_SSL}"
# ── Meilisearch (via search.libnovel.cc Caddy proxy) ────────────────────
MEILI_URL: "${MEILI_URL}"
MEILI_API_KEY: "${MEILI_API_KEY}"
VALKEY_ADDR: ""
# Force IPv4 DNS resolution — homelab has no IPv6 route to search.libnovel.cc
GODEBUG: "preferIPv4=1"
# ── Kokoro TTS ──────────────────────────────────────────────────────────
KOKORO_URL: "${KOKORO_URL}"
KOKORO_VOICE: "${KOKORO_VOICE}"
# ── Pocket TTS ──────────────────────────────────────────────────────────
POCKET_TTS_URL: "${POCKET_TTS_URL}"
# ── Cloudflare Workers AI TTS ────────────────────────────────────────────
CFAI_ACCOUNT_ID: "${CFAI_ACCOUNT_ID}"
CFAI_API_TOKEN: "${CFAI_API_TOKEN}"
# ── LibreTranslate (internal Docker network) ────────────────────────────
LIBRETRANSLATE_URL: "http://libretranslate:5000"
LIBRETRANSLATE_API_KEY: "${LIBRETRANSLATE_API_KEY}"
# ── Asynq / Redis (prod Redis via Caddy TLS proxy) ──────────────────────
# The runner connects to prod Redis over TLS: rediss://redis.libnovel.cc:6380.
# Caddy on prod terminates TLS and proxies to the local redis:6379 sidecar.
REDIS_ADDR: "${REDIS_ADDR}"
REDIS_PASSWORD: "${REDIS_PASSWORD}"
# ── Runner tuning ───────────────────────────────────────────────────────
RUNNER_WORKER_ID: "${RUNNER_WORKER_ID}"
RUNNER_POLL_INTERVAL: "${RUNNER_POLL_INTERVAL}"
RUNNER_MAX_CONCURRENT_SCRAPE: "${RUNNER_MAX_CONCURRENT_SCRAPE}"
RUNNER_MAX_CONCURRENT_AUDIO: "${RUNNER_MAX_CONCURRENT_AUDIO}"
RUNNER_MAX_CONCURRENT_TRANSLATION: "${RUNNER_MAX_CONCURRENT_TRANSLATION}"
RUNNER_TIMEOUT: "${RUNNER_TIMEOUT}"
RUNNER_METRICS_ADDR: "${RUNNER_METRICS_ADDR}"
RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH: "true"
# ── Observability ───────────────────────────────────────────────────────
LOG_LEVEL: "${LOG_LEVEL}"
GLITCHTIP_DSN: "${GLITCHTIP_DSN_RUNNER}"
healthcheck:
test: ["CMD", "/healthcheck", "file", "/tmp/runner.alive", "120"]
interval: 60s
timeout: 5s
retries: 3
volumes:
libretranslate_models:
libretranslate_db:

View File

@@ -45,6 +45,8 @@ export interface Book {
ranking: number;
meta_updated: string;
archived?: boolean;
visibility?: string;
submitted_by?: string;
}
export interface ChapterIdx {
@@ -380,7 +382,7 @@ export async function getTrendingBooks(limit = 8): Promise<Book[]> {
const key = `books:trending:${limit}`;
const cached = await cache.get<Book[]>(key);
if (cached) return cached;
const books = await listN<Book>('books', limit, 'ranking>0', '+ranking');
const books = await listN<Book>('books', limit, 'ranking>0&&visibility="public"', '+ranking');
await cache.set(key, books, 15 * 60);
return books;
}
@@ -403,7 +405,8 @@ export async function getRecommendedBooks(
const genreFilter = sortedGenres
.map((g) => `genres~"${g.replace(/"/g, '')}"`)
.join('||');
books = await listN<Book>('books', limit * 4, genreFilter, '+ranking');
const filter = `visibility="public"&&(${genreFilter})`;
books = await listN<Book>('books', limit * 4, filter, '+ranking');
await cache.set(key, books, 10 * 60);
}
return books.filter((b) => !excludeSlugs.has(b.slug)).slice(0, limit);
@@ -417,7 +420,7 @@ export async function recentlyAddedBooks(limit = 6): Promise<Book[]> {
const key = `books:recent:${limit}`;
const cached = await cache.get<Book[]>(key);
if (cached) return cached;
const books = await listN<Book>('books', limit, '', '-meta_updated');
const books = await listN<Book>('books', limit, 'visibility="public"', '-meta_updated');
await cache.set(key, books, 5 * 60);
return books;
}
@@ -451,9 +454,11 @@ export async function recentlyUpdatedBooks(limit = 8): Promise<Book[]> {
if (!slugs.length) return recentlyAddedBooks(limit);
const books = await getBooksBySlugs(new Set(slugs));
// Restore recency order (getBooksBySlugs returns in title sort order)
// Restore recency order and filter to public-only books.
const bookMap = new Map(books.map((b) => [b.slug, b]));
const ordered = slugs.flatMap((s) => (bookMap.has(s) ? [bookMap.get(s)!] : []));
const ordered = slugs
.flatMap((s) => (bookMap.has(s) ? [bookMap.get(s)!] : []))
.filter((b) => b.visibility === 'public');
await cache.set(key, ordered, 5 * 60);
return ordered;
@@ -1220,7 +1225,7 @@ export async function getBooksWithAudioCount(limit = 100): Promise<AudioBookEntr
const entries: AudioBookEntry[] = [];
for (const [slug, chapters] of chapsBySlug) {
const book = bookMap.get(slug);
if (!book) continue;
if (!book || book.visibility !== 'public') continue;
entries.push({ book, audioChapters: chapters.size });
}
// Sort by most chapters narrated first
@@ -1426,10 +1431,34 @@ export async function isSessionRevoked(authSessionId: string): Promise<boolean>
}
/**
* List all active sessions for a user.
* Returns true for user-agents that are clearly automated tools (curl, scrapers,
* debug logins, etc.) that should not appear in the user-facing sessions list.
* These sessions still exist in the DB so auth checks continue to work.
*/
function isBotUserAgent(ua: string): boolean {
if (!ua) return false;
const lower = ua.toLowerCase();
return (
lower.startsWith('curl/') ||
lower.startsWith('python') ||
lower.startsWith('wget/') ||
lower.startsWith('go-http-client') ||
lower.startsWith('axios/') ||
lower.startsWith('node-fetch') ||
lower.startsWith('undici') ||
lower.startsWith('okhttp') ||
lower.startsWith('java/')
);
}
/**
* List all active sessions for a user, excluding non-browser/tool sessions
* (curl, debug-login artifacts, scrapers, etc.) from the displayed list.
* The records still exist in the DB so auth validity checks are unaffected.
*/
export async function listUserSessions(userId: string): Promise<UserSession[]> {
return listAll<UserSession>('user_sessions', `user_id="${userId}"`, '-last_seen');
const all = await listAll<UserSession>('user_sessions', `user_id="${userId}"`, '-last_seen');
return all.filter((s) => !isBotUserAgent(s.user_agent) && s.ip !== 'debug');
}
/**
@@ -1448,9 +1477,11 @@ async function pruneStaleUserSessions(
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
const toDelete = new Set<string>();
// Mark stale sessions
// Mark stale sessions and debug/tool sessions for deletion
for (const s of all) {
if (s.last_seen < cutoff) toDelete.add(s.id);
if (s.last_seen < cutoff || s.ip === 'debug' || isBotUserAgent(s.user_agent)) {
toDelete.add(s.id);
}
}
// Mark excess sessions beyond the cap (oldest first — list is sorted -last_seen)
@@ -2119,7 +2150,7 @@ export async function getSubscriptionFeed(
for (const p of allProgressArrays[i]) {
if (seen.has(p.slug)) continue;
const book = bookMap.get(p.slug);
if (!book) continue;
if (!book || book.visibility !== 'public') continue;
seen.add(p.slug);
feed.push({ book, readerUsername: username, updated: p.updated });
}
@@ -2319,7 +2350,9 @@ export async function getBooksForDiscovery(
getAllRatings(),
]);
let candidates = allBooks.filter((b) => !votedSlugs.has(b.slug) && !savedSlugs.has(b.slug));
let candidates = allBooks.filter(
(b) => b.visibility === 'public' && !votedSlugs.has(b.slug) && !savedSlugs.has(b.slug)
);
if (prefs?.genres?.length) {
const preferred = new Set(prefs.genres.map((g) => g.toLowerCase()));

View File

@@ -47,6 +47,27 @@ export async function backendFetch(path: string, init?: RequestInit): Promise<Re
}
}
/**
* Like backendFetch but always attaches the admin bearer token regardless of path.
* Use this when an admin user should bypass the visibility filter on public endpoints
* (e.g. GET /api/catalogue for the admin catalogue view).
*/
export async function backendFetchAdmin(path: string, init?: RequestInit): Promise<Response> {
const finalInit: RequestInit = {
...init,
headers: {
...(ADMIN_TOKEN ? { Authorization: `Bearer ${ADMIN_TOKEN}` } : {}),
...((init?.headers ?? {}) as Record<string, string>)
}
};
try {
return await fetch(`${BACKEND_URL}${path}`, finalInit);
} catch (e) {
if (e instanceof Error && 'status' in e) throw e;
throw error(502, 'Could not reach backend');
}
}
// ─── Cached admin model lists ─────────────────────────────────────────────────
const MODELS_CACHE_TTL = 10 * 60; // 10 minutes — model lists rarely change

View File

@@ -586,6 +586,14 @@
>
{m.nav_catalogue()}
</a>
{#if data.user}
<a
href="/submit"
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/submit') ? 'text-(--color-text) font-medium' : 'text-(--color-muted) hover:text-(--color-text)'}"
>
Publish
</a>
{/if}
{#if !data.isPro}
<a
href="/subscribe"
@@ -821,6 +829,15 @@
>
{m.nav_catalogue()}
</a>
{#if data.user}
<a
href="/submit"
onclick={() => (menuOpen = false)}
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/submit') ? 'bg-(--color-surface-2) text-(--color-text)' : 'text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)'}"
>
Publish
</a>
{/if}
<a
href="https://feedback.libnovel.cc"
target="_blank"

View File

@@ -38,6 +38,46 @@
$effect(() => { void imgModel; void numSteps; void width; void height; saveConfig(); });
// ── Visibility management ─────────────────────────────────────────────────────
type VisBook = { slug: string; vis: string; busy: boolean };
let visBooks = $state<VisBook[]>([]);
let visSlugInput = $state('');
let visError = $state('');
async function addVisBook(e: Event) {
e.preventDefault();
const slug = visSlugInput.trim();
if (!slug || visBooks.some((b) => b.slug === slug)) return;
visError = '';
try {
const res = await fetch(`/api/books/${slug}`);
if (!res.ok) { visError = `Book "${slug}" not found.`; return; }
const book = await res.json() as { slug: string; visibility?: string };
visBooks = [...visBooks, { slug: book.slug, vis: book.visibility ?? 'admin_only', busy: false }];
visSlugInput = '';
} catch {
visError = 'Failed to load book.';
}
}
function removeVisBook(slug: string) {
visBooks = visBooks.filter((b) => b.slug !== slug);
}
async function toggleVisibility(slug: string, currentVis: string) {
visBooks = visBooks.map((b) => b.slug === slug ? { ...b, busy: true } : b);
const action = currentVis === 'public' ? 'unpublish' : 'publish';
try {
const res = await fetch(`/api/admin/books/${slug}/${action}`, { method: 'PATCH' });
if (!res.ok) throw new Error(await res.text());
const newVis = action === 'publish' ? 'public' : 'admin_only';
visBooks = visBooks.map((b) => b.slug === slug ? { ...b, vis: newVis, busy: false } : b);
} catch (e) {
visError = String(e);
visBooks = visBooks.map((b) => b.slug === slug ? { ...b, busy: false } : b);
}
}
// ── Batch covers ──────────────────────────────────────────────────────────────
let fromItem = $state(0);
let toItem = $state(0);
@@ -276,4 +316,68 @@
</div>
{/if}
</div>
<!-- ── Visibility management ───────────────────────────────────────────────── -->
<div class="rounded-xl border border-(--color-border) bg-(--color-surface-2) p-5 space-y-4 mt-6">
<div>
<h2 class="text-base font-semibold">Visibility management</h2>
<p class="text-sm text-(--color-muted) mt-0.5">
Scraped books default to <code class="text-xs bg-(--color-surface-3) px-1 py-0.5 rounded">admin_only</code>.
Publish individual books to make them visible to all users, or unpublish to restrict to admins.
</p>
</div>
{#snippet visMsg(msg: string, ok: boolean)}
<p class="text-sm {ok ? 'text-green-400' : 'text-(--color-danger)'}">{msg}</p>
{/snippet}
<div class="flex flex-col gap-3">
{#each visBooks as vb (vb.slug)}
<div class="flex items-center gap-3 rounded-lg border border-(--color-border) bg-(--color-surface) px-3 py-2">
<span class="font-mono text-sm flex-1 truncate">{vb.slug}</span>
<span class="text-xs px-2 py-0.5 rounded-full {vb.vis === 'public'
? 'bg-green-500/15 text-green-400 border border-green-500/30'
: 'bg-(--color-surface-3) text-(--color-muted) border border-(--color-border)'}">
{vb.vis === 'public' ? 'public' : 'admin only'}
</span>
<button
type="button"
onclick={() => toggleVisibility(vb.slug, vb.vis)}
disabled={vb.busy}
class="text-xs px-2.5 py-1 rounded border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:border-(--color-brand)/40 disabled:opacity-50 transition-colors"
>
{vb.busy ? '…' : vb.vis === 'public' ? 'Unpublish' : 'Publish'}
</button>
<button
type="button"
onclick={() => removeVisBook(vb.slug)}
class="text-(--color-muted) hover:text-(--color-danger) transition-colors"
aria-label="Remove"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
{/each}
</div>
<form onsubmit={addVisBook} class="flex gap-2">
<input
type="text"
bind:value={visSlugInput}
placeholder="book-slug"
class="flex-1 rounded-lg border border-(--color-border) bg-(--color-surface) px-3 py-2 text-sm font-mono text-(--color-text) placeholder:text-(--color-muted) focus:outline-none focus:border-(--color-brand) transition-colors"
/>
<button
type="submit"
class="px-4 py-2 rounded-lg border border-(--color-border) text-sm text-(--color-muted) hover:text-(--color-text) hover:border-(--color-brand)/40 transition-colors"
>
Load book
</button>
</form>
{#if visError}
{@render visMsg(visError, false)}
{/if}
</div>
</div>

View File

@@ -0,0 +1,17 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { backendFetch } from '$lib/server/scraper';
import { log } from '$lib/server/logger';
export const PATCH: RequestHandler = async ({ params, locals }) => {
if (!locals.user || locals.user.role !== 'admin') throw error(403, 'Forbidden');
const { slug } = params;
let res: Response;
try {
res = await backendFetch(`/api/admin/books/${encodeURIComponent(slug)}/publish`, { method: 'PATCH' });
} catch (e) {
log.error('admin/books/publish', 'backend proxy error', { slug, err: String(e) });
throw error(502, 'Could not reach backend');
}
return json(await res.json().catch(() => ({})), { status: res.status });
};

View File

@@ -0,0 +1,17 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { backendFetch } from '$lib/server/scraper';
import { log } from '$lib/server/logger';
export const PATCH: RequestHandler = async ({ params, locals }) => {
if (!locals.user || locals.user.role !== 'admin') throw error(403, 'Forbidden');
const { slug } = params;
let res: Response;
try {
res = await backendFetch(`/api/admin/books/${encodeURIComponent(slug)}/unpublish`, { method: 'PATCH' });
} catch (e) {
log.error('admin/books/unpublish', 'backend proxy error', { slug, err: String(e) });
throw error(502, 'Could not reach backend');
}
return json(await res.json().catch(() => ({})), { status: res.status });
};

View File

@@ -0,0 +1,15 @@
/**
* GET /api/books/[slug]
* Returns basic book metadata (slug, title, visibility) for admin tooling.
* Requires admin role — not a public endpoint.
*/
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getBook } from '$lib/server/pocketbase';
export const GET: RequestHandler = async ({ params, locals }) => {
if (!locals.user || locals.user.role !== 'admin') throw error(403, 'Forbidden');
const book = await getBook(params.slug);
if (!book) throw error(404, 'Book not found');
return json({ slug: book.slug, title: book.title, visibility: book.visibility ?? 'admin_only' });
};

View File

@@ -1,5 +1,7 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import { page } from '$app/state';
import { untrack } from 'svelte';
import type { PageData } from './$types';
import * as m from '$lib/paraglide/messages.js';
@@ -17,7 +19,22 @@
}
type Shelf = '' | 'plan_to_read' | 'completed' | 'dropped';
let activeShelf = $state<Shelf | 'all'>('all');
// Map the ?status URL param to the internal shelf key so that links like
// /books?status=reading correctly pre-select the Reading tab.
function urlStatusToShelf(status: string | null): Shelf | 'all' {
switch (status) {
case 'reading': return '';
case 'plan_to_read': return 'plan_to_read';
case 'completed': return 'completed';
case 'dropped': return 'dropped';
default: return 'all';
}
}
let activeShelf = $state<Shelf | 'all'>(
untrack(() => urlStatusToShelf(page.url.searchParams.get('status')))
);
const shelfLabels: Record<string, string> = {
all: 'All',

View File

@@ -1,7 +1,7 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
import { backendFetch, backendFetchAdmin } from '$lib/server/scraper';
import {
bookToListing,
type CatalogueResponse,
@@ -32,7 +32,10 @@ export const load: PageServerLoad = async ({ url, locals }) => {
let statuses: string[] = [];
try {
const res = await backendFetch(`/api/catalogue?${params.toString()}`);
// Admin users bypass the visibility filter and see all non-archived books.
const isAdminUser = locals.user?.role === 'admin';
const fetchCatalogue = isAdminUser ? backendFetchAdmin : backendFetch;
const res = await fetchCatalogue(`/api/catalogue?${params.toString()}`);
if (!res.ok) {
log.error('catalogue', 'catalogue returned error', { status: res.status });
throw error(502, `Catalogue fetch failed: ${res.status}`);

View File

@@ -542,7 +542,12 @@
alt={novel.title}
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy"
/>
onerror={(e) => {
const img = e.currentTarget as HTMLImageElement;
img.style.display = 'none';
(img.nextElementSibling as HTMLElement | null)?.style.setProperty('display', 'flex');
}}
/><div class="w-full h-full absolute inset-0 items-center justify-center bg-(--color-surface-3)" style="display:none"><span class="text-5xl font-bold text-(--color-muted) select-none opacity-50">{novel.title.charAt(0).toUpperCase()}</span></div>
{:else}
<div class="w-full h-full flex items-center justify-center bg-(--color-surface-3)">
<span class="text-5xl font-bold text-(--color-muted) select-none opacity-50">{novel.title.charAt(0).toUpperCase()}</span>
@@ -630,7 +635,18 @@
<!-- Cover thumbnail -->
<div class="w-10 h-14 shrink-0 rounded overflow-hidden bg-(--color-surface) relative">
{#if novel.cover}
<img src={novel.cover} alt={novel.title} class="w-full h-full object-cover" loading="lazy" />
<img
src={novel.cover}
alt={novel.title}
class="w-full h-full object-cover"
loading="lazy"
onerror={(e) => {
const img = e.currentTarget as HTMLImageElement;
img.style.display = 'none';
const fb = img.parentElement?.querySelector('.cover-fallback') as HTMLElement | null;
if (fb) fb.style.display = 'flex';
}}
/><div class="cover-fallback w-full h-full absolute inset-0 items-center justify-center bg-(--color-surface-3)" style="display:none"><span class="text-xl font-bold text-(--color-muted) select-none opacity-50">{novel.title.charAt(0).toUpperCase()}</span></div>
{:else}
<div class="w-full h-full flex items-center justify-center bg-(--color-surface-3)">
<span class="text-xl font-bold text-(--color-muted) select-none opacity-50">{novel.title.charAt(0).toUpperCase()}</span>

View File

@@ -427,7 +427,10 @@
<div class="min-w-0 flex-1">
<h1 class="text-xl font-bold text-(--color-text) truncate">{data.user.username}</h1>
<div class="flex items-center gap-2 mt-0.5 flex-wrap">
{#if data.email}
<p class="text-xs text-(--color-muted) mt-0.5 truncate">{data.email}</p>
{/if}
<div class="flex items-center gap-2 mt-1.5 flex-wrap">
<span class="text-xs font-semibold px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-muted) capitalize border border-(--color-border)">{data.user.role}</span>
{#if data.isPro}
<span class="text-xs font-bold px-2 py-0.5 rounded-full bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30 uppercase tracking-wide">
@@ -435,6 +438,29 @@
</span>
{/if}
</div>
<!-- Quick stats chips -->
{#if data.stats.totalChaptersRead > 0 || data.stats.streak > 0}
<div class="flex items-center gap-2 mt-2 flex-wrap">
{#if data.stats.streak > 0}
<span class="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-(--color-surface-3) border border-(--color-border) text-(--color-muted)">
<svg class="w-3 h-3 text-orange-400" fill="currentColor" viewBox="0 0 24 24"><path d="M13.5 0.67s.74 2.65.74 4.8c0 2.06-1.35 3.73-3.41 3.73-2.07 0-3.63-1.67-3.63-3.73l.03-.36C5.21 7.51 4 10.62 4 14c0 4.42 3.58 8 8 8s8-3.58 8-8C20 8.61 17.41 3.8 13.5.67z"/></svg>
{data.stats.streak}d streak
</span>
{/if}
{#if data.stats.totalChaptersRead > 0}
<span class="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-(--color-surface-3) border border-(--color-border) text-(--color-muted)">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/></svg>
{data.stats.totalChaptersRead.toLocaleString()} chapters
</span>
{/if}
{#if data.stats.booksCompleted > 0}
<span class="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-(--color-surface-3) border border-(--color-border) text-(--color-muted)">
<svg class="w-3 h-3 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{data.stats.booksCompleted} completed
</span>
{/if}
</div>
{/if}
{#if avatarError}
<p class="text-(--color-danger) text-xs mt-1">{avatarError}</p>
{/if}
@@ -455,6 +481,9 @@
</svg>
</span>
<span class="flex-1 text-sm font-medium text-(--color-text)">Library</span>
{#if data.stats.booksReading > 0}
<span class="text-xs text-(--color-muted) mr-2 hidden sm:inline">{data.stats.booksReading} reading</span>
{/if}
<svg class={chevronClass} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
@@ -550,12 +579,14 @@
href="/books/{item.slug}/chapters/{item.chapter}"
class="flex items-center gap-3 px-5 py-3 hover:bg-(--color-surface-3)/60 transition-colors group"
>
<div class="w-7 h-10 rounded overflow-hidden bg-(--color-surface-3) flex-shrink-0">
{#if item.cover}
<img src={item.cover} alt={item.title} class="w-full h-full object-cover" loading="lazy" />
{:else}
<div class="w-full h-full bg-(--color-surface-3)"></div>
{/if}
<div class="w-7 h-10 rounded overflow-hidden bg-(--color-surface-3) flex-shrink-0 relative">
<img
src="/api/cover/novelfire.net/{item.slug}"
alt={item.title}
class="w-full h-full object-cover"
loading="lazy"
onerror={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-(--color-text) truncate group-hover:text-(--color-brand) transition-colors">{item.title}</p>
@@ -1103,6 +1134,21 @@
</div>
{/if}
<!-- Sign out -->
<form method="POST" action="/logout">
<button
type="submit"
class="w-full flex items-center gap-3.5 px-5 py-4 hover:bg-(--color-surface-3)/60 transition-colors group text-left"
>
<span class="shrink-0 w-8 h-8 rounded-lg bg-(--color-surface-3) border border-(--color-border) flex items-center justify-center">
<svg class="w-4 h-4 text-(--color-muted) group-hover:text-(--color-danger) transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
</svg>
</span>
<span class="flex-1 text-sm font-medium text-(--color-text) group-hover:text-(--color-danger) transition-colors">Sign out</span>
</button>
</form>
</div>
<!-- ── Danger zone ───────────────────────────────────────────────────────── -->

View File

@@ -0,0 +1,47 @@
import { redirect, error, fail } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { backendFetchAdmin } from '$lib/server/scraper';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) redirect(302, '/login?next=/submit');
return { userId: locals.user.id, username: locals.user.username };
};
export const actions: Actions = {
default: async ({ request, locals }) => {
if (!locals.user) error(401, 'Not authenticated');
const form = await request.formData();
const title = (form.get('title') as string | null)?.trim() ?? '';
const author = (form.get('author') as string | null)?.trim() ?? '';
const cover = (form.get('cover') as string | null)?.trim() ?? '';
const summary = (form.get('summary') as string | null)?.trim() ?? '';
const statusField = (form.get('status') as string | null)?.trim() ?? 'ongoing';
const genresRaw = (form.get('genres') as string | null)?.trim() ?? '';
const genres = genresRaw.split(',').map((g) => g.trim()).filter(Boolean);
if (!title) return fail(400, { error: 'Title is required.' });
const res = await backendFetchAdmin('/api/admin/books/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
author: author || locals.user.username,
cover,
summary,
genres,
status: statusField,
submitted_by: locals.user.id
})
});
if (!res.ok) {
const body = await res.text().catch(() => '');
return fail(res.status, { error: `Submission failed: ${body || res.statusText}` });
}
const data = await res.json() as { slug: string };
redirect(302, `/books/${data.slug}`);
}
};

View File

@@ -0,0 +1,161 @@
<script lang="ts">
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
const GENRES = [
'Action', 'Fantasy', 'Romance', 'Cultivation', 'System',
'Reincarnation', 'Sci-Fi', 'Horror', 'Slice of Life', 'Adventure',
'Thriller', 'Mystery', 'Drama', 'Comedy', 'Historical',
];
let selectedGenres = $state<string[]>([]);
let submitting = $state(false);
function toggleGenre(genre: string) {
if (selectedGenres.includes(genre)) {
selectedGenres = selectedGenres.filter((g) => g !== genre);
} else {
selectedGenres = [...selectedGenres, genre];
}
}
</script>
<svelte:head>
<title>Publish Your Novel — LibNovel</title>
</svelte:head>
<div class="max-w-2xl mx-auto px-4 py-10">
<!-- Hero header -->
<div class="mb-10">
<div class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-(--color-brand)/10 border border-(--color-brand)/30 text-(--color-brand) text-xs font-semibold mb-4">
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
Original Content Platform
</div>
<h1 class="text-3xl font-bold text-(--color-text) mb-3">Publish your novel</h1>
<p class="text-(--color-muted) leading-relaxed">
Share your original work with readers worldwide. Your novel will be available immediately after submission and you retain full rights to your content.
</p>
</div>
<!-- Value props -->
<div class="grid grid-cols-3 gap-3 mb-10">
{#each [
{ icon: '📖', label: 'Free to publish', desc: 'No fees to list your work' },
{ icon: '🌍', label: 'Global readers', desc: 'Reach audiences in 5 languages' },
{ icon: '🎧', label: 'Audio narration', desc: 'AI narration for your chapters' },
] as prop}
<div class="rounded-lg border border-(--color-border) bg-(--color-surface-2) p-3 text-center">
<div class="text-2xl mb-1">{prop.icon}</div>
<p class="text-xs font-semibold text-(--color-text)">{prop.label}</p>
<p class="text-xs text-(--color-muted) mt-0.5">{prop.desc}</p>
</div>
{/each}
</div>
<!-- Submission form -->
<div class="rounded-xl border border-(--color-border) bg-(--color-surface-2) p-6">
<h2 class="text-lg font-semibold mb-5">Book details</h2>
{#if form?.error}
<div class="mb-4 rounded-lg bg-(--color-danger)/10 border border-(--color-danger)/30 text-(--color-danger) text-sm px-4 py-3">
{form.error}
</div>
{/if}
<form method="POST" onsubmit={() => (submitting = true)} class="flex flex-col gap-4">
<!-- Hidden genres field — updated by button clicks -->
<input type="hidden" name="genres" value={selectedGenres.join(',')} />
<div class="flex flex-col gap-1">
<label for="title" class="text-sm font-medium text-(--color-text)">Title <span class="text-(--color-danger)">*</span></label>
<input
id="title" name="title" type="text" required
placeholder="The Wandering Sword"
class="rounded-lg border border-(--color-border) bg-(--color-surface) px-3 py-2 text-sm text-(--color-text) placeholder:text-(--color-muted) focus:outline-none focus:border-(--color-brand) transition-colors"
/>
</div>
<div class="flex flex-col gap-1">
<label for="author" class="text-sm font-medium text-(--color-text)">
Author name
<span class="text-(--color-muted) font-normal text-xs ml-1">(defaults to your username)</span>
</label>
<input
id="author" name="author" type="text"
placeholder={data.username}
class="rounded-lg border border-(--color-border) bg-(--color-surface) px-3 py-2 text-sm text-(--color-text) placeholder:text-(--color-muted) focus:outline-none focus:border-(--color-brand) transition-colors"
/>
</div>
<div class="flex flex-col gap-1">
<label for="summary" class="text-sm font-medium text-(--color-text)">Summary</label>
<textarea
id="summary" name="summary" rows="4"
placeholder="A compelling description of your story…"
class="rounded-lg border border-(--color-border) bg-(--color-surface) px-3 py-2 text-sm text-(--color-text) placeholder:text-(--color-muted) focus:outline-none focus:border-(--color-brand) transition-colors resize-y"
></textarea>
</div>
<div class="flex gap-3">
<div class="flex-1 flex flex-col gap-1">
<label for="status" class="text-sm font-medium text-(--color-text)">Status</label>
<select
id="status" name="status"
class="rounded-lg border border-(--color-border) bg-(--color-surface) px-3 py-2 text-sm text-(--color-text) focus:outline-none focus:border-(--color-brand) transition-colors"
>
<option value="ongoing">Ongoing</option>
<option value="completed">Completed</option>
<option value="hiatus">Hiatus</option>
</select>
</div>
<div class="flex-1 flex flex-col gap-1">
<label for="cover" class="text-sm font-medium text-(--color-text)">Cover image URL</label>
<input
id="cover" name="cover" type="url"
placeholder="https://…"
class="rounded-lg border border-(--color-border) bg-(--color-surface) px-3 py-2 text-sm text-(--color-text) placeholder:text-(--color-muted) focus:outline-none focus:border-(--color-brand) transition-colors"
/>
</div>
</div>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-(--color-text)">Genres</label>
<div class="flex flex-wrap gap-2">
{#each GENRES as genre}
<button
type="button"
onclick={() => toggleGenre(genre)}
class="px-3 py-1 rounded-full text-sm border transition-colors {selectedGenres.includes(genre)
? 'bg-(--color-brand) border-(--color-brand) text-black font-medium'
: 'border-(--color-border) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
>
{genre}
</button>
{/each}
</div>
</div>
<!-- Rights assertion -->
<div class="rounded-lg bg-(--color-surface-3) border border-(--color-border) p-4 text-xs text-(--color-muted) leading-relaxed">
By submitting you confirm that you are the original author of this work or hold the rights to publish it, and that the content complies with our
<a href="/terms" class="text-(--color-brand) hover:underline">Terms of Service</a>.
You retain full copyright over your submitted content.
</div>
<button
type="submit"
disabled={submitting}
class="w-full py-2.5 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) disabled:opacity-60 transition-colors"
>
{submitting ? 'Submitting…' : 'Submit novel'}
</button>
</form>
</div>
<p class="mt-6 text-center text-xs text-(--color-muted)">
Want to add chapters after submission? You can upload them from your book's page once it's created.
</p>
</div>