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>
This commit is contained in:
Admin
2026-04-16 20:20:16 +05:00
parent 50a13447a4
commit da37b1be88
18 changed files with 769 additions and 23 deletions

View File

@@ -1869,13 +1869,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)

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)
})
}