feat(libs): create library by name only; root_path derived server-side as BOOKS_DIR/SafeName(name), validated at creation; drop root_path input from admin UI; align changelogs to bilingual template; trim AGENTS.md to rules not derivable from repo layout
This commit is contained in:
@@ -20,7 +20,7 @@ func newLibrary(t *testing.T, st *store.Store, h http.Handler, tok, booksDir, na
|
||||
t.Helper()
|
||||
root := filepath.Join(booksDir, name)
|
||||
os.MkdirAll(filepath.Join(root, "series-a"), 0o755)
|
||||
w := do(h, "POST", "/api/libraries", tok, map[string]string{"name": name, "root_path": root})
|
||||
w := do(h, "POST", "/api/libraries", tok, map[string]string{"name": name})
|
||||
if w.Code != 201 {
|
||||
t.Fatalf("create lib %d %s", w.Code, w.Body)
|
||||
}
|
||||
|
||||
@@ -47,29 +47,31 @@ func (h *H) ListLibraries(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// CreateLibrary: root_path 由服务端生成(BooksDir/SafeName(name)),不接受客户端指定
|
||||
func (h *H) CreateLibrary(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
RootPath string `json:"root_path"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if c.ShouldBindJSON(&req) != nil || req.Name == "" || req.RootPath == "" {
|
||||
err(c, http.StatusBadRequest, "bad_request", "name and root_path required")
|
||||
if c.ShouldBindJSON(&req) != nil {
|
||||
err(c, http.StatusBadRequest, "bad_request", "name required")
|
||||
return
|
||||
}
|
||||
if !filepath.IsAbs(req.RootPath) {
|
||||
err(c, http.StatusBadRequest, "bad_request", "root_path must be absolute")
|
||||
safe := bookfile.SafeName(req.Name)
|
||||
if safe == "" || safe == ".." {
|
||||
err(c, http.StatusBadRequest, "bad_request", "bad name")
|
||||
return
|
||||
}
|
||||
id, e := h.st.CreateLibrary(c, req.Name, filepath.Clean(req.RootPath))
|
||||
root := filepath.Join(filepath.Clean(h.cfg.BooksDir), safe)
|
||||
id, e := h.st.CreateLibrary(c, req.Name, root)
|
||||
if e != nil {
|
||||
if isUnique(e) {
|
||||
err(c, http.StatusConflict, "exists", "root_path taken")
|
||||
err(c, http.StatusConflict, "exists", "name taken")
|
||||
return
|
||||
}
|
||||
dbErr(c, e)
|
||||
return
|
||||
}
|
||||
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": root})
|
||||
}
|
||||
|
||||
func (h *H) getLibrary(c *gin.Context) (store.Library, bool) {
|
||||
|
||||
@@ -14,23 +14,35 @@ import (
|
||||
func TestLibraryCreateListUpload(t *testing.T) {
|
||||
_, _, h, booksDir := setupAPI(t)
|
||||
tok := adminToken(t, h)
|
||||
root := filepath.Join(booksDir, "lib1") // 必须落在解析过软链的 booksDir 内
|
||||
os.MkdirAll(root, 0o755)
|
||||
w := do(h, "POST", "/api/libraries", tok, map[string]string{"name": "comics", "root_path": root})
|
||||
root := filepath.Join(booksDir, "comics") // 服务端自动拼接 BooksDir/<清洗后的库名>
|
||||
w := do(h, "POST", "/api/libraries", tok, map[string]string{"name": "comics"})
|
||||
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)
|
||||
if lib["root_path"] != root {
|
||||
t.Fatalf("root_path want %q got %v", root, lib["root_path"])
|
||||
}
|
||||
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"})
|
||||
// 恶意库名必须清洗,root 仍在 booksDir 内
|
||||
w = do(h, "POST", "/api/libraries", tok, map[string]string{"name": "../../etc/passwd"})
|
||||
if w.Code != 201 || !strings.Contains(w.Body.String(), filepath.Join(booksDir, "passwd")) {
|
||||
t.Fatalf("traversal name want sanitized 201 got %d %s", w.Code, w.Body)
|
||||
}
|
||||
// "." / ".." 清洗后非法 → 400
|
||||
w = do(h, "POST", "/api/libraries", tok, map[string]string{"name": ".."})
|
||||
if w.Code != 400 {
|
||||
t.Fatalf("relative root want 400 got %d", w.Code)
|
||||
t.Fatalf("'..' want 400 got %d", w.Code)
|
||||
}
|
||||
// 重名(同 root)→ 409
|
||||
w = do(h, "POST", "/api/libraries", tok, map[string]string{"name": "comics"})
|
||||
if w.Code != 409 {
|
||||
t.Fatalf("dup want 409 got %d", w.Code)
|
||||
}
|
||||
// 上传:白名单 + 防穿越 + 原子落盘
|
||||
body, mw := uploadBody("my 01.cbz", []byte("zipbytes"))
|
||||
|
||||
Reference in New Issue
Block a user