refactor(repo): conform structure to AGENTS.md (web->frontend, internal/api->cmd/webui/{api,handlers}, docs/README+CHANGELOGs, module .gitignores, drop stray library/ and committed debug bins)

This commit is contained in:
2026-09-07 21:37:30 +08:00
parent 961de429f9
commit ca64bc0d73
33 changed files with 324 additions and 205 deletions
+3
View File
@@ -0,0 +1,3 @@
__debug_bin*
server
booklib*
+1 -1
View File
@@ -7,4 +7,4 @@ FROM base AS dev
ENV GOPROXY=https://goproxy.cn,direct
RUN go install github.com/go-delve/delve/cmd/dlv@latest
EXPOSE 8080 2345
CMD ["sh", "-c", "go mod download && dlv debug ./cmd/server --headless --listen=0.0.0.0:2345 --api-version=2 --accept-multiclient --continue --log"]
CMD ["sh", "-c", "go mod download && dlv debug ./cmd/webui --headless --listen=0.0.0.0:2345 --api-version=2 --accept-multiclient --continue --log"]
+1 -1
View File
@@ -3,7 +3,7 @@ WORKDIR /src
COPY backend/go.mod backend/go.sum ./
RUN go mod download
COPY backend/ ./
RUN CGO_ENABLED=0 go build -trimpath -o /server ./cmd/server
RUN CGO_ENABLED=0 go build -trimpath -o /server ./cmd/webui
FROM alpine:3.20 AS runner
RUN adduser -D -H app
Binary file not shown.
Binary file not shown.
+51
View File
@@ -0,0 +1,51 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
"booklib/cmd/webui/handlers"
"booklib/internal/config"
"booklib/internal/redispkg"
"booklib/internal/scanner"
"booklib/internal/store"
)
func NewRouter(cfg *config.Config, st *store.Store, rdb *redispkg.R, sc *scanner.Scanner) *gin.Engine {
gin.SetMode(gin.ReleaseMode)
h := handlers.New(cfg, st, rdb, sc)
r := gin.New()
if e := r.SetTrustedProxies(cfg.TrustedProxies); e != nil {
panic(e)
}
r.Use(gin.Recovery())
g := r.Group("/api")
g.GET("/healthz", func(c *gin.Context) { c.String(http.StatusOK, "ok") })
g.POST("/auth/login", h.Login)
p := g.Group("", h.AuthMw())
p.GET("/auth/me", h.Me)
users := p.Group("/users", h.AdminOnly())
users.GET("", h.ListUsers)
users.POST("", h.CreateUser)
users.DELETE("/:id", h.DeleteUser)
libs := p.Group("/libraries")
libs.GET("", h.ListLibraries)
libs.POST("", h.AdminOnly(), h.CreateLibrary)
libs.POST("/:id/scan", h.AdminOnly(), h.ScanLibrary)
libs.POST("/:id/upload", h.AdminOnly(), h.Upload)
p.GET("/books", h.ListBooks)
p.GET("/books/:id", h.GetBook)
p.DELETE("/books/:id", h.AdminOnly(), h.DeleteBook)
p.GET("/books/:id/cover", h.ServeCover)
p.GET("/books/:id/file", h.ServeFile)
p.GET("/books/:id/pages", h.PagesCount)
p.GET("/books/:id/pages/:n", h.Page)
p.PUT("/books/:id/progress", h.PutProgress)
p.GET("/progress", h.ListProgress)
return r
}
+26
View File
@@ -0,0 +1,26 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"booklib/internal/config"
"booklib/internal/redispkg"
)
func testCfg() *config.Config {
return &config.Config{Addr: ":8080", JWTSecret: []byte("s3cret"), ScanInterval: time.Minute, UploadMaxMB: 200,
TrustedProxies: []string{"172.16.0.0/12"}} // 与 prod 默认一致: 只有 compose 网段内代理才可信
}
func TestHealthz(t *testing.T) {
r := NewRouter(testCfg(), nil, redispkg.New(""), nil)
req := httptest.NewRequest(http.MethodGet, "/api/healthz", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("healthz = %d, want 200", w.Code)
}
}
@@ -1,4 +1,4 @@
package api
package handlers
import (
"errors"
@@ -14,17 +14,17 @@ import (
const loginWindow = time.Minute
const loginMax = 5
func (a *api) login(c *gin.Context) {
func (h *H) Login(c *gin.Context) {
var req struct{ Username, Password string }
if c.ShouldBindJSON(&req) != nil || req.Username == "" || req.Password == "" {
err(c, http.StatusBadRequest, "bad_request", "username and password required")
return
}
if n := a.rdb.IncrWindow(c, "loginrl:"+c.ClientIP(), loginWindow); n > loginMax {
if n := h.rdb.IncrWindow(c, "loginrl:"+c.ClientIP(), loginWindow); n > loginMax {
err(c, http.StatusTooManyRequests, "rate_limited", "too many login attempts")
return
}
u, qerr := a.st.GetUserByName(c, req.Username)
u, qerr := h.st.GetUserByName(c, req.Username)
if qerr != nil {
if !errors.Is(qerr, pgx.ErrNoRows) {
dbErr(c, qerr)
@@ -39,7 +39,7 @@ func (a *api) login(c *gin.Context) {
err(c, http.StatusUnauthorized, "unauthorized", "bad credentials")
return
}
tok, serr := auth.Sign(a.cfg.JWTSecret, u.ID, u.Role)
tok, serr := auth.Sign(h.cfg.JWTSecret, u.ID, u.Role)
if serr != nil {
err(c, http.StatusInternalServerError, "internal", "sign")
return
@@ -47,8 +47,8 @@ func (a *api) login(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"token": tok})
}
func (a *api) me(c *gin.Context) {
u, qerr := a.st.GetUserByID(c, uid(c))
func (h *H) Me(c *gin.Context) {
u, qerr := h.st.GetUserByID(c, uid(c))
if qerr != nil {
err(c, http.StatusUnauthorized, "unauthorized", "no such user")
return
@@ -1,4 +1,4 @@
package api
package handlers_test
import (
"bytes"
@@ -10,16 +10,24 @@ import (
"os"
"path/filepath"
"testing"
"time"
"github.com/redis/go-redis/v9"
"booklib/cmd/webui/api"
"booklib/internal/auth"
"booklib/internal/config"
"booklib/internal/db"
"booklib/internal/redispkg"
"booklib/internal/scanner"
"booklib/internal/store"
)
func testCfg() *config.Config {
return &config.Config{Addr: ":8080", JWTSecret: []byte("s3cret"), ScanInterval: time.Minute, UploadMaxMB: 200,
TrustedProxies: []string{"172.16.0.0/12"}} // 与 prod 默认一致: 只有 compose 网段内代理才可信
}
func setupAPI(t *testing.T) (*store.Store, *scanner.Scanner, http.Handler, string) {
t.Helper()
url := os.Getenv("DATABASE_URL")
@@ -50,7 +58,7 @@ func setupAPI(t *testing.T) (*store.Store, *scanner.Scanner, http.Handler, strin
cfg.CacheDir = t.TempDir()
rdb := redispkg.New(os.Getenv("REDIS_URL"))
sc := scanner.New(st, cfg, rdb)
r := NewRouter(cfg, st, rdb, sc)
r := api.NewRouter(cfg, st, rdb, sc)
if u := os.Getenv("REDIS_URL"); u != "" { // 测试卫生: 共享 redis 上重置登录限流桶, 防跨测试累计 429
if opt, e := redis.ParseURL(u); e == nil {
rc := redis.NewClient(opt)
@@ -1,4 +1,4 @@
package api
package handlers
import (
"errors"
@@ -17,8 +17,8 @@ import (
"booklib/internal/store"
)
func (a *api) getBookRow(c *gin.Context, id int64) (store.Book, bool) {
b, e := a.st.GetBook(c, id)
func (h *H) getBookRow(c *gin.Context, id int64) (store.Book, bool) {
b, e := h.st.GetBook(c, id)
if e != nil {
if errors.Is(e, pgx.ErrNoRows) {
err(c, http.StatusNotFound, "not_found", "no such book")
@@ -30,17 +30,17 @@ func (a *api) getBookRow(c *gin.Context, id int64) (store.Book, bool) {
return b, true
}
func (a *api) bookFromParam(c *gin.Context) (store.Book, bool) {
func (h *H) bookFromParam(c *gin.Context) (store.Book, bool) {
id, e := strconv.ParseInt(c.Param("id"), 10, 64)
if e != nil {
err(c, http.StatusBadRequest, "bad_request", "bad id")
return store.Book{}, false
}
return a.getBookRow(c, id)
return h.getBookRow(c, id)
}
func (a *api) getLibRow(c *gin.Context, id int64) (store.Library, bool) {
l, e := a.st.GetLibrary(c, id)
func (h *H) getLibRow(c *gin.Context, id int64) (store.Library, bool) {
l, e := h.st.GetLibrary(c, id)
if e != nil {
if errors.Is(e, pgx.ErrNoRows) {
err(c, http.StatusNotFound, "not_found", "no such library")
@@ -87,9 +87,9 @@ func bookJSON(b store.Book, percent float64, libraryName string) gin.H {
return j
}
func (a *api) listBooks(c *gin.Context) {
func (h *H) ListBooks(c *gin.Context) {
libID, _ := strconv.ParseInt(c.Query("library"), 10, 64)
views, e := a.st.ListBooks(c, libID, c.Query("q"), c.Query("prefix"), uid(c))
views, e := h.st.ListBooks(c, libID, c.Query("q"), c.Query("prefix"), uid(c))
if e != nil {
dbErr(c, e)
return
@@ -101,17 +101,17 @@ func (a *api) listBooks(c *gin.Context) {
c.JSON(http.StatusOK, out)
}
func (a *api) getBook(c *gin.Context) {
b, ok := a.bookFromParam(c)
func (h *H) GetBook(c *gin.Context) {
b, ok := h.bookFromParam(c)
if !ok {
return
}
p, e := a.st.GetProgress(c, uid(c), b.LibraryID, b.Path) // ErrNoRows → 零值 percent
p, e := h.st.GetProgress(c, uid(c), b.LibraryID, b.Path) // ErrNoRows → 零值 percent
if e != nil && !errors.Is(e, pgx.ErrNoRows) {
dbErr(c, e)
return
}
lib, e := a.st.GetLibrary(c, b.LibraryID)
lib, e := h.st.GetLibrary(c, b.LibraryID)
if e != nil && !errors.Is(e, pgx.ErrNoRows) { // 库被并发删则留空 library 名,书仍可见
dbErr(c, e)
return
@@ -119,16 +119,16 @@ func (a *api) getBook(c *gin.Context) {
c.JSON(http.StatusOK, bookJSON(b, p.Percent, lib.Name))
}
func (a *api) deleteBook(c *gin.Context) {
b, ok := a.bookFromParam(c)
func (h *H) DeleteBook(c *gin.Context) {
b, ok := h.bookFromParam(c)
if !ok {
return
}
lib, ok := a.getLibRow(c, b.LibraryID)
lib, ok := h.getLibRow(c, b.LibraryID)
if !ok {
return
}
root, ok := a.libRoot(c, lib)
root, ok := h.libRoot(c, lib)
if !ok {
return
}
@@ -142,9 +142,9 @@ func (a *api) deleteBook(c *gin.Context) {
return
}
key := bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS))
os.RemoveAll(bookfile.CoverDir(a.cfg.CacheDir, key))
os.RemoveAll(bookfile.PagesDir(a.cfg.CacheDir, key))
if e := a.st.DeleteBook(c, b.ID); e != nil {
os.RemoveAll(bookfile.CoverDir(h.cfg.CacheDir, key))
os.RemoveAll(bookfile.PagesDir(h.cfg.CacheDir, key))
if e := h.st.DeleteBook(c, b.ID); e != nil {
dbErr(c, e)
return
}
@@ -1,4 +1,4 @@
package api
package handlers_test
import (
"archive/zip"
@@ -160,15 +160,3 @@ func TestDeleteUnsafePath403(t *testing.T) {
t.Fatalf("row must survive: %v", e)
}
}
func TestAbsBookPathTraversalRejected(t *testing.T) {
root := "/data/books/lib" // 纯路径逻辑,不碰文件系统,无需 DB
for _, bad := range []string{"../../etc/passwd", "a/../../../etc/x", "../sibling"} {
if _, e := absBookPath(root, store.Book{Path: bad}); e == nil {
t.Fatalf("must reject %q", bad)
}
}
if p, e := absBookPath(root, store.Book{Path: "series-a/vol.cbz"}); e != nil || p != filepath.Join(root, "series-a", "vol.cbz") {
t.Fatalf("must accept relative path: %q %v", p, e)
}
}
@@ -1,4 +1,4 @@
package api
package handlers
import (
"fmt"
@@ -17,25 +17,25 @@ import (
const defaultCover = `<svg xmlns="http://www.w3.org/2000/svg" width="120" height="170"><rect width="120" height="170" rx="6" fill="#2a2a33"/><path d="M30 25h60v120H30z" fill="#3a3a45"/><path d="M30 25h60M60 25v120" stroke="#555" stroke-width="2"/></svg>`
func (a *api) bookRoot(c *gin.Context, b store.Book) (string, bool) {
lib, ok := a.getLibRow(c, b.LibraryID)
func (h *H) bookRoot(c *gin.Context, b store.Book) (string, bool) {
lib, ok := h.getLibRow(c, b.LibraryID)
if !ok {
return "", false
}
return a.libRoot(c, lib)
return h.libRoot(c, lib)
}
func (a *api) immutable(c *gin.Context) {
func (h *H) immutable(c *gin.Context) {
c.Header("Cache-Control", "public, max-age=31536000, immutable")
}
func (a *api) serveCover(c *gin.Context) {
b, ok := a.bookFromParam(c)
func (h *H) ServeCover(c *gin.Context) {
b, ok := h.bookFromParam(c)
if !ok {
return
}
a.immutable(c)
dir := bookfile.CoverDir(a.cfg.CacheDir, bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS)))
h.immutable(c)
dir := bookfile.CoverDir(h.cfg.CacheDir, bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS)))
if entries, e := os.ReadDir(dir); e == nil {
for _, en := range entries { // 跳过写一半的 .tmp 落盘中间态
if !strings.Contains(en.Name(), ".tmp") {
@@ -45,8 +45,8 @@ func (a *api) serveCover(c *gin.Context) {
}
}
if b.Format == "cbz" || b.Format == "epub" { // 自愈:缓存丢了就地抽封面(重启/卷漂移/扫描器还没跑到)
if root, ok := a.bookRoot(c, b); ok {
if f, size, ok := a.openBook(c, b, root); ok {
if root, ok := h.bookRoot(c, b); ok {
if f, size, ok := h.openBook(c, b, root); ok {
defer f.Close()
var img []byte
var ext string
@@ -77,12 +77,12 @@ func (a *api) serveCover(c *gin.Context) {
c.Data(http.StatusOK, "image/svg+xml", []byte(defaultCover))
}
func (a *api) serveFile(c *gin.Context) {
b, ok := a.bookFromParam(c)
func (h *H) ServeFile(c *gin.Context) {
b, ok := h.bookFromParam(c)
if !ok {
return
}
root, ok := a.bookRoot(c, b)
root, ok := h.bookRoot(c, b)
if !ok {
return
}
@@ -96,7 +96,7 @@ func (a *api) serveFile(c *gin.Context) {
http.ServeFile(c.Writer, c.Request, abs)
}
func (a *api) openBook(c *gin.Context, b store.Book, root string) (*os.File, int64, bool) {
func (h *H) openBook(c *gin.Context, b store.Book, root string) (*os.File, int64, bool) {
abs, perr := absBookPath(root, b)
if perr != nil {
err(c, http.StatusForbidden, "forbidden", "unsafe path")
@@ -116,13 +116,13 @@ func (a *api) openBook(c *gin.Context, b store.Book, root string) (*os.File, int
return f, st.Size(), true
}
func (a *api) pageIndex(c *gin.Context, b store.Book, root string) ([]string, error) {
func (h *H) pageIndex(c *gin.Context, b store.Book, root string) ([]string, error) {
hash := bookfile.Hash(b.FileSize, b.ModTS)
key := fmt.Sprintf("pagesidx:%d:%s", b.ID, hash)
if v, ok := a.rdb.Get(c, key); ok && v != "" {
if v, ok := h.rdb.Get(c, key); ok && v != "" {
return strings.Split(v, "\n"), nil
}
f, size, ok := a.openBook(c, b, root)
f, size, ok := h.openBook(c, b, root)
if !ok {
return nil, os.ErrNotExist
}
@@ -132,13 +132,13 @@ func (a *api) pageIndex(c *gin.Context, b store.Book, root string) ([]string, er
return nil, e
}
if len(idx) > 0 { // 空索引不缓存,否则 warm 命中 "" 会 Split 出幽灵页
a.rdb.Set(c, key, strings.Join(idx, "\n"), 7*24*time.Hour)
h.rdb.Set(c, key, strings.Join(idx, "\n"), 7*24*time.Hour)
}
return idx, nil
}
func (a *api) pagesCount(c *gin.Context) {
b, ok := a.bookFromParam(c)
func (h *H) PagesCount(c *gin.Context) {
b, ok := h.bookFromParam(c)
if !ok {
return
}
@@ -146,11 +146,11 @@ func (a *api) pagesCount(c *gin.Context) {
err(c, http.StatusBadRequest, "bad_request", "pages only for cbz")
return
}
root, ok := a.bookRoot(c, b)
root, ok := h.bookRoot(c, b)
if !ok {
return
}
idx, e := a.pageIndex(c, b, root)
idx, e := h.pageIndex(c, b, root)
if e != nil {
if c.Writer.Written() {
return // openBook 已写 403/404,不再叠加 422
@@ -161,8 +161,8 @@ func (a *api) pagesCount(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"count": len(idx)})
}
func (a *api) page(c *gin.Context) {
b, ok := a.bookFromParam(c)
func (h *H) Page(c *gin.Context) {
b, ok := h.bookFromParam(c)
if !ok {
return
}
@@ -175,11 +175,11 @@ func (a *api) page(c *gin.Context) {
err(c, http.StatusBadRequest, "bad_request", "bad page number")
return
}
root, ok := a.bookRoot(c, b)
root, ok := h.bookRoot(c, b)
if !ok {
return
}
idx, e := a.pageIndex(c, b, root)
idx, e := h.pageIndex(c, b, root)
if e != nil {
if c.Writer.Written() {
return // openBook 已写 403/404,不再叠加 422
@@ -192,10 +192,10 @@ func (a *api) page(c *gin.Context) {
return
}
ext := strings.ToLower(filepath.Ext(idx[n]))
dir := bookfile.PagesDir(a.cfg.CacheDir, bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS)))
dir := bookfile.PagesDir(h.cfg.CacheDir, bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS)))
dst := filepath.Join(dir, strconv.Itoa(n)+ext)
if _, e := os.Stat(dst); e != nil { // miss → 解压落盘(并发重做同页幂等,唯一 tmp 名 + rename 原子)
f, size, ok := a.openBook(c, b, root)
f, size, ok := h.openBook(c, b, root)
if !ok {
return
}
@@ -221,6 +221,6 @@ func (a *api) page(c *gin.Context) {
return
}
}
a.immutable(c)
h.immutable(c)
http.ServeFile(c.Writer, c.Request, dst)
}
@@ -1,4 +1,4 @@
package api
package handlers_test
import (
"context"
@@ -1,4 +1,4 @@
package api
package handlers
import (
"errors"
@@ -20,13 +20,17 @@ import (
"booklib/internal/store"
)
type api struct {
type H struct {
cfg *config.Config
st *store.Store
rdb *redispkg.R
sc *scanner.Scanner
}
func New(cfg *config.Config, st *store.Store, rdb *redispkg.R, sc *scanner.Scanner) *H {
return &H{cfg: cfg, st: st, rdb: rdb, sc: sc}
}
func err(c *gin.Context, status int, code, msg string) {
c.AbortWithStatusJSON(status, gin.H{"error": gin.H{"code": code, "message": msg}})
}
@@ -46,15 +50,15 @@ func dbErr(c *gin.Context, e error) {
err(c, status, code, "db error")
}
func (a *api) authMw() gin.HandlerFunc {
func (h *H) AuthMw() gin.HandlerFunc {
return func(c *gin.Context) {
h := c.GetHeader("Authorization")
tok, ok := strings.CutPrefix(h, "Bearer ")
hdr := c.GetHeader("Authorization")
tok, ok := strings.CutPrefix(hdr, "Bearer ")
if !ok {
err(c, http.StatusUnauthorized, "unauthorized", "missing bearer token")
return
}
cl, perr := auth.Parse(a.cfg.JWTSecret, tok)
cl, perr := auth.Parse(h.cfg.JWTSecret, tok)
if perr != nil {
err(c, http.StatusUnauthorized, "unauthorized", "invalid token")
return
@@ -65,7 +69,7 @@ func (a *api) authMw() gin.HandlerFunc {
}
}
func (a *api) adminOnly() gin.HandlerFunc {
func (h *H) AdminOnly() gin.HandlerFunc {
return func(c *gin.Context) {
if c.GetString("role") != "admin" {
err(c, http.StatusForbidden, "forbidden", "admin only")
@@ -1,34 +1,30 @@
package api
package handlers
import (
"errors"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"syscall"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/puddle/v2"
"booklib/internal/config"
"booklib/internal/redispkg"
"booklib/internal/store"
)
func testCfg() *config.Config {
return &config.Config{Addr: ":8080", JWTSecret: []byte("s3cret"), ScanInterval: time.Minute, UploadMaxMB: 200,
TrustedProxies: []string{"172.16.0.0/12"}} // 与 prod 默认一致: 只有 compose 网段内代理才可信
}
func TestHealthz(t *testing.T) {
r := NewRouter(testCfg(), nil, redispkg.New(""), nil)
req := httptest.NewRequest(http.MethodGet, "/api/healthz", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("healthz = %d, want 200", w.Code)
func TestAbsBookPathTraversalRejected(t *testing.T) {
root := "/data/books/lib" // 纯路径逻辑,不碰文件系统,无需 DB
for _, bad := range []string{"../../etc/passwd", "a/../../../etc/x", "../sibling"} {
if _, e := absBookPath(root, store.Book{Path: bad}); e == nil {
t.Fatalf("must reject %q", bad)
}
}
if p, e := absBookPath(root, store.Book{Path: "series-a/vol.cbz"}); e != nil || p != filepath.Join(root, "series-a", "vol.cbz") {
t.Fatalf("must accept relative path: %q %v", p, e)
}
}
@@ -1,4 +1,4 @@
package api
package handlers
import (
"context"
@@ -19,9 +19,9 @@ import (
)
// resolveLibRoot: root_path 必须绝对且落在 BooksDir 内(spec §7 前缀校验)
func (a *api) libRoot(c *gin.Context, lib store.Library) (string, bool) {
func (h *H) libRoot(c *gin.Context, lib store.Library) (string, bool) {
root := filepath.Clean(lib.RootPath)
books := filepath.Clean(a.cfg.BooksDir)
books := filepath.Clean(h.cfg.BooksDir)
if !filepath.IsAbs(root) || (root != books && !strings.HasPrefix(root, books+string(os.PathSeparator))) {
err(c, http.StatusForbidden, "forbidden", "library root outside books dir")
return "", false
@@ -33,8 +33,8 @@ func (a *api) libRoot(c *gin.Context, lib store.Library) (string, bool) {
return root, true
}
func (a *api) listLibraries(c *gin.Context) {
libs, e := a.st.ListLibraries(c)
func (h *H) ListLibraries(c *gin.Context) {
libs, e := h.st.ListLibraries(c)
if e != nil {
dbErr(c, e)
return
@@ -47,7 +47,7 @@ func (a *api) listLibraries(c *gin.Context) {
c.JSON(http.StatusOK, out)
}
func (a *api) createLibrary(c *gin.Context) {
func (h *H) CreateLibrary(c *gin.Context) {
var req struct {
Name string `json:"name"`
RootPath string `json:"root_path"`
@@ -60,7 +60,7 @@ func (a *api) createLibrary(c *gin.Context) {
err(c, http.StatusBadRequest, "bad_request", "root_path must be absolute")
return
}
id, e := a.st.CreateLibrary(c, req.Name, filepath.Clean(req.RootPath))
id, e := h.st.CreateLibrary(c, req.Name, filepath.Clean(req.RootPath))
if e != nil {
if isUnique(e) {
err(c, http.StatusConflict, "exists", "root_path taken")
@@ -72,13 +72,13 @@ func (a *api) createLibrary(c *gin.Context) {
c.JSON(http.StatusCreated, gin.H{"id": id, "name": req.Name, "root_path": filepath.Clean(req.RootPath)})
}
func (a *api) getLibrary(c *gin.Context) (store.Library, bool) {
func (h *H) getLibrary(c *gin.Context) (store.Library, bool) {
id, e := strconv.ParseInt(c.Param("id"), 10, 64)
if e != nil {
err(c, http.StatusBadRequest, "bad_request", "bad id")
return store.Library{}, false
}
lib, e := a.st.GetLibrary(c, id)
lib, e := h.st.GetLibrary(c, id)
if e != nil {
if errors.Is(e, pgx.ErrNoRows) {
err(c, http.StatusNotFound, "not_found", "no such library")
@@ -90,28 +90,28 @@ func (a *api) getLibrary(c *gin.Context) (store.Library, bool) {
return lib, true
}
func (a *api) scanLibrary(c *gin.Context) {
lib, ok := a.getLibrary(c)
func (h *H) ScanLibrary(c *gin.Context) {
lib, ok := h.getLibrary(c)
if !ok {
return
}
if _, ok := a.libRoot(c, lib); !ok {
if _, ok := h.libRoot(c, lib); !ok {
return
}
go a.sc.ScanLibraryByID(context.WithoutCancel(c), lib.ID)
go h.sc.ScanLibraryByID(context.WithoutCancel(c), lib.ID)
c.JSON(http.StatusAccepted, gin.H{"accepted": true})
}
func (a *api) upload(c *gin.Context) {
lib, ok := a.getLibrary(c)
func (h *H) Upload(c *gin.Context) {
lib, ok := h.getLibrary(c)
if !ok {
return
}
root, ok := a.libRoot(c, lib)
root, ok := h.libRoot(c, lib)
if !ok {
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, a.cfg.UploadMaxMB<<20)
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, h.cfg.UploadMaxMB<<20)
fh, e := c.FormFile("file")
if e != nil {
err(c, http.StatusBadRequest, "bad_request", "multipart field 'file' required")
@@ -122,7 +122,7 @@ func (a *api) upload(c *gin.Context) {
err(c, http.StatusBadRequest, "bad_format", "extension must be cbz/pdf/epub/txt/md")
return
}
dst, e := a.uniquePath(root, name)
dst, e := h.uniquePath(root, name)
if e != nil {
err(c, http.StatusForbidden, "forbidden", e.Error())
return
@@ -155,7 +155,7 @@ func (a *api) upload(c *gin.Context) {
}
// uniquePath 清洗后的 name 必须仍在 root 内;重名加 " (n)" 后缀
func (a *api) uniquePath(root, name string) (string, error) {
func (h *H) uniquePath(root, name string) (string, error) {
ext := filepath.Ext(name)
base := strings.TrimSuffix(name, ext)
for i := 0; ; i++ {
@@ -1,4 +1,4 @@
package api
package handlers_test
import (
"bytes"
@@ -1,4 +1,4 @@
package api
package handlers
import (
"encoding/json"
@@ -8,8 +8,8 @@ import (
"github.com/gin-gonic/gin"
)
func (a *api) putProgress(c *gin.Context) {
b, ok := a.bookFromParam(c)
func (h *H) PutProgress(c *gin.Context) {
b, ok := h.bookFromParam(c)
if !ok {
return
}
@@ -32,15 +32,15 @@ func (a *api) putProgress(c *gin.Context) {
err(c, http.StatusBadRequest, "bad_request", "locator must be valid json")
return
}
if e := a.st.UpsertProgress(c, uid(c), b.LibraryID, b.Path, req.Locator, req.Percent); e != nil {
if e := h.st.UpsertProgress(c, uid(c), b.LibraryID, b.Path, req.Locator, req.Percent); e != nil {
dbErr(c, e)
return
}
c.Status(http.StatusNoContent)
}
func (a *api) listProgress(c *gin.Context) {
rows, e := a.st.ListProgress(c, uid(c))
func (h *H) ListProgress(c *gin.Context) {
rows, e := h.st.ListProgress(c, uid(c))
if e != nil {
dbErr(c, e)
return
@@ -1,4 +1,4 @@
package api
package handlers_test
import (
"encoding/json"
@@ -1,4 +1,4 @@
package api
package handlers
import (
"errors"
@@ -18,8 +18,8 @@ func isUnique(e error) bool {
return errors.As(e, &pgErr) && pgErr.Code == "23505"
}
func (a *api) listUsers(c *gin.Context) {
users, e := a.st.ListUsers(c)
func (h *H) ListUsers(c *gin.Context) {
users, e := h.st.ListUsers(c)
if e != nil {
dbErr(c, e)
return
@@ -32,7 +32,7 @@ func (a *api) listUsers(c *gin.Context) {
c.JSON(http.StatusOK, out)
}
func (a *api) createUser(c *gin.Context) {
func (h *H) CreateUser(c *gin.Context) {
var req struct{ Username, Password, Role string }
if c.ShouldBindJSON(&req) != nil {
err(c, http.StatusBadRequest, "bad_request", "json body required")
@@ -46,12 +46,12 @@ func (a *api) createUser(c *gin.Context) {
err(c, http.StatusBadRequest, "bad_request", "password too short (min 8)")
return
}
h, e := auth.HashPassword(req.Password)
hp, e := auth.HashPassword(req.Password)
if e != nil {
err(c, http.StatusInternalServerError, "internal", "hash")
return
}
id, e := a.st.CreateUser(c, req.Username, h, req.Role)
id, e := h.st.CreateUser(c, req.Username, hp, req.Role)
if e != nil {
if isUnique(e) {
err(c, http.StatusConflict, "exists", "username taken")
@@ -63,7 +63,7 @@ func (a *api) createUser(c *gin.Context) {
c.JSON(http.StatusCreated, gin.H{"id": id, "username": req.Username, "role": req.Role})
}
func (a *api) deleteUser(c *gin.Context) {
func (h *H) DeleteUser(c *gin.Context) {
id, e := strconv.ParseInt(c.Param("id"), 10, 64)
if e != nil {
err(c, http.StatusBadRequest, "bad_request", "bad id")
@@ -73,7 +73,7 @@ func (a *api) deleteUser(c *gin.Context) {
err(c, http.StatusBadRequest, "bad_request", "cannot delete yourself")
return
}
target, e := a.st.GetUserByID(c, id)
target, e := h.st.GetUserByID(c, id)
if e != nil {
if errors.Is(e, pgx.ErrNoRows) {
err(c, http.StatusNotFound, "not_found", "no such user")
@@ -83,13 +83,13 @@ func (a *api) deleteUser(c *gin.Context) {
return
}
if target.Role == "admin" {
n, _ := a.st.CountAdmins(c) // 防删光最后一个 admin
n, _ := h.st.CountAdmins(c) // 防删光最后一个 admin
if n <= 1 {
err(c, http.StatusBadRequest, "bad_request", "cannot delete the last admin")
return
}
}
if e := a.st.DeleteUser(c, id); e != nil {
if e := h.st.DeleteUser(c, id); e != nil {
dbErr(c, e)
return
}
@@ -1,4 +1,4 @@
package api
package handlers_test
import (
"encoding/json"
@@ -9,7 +9,7 @@ import (
"syscall"
"time"
"booklib/internal/api"
"booklib/cmd/webui/api"
"booklib/internal/config"
"booklib/internal/db"
"booklib/internal/redispkg"
-50
View File
@@ -1,50 +0,0 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
"booklib/internal/config"
"booklib/internal/redispkg"
"booklib/internal/scanner"
"booklib/internal/store"
)
func NewRouter(cfg *config.Config, st *store.Store, rdb *redispkg.R, sc *scanner.Scanner) *gin.Engine {
gin.SetMode(gin.ReleaseMode)
a := &api{cfg: cfg, st: st, rdb: rdb, sc: sc}
r := gin.New()
if e := r.SetTrustedProxies(cfg.TrustedProxies); e != nil {
panic(e)
}
r.Use(gin.Recovery())
g := r.Group("/api")
g.GET("/healthz", func(c *gin.Context) { c.String(http.StatusOK, "ok") })
g.POST("/auth/login", a.login)
p := g.Group("", a.authMw())
p.GET("/auth/me", a.me)
users := p.Group("/users", a.adminOnly())
users.GET("", a.listUsers)
users.POST("", a.createUser)
users.DELETE("/:id", a.deleteUser)
libs := p.Group("/libraries")
libs.GET("", a.listLibraries)
libs.POST("", a.adminOnly(), a.createLibrary)
libs.POST("/:id/scan", a.adminOnly(), a.scanLibrary)
libs.POST("/:id/upload", a.adminOnly(), a.upload)
p.GET("/books", a.listBooks)
p.GET("/books/:id", a.getBook)
p.DELETE("/books/:id", a.adminOnly(), a.deleteBook)
p.GET("/books/:id/cover", a.serveCover)
p.GET("/books/:id/file", a.serveFile)
p.GET("/books/:id/pages", a.pagesCount)
p.GET("/books/:id/pages/:n", a.page)
p.PUT("/books/:id/progress", a.putProgress)
p.GET("/progress", a.listProgress)
return r
}