diff --git a/backend/go.mod b/backend/go.mod index 59e24c6..8ce6304 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -6,6 +6,7 @@ require ( github.com/gin-gonic/gin v1.12.0 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/jackc/pgx/v5 v5.10.0 + github.com/redis/go-redis/v9 v9.22.0 golang.org/x/crypto v0.56.0 ) @@ -34,7 +35,6 @@ require ( github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect - github.com/redis/go-redis/v9 v9.22.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 66d6824..0f4291f 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,3 +1,7 @@ +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= @@ -81,6 +85,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= diff --git a/backend/internal/api/libraries.go b/backend/internal/api/libraries.go new file mode 100644 index 0000000..0bbdfd1 --- /dev/null +++ b/backend/internal/api/libraries.go @@ -0,0 +1,167 @@ +package api + +import ( + "io" + "log" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "booklib/internal/bookfile" + "booklib/internal/store" +) + +// resolveLibRoot: root_path 必须绝对且落在 BooksDir 内(spec §7 前缀校验) +func (a *api) libRoot(c *gin.Context, lib store.Library) (string, bool) { + root := filepath.Clean(lib.RootPath) + books := filepath.Clean(a.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 + } + return root, true +} + +func (a *api) listLibraries(c *gin.Context) { + libs, e := a.st.ListLibraries(c) + if e != nil { + log.Printf("db: %v", e) + err(c, http.StatusInternalServerError, "internal", "db error") + return + } + out := make([]gin.H, 0, len(libs)) + for _, l := range libs { + out = append(out, gin.H{"id": l.ID, "name": l.Name, "root_path": l.RootPath, + "created_at": l.CreatedAt.Format(time.RFC3339)}) + } + c.JSON(http.StatusOK, out) +} + +func (a *api) createLibrary(c *gin.Context) { + var req struct { + Name string `json:"name"` + RootPath string `json:"root_path"` + } + if c.ShouldBindJSON(&req) != nil || req.Name == "" || req.RootPath == "" { + err(c, http.StatusBadRequest, "bad_request", "name and root_path required") + return + } + if !filepath.IsAbs(req.RootPath) { + err(c, http.StatusBadRequest, "bad_request", "root_path must be absolute") + return + } + id, e := a.st.CreateLibrary(c, req.Name, filepath.Clean(req.RootPath)) + if e != nil { + if isUnique(e) { + err(c, http.StatusConflict, "exists", "root_path taken") + return + } + err(c, http.StatusBadRequest, "bad_request", "invalid input") + return + } + 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) { + 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) + if e != nil { + err(c, http.StatusNotFound, "not_found", "no such library") + return store.Library{}, false + } + return lib, true +} + +func (a *api) scanLibrary(c *gin.Context) { + if _, ok := a.getLibrary(c); !ok { + return + } + // Task 9 接线: go a.sc.ScanLibraryByID(...) + err(c, http.StatusNotImplemented, "not_ready", "scanner not wired yet") +} + +func (a *api) upload(c *gin.Context) { + lib, ok := a.getLibrary(c) + if !ok { + return + } + root, ok := a.libRoot(c, lib) + if !ok { + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, a.cfg.UploadMaxMB<<20) + fh, e := c.FormFile("file") + if e != nil { + err(c, http.StatusBadRequest, "bad_request", "multipart field 'file' required") + return + } + name := bookfile.SafeName(fh.Filename) + if bookfile.FormatFromExt(name) == "" { + err(c, http.StatusBadRequest, "bad_format", "extension must be cbz/pdf/epub/txt/md") + return + } + dst, e := a.uniquePath(root, name) + if e != nil { + err(c, http.StatusForbidden, "forbidden", e.Error()) + return + } + src, e := fh.Open() + if e != nil { + 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 + } + if _, e := io.Copy(out, src); e != nil { + out.Close() + os.Remove(tmp) + err(c, http.StatusRequestEntityTooLarge, "too_large", "upload failed") + return + } + out.Close() + if e := os.Rename(tmp, dst); e != nil { // 原子落盘,scanner 自动收编 + os.Remove(tmp) + err(c, http.StatusInternalServerError, "internal", "rename") + return + } + c.JSON(http.StatusAccepted, gin.H{"accepted": true, "path": strings.TrimPrefix(dst, root+string(os.PathSeparator))}) +} + +// uniquePath 清洗后的 name 必须仍在 root 内;重名加 " (n)" 后缀 +func (a *api) uniquePath(root, name string) (string, error) { + ext := filepath.Ext(name) + base := strings.TrimSuffix(name, ext) + for i := 0; ; i++ { + cand := base + ext + if i > 0 { + cand = base + " (" + strconv.Itoa(i) + ")" + ext + } + p := filepath.Join(root, cand) + if filepath.Clean(p) != filepath.Join(root, filepath.Clean(cand)) || + !strings.HasPrefix(filepath.Clean(p), root+string(os.PathSeparator)) { + return "", os.ErrInvalid + } + if _, e := os.Stat(p); os.IsNotExist(e) { + return p, nil + } else if e != nil { + return "", e + } + if i > 999 { + return "", os.ErrExist + } + } +} diff --git a/backend/internal/api/libraries_test.go b/backend/internal/api/libraries_test.go new file mode 100644 index 0000000..103dcc8 --- /dev/null +++ b/backend/internal/api/libraries_test.go @@ -0,0 +1,77 @@ +package api + +import ( + "bytes" + "encoding/json" + "mime/multipart" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLibraryCreateListUpload(t *testing.T) { + _, h := setupAPI(t) + tok := adminToken(t, h) + root := t.TempDir() + w := do(h, "POST", "/api/libraries", tok, map[string]string{"name": "comics", "root_path": root}) + if w.Code != 201 { + t.Fatalf("create lib %d %s", w.Code, w.Body) + } + var lib map[string]any + json.Unmarshal(w.Body.Bytes(), &lib) + libID := itoa(lib["id"]) + w = do(h, "GET", "/api/libraries", tok, nil) + if !strings.Contains(w.Body.String(), `"comics"`) { + t.Fatalf("list: %s", w.Body) + } + // 相对路径 root 必须 400(前缀校验的根) + w = do(h, "POST", "/api/libraries", tok, map[string]string{"name": "x", "root_path": "relative/path"}) + if w.Code != 400 { + t.Fatalf("relative root want 400 got %d", w.Code) + } + // 上传:白名单 + 防穿越 + 原子落盘 + body, mw := uploadBody("my 01.cbz", []byte("zipbytes")) + req := httptest.NewRequest("POST", "/api/libraries/"+libID+"/upload", body) + req.Header.Set("Content-Type", mw.FormDataContentType()) + req.Header.Set("Authorization", "Bearer "+tok) + ww := httptest.NewRecorder() + h.ServeHTTP(ww, req) + if ww.Code != 202 { + t.Fatalf("upload %d %s", ww.Code, ww.Body) + } + if _, err := os.Stat(filepath.Join(root, "my 01.cbz")); err != nil { + t.Fatal("uploaded file missing:", err) + } + body, mw = uploadBody("../../evil.cbz", []byte("x")) + req = httptest.NewRequest("POST", "/api/libraries/"+libID+"/upload", body) + req.Header.Set("Content-Type", mw.FormDataContentType()) + req.Header.Set("Authorization", "Bearer "+tok) + ww = httptest.NewRecorder() + h.ServeHTTP(ww, req) + if ww.Code != 202 { // 名字被清洗成 evil.cbz,落在 root 内 + t.Fatalf("sanitize upload %d", ww.Code) + } + if _, err := os.Stat(filepath.Join(root, "evil.cbz")); err != nil { + t.Fatal("evil upload not sanitized") + } + body, mw = uploadBody("virus.exe", []byte("x")) + req = httptest.NewRequest("POST", "/api/libraries/"+libID+"/upload", body) + req.Header.Set("Content-Type", mw.FormDataContentType()) + req.Header.Set("Authorization", "Bearer "+tok) + ww = httptest.NewRecorder() + h.ServeHTTP(ww, req) + if ww.Code != 400 { + t.Fatalf("bad ext want 400 got %d", ww.Code) + } +} + +func uploadBody(filename string, content []byte) (*bytes.Buffer, *multipart.Writer) { + buf := &bytes.Buffer{} + mw := multipart.NewWriter(buf) + fw, _ := mw.CreateFormFile("file", filename) + fw.Write(content) + mw.Close() + return buf, mw +} diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index 451c112..8129b87 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -21,7 +21,16 @@ func NewRouter(cfg *config.Config, st *store.Store, rdb *redispkg.R) *gin.Engine p := g.Group("", a.authMw()) p.GET("/auth/me", a.me) - // ponytail: 501 垫片,Task 5 换成 a.createUser;无此路由则 member 403 测不到 - p.POST("/users", a.adminOnly(), func(c *gin.Context) { c.Status(http.StatusNotImplemented) }) + + 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) return r } diff --git a/backend/internal/api/users.go b/backend/internal/api/users.go new file mode 100644 index 0000000..2778d90 --- /dev/null +++ b/backend/internal/api/users.go @@ -0,0 +1,99 @@ +package api + +import ( + "errors" + "log" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + + "booklib/internal/auth" +) + +func isUnique(e error) bool { + var pgErr *pgconn.PgError + return errors.As(e, &pgErr) && pgErr.Code == "23505" +} + +func (a *api) listUsers(c *gin.Context) { + users, e := a.st.ListUsers(c) + if e != nil { + log.Printf("db: %v", e) + err(c, http.StatusInternalServerError, "internal", "db error") + return + } + out := make([]gin.H, 0, len(users)) + for _, u := range users { + out = append(out, gin.H{"id": u.ID, "username": u.Username, "role": u.Role, + "created_at": u.CreatedAt.Format(time.RFC3339)}) + } + c.JSON(http.StatusOK, out) +} + +func (a *api) 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") + return + } + if req.Role != "admin" && req.Role != "member" { + err(c, http.StatusBadRequest, "bad_request", "role must be admin|member") + return + } + if len(req.Password) < 8 { + err(c, http.StatusBadRequest, "bad_request", "password too short (min 8)") + return + } + h, 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) + if e != nil { + if isUnique(e) { + err(c, http.StatusConflict, "exists", "username taken") + return + } + err(c, http.StatusBadRequest, "bad_request", "invalid input") + return + } + c.JSON(http.StatusCreated, gin.H{"id": id, "username": req.Username, "role": req.Role}) +} + +func (a *api) deleteUser(c *gin.Context) { + id, e := strconv.ParseInt(c.Param("id"), 10, 64) + if e != nil { + err(c, http.StatusBadRequest, "bad_request", "bad id") + return + } + if id == uid(c) { + err(c, http.StatusBadRequest, "bad_request", "cannot delete yourself") + return + } + target, e := a.st.GetUserByID(c, id) + if e != nil { + if errors.Is(e, pgx.ErrNoRows) { + err(c, http.StatusNotFound, "not_found", "no such user") + return + } + err(c, http.StatusInternalServerError, "internal", "db error") + return + } + if target.Role == "admin" { + n, _ := a.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 { + err(c, http.StatusInternalServerError, "internal", "db error") + return + } + c.Status(http.StatusNoContent) +} diff --git a/backend/internal/api/users_test.go b/backend/internal/api/users_test.go new file mode 100644 index 0000000..852442f --- /dev/null +++ b/backend/internal/api/users_test.go @@ -0,0 +1,79 @@ +package api + +import ( + "encoding/json" + "net/http" + "strconv" + "testing" +) + +func adminToken(t *testing.T, h http.Handler) string { + t.Helper() + w := do(h, "POST", "/api/auth/login", "", map[string]string{"username": "alice", "password": "pw12345"}) + var v struct{ Token string } + json.Unmarshal(w.Body.Bytes(), &v) + return v.Token +} + +func TestUserCRUD(t *testing.T) { + _, h := setupAPI(t) + tok := adminToken(t, h) + w := do(h, "POST", "/api/users", tok, map[string]string{"username": "carol", "password": "pw123456", "role": "member"}) + if w.Code != 201 { + t.Fatalf("create %d %s", w.Code, w.Body) + } + w = do(h, "GET", "/api/users", tok, nil) + var users []map[string]any + json.Unmarshal(w.Body.Bytes(), &users) + if len(users) != 3 { + t.Fatalf("list want 3 got %d: %s", len(users), w.Body) + } + carolID := findID(users, "carol") + // 重名 → 409 + w = do(h, "POST", "/api/users", tok, map[string]string{"username": "carol", "password": "pw123456", "role": "member"}) + if w.Code != 409 { + t.Fatalf("dup want 409 got %d", w.Code) + } + // 弱密码 → 400 + w = do(h, "POST", "/api/users", tok, map[string]string{"username": "dave", "password": "1", "role": "member"}) + if w.Code != 400 { + t.Fatalf("weak want 400 got %d", w.Code) + } + // 不能删自己:先 me 拿 id + w = do(h, "GET", "/api/auth/me", tok, nil) + var me map[string]any + json.Unmarshal(w.Body.Bytes(), &me) + w = do(h, "DELETE", "/api/users/"+itoa(me["id"]), tok, nil) + if w.Code != 400 { + t.Fatalf("self-delete want 400 got %d %s", w.Code, w.Body) + } + // 删 carol → 204,再删 → 404 + w = do(h, "DELETE", "/api/users/"+itoa(carolID), tok, nil) + if w.Code != 204 { + t.Fatalf("delete %d %s", w.Code, w.Body) + } + w = do(h, "DELETE", "/api/users/"+itoa(carolID), tok, nil) + if w.Code != 404 { + t.Fatalf("redelete want 404 got %d", w.Code) + } +} + +func TestBadRoleRejected(t *testing.T) { + _, h := setupAPI(t) + tok := adminToken(t, h) + w := do(h, "POST", "/api/users", tok, map[string]string{"username": "e", "password": "pw123456", "role": "god"}) + if w.Code != 400 { + t.Fatalf("bad role want 400 got %d", w.Code) + } +} + +func findID(rows []map[string]any, name string) float64 { + for _, r := range rows { + if r["username"] == name { + return r["id"].(float64) + } + } + return 0 +} + +func itoa(v any) string { return strconv.FormatFloat(v.(float64), 'f', 0, 64) } diff --git a/backend/internal/bookfile/bookfile.go b/backend/internal/bookfile/bookfile.go new file mode 100644 index 0000000..fd7fae1 --- /dev/null +++ b/backend/internal/bookfile/bookfile.go @@ -0,0 +1,42 @@ +package bookfile + +import ( + "path/filepath" + "strings" + "unicode" +) + +func FormatFromExt(name string) string { + switch strings.ToLower(filepath.Ext(name)) { + case ".cbz", ".zip": + return "cbz" + case ".pdf": + return "pdf" + case ".epub": + return "epub" + case ".txt": + return "txt" + case ".md": + return "md" + } + return "" +} + +func SafeName(s string) string { + s = strings.Map(func(r rune) rune { + if unicode.IsControl(r) { + return -1 + } + return r + }, s) + s = strings.ReplaceAll(s, "\\", "/") + s = filepath.Base(filepath.ToSlash(s)) + if s == "." || s == "/" { + return "" + } + s = strings.TrimSpace(s) + if len(s) > 200 { + s = strings.TrimSpace(s[:200]) + } + return s +} diff --git a/backend/internal/bookfile/bookfile_test.go b/backend/internal/bookfile/bookfile_test.go new file mode 100644 index 0000000..311eb20 --- /dev/null +++ b/backend/internal/bookfile/bookfile_test.go @@ -0,0 +1,32 @@ +package bookfile + +import "testing" + +func TestFormatFromExt(t *testing.T) { + cases := map[string]string{ + "a.cbz": "cbz", "B.PDF": "pdf", "x.epub": "epub", "y.txt": "txt", "z.md": "md", + "w.rar": "", "noext": "", "cbr.cbZ": "cbz", "tar.gz": "", + } + for in, want := range cases { + if got := FormatFromExt(in); got != want { + t.Errorf("FormatFromExt(%q)=%q want %q", in, got, want) + } + } +} + +func TestSafeName(t *testing.T) { + cases := map[string]string{ + "my book.cbz": "my book.cbz", + "../../etc/passwd": "passwd", + "/abs/name.pdf": "name.pdf", + "a\\b\\c.epub": "c.epub", + " spaced .txt ": "spaced .txt", + "con\ntl.bin": "contl.bin", + "": "", + } + for in, want := range cases { + if got := SafeName(in); got != want { + t.Errorf("SafeName(%q)=%q want %q", in, got, want) + } + } +}