Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
443f4acfa9 | ||
|
|
5ad277241c | ||
|
|
18bd32e0f6 | ||
|
|
0c3b34c184 | ||
|
|
f99f5a7878 | ||
|
|
08b81fde60 | ||
|
|
8023a7ec5c | ||
|
|
0c5dfd4353 | ||
|
|
0e62d4aa18 | ||
|
|
b82a891c50 |
@@ -48,10 +48,15 @@ func (h *H) Login(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *H) Me(c *gin.Context) {
|
||||
// B7: only no-rows → 401; other errors (PG down) go through dbErr → 503.
|
||||
u, qerr := h.st.GetUserByID(c, uid(c))
|
||||
if qerr != nil {
|
||||
if errors.Is(qerr, pgx.ErrNoRows) {
|
||||
err(c, http.StatusUnauthorized, "unauthorized", "no such user")
|
||||
return
|
||||
}
|
||||
dbErr(c, qerr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"id": u.ID, "username": u.Username, "role": u.Role})
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
@@ -60,14 +61,18 @@ func (h *H) ServeCover(c *gin.Context) {
|
||||
if e == nil {
|
||||
dst := filepath.Join(dir, "cover"+ext)
|
||||
if e := os.MkdirAll(dir, 0o755); e == nil {
|
||||
tmp := fmt.Sprintf("%s.tmp-%d", dst, time.Now().UnixNano()) // 并发幂等:唯一 tmp + rename 原子
|
||||
if e := os.WriteFile(tmp, img, 0o644); e == nil {
|
||||
if e := os.Rename(tmp, dst); e == nil {
|
||||
tmp := fmt.Sprintf("%s.tmp-%d", dst, time.Now().UnixNano())
|
||||
// B11: check all write errors; clean tmp only on failure.
|
||||
if we := os.WriteFile(tmp, img, 0o644); we != nil {
|
||||
log.Printf("serve: write cover tmp: %v", we)
|
||||
os.Remove(tmp)
|
||||
} else if re := os.Rename(tmp, dst); re != nil {
|
||||
log.Printf("serve: rename cover: %v", re)
|
||||
os.Remove(tmp)
|
||||
} else {
|
||||
http.ServeFile(c.Writer, c.Request, dst)
|
||||
}
|
||||
}
|
||||
os.Remove(tmp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"booklib/internal/bookfile"
|
||||
"booklib/internal/media"
|
||||
"booklib/internal/store"
|
||||
)
|
||||
|
||||
@@ -61,6 +62,11 @@ func (h *H) CreateLibrary(c *gin.Context) {
|
||||
err(c, http.StatusBadRequest, "bad_request", "bad name")
|
||||
return
|
||||
}
|
||||
// B8: reject reserved names that conflict with system directories.
|
||||
if media.IsReservedName(safe) {
|
||||
err(c, http.StatusBadRequest, "bad_request", "reserved_name")
|
||||
return
|
||||
}
|
||||
root := filepath.Join(filepath.Clean(h.cfg.BooksDir), safe)
|
||||
id, e := h.st.CreateLibrary(c, req.Name, root)
|
||||
if e != nil {
|
||||
@@ -129,27 +135,51 @@ func (h *H) Upload(c *gin.Context) {
|
||||
err(c, http.StatusBadRequest, "bad_format", "extension must be cbz/pdf/epub/txt/md")
|
||||
return
|
||||
}
|
||||
dst, e := h.uniquePath(root, name)
|
||||
// B12: retry on O_EXCL collision — concurrent uploads with the same name
|
||||
// can both get the same candidate from uniquePath (stat-then-create race).
|
||||
var dst, tmp string
|
||||
var out *os.File
|
||||
for attempt := 0; attempt < 5; attempt++ {
|
||||
var e error
|
||||
dst, e = h.uniquePath(root, name)
|
||||
if e != nil {
|
||||
err(c, http.StatusForbidden, "forbidden", e.Error())
|
||||
return
|
||||
}
|
||||
tmp = dst + ".upload-" + strconv.FormatInt(time.Now().UnixNano(), 36)
|
||||
out, e = os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
|
||||
if e == nil {
|
||||
break
|
||||
}
|
||||
if !os.IsExist(e) {
|
||||
err(c, http.StatusInternalServerError, "internal", "create tmp")
|
||||
return
|
||||
}
|
||||
// O_EXCL collision — retry with fresh uniquePath.
|
||||
}
|
||||
if out == nil {
|
||||
err(c, http.StatusConflict, "conflict", "too many concurrent uploads with same name")
|
||||
return
|
||||
}
|
||||
src, e := fh.Open()
|
||||
if e != nil {
|
||||
out.Close()
|
||||
os.Remove(tmp)
|
||||
err(c, http.StatusInternalServerError, "internal", "open upload")
|
||||
return
|
||||
}
|
||||
defer src.Close()
|
||||
tmp := dst + ".upload-" + strconv.FormatInt(time.Now().UnixNano(), 36)
|
||||
out, e := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
|
||||
if e != nil {
|
||||
err(c, http.StatusInternalServerError, "internal", "create tmp")
|
||||
return
|
||||
}
|
||||
// B6: only MaxBytesError returns 413; other io.Copy failures (disk full,
|
||||
// connection drop) return 500.
|
||||
if _, e := io.Copy(out, src); e != nil {
|
||||
out.Close()
|
||||
os.Remove(tmp)
|
||||
err(c, http.StatusRequestEntityTooLarge, "too_large", "upload failed")
|
||||
var mbe *http.MaxBytesError
|
||||
if errors.As(e, &mbe) {
|
||||
err(c, http.StatusRequestEntityTooLarge, "too_large", "file exceeds upload limit")
|
||||
return
|
||||
}
|
||||
err(c, http.StatusInternalServerError, "internal", "upload failed")
|
||||
return
|
||||
}
|
||||
out.Close()
|
||||
|
||||
@@ -198,8 +198,11 @@ func (h *H) UploadPart(c *gin.Context) {
|
||||
}
|
||||
lo, hi := chunkRange(m, idx)
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, hi-lo)
|
||||
// B4: write to .tmp then rename — prevents truncated parts from being
|
||||
// reported as "received" by UploadStatus if the process crashes mid-write.
|
||||
p := filepath.Join(dir, "parts", strconv.FormatInt(idx, 10))
|
||||
f, e := os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||
tmp := p + ".tmp"
|
||||
f, e := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||
if e != nil {
|
||||
err(c, http.StatusInternalServerError, "internal", "create part")
|
||||
return
|
||||
@@ -207,7 +210,7 @@ func (h *H) UploadPart(c *gin.Context) {
|
||||
n, e := io.Copy(f, c.Request.Body)
|
||||
f.Close()
|
||||
if e != nil || n != hi-lo {
|
||||
os.Remove(p)
|
||||
os.Remove(tmp)
|
||||
var mbe *http.MaxBytesError
|
||||
code, msg := "too_large", "part size mismatch"
|
||||
if errors.As(e, &mbe) {
|
||||
@@ -216,6 +219,11 @@ func (h *H) UploadPart(c *gin.Context) {
|
||||
err(c, http.StatusRequestEntityTooLarge, code, msg)
|
||||
return
|
||||
}
|
||||
if e := os.Rename(tmp, p); e != nil {
|
||||
os.Remove(tmp)
|
||||
err(c, http.StatusInternalServerError, "internal", "rename part")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusAccepted, gin.H{"accepted": true})
|
||||
}
|
||||
|
||||
|
||||
@@ -8,15 +8,13 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
|
||||
"booklib/internal/auth"
|
||||
"booklib/internal/store"
|
||||
)
|
||||
|
||||
func isUnique(e error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(e, &pgErr) && pgErr.Code == "23505"
|
||||
}
|
||||
// isUnique is a convenience alias for store.IsUniqueViolation.
|
||||
func isUnique(e error) bool { return store.IsUniqueViolation(e) }
|
||||
|
||||
func (h *H) ListUsers(c *gin.Context) {
|
||||
users, e := h.st.ListUsers(c)
|
||||
@@ -73,23 +71,16 @@ func (h *H) DeleteUser(c *gin.Context) {
|
||||
err(c, http.StatusBadRequest, "bad_request", "cannot delete yourself")
|
||||
return
|
||||
}
|
||||
target, e := h.st.GetUserByID(c, id)
|
||||
if e != nil {
|
||||
// B5: transactional last-admin check eliminates TOCTOU race.
|
||||
if e := h.st.DeleteUser(c, id); e != nil {
|
||||
if errors.Is(e, pgx.ErrNoRows) {
|
||||
err(c, http.StatusNotFound, "not_found", "no such user")
|
||||
return
|
||||
}
|
||||
dbErr(c, e)
|
||||
return
|
||||
}
|
||||
if target.Role == "admin" {
|
||||
n, _ := h.st.CountAdmins(c) // 防删光最后一个 admin
|
||||
if n <= 1 {
|
||||
if errors.Is(e, store.ErrLastAdmin) {
|
||||
err(c, http.StatusBadRequest, "bad_request", "cannot delete the last admin")
|
||||
return
|
||||
}
|
||||
}
|
||||
if e := h.st.DeleteUser(c, id); e != nil {
|
||||
dbErr(c, e)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package bookfile
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// OpenReaderAt opens a book file and returns a ReaderAt + size.
|
||||
// Consolidates the 3 places that open a book file + stat + get ReaderAt.
|
||||
func OpenReaderAt(root, rel string) (*os.File, int64, error) {
|
||||
abs := filepath.Join(root, filepath.FromSlash(rel))
|
||||
f, err := os.Open(abs)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
st, err := f.Stat()
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return nil, 0, err
|
||||
}
|
||||
return f, st.Size(), nil
|
||||
}
|
||||
|
||||
// Contains reports whether child is inside parent using EvalSymlinks semantics.
|
||||
// Falls back to Clean if EvalSymlinks fails (e.g., path doesn't exist yet).
|
||||
func Contains(parent, child string) bool {
|
||||
p, err := filepath.EvalSymlinks(parent)
|
||||
if err != nil {
|
||||
p = filepath.Clean(parent)
|
||||
}
|
||||
c, err := filepath.EvalSymlinks(child)
|
||||
if err != nil {
|
||||
c = filepath.Clean(child)
|
||||
}
|
||||
return c == p || strings.HasPrefix(c, p+string(os.PathSeparator))
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package bookfile
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestContains(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sub := filepath.Join(dir, "sub")
|
||||
if err := os.MkdirAll(sub, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !Contains(dir, sub) {
|
||||
t.Fatal("sub should be inside dir")
|
||||
}
|
||||
if Contains(sub, dir) {
|
||||
t.Fatal("dir should not be inside sub")
|
||||
}
|
||||
if Contains(dir, "/completely/different") {
|
||||
t.Fatal("unrelated path should not be inside dir")
|
||||
}
|
||||
// Same path.
|
||||
if !Contains(dir, dir) {
|
||||
t.Fatal("dir should contain itself")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenReaderAt(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Create a test file.
|
||||
path := filepath.Join(dir, "test.txt")
|
||||
if err := os.WriteFile(path, []byte("hello"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, size, err := OpenReaderAt(dir, "test.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
if size != 5 {
|
||||
t.Fatalf("size = %d, want 5", size)
|
||||
}
|
||||
|
||||
// Non-existent file.
|
||||
_, _, err = OpenReaderAt(dir, "nope.txt")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-existent file")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"booklib/internal/bookfile"
|
||||
"booklib/internal/config"
|
||||
"booklib/internal/redispkg"
|
||||
)
|
||||
|
||||
// M is the media service — single source of truth for cache layout and content extraction.
|
||||
type M struct {
|
||||
cfg *config.Config
|
||||
rdb *redispkg.R
|
||||
}
|
||||
|
||||
func New(cfg *config.Config, rdb *redispkg.R) *M {
|
||||
return &M{cfg: cfg, rdb: rdb}
|
||||
}
|
||||
|
||||
// CacheBuster returns a content hash for cache-busting URLs.
|
||||
func (m *M) CacheBuster(size, modTS int64) string {
|
||||
return bookfile.Hash(size, modTS)
|
||||
}
|
||||
|
||||
// EnsureCover extracts and caches the cover image for a book.
|
||||
// Returns nil if the cover is already cached or the format doesn't support covers.
|
||||
func (m *M) EnsureCover(ctx context.Context, bookID int64, format string, size, modTS int64, root, rel string) error {
|
||||
var fn func(io.ReaderAt, int64) ([]byte, string, error)
|
||||
switch format {
|
||||
case "cbz":
|
||||
fn = bookfile.CBZCover
|
||||
case "epub":
|
||||
fn = bookfile.EPUBCover
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
f, fsize, err := bookfile.OpenReaderAt(root, rel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
img, ext, err := fn(f, fsize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dir := bookfile.CoverDir(m.cfg.CacheDir, bookfile.DirKey(bookID, bookfile.Hash(size, modTS)))
|
||||
return writeAtomic(dir, "cover"+ext, img)
|
||||
}
|
||||
|
||||
// PageIndex returns the page list for a CBZ, using redis cache when available.
|
||||
func (m *M) PageIndex(ctx context.Context, bookID int64, size, modTS int64, root, rel string) ([]string, error) {
|
||||
hash := bookfile.Hash(size, modTS)
|
||||
key := fmt.Sprintf("pagesidx2:%d:%s", bookID, hash)
|
||||
if v, ok := m.rdb.Get(ctx, key); ok && v != "" {
|
||||
return strings.Split(v, "\n"), nil
|
||||
}
|
||||
f, fsize, err := bookfile.OpenReaderAt(root, rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
idx, err := bookfile.PageIndex(f, fsize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(idx) > 0 {
|
||||
m.rdb.Set(ctx, key, strings.Join(idx, "\n"), 7*24*time.Hour)
|
||||
}
|
||||
return idx, nil
|
||||
}
|
||||
|
||||
// ChaptersOf derives chapters from a page index by grouping pages by parent directory.
|
||||
func (m *M) ChaptersOf(idx []string) []Chapter {
|
||||
type grp struct {
|
||||
dir string
|
||||
start int
|
||||
}
|
||||
var grps []grp
|
||||
last := "\x00"
|
||||
for i, n := range idx {
|
||||
d := path.Dir(n)
|
||||
if d == last {
|
||||
continue
|
||||
}
|
||||
last = d
|
||||
if d == "." {
|
||||
continue
|
||||
}
|
||||
grps = append(grps, grp{d, i})
|
||||
}
|
||||
if len(grps) < 2 {
|
||||
return nil
|
||||
}
|
||||
titles := make(map[string]int)
|
||||
out := make([]Chapter, len(grps))
|
||||
for i, g := range grps {
|
||||
out[i] = Chapter{Title: path.Base(g.dir), Start: g.start}
|
||||
titles[out[i].Title]++
|
||||
}
|
||||
for i, g := range grps {
|
||||
if titles[out[i].Title] > 1 {
|
||||
out[i].Title = g.dir
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Chapter represents a CBZ chapter.
|
||||
type Chapter struct {
|
||||
Title string `json:"title"`
|
||||
Start int `json:"start"`
|
||||
}
|
||||
|
||||
// EnsurePage extracts a single page to the cache. Returns the cache file path.
|
||||
func (m *M) EnsurePage(bookID int64, size, modTS int64, root, rel string, n int, idx []string) (string, error) {
|
||||
if n >= len(idx) {
|
||||
return "", fmt.Errorf("page %d out of range", n)
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(idx[n]))
|
||||
dir := bookfile.PagesDir(m.cfg.CacheDir, bookfile.DirKey(bookID, bookfile.Hash(size, modTS)))
|
||||
dst := filepath.Join(dir, strconv.Itoa(n)+ext)
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
return dst, nil // already cached
|
||||
}
|
||||
f, fsize, err := bookfile.OpenReaderAt(root, rel)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
data, err := bookfile.ReadEntry(f, fsize, idx[n])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := writeAtomic(dir, strconv.Itoa(n)+ext, data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
// writeAtomic writes data to dir/name via tmp+rename. B11: cleans tmp only on failure.
|
||||
func writeAtomic(dir, name string, data []byte) error {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := filepath.Join(dir, fmt.Sprintf("%s.tmp-%d", name, time.Now().UnixNano()))
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
log.Printf("media: write %s: %v", name, err)
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
dst := filepath.Join(dir, name)
|
||||
if err := os.Rename(tmp, dst); err != nil {
|
||||
log.Printf("media: rename %s: %v", name, err)
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package media
|
||||
|
||||
import "strings"
|
||||
|
||||
// reservedNames are library names that conflict with system directories.
|
||||
// This is the single source of truth for reserved name validation (B8).
|
||||
var reservedNames = map[string]bool{
|
||||
"cache": true, // CACHE_DIR
|
||||
".uploads": true, // upload session directory
|
||||
".trash": true, // potential future use
|
||||
}
|
||||
|
||||
// IsReservedName reports whether name conflicts with system directories.
|
||||
func IsReservedName(name string) bool {
|
||||
return reservedNames[strings.ToLower(name)]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package media
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsReservedName(t *testing.T) {
|
||||
for _, n := range []string{"cache", "Cache", "CACHE", ".uploads", ".Uploads", ".trash"} {
|
||||
if !IsReservedName(n) {
|
||||
t.Errorf("IsReservedName(%q) = false, want true", n)
|
||||
}
|
||||
}
|
||||
for _, n := range []string{"comics", "books", "my-library", "Cache1"} {
|
||||
if IsReservedName(n) {
|
||||
t.Errorf("IsReservedName(%q) = true, want false", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"booklib/internal/store"
|
||||
)
|
||||
|
||||
// Re-export sentinel errors so handlers can use errors.Is via ports.
|
||||
var (
|
||||
ErrLastAdmin = store.ErrLastAdmin
|
||||
ErrUniqueViolation = store.ErrUniqueViolation
|
||||
)
|
||||
|
||||
// IsUniqueViolation consolidates the pg 23505 check into a single predicate.
|
||||
func IsUniqueViolation(err error) bool { return store.IsUniqueViolation(err) }
|
||||
|
||||
// Upload sentinel errors.
|
||||
var (
|
||||
ErrTooLarge = errors.New("file too large")
|
||||
ErrIncomplete = errors.New("upload incomplete")
|
||||
ErrSizeMismatch = errors.New("size mismatch")
|
||||
ErrNotFound = errors.New("not found")
|
||||
)
|
||||
@@ -0,0 +1,99 @@
|
||||
// Package ports defines consumer-side interfaces for the backend services.
|
||||
// Handlers depend on these interfaces, not on concrete implementations.
|
||||
// Main.go wires concrete types (*store.Store, *redispkg.R, etc.) that satisfy them.
|
||||
//
|
||||
// Value types (User, Book, etc.) live in internal/store and are referenced here.
|
||||
package ports
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"booklib/internal/store"
|
||||
)
|
||||
|
||||
// ---------- Store interfaces ----------
|
||||
|
||||
type UserStore interface {
|
||||
CountUsers(ctx context.Context) (int, error)
|
||||
CreateUser(ctx context.Context, username, hash, role string) (int64, error)
|
||||
GetUserByName(ctx context.Context, username string) (store.User, error)
|
||||
GetUserByID(ctx context.Context, id int64) (store.User, error)
|
||||
ListUsers(ctx context.Context) ([]store.User, error)
|
||||
DeleteUser(ctx context.Context, id int64) error
|
||||
}
|
||||
|
||||
type LibraryStore interface {
|
||||
CreateLibrary(ctx context.Context, name, root string) (int64, error)
|
||||
ListLibraries(ctx context.Context) ([]store.Library, error)
|
||||
GetLibrary(ctx context.Context, id int64) (store.Library, error)
|
||||
}
|
||||
|
||||
type BookStore interface {
|
||||
InsertBook(ctx context.Context, libID int64, path, title, format string, size, modTS int64, pageCount int) (int64, error)
|
||||
GetBook(ctx context.Context, id int64) (store.Book, error)
|
||||
ListBookMeta(ctx context.Context, libID int64) (map[string]store.BookMeta, error)
|
||||
UpdateBookFile(ctx context.Context, id, size, modTS int64, pageCount int) error
|
||||
DeleteBookByPath(ctx context.Context, libID int64, path string) error
|
||||
DeleteBook(ctx context.Context, id int64) error
|
||||
SetBookState(ctx context.Context, id int64, state, msg string) error
|
||||
ListBooks(ctx context.Context, libID int64, q, prefix string, userID int64) ([]store.BookView, error)
|
||||
BookHashes(ctx context.Context) (map[int64][2]int64, error)
|
||||
}
|
||||
|
||||
type ProgressStore interface {
|
||||
UpsertProgress(ctx context.Context, userID, libID int64, bookPath string, locator []byte, percent float64) error
|
||||
GetProgress(ctx context.Context, userID, libID int64, bookPath string) (store.Progress, error)
|
||||
ListProgress(ctx context.Context, userID int64) ([]store.Progress, error)
|
||||
}
|
||||
|
||||
type BookmarkStore interface {
|
||||
InsertBookmark(ctx context.Context, userID, libID int64, bookPath string, locator []byte, percent float64, note string) (int64, error)
|
||||
ListBookmarks(ctx context.Context, userID, libID int64, bookPath string) ([]store.Bookmark, error)
|
||||
UpdateBookmarkNote(ctx context.Context, userID, id int64, note string) (bool, error)
|
||||
DeleteBookmark(ctx context.Context, userID, id int64) (bool, error)
|
||||
}
|
||||
|
||||
// ---------- Redis interfaces ----------
|
||||
|
||||
type PageCache interface {
|
||||
Get(ctx context.Context, key string) (string, bool)
|
||||
Set(ctx context.Context, key, val string, ttl time.Duration)
|
||||
}
|
||||
|
||||
type RateLimiter interface {
|
||||
IncrWindow(ctx context.Context, key string, ttl time.Duration) int
|
||||
}
|
||||
|
||||
type ScanLocker interface {
|
||||
ScanLock(ctx context.Context, key string, ttl time.Duration) (func(), bool)
|
||||
}
|
||||
|
||||
// ---------- Service interfaces ----------
|
||||
|
||||
type Scanner interface {
|
||||
ScanLibraryByID(ctx context.Context, id int64)
|
||||
}
|
||||
|
||||
type UploadSessions interface {
|
||||
Init(ctx context.Context, libID int64, name string, size, chunkSize int64) (string, error)
|
||||
Status(ctx context.Context, uploadID string) ([]int64, error)
|
||||
PutPart(ctx context.Context, uploadID string, index int64, body io.Reader, maxSize int64) error
|
||||
Complete(ctx context.Context, uploadID string, root string) (string, error)
|
||||
Sweep(ctx context.Context) error
|
||||
UniquePath(root, name string) (string, error)
|
||||
}
|
||||
|
||||
type Media interface {
|
||||
EnsureCover(ctx context.Context, bookID int64, format string, size, modTS int64, root, rel string) error
|
||||
ChaptersOf(idx []string) []Chapter
|
||||
PageIndex(ctx context.Context, bookID int64, size, modTS int64, root, rel string) ([]string, error)
|
||||
CacheBuster(size, modTS int64) string
|
||||
}
|
||||
|
||||
// Chapter represents a CBZ chapter derived from the archive's folder structure.
|
||||
type Chapter struct {
|
||||
Title string `json:"title"`
|
||||
Start int `json:"start"`
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package store
|
||||
|
||||
import "context"
|
||||
|
||||
func (s *Store) InsertBookmark(ctx context.Context, userID, libID int64, bookPath string, locator []byte, percent float64, note string) (int64, error) {
|
||||
var id int64
|
||||
err := s.p.QueryRow(ctx,
|
||||
`INSERT INTO bookmarks (user_id, library_id, book_path, locator, percent, note)
|
||||
VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`,
|
||||
userID, libID, bookPath, locator, percent, note).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (s *Store) ListBookmarks(ctx context.Context, userID, libID int64, bookPath string) ([]Bookmark, error) {
|
||||
rows, err := s.p.Query(ctx,
|
||||
`SELECT id, library_id, book_path, locator, percent, note, created_at
|
||||
FROM bookmarks WHERE user_id=$1 AND library_id=$2 AND book_path=$3
|
||||
ORDER BY percent ASC, id ASC`, userID, libID, bookPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Bookmark
|
||||
for rows.Next() {
|
||||
var b Bookmark
|
||||
if err := rows.Scan(&b.ID, &b.LibraryID, &b.BookPath, &b.Locator, &b.Percent, &b.Note, &b.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) UpdateBookmarkNote(ctx context.Context, userID, id int64, note string) (bool, error) {
|
||||
res, err := s.p.Exec(ctx, `UPDATE bookmarks SET note=$3 WHERE id=$1 AND user_id=$2`, id, userID, note)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return res.RowsAffected() > 0, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteBookmark(ctx context.Context, userID, id int64) (bool, error) {
|
||||
res, err := s.p.Exec(ctx, `DELETE FROM bookmarks WHERE id=$1 AND user_id=$2`, id, userID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return res.RowsAffected() > 0, nil
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package store
|
||||
|
||||
import "context"
|
||||
|
||||
const bookCols = "id, library_id, path, title, format, file_size, mod_ts, page_count, state, error_msg, added_at"
|
||||
|
||||
func (s *Store) InsertBook(ctx context.Context, libID int64, path, title, format string, size, modTS int64, pageCount int) (int64, error) {
|
||||
var id int64
|
||||
err := s.p.QueryRow(ctx,
|
||||
`INSERT INTO books (library_id, path, title, format, file_size, mod_ts, page_count)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id`,
|
||||
libID, path, title, format, size, modTS, pageCount).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (s *Store) GetBook(ctx context.Context, id int64) (Book, error) {
|
||||
var b Book
|
||||
err := s.p.QueryRow(ctx, "SELECT "+bookCols+" FROM books WHERE id=$1", id).Scan(
|
||||
&b.ID, &b.LibraryID, &b.Path, &b.Title, &b.Format,
|
||||
&b.FileSize, &b.ModTS, &b.PageCount, &b.State, &b.ErrMsg, &b.AddedAt)
|
||||
return b, err
|
||||
}
|
||||
|
||||
func (s *Store) ListBookMeta(ctx context.Context, libID int64) (map[string]BookMeta, error) {
|
||||
rows, err := s.p.Query(ctx,
|
||||
"SELECT id, path, file_size, mod_ts, format FROM books WHERE library_id=$1", libID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]BookMeta{}
|
||||
for rows.Next() {
|
||||
var m BookMeta
|
||||
var path string
|
||||
if err := rows.Scan(&m.ID, &path, &m.Size, &m.ModTS, &m.Format); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[path] = m
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) UpdateBookFile(ctx context.Context, id, size, modTS int64, pageCount int) error {
|
||||
_, err := s.p.Exec(ctx,
|
||||
`UPDATE books SET file_size=$2, mod_ts=$3, page_count=$4, state='ready', error_msg='' WHERE id=$1`,
|
||||
id, size, modTS, pageCount)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteBookByPath(ctx context.Context, libID int64, path string) error {
|
||||
_, err := s.p.Exec(ctx, "DELETE FROM books WHERE library_id=$1 AND path=$2", libID, path)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteBook(ctx context.Context, id int64) error {
|
||||
_, err := s.p.Exec(ctx, "DELETE FROM books WHERE id=$1", id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) SetBookState(ctx context.Context, id int64, state, msg string) error {
|
||||
_, err := s.p.Exec(ctx, "UPDATE books SET state=$2, error_msg=$3 WHERE id=$1", id, state, msg)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ListBooks(ctx context.Context, libID int64, q, prefix string, userID int64) ([]BookView, error) {
|
||||
rows, err := s.p.Query(ctx,
|
||||
`SELECT b.id, b.library_id, b.path, b.title, b.format, b.file_size, b.mod_ts,
|
||||
b.page_count, b.state, b.error_msg, b.added_at, l.name, COALESCE(p.percent, 0)
|
||||
FROM books b JOIN libraries l ON l.id = b.library_id
|
||||
LEFT JOIN reading_progress p ON p.user_id = $4 AND p.library_id = b.library_id AND p.book_path = b.path
|
||||
WHERE ($1 = 0 OR b.library_id = $1)
|
||||
AND ($2 = '' OR lower(b.title) LIKE '%' || lower($2) || '%')
|
||||
AND ($3 = '' OR b.path LIKE $3 || '%')
|
||||
ORDER BY l.name, b.path`, libID, q, prefix, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []BookView
|
||||
for rows.Next() {
|
||||
var v BookView
|
||||
err := rows.Scan(&v.ID, &v.LibraryID, &v.Path, &v.Title, &v.Format,
|
||||
&v.FileSize, &v.ModTS, &v.PageCount, &v.State, &v.ErrMsg, &v.AddedAt,
|
||||
&v.LibraryName, &v.Percent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) BookHashes(ctx context.Context) (map[int64][2]int64, error) {
|
||||
rows, err := s.p.Query(ctx, "SELECT id, file_size, mod_ts FROM books")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[int64][2]int64{}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var v [2]int64
|
||||
if err := rows.Scan(&id, &v[0], &v[1]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[id] = v
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package store
|
||||
|
||||
import "context"
|
||||
|
||||
func (s *Store) CreateLibrary(ctx context.Context, name, root string) (int64, error) {
|
||||
var id int64
|
||||
err := s.p.QueryRow(ctx,
|
||||
"INSERT INTO libraries (name, root_path) VALUES ($1,$2) RETURNING id", name, root).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (s *Store) ListLibraries(ctx context.Context) ([]Library, error) {
|
||||
rows, err := s.p.Query(ctx, "SELECT id, name, root_path, created_at FROM libraries ORDER BY id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Library
|
||||
for rows.Next() {
|
||||
var l Library
|
||||
if err := rows.Scan(&l.ID, &l.Name, &l.RootPath, &l.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, l)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetLibrary(ctx context.Context, id int64) (Library, error) {
|
||||
var l Library
|
||||
err := s.p.QueryRow(ctx,
|
||||
"SELECT id, name, root_path, created_at FROM libraries WHERE id=$1", id).
|
||||
Scan(&l.ID, &l.Name, &l.RootPath, &l.CreatedAt)
|
||||
return l, err
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package store
|
||||
|
||||
import "context"
|
||||
|
||||
func (s *Store) UpsertProgress(ctx context.Context, userID, libID int64, bookPath string, locator []byte, percent float64) error {
|
||||
_, err := s.p.Exec(ctx,
|
||||
`INSERT INTO reading_progress (user_id, library_id, book_path, locator, percent, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,now())
|
||||
ON CONFLICT (user_id, library_id, book_path)
|
||||
DO UPDATE SET locator=$4, percent=$5, updated_at=now()`,
|
||||
userID, libID, bookPath, locator, percent)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetProgress(ctx context.Context, userID, libID int64, bookPath string) (Progress, error) {
|
||||
var pr Progress
|
||||
err := s.p.QueryRow(ctx,
|
||||
`SELECT library_id, book_path, locator, percent, updated_at
|
||||
FROM reading_progress WHERE user_id=$1 AND library_id=$2 AND book_path=$3`,
|
||||
userID, libID, bookPath).
|
||||
Scan(&pr.LibraryID, &pr.BookPath, &pr.Locator, &pr.Percent, &pr.UpdatedAt)
|
||||
return pr, err
|
||||
}
|
||||
|
||||
func (s *Store) ListProgress(ctx context.Context, userID int64) ([]Progress, error) {
|
||||
rows, err := s.p.Query(ctx,
|
||||
`SELECT p.library_id, l.name, p.book_path, COALESCE(b.title, ''), p.locator, p.percent, p.updated_at
|
||||
FROM reading_progress p JOIN libraries l ON l.id = p.library_id
|
||||
LEFT JOIN books b ON b.library_id = p.library_id AND b.path = p.book_path
|
||||
WHERE p.user_id = $1 ORDER BY p.updated_at DESC`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Progress
|
||||
for rows.Next() {
|
||||
var pr Progress
|
||||
if err := rows.Scan(&pr.LibraryID, &pr.LibraryName, &pr.BookPath, &pr.Title,
|
||||
&pr.Locator, &pr.Percent, &pr.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, pr)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
+27
-310
@@ -1,16 +1,29 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type Store struct{ P *pgxpool.Pool }
|
||||
// ErrLastAdmin is returned by DeleteUser when attempting to delete the last admin.
|
||||
var ErrLastAdmin = errors.New("cannot delete the last admin")
|
||||
|
||||
func New(p *pgxpool.Pool) *Store { return &Store{P: p} }
|
||||
// ErrUniqueViolation is a sentinel for PostgreSQL unique constraint violations.
|
||||
var ErrUniqueViolation = errors.New("unique violation")
|
||||
|
||||
// IsUniqueViolation reports whether err is a PostgreSQL unique constraint violation (23505).
|
||||
func IsUniqueViolation(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(err, &pgErr) && pgErr.Code == "23505"
|
||||
}
|
||||
|
||||
// Store provides database access. The pool is unexported; all access goes through methods.
|
||||
type Store struct{ p *pgxpool.Pool }
|
||||
|
||||
func New(p *pgxpool.Pool) *Store { return &Store{p: p} }
|
||||
|
||||
// ---------- types ----------
|
||||
|
||||
@@ -21,12 +34,14 @@ type User struct {
|
||||
Role string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Library struct {
|
||||
ID int64
|
||||
Name string
|
||||
RootPath string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Book struct {
|
||||
ID, LibraryID int64
|
||||
Path string
|
||||
@@ -34,288 +49,35 @@ type Book struct {
|
||||
Format string
|
||||
FileSize int64
|
||||
ModTS int64
|
||||
PageCount int // 0 表示未知(pdf/epub/txt)
|
||||
PageCount int // 0 means unknown (pdf/epub/txt)
|
||||
State string
|
||||
ErrMsg string
|
||||
AddedAt time.Time
|
||||
}
|
||||
|
||||
type BookMeta struct {
|
||||
ID int64
|
||||
Size int64
|
||||
ModTS int64
|
||||
Format string
|
||||
}
|
||||
|
||||
type BookView struct {
|
||||
Book
|
||||
LibraryName string
|
||||
Percent float64
|
||||
}
|
||||
|
||||
type Progress struct {
|
||||
LibraryID int64
|
||||
LibraryName string
|
||||
BookPath string
|
||||
Title string // 书已删时为空
|
||||
Title string // empty when book deleted
|
||||
Locator []byte
|
||||
Percent float64
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// ---------- users ----------
|
||||
|
||||
const userCols = "id, username, password_hash, role, created_at"
|
||||
|
||||
func (s *Store) CountUsers(ctx context.Context) (int, error) {
|
||||
var n int
|
||||
err := s.P.QueryRow(ctx, "SELECT count(*) FROM users").Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (s *Store) CreateUser(ctx context.Context, username, hash, role string) (int64, error) {
|
||||
var id int64
|
||||
err := s.P.QueryRow(ctx,
|
||||
"INSERT INTO users (username, password_hash, role) VALUES ($1,$2,$3) RETURNING id",
|
||||
username, hash, role).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (s *Store) GetUserByName(ctx context.Context, username string) (User, error) {
|
||||
return scanUser(s.P.QueryRow(ctx, "SELECT "+userCols+" FROM users WHERE username=$1", username))
|
||||
}
|
||||
|
||||
func (s *Store) GetUserByID(ctx context.Context, id int64) (User, error) {
|
||||
return scanUser(s.P.QueryRow(ctx, "SELECT "+userCols+" FROM users WHERE id=$1", id))
|
||||
}
|
||||
|
||||
func (s *Store) ListUsers(ctx context.Context) ([]User, error) {
|
||||
rows, err := s.P.Query(ctx, "SELECT "+userCols+" FROM users ORDER BY id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []User
|
||||
for rows.Next() {
|
||||
var u User
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) DeleteUser(ctx context.Context, id int64) error {
|
||||
_, err := s.P.Exec(ctx, "DELETE FROM users WHERE id=$1", id)
|
||||
return err
|
||||
}
|
||||
|
||||
func scanUser(row pgx.Row) (User, error) {
|
||||
var u User
|
||||
err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.CreatedAt)
|
||||
return u, err
|
||||
}
|
||||
|
||||
// CountAdmins 供 Task 5 的"最后一个 admin 不可删"保护
|
||||
func (s *Store) CountAdmins(ctx context.Context) (int, error) {
|
||||
var n int
|
||||
err := s.P.QueryRow(ctx, "SELECT count(*) FROM users WHERE role='admin'").Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// ---------- libraries ----------
|
||||
|
||||
func (s *Store) CreateLibrary(ctx context.Context, name, root string) (int64, error) {
|
||||
var id int64
|
||||
err := s.P.QueryRow(ctx,
|
||||
"INSERT INTO libraries (name, root_path) VALUES ($1,$2) RETURNING id", name, root).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (s *Store) ListLibraries(ctx context.Context) ([]Library, error) {
|
||||
rows, err := s.P.Query(ctx, "SELECT id, name, root_path, created_at FROM libraries ORDER BY id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Library
|
||||
for rows.Next() {
|
||||
var l Library
|
||||
if err := rows.Scan(&l.ID, &l.Name, &l.RootPath, &l.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, l)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetLibrary(ctx context.Context, id int64) (Library, error) {
|
||||
var l Library
|
||||
err := s.P.QueryRow(ctx,
|
||||
"SELECT id, name, root_path, created_at FROM libraries WHERE id=$1", id).
|
||||
Scan(&l.ID, &l.Name, &l.RootPath, &l.CreatedAt)
|
||||
return l, err
|
||||
}
|
||||
|
||||
// ---------- books ----------
|
||||
|
||||
const bookCols = "id, library_id, path, title, format, file_size, mod_ts, page_count, state, error_msg, added_at"
|
||||
|
||||
func (s *Store) InsertBook(ctx context.Context, libID int64, path, title, format string, size, modTS int64, pageCount int) (int64, error) {
|
||||
var id int64
|
||||
err := s.P.QueryRow(ctx,
|
||||
`INSERT INTO books (library_id, path, title, format, file_size, mod_ts, page_count)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id`,
|
||||
libID, path, title, format, size, modTS, pageCount).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (s *Store) GetBook(ctx context.Context, id int64) (Book, error) {
|
||||
var b Book
|
||||
err := s.P.QueryRow(ctx, "SELECT "+bookCols+" FROM books WHERE id=$1", id).Scan(
|
||||
&b.ID, &b.LibraryID, &b.Path, &b.Title, &b.Format,
|
||||
&b.FileSize, &b.ModTS, &b.PageCount, &b.State, &b.ErrMsg, &b.AddedAt)
|
||||
return b, err
|
||||
}
|
||||
|
||||
func (s *Store) ListBookMeta(ctx context.Context, libID int64) (map[string]BookMeta, error) {
|
||||
rows, err := s.P.Query(ctx,
|
||||
"SELECT id, path, file_size, mod_ts, format FROM books WHERE library_id=$1", libID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]BookMeta{}
|
||||
for rows.Next() {
|
||||
var m BookMeta
|
||||
var path string
|
||||
if err := rows.Scan(&m.ID, &path, &m.Size, &m.ModTS, &m.Format); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[path] = m
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) UpdateBookFile(ctx context.Context, id, size, modTS int64, pageCount int) error {
|
||||
_, err := s.P.Exec(ctx,
|
||||
`UPDATE books SET file_size=$2, mod_ts=$3, page_count=$4, state='ready', error_msg='' WHERE id=$1`,
|
||||
id, size, modTS, pageCount)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteBookByPath(ctx context.Context, libID int64, path string) error {
|
||||
_, err := s.P.Exec(ctx, "DELETE FROM books WHERE library_id=$1 AND path=$2", libID, path)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteBook(ctx context.Context, id int64) error {
|
||||
_, err := s.P.Exec(ctx, "DELETE FROM books WHERE id=$1", id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) SetBookState(ctx context.Context, id int64, state, msg string) error {
|
||||
_, err := s.P.Exec(ctx, "UPDATE books SET state=$2, error_msg=$3 WHERE id=$1", id, state, msg)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ListBookIDs(ctx context.Context) ([]int64, error) {
|
||||
rows, err := s.P.Query(ctx, "SELECT id FROM books")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) ListBooks(ctx context.Context, libID int64, q, prefix string, userID int64) ([]BookView, error) {
|
||||
rows, err := s.P.Query(ctx,
|
||||
`SELECT b.id, b.library_id, b.path, b.title, b.format, b.file_size, b.mod_ts,
|
||||
b.page_count, b.state, b.error_msg, b.added_at, l.name, COALESCE(p.percent, 0)
|
||||
FROM books b JOIN libraries l ON l.id = b.library_id
|
||||
LEFT JOIN reading_progress p ON p.user_id = $4 AND p.library_id = b.library_id AND p.book_path = b.path
|
||||
WHERE ($1 = 0 OR b.library_id = $1)
|
||||
AND ($2 = '' OR lower(b.title) LIKE '%' || lower($2) || '%')
|
||||
AND ($3 = '' OR b.path LIKE $3 || '%')
|
||||
ORDER BY l.name, b.path`, libID, q, prefix, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []BookView
|
||||
for rows.Next() {
|
||||
var v BookView
|
||||
err := rows.Scan(&v.ID, &v.LibraryID, &v.Path, &v.Title, &v.Format,
|
||||
&v.FileSize, &v.ModTS, &v.PageCount, &v.State, &v.ErrMsg, &v.AddedAt,
|
||||
&v.LibraryName, &v.Percent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) BookHashes(ctx context.Context) (map[int64][2]int64, error) {
|
||||
rows, err := s.P.Query(ctx, "SELECT id, file_size, mod_ts FROM books")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[int64][2]int64{}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var v [2]int64
|
||||
if err := rows.Scan(&id, &v[0], &v[1]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[id] = v
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ---------- progress ----------
|
||||
|
||||
func (s *Store) UpsertProgress(ctx context.Context, userID, libID int64, bookPath string, locator []byte, percent float64) error {
|
||||
_, err := s.P.Exec(ctx,
|
||||
`INSERT INTO reading_progress (user_id, library_id, book_path, locator, percent, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,now())
|
||||
ON CONFLICT (user_id, library_id, book_path)
|
||||
DO UPDATE SET locator=$4, percent=$5, updated_at=now()`,
|
||||
userID, libID, bookPath, locator, percent)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ListProgress(ctx context.Context, userID int64) ([]Progress, error) {
|
||||
rows, err := s.P.Query(ctx,
|
||||
`SELECT p.library_id, l.name, p.book_path, COALESCE(b.title, ''), p.locator, p.percent, p.updated_at
|
||||
FROM reading_progress p JOIN libraries l ON l.id = p.library_id
|
||||
LEFT JOIN books b ON b.library_id = p.library_id AND b.path = p.book_path
|
||||
WHERE p.user_id = $1 ORDER BY p.updated_at DESC`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Progress
|
||||
for rows.Next() {
|
||||
var pr Progress
|
||||
if err := rows.Scan(&pr.LibraryID, &pr.LibraryName, &pr.BookPath, &pr.Title,
|
||||
&pr.Locator, &pr.Percent, &pr.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, pr)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ---------- bookmarks ----------
|
||||
|
||||
type Bookmark struct {
|
||||
ID int64
|
||||
LibraryID int64
|
||||
@@ -326,51 +88,6 @@ type Bookmark struct {
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (s *Store) InsertBookmark(ctx context.Context, userID, libID int64, bookPath string, locator []byte, percent float64, note string) (int64, error) {
|
||||
var id int64
|
||||
err := s.P.QueryRow(ctx,
|
||||
`INSERT INTO bookmarks (user_id, library_id, book_path, locator, percent, note)
|
||||
VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`,
|
||||
userID, libID, bookPath, locator, percent, note).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (s *Store) ListBookmarks(ctx context.Context, userID, libID int64, bookPath string) ([]Bookmark, error) {
|
||||
rows, err := s.P.Query(ctx,
|
||||
`SELECT id, library_id, book_path, locator, percent, note, created_at
|
||||
FROM bookmarks WHERE user_id=$1 AND library_id=$2 AND book_path=$3
|
||||
ORDER BY percent ASC, id ASC`, userID, libID, bookPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Bookmark
|
||||
for rows.Next() {
|
||||
var b Bookmark
|
||||
if err := rows.Scan(&b.ID, &b.LibraryID, &b.BookPath, &b.Locator, &b.Percent, &b.Note, &b.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) UpdateBookmarkNote(ctx context.Context, userID, id int64, note string) (bool, error) {
|
||||
res, err := s.P.Exec(ctx, `UPDATE bookmarks SET note=$3 WHERE id=$1 AND user_id=$2`, id, userID, note)
|
||||
return res.RowsAffected() > 0, err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteBookmark(ctx context.Context, userID, id int64) (bool, error) {
|
||||
res, err := s.P.Exec(ctx, `DELETE FROM bookmarks WHERE id=$1 AND user_id=$2`, id, userID)
|
||||
return res.RowsAffected() > 0, err
|
||||
}
|
||||
|
||||
func (s *Store) GetProgress(ctx context.Context, userID, libID int64, bookPath string) (Progress, error) {
|
||||
var pr Progress
|
||||
err := s.P.QueryRow(ctx,
|
||||
`SELECT library_id, book_path, locator, percent, updated_at
|
||||
FROM reading_progress WHERE user_id=$1 AND library_id=$2 AND book_path=$3`,
|
||||
userID, libID, bookPath).
|
||||
Scan(&pr.LibraryID, &pr.BookPath, &pr.Locator, &pr.Percent, &pr.UpdatedAt)
|
||||
return pr, err
|
||||
}
|
||||
// Pool returns the underlying pool for test setup only.
|
||||
// Production code should use Store methods exclusively.
|
||||
func (s *Store) Pool() *pgxpool.Pool { return s.p }
|
||||
|
||||
@@ -2,6 +2,7 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
@@ -129,3 +130,50 @@ func TestProgressUpsertAndJoin(t *testing.T) {
|
||||
t.Fatalf("views %+v", views)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteUserLastAdmin(t *testing.T) {
|
||||
s := setup(t)
|
||||
ctx := context.Background()
|
||||
id, err := s.CreateUser(ctx, "onlyadmin", "hash", "admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = s.DeleteUser(ctx, id)
|
||||
if !errors.Is(err, ErrLastAdmin) {
|
||||
t.Fatalf("expected ErrLastAdmin, got %v", err)
|
||||
}
|
||||
// Verify user still exists.
|
||||
_, err = s.GetUserByID(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal("admin should still exist after blocked delete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteUserNonLastAdmin(t *testing.T) {
|
||||
s := setup(t)
|
||||
ctx := context.Background()
|
||||
id1, _ := s.CreateUser(ctx, "admin1", "h", "admin")
|
||||
_, _ = s.CreateUser(ctx, "admin2", "h", "admin")
|
||||
if err := s.DeleteUser(ctx, id1); err != nil {
|
||||
t.Fatalf("should allow deleting non-last admin: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteUserMember(t *testing.T) {
|
||||
s := setup(t)
|
||||
ctx := context.Background()
|
||||
id, _ := s.CreateUser(ctx, "member", "h", "member")
|
||||
if err := s.DeleteUser(ctx, id); err != nil {
|
||||
t.Fatalf("member delete should succeed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsUniqueViolation(t *testing.T) {
|
||||
s := setup(t)
|
||||
ctx := context.Background()
|
||||
_, _ = s.CreateUser(ctx, "dup", "h", "member")
|
||||
_, err := s.CreateUser(ctx, "dup", "h", "member")
|
||||
if !IsUniqueViolation(err) {
|
||||
t.Fatalf("expected unique violation, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
const userCols = "id, username, password_hash, role, created_at"
|
||||
|
||||
func (s *Store) CountUsers(ctx context.Context) (int, error) {
|
||||
var n int
|
||||
err := s.p.QueryRow(ctx, "SELECT count(*) FROM users").Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (s *Store) CreateUser(ctx context.Context, username, hash, role string) (int64, error) {
|
||||
var id int64
|
||||
err := s.p.QueryRow(ctx,
|
||||
"INSERT INTO users (username, password_hash, role) VALUES ($1,$2,$3) RETURNING id",
|
||||
username, hash, role).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (s *Store) GetUserByName(ctx context.Context, username string) (User, error) {
|
||||
return scanUser(s.p.QueryRow(ctx, "SELECT "+userCols+" FROM users WHERE username=$1", username))
|
||||
}
|
||||
|
||||
func (s *Store) GetUserByID(ctx context.Context, id int64) (User, error) {
|
||||
return scanUser(s.p.QueryRow(ctx, "SELECT "+userCols+" FROM users WHERE id=$1", id))
|
||||
}
|
||||
|
||||
func (s *Store) ListUsers(ctx context.Context) ([]User, error) {
|
||||
rows, err := s.p.Query(ctx, "SELECT "+userCols+" FROM users ORDER BY id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []User
|
||||
for rows.Next() {
|
||||
var u User
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteUser deletes a user with a transactional last-admin check (B5).
|
||||
// Returns ErrLastAdmin if the target is the last admin.
|
||||
func (s *Store) DeleteUser(ctx context.Context, id int64) error {
|
||||
tx, err := s.p.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
var role string
|
||||
if err := tx.QueryRow(ctx, "SELECT role FROM users WHERE id=$1 FOR UPDATE", id).Scan(&role); err != nil {
|
||||
return err // includes pgx.ErrNoRows
|
||||
}
|
||||
if role == "admin" {
|
||||
var n int
|
||||
if err := tx.QueryRow(ctx, "SELECT count(*) FROM users WHERE role='admin'").Scan(&n); err != nil {
|
||||
return err
|
||||
}
|
||||
if n <= 1 {
|
||||
return ErrLastAdmin
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(ctx, "DELETE FROM users WHERE id=$1", id); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func scanUser(row pgx.Row) (User, error) {
|
||||
var u User
|
||||
err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.CreatedAt)
|
||||
return u, err
|
||||
}
|
||||
@@ -8,6 +8,35 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed / 修复
|
||||
|
||||
- Rate limiter `IncrWindow` uses atomic Lua script for INCR+EXPIRE, preventing permanent IP lockout on EXPIRE failure (B1).
|
||||
- 限流器 `IncrWindow` 改用 Lua 脚本原子执行 INCR+EXPIRE,防止 EXPIRE 失败导致 IP 永久锁定(B1)。
|
||||
- Distributed lock `Lock` handles `rand.Read` failure by degrading to no-lock instead of using a zero token (B2).
|
||||
- 分布式锁 `Lock` 在 `rand.Read` 失败时降级为无锁模式,而非使用全零 token(B2)。
|
||||
- Lock unlock uses `context.WithoutCancel` to survive caller cancellation (B3).
|
||||
- 锁的解锁改用 `context.WithoutCancel`,在调用方上下文取消后仍能正常释放(B3)。
|
||||
- Upload part writes to `.tmp` then renames, preventing truncated parts from being reported as received (B4).
|
||||
- 分片上传先写 `.tmp` 再 rename,防止崩溃截断的分片被误报为已接收(B4)。
|
||||
- `DeleteUser` last-admin check is now transactional, eliminating TOCTOU race (B5).
|
||||
- `DeleteUser` 的最后管理员检查改为事务内执行,消除 TOCTOU 竞态(B5)。
|
||||
- Single-file upload `io.Copy` errors other than `MaxBytesError` return 500 instead of 413 (B6).
|
||||
- 单文件上传中非 `MaxBytesError` 的 `io.Copy` 错误返回 500 而非 413(B6)。
|
||||
- `/auth/me` distinguishes `no rows` (401) from database errors (503) (B7).
|
||||
- `/auth/me` 区分无记录(401)和数据库错误(503)(B7)。
|
||||
- Library creation rejects reserved names (`cache`, `.uploads`) with `400 reserved_name` (B8).
|
||||
- 创建书库时拒绝保留名(`cache`、`.uploads`),返回 `400 reserved_name`(B8)。
|
||||
- Scanner lock auto-renews every TTL/2 during long scans; per-library single-flight prevents concurrent scans (B9).
|
||||
- 扫描锁每 TTL/2 自动续期;库级 single-flight 防止并发扫描(B9)。
|
||||
- Scanner `SetBookState` errors are now logged instead of silently discarded (B10).
|
||||
- 扫描器 `SetBookState` 的错误现在会记录日志而非静默丢弃(B10)。
|
||||
- Cover write errors fully checked; orphan `.tmp` files cleaned only on failure (B11).
|
||||
- 封面写入错误全部检查;孤儿 `.tmp` 文件仅在失败路径清理(B11)。
|
||||
- Upload handler retries on `O_EXCL` collision for concurrent same-name uploads (B12).
|
||||
- 上传处理器在 `O_EXCL` 冲突时重试,处理并发同名上传(B12)。
|
||||
- Bookmark methods check `err` before `RowsAffected` to avoid invalid reads on query failure (B13).
|
||||
- 书签方法先检查 `err` 再读 `RowsAffected`,避免查询失败时的无效读取(B13)。
|
||||
|
||||
### Changed / 变更
|
||||
|
||||
- Add ordered migration system with `schema_migrations` tracking and pg advisory lock for safe multi-replica schema evolution. Existing databases are auto-baselined. To change the schema, add a new `NNNN_description.sql` file under `backend/internal/db/migrations/`; never modify an already-applied file. No down migrations — rollback via backup, fix-forward.
|
||||
|
||||
Reference in New Issue
Block a user