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

+30 -9
View File
@@ -4,7 +4,6 @@ package api
import (
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
@@ -85,6 +84,7 @@ func (s *Server) routes() []route {
{"POST /api/v1/import", authed, s.importNotes},
{"POST /api/v1/assets", authed, s.uploadAsset},
{"POST /api/v1/assets/import", authed, s.importAsset},
{"PUT /api/v1/assets/{path...}", authed, s.replaceImage},
{"GET /api/v1/raw/{path...}", authed, s.raw},
{"GET /api/v1/trash", authed, s.trashList},
{"POST /api/v1/trash/{id}/restore", authed, s.trashRestore},
@@ -352,18 +352,13 @@ func (s *Server) move(w http.ResponseWriter, r *http.Request) {
if !readJSON(w, r, &body) {
return
}
rewritten, err := s.vault.Move(body.From, body.To)
rewritten, err := s.vault.Move(body.From, body.To, func() error {
return s.store.MoveShares(body.From, body.To)
})
if err != nil {
vaultError(w, r, err)
return
}
if err := s.store.MoveShares(body.From, body.To); err != nil {
if _, rollback := s.vault.Move(body.To, body.From); rollback != nil {
err = errors.Join(err, fmt.Errorf("回滚笔记移动失败: %w", rollback))
}
internalError(w, r, err)
return
}
if rewritten {
// 原子改写替换了文件身份,监听器无法再从快照中识别这次移动。
s.hub.Broadcast(events.Event{Type: "moved", From: body.From, Path: body.To}, events.Event{Type: "tree"})
@@ -400,6 +395,32 @@ func (s *Server) uploadAsset(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]string{"link": link})
}
// replaceImage 仅替换已存在的图片;路径与引用保持不变。
func (s *Server) replaceImage(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxAssetBytes+1<<20)
if err := r.ParseMultipartForm(1 << 20); err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
writeError(w, 413, "图片不能超过 50 MB")
} else {
writeError(w, 400, "无法读取上传文件")
}
return
}
defer r.MultipartForm.RemoveAll()
file, header, err := r.FormFile("file")
if err != nil {
writeError(w, 400, "缺少上传文件")
return
}
defer file.Close()
if err := s.vault.ReplaceImage(r.PathValue("path"), header.Filename, file, maxAssetBytes); err != nil {
vaultError(w, r, err)
return
}
writeJSON(w, 200, map[string]bool{"ok": true})
}
// raw 输出仓库内任意非隐藏文件(主要是图片)。以沙箱 CSP 返回,防止 SVG/HTML 中的脚本在本站执行
func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
s.serveRaw(w, r, r.PathValue("path"))
+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)
}
}
+57 -4
View File
@@ -398,7 +398,7 @@
},
"/tree": {
"get": {
"summary": "全部笔记与文件夹(不含 .assets 图片目录与隐藏文件)",
"summary": "全部笔记、图片与文件夹(含 .assets,不含隐藏条目或符号链接)",
"tags": [
"笔记"
],
@@ -583,7 +583,7 @@
}
},
"delete": {
"summary": "把笔记或文件夹移入回收站(连同 .assets)",
"summary": "把笔记、图片或文件夹移入回收站(笔记连同 .assets)",
"tags": [
"笔记"
],
@@ -613,11 +613,11 @@
},
"/move": {
"post": {
"summary": "移动或重命名笔记/文件夹",
"summary": "移动或重命名笔记、图片或文件夹",
"tags": [
"笔记"
],
"description": "笔记的 .assets 目录随之移动,改名时同步改写正文中指向自身图片目录的链接;分享链接随之更新。不改写其他笔记指向它的链接。",
"description": "笔记的 .assets 目录随之移动,改名时改写本篇图片目录链接;图片移动或改名保持扩展名,并更新仓库笔记中的本地引用。分享链接随主体更新。不改写其他笔记指向笔记或文件夹的链接。",
"requestBody": {
"required": true,
"content": {
@@ -2273,6 +2273,59 @@
}
}
}
},
"/assets/{path}": {
"put": {
"summary": "原位替换已有图片,保留路径和引用",
"tags": [
"图片"
],
"parameters": [
{
"name": "path",
"in": "path",
"required": true,
"description": "仓库中的图片路径",
"schema": {
"type": "string"
}
}
],
"description": "multipart file 字段的扩展名须与目标图片一致;不创建新文件,最大 50 MB。旧图片不会进入回收站。",
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"properties": {
"file": {
"type": "string",
"format": "binary"
}
},
"required": [
"file"
]
}
}
}
},
"responses": {
"200": {
"description": "替换成功"
},
"400": {
"description": "非法路径、空文件或扩展名不一致"
},
"404": {
"description": "目标图片不存在"
},
"413": {
"description": "超过 50 MB"
}
}
}
}
},
"components": {