Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca64bc0d73 | ||
|
|
961de429f9 |
+3
-3
@@ -7,6 +7,6 @@ deploy/nginx/conf.d/default.conf
|
|||||||
deploy/redis/redis.conf
|
deploy/redis/redis.conf
|
||||||
deploy/api/storage/
|
deploy/api/storage/
|
||||||
backend/booklib*
|
backend/booklib*
|
||||||
web/node_modules
|
frontend/node_modules
|
||||||
web/dist
|
frontend/dist
|
||||||
web/dev-dist
|
frontend/dev-dist
|
||||||
|
|||||||
+1
-7
@@ -1,9 +1,3 @@
|
|||||||
.env
|
.env
|
||||||
.superpowers/
|
.superpowers/
|
||||||
backend/server
|
tasks/
|
||||||
deploy/nginx/nginx.conf
|
|
||||||
deploy/nginx/conf.d/default.conf
|
|
||||||
deploy/redis/redis.conf
|
|
||||||
deploy/logs/
|
|
||||||
deploy/api/storage/*
|
|
||||||
!deploy/api/storage/.gitkeep
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
__debug_bin*
|
||||||
|
server
|
||||||
|
booklib*
|
||||||
@@ -7,4 +7,4 @@ FROM base AS dev
|
|||||||
ENV GOPROXY=https://goproxy.cn,direct
|
ENV GOPROXY=https://goproxy.cn,direct
|
||||||
RUN go install github.com/go-delve/delve/cmd/dlv@latest
|
RUN go install github.com/go-delve/delve/cmd/dlv@latest
|
||||||
EXPOSE 8080 2345
|
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"]
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ WORKDIR /src
|
|||||||
COPY backend/go.mod backend/go.sum ./
|
COPY backend/go.mod backend/go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY backend/ ./
|
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
|
FROM alpine:3.20 AS runner
|
||||||
RUN adduser -D -H app
|
RUN adduser -D -H app
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -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
|
||||||
|
}
|
||||||
@@ -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 (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -14,17 +14,17 @@ import (
|
|||||||
const loginWindow = time.Minute
|
const loginWindow = time.Minute
|
||||||
const loginMax = 5
|
const loginMax = 5
|
||||||
|
|
||||||
func (a *api) login(c *gin.Context) {
|
func (h *H) Login(c *gin.Context) {
|
||||||
var req struct{ Username, Password string }
|
var req struct{ Username, Password string }
|
||||||
if c.ShouldBindJSON(&req) != nil || req.Username == "" || req.Password == "" {
|
if c.ShouldBindJSON(&req) != nil || req.Username == "" || req.Password == "" {
|
||||||
err(c, http.StatusBadRequest, "bad_request", "username and password required")
|
err(c, http.StatusBadRequest, "bad_request", "username and password required")
|
||||||
return
|
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")
|
err(c, http.StatusTooManyRequests, "rate_limited", "too many login attempts")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
u, qerr := a.st.GetUserByName(c, req.Username)
|
u, qerr := h.st.GetUserByName(c, req.Username)
|
||||||
if qerr != nil {
|
if qerr != nil {
|
||||||
if !errors.Is(qerr, pgx.ErrNoRows) {
|
if !errors.Is(qerr, pgx.ErrNoRows) {
|
||||||
dbErr(c, qerr)
|
dbErr(c, qerr)
|
||||||
@@ -39,7 +39,7 @@ func (a *api) login(c *gin.Context) {
|
|||||||
err(c, http.StatusUnauthorized, "unauthorized", "bad credentials")
|
err(c, http.StatusUnauthorized, "unauthorized", "bad credentials")
|
||||||
return
|
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 {
|
if serr != nil {
|
||||||
err(c, http.StatusInternalServerError, "internal", "sign")
|
err(c, http.StatusInternalServerError, "internal", "sign")
|
||||||
return
|
return
|
||||||
@@ -47,8 +47,8 @@ func (a *api) login(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"token": tok})
|
c.JSON(http.StatusOK, gin.H{"token": tok})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *api) me(c *gin.Context) {
|
func (h *H) Me(c *gin.Context) {
|
||||||
u, qerr := a.st.GetUserByID(c, uid(c))
|
u, qerr := h.st.GetUserByID(c, uid(c))
|
||||||
if qerr != nil {
|
if qerr != nil {
|
||||||
err(c, http.StatusUnauthorized, "unauthorized", "no such user")
|
err(c, http.StatusUnauthorized, "unauthorized", "no such user")
|
||||||
return
|
return
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package api
|
package handlers_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -10,16 +10,24 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
|
|
||||||
|
"booklib/cmd/webui/api"
|
||||||
"booklib/internal/auth"
|
"booklib/internal/auth"
|
||||||
|
"booklib/internal/config"
|
||||||
"booklib/internal/db"
|
"booklib/internal/db"
|
||||||
"booklib/internal/redispkg"
|
"booklib/internal/redispkg"
|
||||||
"booklib/internal/scanner"
|
"booklib/internal/scanner"
|
||||||
"booklib/internal/store"
|
"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) {
|
func setupAPI(t *testing.T) (*store.Store, *scanner.Scanner, http.Handler, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
url := os.Getenv("DATABASE_URL")
|
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()
|
cfg.CacheDir = t.TempDir()
|
||||||
rdb := redispkg.New(os.Getenv("REDIS_URL"))
|
rdb := redispkg.New(os.Getenv("REDIS_URL"))
|
||||||
sc := scanner.New(st, cfg, rdb)
|
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 u := os.Getenv("REDIS_URL"); u != "" { // 测试卫生: 共享 redis 上重置登录限流桶, 防跨测试累计 429
|
||||||
if opt, e := redis.ParseURL(u); e == nil {
|
if opt, e := redis.ParseURL(u); e == nil {
|
||||||
rc := redis.NewClient(opt)
|
rc := redis.NewClient(opt)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package api
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -17,8 +17,8 @@ import (
|
|||||||
"booklib/internal/store"
|
"booklib/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (a *api) getBookRow(c *gin.Context, id int64) (store.Book, bool) {
|
func (h *H) getBookRow(c *gin.Context, id int64) (store.Book, bool) {
|
||||||
b, e := a.st.GetBook(c, id)
|
b, e := h.st.GetBook(c, id)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
if errors.Is(e, pgx.ErrNoRows) {
|
if errors.Is(e, pgx.ErrNoRows) {
|
||||||
err(c, http.StatusNotFound, "not_found", "no such book")
|
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
|
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)
|
id, e := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
err(c, http.StatusBadRequest, "bad_request", "bad id")
|
err(c, http.StatusBadRequest, "bad_request", "bad id")
|
||||||
return store.Book{}, false
|
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) {
|
func (h *H) getLibRow(c *gin.Context, id int64) (store.Library, bool) {
|
||||||
l, e := a.st.GetLibrary(c, id)
|
l, e := h.st.GetLibrary(c, id)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
if errors.Is(e, pgx.ErrNoRows) {
|
if errors.Is(e, pgx.ErrNoRows) {
|
||||||
err(c, http.StatusNotFound, "not_found", "no such library")
|
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
|
return j
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *api) listBooks(c *gin.Context) {
|
func (h *H) ListBooks(c *gin.Context) {
|
||||||
libID, _ := strconv.ParseInt(c.Query("library"), 10, 64)
|
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 {
|
if e != nil {
|
||||||
dbErr(c, e)
|
dbErr(c, e)
|
||||||
return
|
return
|
||||||
@@ -101,17 +101,17 @@ func (a *api) listBooks(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, out)
|
c.JSON(http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *api) getBook(c *gin.Context) {
|
func (h *H) GetBook(c *gin.Context) {
|
||||||
b, ok := a.bookFromParam(c)
|
b, ok := h.bookFromParam(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
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) {
|
if e != nil && !errors.Is(e, pgx.ErrNoRows) {
|
||||||
dbErr(c, e)
|
dbErr(c, e)
|
||||||
return
|
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 名,书仍可见
|
if e != nil && !errors.Is(e, pgx.ErrNoRows) { // 库被并发删则留空 library 名,书仍可见
|
||||||
dbErr(c, e)
|
dbErr(c, e)
|
||||||
return
|
return
|
||||||
@@ -119,16 +119,16 @@ func (a *api) getBook(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, bookJSON(b, p.Percent, lib.Name))
|
c.JSON(http.StatusOK, bookJSON(b, p.Percent, lib.Name))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *api) deleteBook(c *gin.Context) {
|
func (h *H) DeleteBook(c *gin.Context) {
|
||||||
b, ok := a.bookFromParam(c)
|
b, ok := h.bookFromParam(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
lib, ok := a.getLibRow(c, b.LibraryID)
|
lib, ok := h.getLibRow(c, b.LibraryID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
root, ok := a.libRoot(c, lib)
|
root, ok := h.libRoot(c, lib)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -142,9 +142,9 @@ func (a *api) deleteBook(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
key := bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS))
|
key := bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS))
|
||||||
os.RemoveAll(bookfile.CoverDir(a.cfg.CacheDir, key))
|
os.RemoveAll(bookfile.CoverDir(h.cfg.CacheDir, key))
|
||||||
os.RemoveAll(bookfile.PagesDir(a.cfg.CacheDir, key))
|
os.RemoveAll(bookfile.PagesDir(h.cfg.CacheDir, key))
|
||||||
if e := a.st.DeleteBook(c, b.ID); e != nil {
|
if e := h.st.DeleteBook(c, b.ID); e != nil {
|
||||||
dbErr(c, e)
|
dbErr(c, e)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package api
|
package handlers_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
@@ -160,15 +160,3 @@ func TestDeleteUnsafePath403(t *testing.T) {
|
|||||||
t.Fatalf("row must survive: %v", e)
|
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 (
|
import (
|
||||||
"fmt"
|
"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>`
|
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) {
|
func (h *H) bookRoot(c *gin.Context, b store.Book) (string, bool) {
|
||||||
lib, ok := a.getLibRow(c, b.LibraryID)
|
lib, ok := h.getLibRow(c, b.LibraryID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", false
|
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")
|
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *api) serveCover(c *gin.Context) {
|
func (h *H) ServeCover(c *gin.Context) {
|
||||||
b, ok := a.bookFromParam(c)
|
b, ok := h.bookFromParam(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.immutable(c)
|
h.immutable(c)
|
||||||
dir := bookfile.CoverDir(a.cfg.CacheDir, bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS)))
|
dir := bookfile.CoverDir(h.cfg.CacheDir, bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS)))
|
||||||
if entries, e := os.ReadDir(dir); e == nil {
|
if entries, e := os.ReadDir(dir); e == nil {
|
||||||
for _, en := range entries { // 跳过写一半的 .tmp 落盘中间态
|
for _, en := range entries { // 跳过写一半的 .tmp 落盘中间态
|
||||||
if !strings.Contains(en.Name(), ".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 b.Format == "cbz" || b.Format == "epub" { // 自愈:缓存丢了就地抽封面(重启/卷漂移/扫描器还没跑到)
|
||||||
if root, ok := a.bookRoot(c, b); ok {
|
if root, ok := h.bookRoot(c, b); ok {
|
||||||
if f, size, ok := a.openBook(c, b, root); ok {
|
if f, size, ok := h.openBook(c, b, root); ok {
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
var img []byte
|
var img []byte
|
||||||
var ext string
|
var ext string
|
||||||
@@ -77,12 +77,12 @@ func (a *api) serveCover(c *gin.Context) {
|
|||||||
c.Data(http.StatusOK, "image/svg+xml", []byte(defaultCover))
|
c.Data(http.StatusOK, "image/svg+xml", []byte(defaultCover))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *api) serveFile(c *gin.Context) {
|
func (h *H) ServeFile(c *gin.Context) {
|
||||||
b, ok := a.bookFromParam(c)
|
b, ok := h.bookFromParam(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
root, ok := a.bookRoot(c, b)
|
root, ok := h.bookRoot(c, b)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -96,7 +96,7 @@ func (a *api) serveFile(c *gin.Context) {
|
|||||||
http.ServeFile(c.Writer, c.Request, abs)
|
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)
|
abs, perr := absBookPath(root, b)
|
||||||
if perr != nil {
|
if perr != nil {
|
||||||
err(c, http.StatusForbidden, "forbidden", "unsafe path")
|
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
|
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)
|
hash := bookfile.Hash(b.FileSize, b.ModTS)
|
||||||
key := fmt.Sprintf("pagesidx:%d:%s", b.ID, hash)
|
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
|
return strings.Split(v, "\n"), nil
|
||||||
}
|
}
|
||||||
f, size, ok := a.openBook(c, b, root)
|
f, size, ok := h.openBook(c, b, root)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, os.ErrNotExist
|
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
|
return nil, e
|
||||||
}
|
}
|
||||||
if len(idx) > 0 { // 空索引不缓存,否则 warm 命中 "" 会 Split 出幽灵页
|
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
|
return idx, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *api) pagesCount(c *gin.Context) {
|
func (h *H) PagesCount(c *gin.Context) {
|
||||||
b, ok := a.bookFromParam(c)
|
b, ok := h.bookFromParam(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -146,11 +146,11 @@ func (a *api) pagesCount(c *gin.Context) {
|
|||||||
err(c, http.StatusBadRequest, "bad_request", "pages only for cbz")
|
err(c, http.StatusBadRequest, "bad_request", "pages only for cbz")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
root, ok := a.bookRoot(c, b)
|
root, ok := h.bookRoot(c, b)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
idx, e := a.pageIndex(c, b, root)
|
idx, e := h.pageIndex(c, b, root)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
if c.Writer.Written() {
|
if c.Writer.Written() {
|
||||||
return // openBook 已写 403/404,不再叠加 422
|
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)})
|
c.JSON(http.StatusOK, gin.H{"count": len(idx)})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *api) page(c *gin.Context) {
|
func (h *H) Page(c *gin.Context) {
|
||||||
b, ok := a.bookFromParam(c)
|
b, ok := h.bookFromParam(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -175,11 +175,11 @@ func (a *api) page(c *gin.Context) {
|
|||||||
err(c, http.StatusBadRequest, "bad_request", "bad page number")
|
err(c, http.StatusBadRequest, "bad_request", "bad page number")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
root, ok := a.bookRoot(c, b)
|
root, ok := h.bookRoot(c, b)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
idx, e := a.pageIndex(c, b, root)
|
idx, e := h.pageIndex(c, b, root)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
if c.Writer.Written() {
|
if c.Writer.Written() {
|
||||||
return // openBook 已写 403/404,不再叠加 422
|
return // openBook 已写 403/404,不再叠加 422
|
||||||
@@ -192,10 +192,10 @@ func (a *api) page(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
ext := strings.ToLower(filepath.Ext(idx[n]))
|
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)
|
dst := filepath.Join(dir, strconv.Itoa(n)+ext)
|
||||||
if _, e := os.Stat(dst); e != nil { // miss → 解压落盘(并发重做同页幂等,唯一 tmp 名 + rename 原子)
|
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 {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -221,6 +221,6 @@ func (a *api) page(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
a.immutable(c)
|
h.immutable(c)
|
||||||
http.ServeFile(c.Writer, c.Request, dst)
|
http.ServeFile(c.Writer, c.Request, dst)
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package api
|
package handlers_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package api
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -20,13 +20,17 @@ import (
|
|||||||
"booklib/internal/store"
|
"booklib/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type api struct {
|
type H struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
st *store.Store
|
st *store.Store
|
||||||
rdb *redispkg.R
|
rdb *redispkg.R
|
||||||
sc *scanner.Scanner
|
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) {
|
func err(c *gin.Context, status int, code, msg string) {
|
||||||
c.AbortWithStatusJSON(status, gin.H{"error": gin.H{"code": code, "message": msg}})
|
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")
|
err(c, status, code, "db error")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *api) authMw() gin.HandlerFunc {
|
func (h *H) AuthMw() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
h := c.GetHeader("Authorization")
|
hdr := c.GetHeader("Authorization")
|
||||||
tok, ok := strings.CutPrefix(h, "Bearer ")
|
tok, ok := strings.CutPrefix(hdr, "Bearer ")
|
||||||
if !ok {
|
if !ok {
|
||||||
err(c, http.StatusUnauthorized, "unauthorized", "missing bearer token")
|
err(c, http.StatusUnauthorized, "unauthorized", "missing bearer token")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cl, perr := auth.Parse(a.cfg.JWTSecret, tok)
|
cl, perr := auth.Parse(h.cfg.JWTSecret, tok)
|
||||||
if perr != nil {
|
if perr != nil {
|
||||||
err(c, http.StatusUnauthorized, "unauthorized", "invalid token")
|
err(c, http.StatusUnauthorized, "unauthorized", "invalid token")
|
||||||
return
|
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) {
|
return func(c *gin.Context) {
|
||||||
if c.GetString("role") != "admin" {
|
if c.GetString("role") != "admin" {
|
||||||
err(c, http.StatusForbidden, "forbidden", "admin only")
|
err(c, http.StatusForbidden, "forbidden", "admin only")
|
||||||
@@ -1,34 +1,30 @@
|
|||||||
package api
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
"syscall"
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/jackc/pgx/v5/pgconn"
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
"github.com/jackc/puddle/v2"
|
"github.com/jackc/puddle/v2"
|
||||||
|
|
||||||
"booklib/internal/config"
|
"booklib/internal/store"
|
||||||
"booklib/internal/redispkg"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func testCfg() *config.Config {
|
func TestAbsBookPathTraversalRejected(t *testing.T) {
|
||||||
return &config.Config{Addr: ":8080", JWTSecret: []byte("s3cret"), ScanInterval: time.Minute, UploadMaxMB: 200,
|
root := "/data/books/lib" // 纯路径逻辑,不碰文件系统,无需 DB
|
||||||
TrustedProxies: []string{"172.16.0.0/12"}} // 与 prod 默认一致: 只有 compose 网段内代理才可信
|
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)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
func TestHealthz(t *testing.T) {
|
if p, e := absBookPath(root, store.Book{Path: "series-a/vol.cbz"}); e != nil || p != filepath.Join(root, "series-a", "vol.cbz") {
|
||||||
r := NewRouter(testCfg(), nil, redispkg.New(""), nil)
|
t.Fatalf("must accept relative path: %q %v", p, e)
|
||||||
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -19,9 +19,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// resolveLibRoot: root_path 必须绝对且落在 BooksDir 内(spec §7 前缀校验)
|
// 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)
|
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))) {
|
if !filepath.IsAbs(root) || (root != books && !strings.HasPrefix(root, books+string(os.PathSeparator))) {
|
||||||
err(c, http.StatusForbidden, "forbidden", "library root outside books dir")
|
err(c, http.StatusForbidden, "forbidden", "library root outside books dir")
|
||||||
return "", false
|
return "", false
|
||||||
@@ -33,8 +33,8 @@ func (a *api) libRoot(c *gin.Context, lib store.Library) (string, bool) {
|
|||||||
return root, true
|
return root, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *api) listLibraries(c *gin.Context) {
|
func (h *H) ListLibraries(c *gin.Context) {
|
||||||
libs, e := a.st.ListLibraries(c)
|
libs, e := h.st.ListLibraries(c)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
dbErr(c, e)
|
dbErr(c, e)
|
||||||
return
|
return
|
||||||
@@ -47,7 +47,7 @@ func (a *api) listLibraries(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, out)
|
c.JSON(http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *api) createLibrary(c *gin.Context) {
|
func (h *H) CreateLibrary(c *gin.Context) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
RootPath string `json:"root_path"`
|
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")
|
err(c, http.StatusBadRequest, "bad_request", "root_path must be absolute")
|
||||||
return
|
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 e != nil {
|
||||||
if isUnique(e) {
|
if isUnique(e) {
|
||||||
err(c, http.StatusConflict, "exists", "root_path taken")
|
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)})
|
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)
|
id, e := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
err(c, http.StatusBadRequest, "bad_request", "bad id")
|
err(c, http.StatusBadRequest, "bad_request", "bad id")
|
||||||
return store.Library{}, false
|
return store.Library{}, false
|
||||||
}
|
}
|
||||||
lib, e := a.st.GetLibrary(c, id)
|
lib, e := h.st.GetLibrary(c, id)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
if errors.Is(e, pgx.ErrNoRows) {
|
if errors.Is(e, pgx.ErrNoRows) {
|
||||||
err(c, http.StatusNotFound, "not_found", "no such library")
|
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
|
return lib, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *api) scanLibrary(c *gin.Context) {
|
func (h *H) ScanLibrary(c *gin.Context) {
|
||||||
lib, ok := a.getLibrary(c)
|
lib, ok := h.getLibrary(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, ok := a.libRoot(c, lib); !ok {
|
if _, ok := h.libRoot(c, lib); !ok {
|
||||||
return
|
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})
|
c.JSON(http.StatusAccepted, gin.H{"accepted": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *api) upload(c *gin.Context) {
|
func (h *H) Upload(c *gin.Context) {
|
||||||
lib, ok := a.getLibrary(c)
|
lib, ok := h.getLibrary(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
root, ok := a.libRoot(c, lib)
|
root, ok := h.libRoot(c, lib)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
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")
|
fh, e := c.FormFile("file")
|
||||||
if e != nil {
|
if e != nil {
|
||||||
err(c, http.StatusBadRequest, "bad_request", "multipart field 'file' required")
|
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")
|
err(c, http.StatusBadRequest, "bad_format", "extension must be cbz/pdf/epub/txt/md")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dst, e := a.uniquePath(root, name)
|
dst, e := h.uniquePath(root, name)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
err(c, http.StatusForbidden, "forbidden", e.Error())
|
err(c, http.StatusForbidden, "forbidden", e.Error())
|
||||||
return
|
return
|
||||||
@@ -155,7 +155,7 @@ func (a *api) upload(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// uniquePath 清洗后的 name 必须仍在 root 内;重名加 " (n)" 后缀
|
// 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)
|
ext := filepath.Ext(name)
|
||||||
base := strings.TrimSuffix(name, ext)
|
base := strings.TrimSuffix(name, ext)
|
||||||
for i := 0; ; i++ {
|
for i := 0; ; i++ {
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package api
|
package handlers_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package api
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (a *api) putProgress(c *gin.Context) {
|
func (h *H) PutProgress(c *gin.Context) {
|
||||||
b, ok := a.bookFromParam(c)
|
b, ok := h.bookFromParam(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -32,15 +32,15 @@ func (a *api) putProgress(c *gin.Context) {
|
|||||||
err(c, http.StatusBadRequest, "bad_request", "locator must be valid json")
|
err(c, http.StatusBadRequest, "bad_request", "locator must be valid json")
|
||||||
return
|
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)
|
dbErr(c, e)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *api) listProgress(c *gin.Context) {
|
func (h *H) ListProgress(c *gin.Context) {
|
||||||
rows, e := a.st.ListProgress(c, uid(c))
|
rows, e := h.st.ListProgress(c, uid(c))
|
||||||
if e != nil {
|
if e != nil {
|
||||||
dbErr(c, e)
|
dbErr(c, e)
|
||||||
return
|
return
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package api
|
package handlers_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package api
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -18,8 +18,8 @@ func isUnique(e error) bool {
|
|||||||
return errors.As(e, &pgErr) && pgErr.Code == "23505"
|
return errors.As(e, &pgErr) && pgErr.Code == "23505"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *api) listUsers(c *gin.Context) {
|
func (h *H) ListUsers(c *gin.Context) {
|
||||||
users, e := a.st.ListUsers(c)
|
users, e := h.st.ListUsers(c)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
dbErr(c, e)
|
dbErr(c, e)
|
||||||
return
|
return
|
||||||
@@ -32,7 +32,7 @@ func (a *api) listUsers(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, out)
|
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 }
|
var req struct{ Username, Password, Role string }
|
||||||
if c.ShouldBindJSON(&req) != nil {
|
if c.ShouldBindJSON(&req) != nil {
|
||||||
err(c, http.StatusBadRequest, "bad_request", "json body required")
|
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)")
|
err(c, http.StatusBadRequest, "bad_request", "password too short (min 8)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h, e := auth.HashPassword(req.Password)
|
hp, e := auth.HashPassword(req.Password)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
err(c, http.StatusInternalServerError, "internal", "hash")
|
err(c, http.StatusInternalServerError, "internal", "hash")
|
||||||
return
|
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 e != nil {
|
||||||
if isUnique(e) {
|
if isUnique(e) {
|
||||||
err(c, http.StatusConflict, "exists", "username taken")
|
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})
|
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)
|
id, e := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
err(c, http.StatusBadRequest, "bad_request", "bad id")
|
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")
|
err(c, http.StatusBadRequest, "bad_request", "cannot delete yourself")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
target, e := a.st.GetUserByID(c, id)
|
target, e := h.st.GetUserByID(c, id)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
if errors.Is(e, pgx.ErrNoRows) {
|
if errors.Is(e, pgx.ErrNoRows) {
|
||||||
err(c, http.StatusNotFound, "not_found", "no such user")
|
err(c, http.StatusNotFound, "not_found", "no such user")
|
||||||
@@ -83,13 +83,13 @@ func (a *api) deleteUser(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if target.Role == "admin" {
|
if target.Role == "admin" {
|
||||||
n, _ := a.st.CountAdmins(c) // 防删光最后一个 admin
|
n, _ := h.st.CountAdmins(c) // 防删光最后一个 admin
|
||||||
if n <= 1 {
|
if n <= 1 {
|
||||||
err(c, http.StatusBadRequest, "bad_request", "cannot delete the last admin")
|
err(c, http.StatusBadRequest, "bad_request", "cannot delete the last admin")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if e := a.st.DeleteUser(c, id); e != nil {
|
if e := h.st.DeleteUser(c, id); e != nil {
|
||||||
dbErr(c, e)
|
dbErr(c, e)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package api
|
package handlers_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"booklib/internal/api"
|
"booklib/cmd/webui/api"
|
||||||
"booklib/internal/config"
|
"booklib/internal/config"
|
||||||
"booklib/internal/db"
|
"booklib/internal/db"
|
||||||
"booklib/internal/redispkg"
|
"booklib/internal/redispkg"
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.env
|
||||||
|
nginx/nginx.conf
|
||||||
|
nginx/conf.d/default.conf
|
||||||
|
redis/redis.conf
|
||||||
|
logs/
|
||||||
|
api/storage/*
|
||||||
|
!api/storage/.gitkeep
|
||||||
@@ -16,8 +16,9 @@ services:
|
|||||||
- ./redis/redis.conf:/usr/local/etc/redis/redis.conf:ro
|
- ./redis/redis.conf:/usr/local/etc/redis/redis.conf:ro
|
||||||
- redis_data:/data
|
- redis_data:/data
|
||||||
api:
|
api:
|
||||||
|
image: booklib/api:dev
|
||||||
build: { context: .., dockerfile: backend/Dockerfile.dev, target: dev }
|
build: { context: .., dockerfile: backend/Dockerfile.dev, target: dev }
|
||||||
command: sh -c "go mod download && dlv debug ./cmd/server --headless --listen=0.0.0.0:2345 --api-version=2 --accept-multiclient --continue --log"
|
command: sh -c "go mod download && dlv debug ./cmd/webui --headless --listen=0.0.0.0:2345 --api-version=2 --accept-multiclient --continue --log"
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgres://lib:lib@postgres:5432/lib?sslmode=disable
|
DATABASE_URL: postgres://lib:lib@postgres:5432/lib?sslmode=disable
|
||||||
REDIS_URL: redis://redis:6379
|
REDIS_URL: redis://redis:6379
|
||||||
@@ -35,11 +36,12 @@ services:
|
|||||||
postgres: { condition: service_healthy }
|
postgres: { condition: service_healthy }
|
||||||
redis: { condition: service_started }
|
redis: { condition: service_started }
|
||||||
web:
|
web:
|
||||||
build: { context: .., dockerfile: web/Dockerfile.dev, target: dev }
|
image: booklib/web:dev
|
||||||
|
build: { context: .., dockerfile: frontend/Dockerfile.dev, target: dev }
|
||||||
command: npm run dev -- --host 0.0.0.0
|
command: npm run dev -- --host 0.0.0.0
|
||||||
environment: { VITE_PROXY_TARGET: http://api:8080 }
|
environment: { VITE_PROXY_TARGET: http://api:8080 }
|
||||||
volumes:
|
volumes:
|
||||||
- ../web:/app
|
- ../frontend:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
ports: ["5173:5173"]
|
ports: ["5173:5173"]
|
||||||
depends_on: [api]
|
depends_on: [api]
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
name: booklib
|
name: booklib
|
||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
build: { context: .., dockerfile: web/Dockerfile.prod, target: runner }
|
image: booklib/web:prod
|
||||||
|
build: { context: .., dockerfile: frontend/Dockerfile.prod, target: runner }
|
||||||
ports: ["${WEB_PORT:-8080}:80"]
|
ports: ["${WEB_PORT:-8080}:80"]
|
||||||
volumes:
|
volumes:
|
||||||
- ./nginx/nginx.conf:/etc/booklib/nginx.conf:ro
|
- ./nginx/nginx.conf:/etc/booklib/nginx.conf:ro
|
||||||
@@ -10,6 +11,7 @@ services:
|
|||||||
- ./logs/nginx:/var/log/nginx
|
- ./logs/nginx:/var/log/nginx
|
||||||
depends_on: [api]
|
depends_on: [api]
|
||||||
api:
|
api:
|
||||||
|
image: booklib/api:prod
|
||||||
build: { context: .., dockerfile: backend/Dockerfile.prod, target: runner }
|
build: { context: .., dockerfile: backend/Dockerfile.prod, target: runner }
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgres://lib:lib@postgres:5432/lib?sslmode=disable
|
DATABASE_URL: postgres://lib:lib@postgres:5432/lib?sslmode=disable
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All non-WebUI user-visible changes. Newest version on top.
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Repo structure conformed to `AGENTS.md`: `web/` renamed to `frontend/`; backend HTTP layer moved from `internal/api` to `cmd/webui/{api,handlers}` (`cmd/server` → `cmd/webui`); README/CHANGELOGs relocated under `docs/` (`README_zh.md` added as Chinese mirror); module-level `.gitignore`s added (`backend/`, `deploy/`); stray root `library/` removed (book files live in `deploy/api/storage/`); debug binaries untracked.
|
||||||
|
- Docs: `docs/README.md` is now the English primary; previous Chinese README mirrored to `docs/README_zh.md`.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Changelog (WebUI)
|
||||||
|
|
||||||
|
All WebUI user-visible changes. Newest version on top.
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
_No WebUI user-visible changes in this release; the structural refactor (`web/` → `frontend/`) does not affect the served app._
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Book & Comic Library
|
||||||
|
|
||||||
|
Personal book/comic library: Go+Gin backend (scan/upload ingestion, multi-user JWT, reading progress, disk+Redis cache) + Docker Compose deployment. Design: `superpowers/specs/2026-09-04-book-comic-library-design.md`; deployment spec: `superpowers/specs/2026-09-07-docker-deploy-spec-design.md`. Chinese mirror: `README_zh.md`.
|
||||||
|
|
||||||
|
## Deploy mode
|
||||||
|
|
||||||
|
- release: `deploy/docker-compose.yml` — multi-stage `backend|frontend/Dockerfile.prod` (target runner) compiled artifacts, no source mounts; infra ports stay on the internal network.
|
||||||
|
- dev: `deploy/docker-compose.dev.yml` — all four services containerized, source mounted as `../backend:/app` and `../frontend:/app` (frontend with an anonymous `node_modules` volume), `target: dev` images; api runs `go mod download && dlv debug ./cmd/webui` (hot restart without rebuild, delve :2345); infra exposed to host PG 5432 / Redis 6379; healthcheck-gated startup.
|
||||||
|
|
||||||
|
## Volume Mount
|
||||||
|
|
||||||
|
- Shared named volumes: `booklib_postgres_data`, `booklib_redis_data` (identical names in both composes, so dev/release see the same data; only `down -v` clears them).
|
||||||
|
- Config/log bind mounts: `deploy/nginx/{nginx.conf,conf.d/default.conf}`, `deploy/redis/redis.conf` (`:ro`) and `deploy/logs/nginx` — all rendered by `deploy/prepare.sh` (templates are the `*.tpl` files beside each output); **wrong/missing config in the container = you forgot to rerun it**.
|
||||||
|
- File storage: `deploy/api/storage` → container `/data/books` (cache writes to `/data/books/cache`).
|
||||||
|
|
||||||
|
## Run it (production shape)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp deploy/.env.example deploy/.env # set JWT_SECRET, ADMIN_USER, ADMIN_PASSWORD (>= 8 chars; seed skips and logs below 8)
|
||||||
|
deploy/prepare.sh
|
||||||
|
docker compose -f deploy/docker-compose.yml up -d --build
|
||||||
|
bash scripts/smoke.sh && bash scripts/smoke-web.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
- web: `http://localhost:8080` (change via `WEB_PORT`); the API goes through the nginx `/api/` prefix reverse proxy to stateless api replicas (`--scale api=N`).
|
||||||
|
- Drop raw books into `deploy/api/storage/` (mounted at `/data/books`); the scanner ingests periodically (default 60s).
|
||||||
|
- nginx access/error logs: `deploy/logs/nginx/`.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
deploy/prepare.sh
|
||||||
|
docker compose -f deploy/docker-compose.dev.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
- Frontend: http://localhost:5173 (vite, HMR works directly; `/api` proxied to the in-container api).
|
||||||
|
- After editing Go code: `docker compose -f deploy/docker-compose.dev.yml restart api` (recompiles the mounted source, no rebuild).
|
||||||
|
- Breakpoint debugging: delve headless at `localhost:2345` (VSCode launch: `{"type":"go","request":"attach","mode":"remote","host":"localhost","port":2345,"substitutePath":[{"from":"${workspaceFolder}/backend","to":"/app"}]}`; continue via dlv commands after hitting a breakpoint).
|
||||||
|
- Run tests against directly reachable infra: PG `localhost:5432` (lib/lib/lib), Redis `localhost:6379`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
export DATABASE_URL='postgres://lib:lib@localhost:5432/lib?sslmode=disable'
|
||||||
|
export REDIS_URL='redis://localhost:6379'
|
||||||
|
go vet ./... && gofmt -l .
|
||||||
|
go test -p 1 -count=1 ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
`-p 1` is required: integration tests share one PG database and each clears tables with `DELETE FROM ...` — running in parallel deletes each other's data and fails randomly. Bring the dev stack down with `down` (not `rm`), or the anonymous node_modules volume becomes an orphan. Tests that need PG/Redis skip automatically when absent; Redis downtime doesn't break functionality (the whole chain degrades to miss/passthrough, see spec §9).
|
||||||
|
|
||||||
|
Frontend gate: `cd frontend && npm run check` (tsc + vitest + vite build).
|
||||||
|
|
||||||
|
## Old-volume migration (one-off, upgrading from the previous deploy layout)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# old PG data → new shared volume
|
||||||
|
docker run --rm -v book-comic-library_pgdata:/from -v booklib_postgres_data:/to alpine cp -a /from/. /to/
|
||||||
|
# the old cache volume is derived data (covers/unzipped pages), just drop it (auto-rebuilt)
|
||||||
|
docker volume rm book-comic-library_pgdata book-comic-library_cache
|
||||||
|
```
|
||||||
|
|
||||||
|
Book files: move the contents of the old host `./library/` into `deploy/api/storage/`.
|
||||||
|
|
||||||
|
## Trusted proxies & rate limiting
|
||||||
|
|
||||||
|
- nginx lives inside the compose network, so api's `ClientIP` only trusts `TRUSTED_PROXY_CIDRS` (comma-separated CIDRs, default `172.16.0.0/12`, the compose subnet). Spoofed external `X-Forwarded-For` can't bypass rate-limit buckets; change this env when the deploy network changes.
|
||||||
|
- Login is limited to 5 attempts/min/IP **counted per attempt, successful logins included** — brute force and normal high-frequency login share the budget.
|
||||||
|
|
||||||
|
## Read this before changing the schema
|
||||||
|
|
||||||
|
`db.Migrate` only runs the `CREATE TABLE IF NOT EXISTS` statements of `schema.sql` — column adds/changes **do not take effect** on existing databases. Before any column change, introduce a `schema_migrations` version table + ordered migrations, otherwise old deployments silently run on the old shape.
|
||||||
|
|
||||||
|
## PWA
|
||||||
|
|
||||||
|
Immutable assets (covers/CBZ pages/raw files) are SW cache-first; read content stays available offline; logging out clears the SW cache.
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
# Book & Comic Library
|
# Book & Comic Library
|
||||||
|
|
||||||
个人书库/漫画库:Go+Gin 后端(扫描/上传入库、多用户 JWT、阅读进度、磁盘+Redis 缓存)+ Docker Compose 部署。设计见 `docs/superpowers/specs/2026-09-04-book-comic-library-design.md`;部署规范见 `docs/superpowers/specs/2026-09-07-docker-deploy-spec-design.md`。
|
个人书库/漫画库:Go+Gin 后端(扫描/上传入库、多用户 JWT、阅读进度、磁盘+Redis 缓存)+ Docker Compose 部署。设计见 `superpowers/specs/2026-09-04-book-comic-library-design.md`;部署规范见 `superpowers/specs/2026-09-07-docker-deploy-spec-design.md`。英文镜像:`README.md`。
|
||||||
|
|
||||||
## Deploy mode
|
## Deploy mode
|
||||||
|
|
||||||
- release:`deploy/docker-compose.yml` — 多阶段 `backend|web/Dockerfile.prod`(target runner)编译产物,不挂源码;infra 端口只在容器网络。
|
- release:`deploy/docker-compose.yml` — 多阶段 `backend|frontend/Dockerfile.prod`(target runner)编译产物,不挂源码;infra 端口只在容器网络。
|
||||||
- dev:`deploy/docker-compose.dev.yml` — 四服务全容器化,源码挂 `../backend:/app`、`../web:/app`(web 带匿名 `node_modules` 卷),`target: dev` 镜像;api 跑 `go mod download && dlv debug ./cmd/server`(热重启不 rebuild,delve :2345);infra 暴露宿主 PG 5432 / Redis 6379;healthcheck 门控。
|
- dev:`deploy/docker-compose.dev.yml` — 四服务全容器化,源码挂 `../backend:/app`、`../frontend:/app`(web 服务带匿名 `node_modules` 卷),`target: dev` 镜像;api 跑 `go mod download && dlv debug ./cmd/webui`(热重启不 rebuild,delve :2345);infra 暴露宿主 PG 5432 / Redis 6379;healthcheck 门控。
|
||||||
|
|
||||||
## Volume Mount
|
## Volume Mount
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ go test -p 1 -count=1 ./...
|
|||||||
|
|
||||||
`-p 1` 是必须的:集成测试共用同一个 PG 库,各自 `DELETE FROM ...` 清表——并行跑会互相删数据导致随机失败。dev 栈起停用 `down`(不是 `rm`),否则匿名 node_modules 卷成孤儿。无 PG/Redis 时依赖它们的测试自动 skip;Redis 挂掉不影响功能(全链路降级为 miss/放行,见 spec §9)。
|
`-p 1` 是必须的:集成测试共用同一个 PG 库,各自 `DELETE FROM ...` 清表——并行跑会互相删数据导致随机失败。dev 栈起停用 `down`(不是 `rm`),否则匿名 node_modules 卷成孤儿。无 PG/Redis 时依赖它们的测试自动 skip;Redis 挂掉不影响功能(全链路降级为 miss/放行,见 spec §9)。
|
||||||
|
|
||||||
前端门槛:`cd web && npm run check`(tsc + vitest + vite build)。
|
前端门槛:`cd frontend && npm run check`(tsc + vitest + vite build)。
|
||||||
|
|
||||||
## 旧卷迁移(一次性,升级自上一版部署)
|
## 旧卷迁移(一次性,升级自上一版部署)
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
FROM node:22 AS dev
|
FROM node:22 AS dev
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY web/package.json web/package-lock.json ./
|
COPY frontend/package.json web/package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
EXPOSE 5173
|
EXPOSE 5173
|
||||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
FROM node:22-alpine AS build
|
FROM node:22-alpine AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY web/package.json web/package-lock.json ./
|
COPY frontend/package.json web/package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY web/ ./
|
COPY frontend/ ./
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM nginx:1.27-alpine AS runner
|
FROM nginx:1.27-alpine AS runner
|
||||||
|
Before Width: | Height: | Size: 269 B After Width: | Height: | Size: 269 B |
Reference in New Issue
Block a user