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
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:
@@ -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)
|
||||
|
||||
161
backend/internal/backend/handlers_submit.go
Normal file
161
backend/internal/backend/handlers_submit.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user