Add image listing and asset management

Show images in asset directories with preview, rename, replacement, and trash recovery. Update local Markdown references when images move and restore original files if related metadata cannot be committed.

Refresh image previews after replacement and compare actual contents when deduplicating subsequent uploads.
This commit is contained in:
eezd committed 2026-10-21 15:14:07 +08:00
1 parent 8b54ccba18
commit d253536009
25 files changed
+801 -103

No files matched your search

+58
View File
@@ -0,0 +1,58 @@
package api
import (
"bytes"
"io"
"mime/multipart"
"net/http"
"testing"
)
func TestReplaceImageAPI(t *testing.T) {
f := newFixture(t)
f.write("assets/a.png", "original")
f.write("a.md", "![](assets/a.png)")
var body bytes.Buffer
form := multipart.NewWriter(&body)
file, err := form.CreateFormFile("file", "replacement.png")
if err != nil {
t.Fatal(err)
}
if _, err := io.WriteString(file, "replacement"); err != nil {
t.Fatal(err)
}
if err := form.Close(); err != nil {
t.Fatal(err)
}
headers := map[string]string{"Content-Type": form.FormDataContentType()}
if code := f.status(newClient(), "PUT", "/api/v1/assets/assets/a.png", body.String(), headers); code != 401 {
t.Fatalf("未登录应被拒绝:%d", code)
}
readToken := decode[struct{ Token string }](t, f.do(f.admin, "POST", "/api/v1/tokens", `{"name":"read","scope":"read"}`, nil)).Token
if code := f.status(newClient(), "PUT", "/api/v1/assets/assets/a.png", body.String(), map[string]string{"Content-Type": form.FormDataContentType(), "Authorization": "Bearer " + readToken}); code != 403 {
t.Fatalf("只读令牌不能替换图片:%d", code)
}
for route, want := range map[string]int{"/api/v1/assets/assets/a.png": 200, "/api/v1/assets/missing.png": 404, "/api/v1/assets/a.md": 400} {
if code := f.status(f.admin, "PUT", route, body.String(), headers); code != want {
t.Fatalf("%s:得到 %d,期望 %d", route, code, want)
}
}
res := f.do(f.admin, "GET", "/api/v1/raw/assets/a.png", "", nil)
data, err := io.ReadAll(res.Body)
if err != nil || string(data) != "replacement" || res.Header.Get("Content-Security-Policy") == "" || res.Header.Get("Cache-Control") != "no-store" {
t.Fatalf("替换内容或安全边界错误:%q %v", data, err)
}
note := f.do(f.admin, "GET", "/api/v1/files/a.md", "", nil)
data, err = io.ReadAll(note.Body)
if err != nil || string(data) != "![](assets/a.png)" {
t.Fatalf("替换不应改变引用:%q %v", data, err)
}
// 复用现有移动与回收站接口,图片不会被当作 Markdown 正文读取。
if code := f.status(f.admin, "POST", "/api/v1/move", `{"from":"assets/a.png","to":"assets/b.png"}`, nil); code != 200 {
t.Fatal(code)
}
deleted := decode[struct{ ID string }](t, f.do(f.admin, "DELETE", "/api/v1/files/assets/b.png", "", nil))
if code := f.status(f.admin, "POST", "/api/v1/trash/"+deleted.ID+"/restore", "", nil); code != http.StatusOK {
t.Fatal(code)
}
}