Support live recursive folder shares and follow subject moves
This commit is contained in:
1 parent
89472fe6fd
commit
84e89c8fe4
17 files changed
+620
-127
No files matched your search
+7
-2
@@ -355,17 +355,22 @@ func (s *Server) move(w http.ResponseWriter, r *http.Request) {
|
||||
if !readJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if err := s.vault.Move(body.From, body.To); err != nil {
|
||||
rewritten, err := s.vault.Move(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 {
|
||||
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"})
|
||||
}
|
||||
writeJSON(w, 200, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
|
||||
+123
-6
@@ -37,6 +37,33 @@ type fixture struct {
|
||||
admin *http.Client
|
||||
}
|
||||
|
||||
func TestMoveBroadcastsWhenAssetRewriteReplacesFileIdentity(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.write("from.md", "")
|
||||
if err := os.Mkdir(filepath.Join(f.root, "from.assets"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ch, cancel := f.hub.Subscribe()
|
||||
defer cancel()
|
||||
res := f.do(f.admin, "POST", "/api/v1/move", `{"from":"from.md","to":"to.md"}`, nil)
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != 200 {
|
||||
t.Fatalf("移动失败: %d", res.StatusCode)
|
||||
}
|
||||
select {
|
||||
case msg := <-ch:
|
||||
var batch []events.Event
|
||||
if err := json.Unmarshal(msg, &batch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(batch) != 2 || batch[0].Type != "moved" || batch[0].From != "from.md" || batch[0].Path != "to.md" || batch[1].Type != "tree" {
|
||||
t.Fatalf("移动事件不符: %s", msg)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("正文改写后未广播移动事件")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveRollsBackWhenShareUpdateFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
v, err := vault.Open(filepath.Join(dir, "vault"))
|
||||
@@ -352,7 +379,7 @@ func TestShareAccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestShareFilenameToken(t *testing.T) {
|
||||
func TestShareRandomToken(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
name := "计划 #1? 50% +;\""
|
||||
p := "目录/" + name + ".md"
|
||||
@@ -363,10 +390,9 @@ func TestShareFilenameToken(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sh := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", string(body), nil))
|
||||
suffix, ok := strings.CutPrefix(sh.Token, name+"-")
|
||||
random, err := base64.RawURLEncoding.DecodeString(suffix)
|
||||
if !ok || err != nil || len(random) != 16 {
|
||||
t.Fatal("分享应包含完整文件名和 128 位随机字符,不包含目录")
|
||||
random, err := base64.RawURLEncoding.DecodeString(sh.Token)
|
||||
if err != nil || len(random) != 16 {
|
||||
t.Fatal("分享标识应为 128 位随机字符,不包含文件名称或目录")
|
||||
}
|
||||
other := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", string(body), nil))
|
||||
if other.Token == sh.Token {
|
||||
@@ -398,6 +424,97 @@ func TestShareFilenameToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderShareTracksItsCurrentContentsAndSubject(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.write("公开/子目录/文章.md", "第一版")
|
||||
f.write("公开/子目录/文章.assets/图.png", "PNG")
|
||||
f.write("公开/资料.txt", "资料")
|
||||
f.write("公开/.私密.md", "不能公开")
|
||||
f.write("公开/.git/config", "不能公开")
|
||||
f.write("其他/私密.md", "不能公开")
|
||||
if err := os.Symlink(filepath.Join(f.root, "其他"), filepath.Join(f.root, "公开", "别名")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
folder := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", `{"path":"公开","password":"访问密码"}`, nil))
|
||||
note := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", `{"path":"公开/子目录/文章.md"}`, nil))
|
||||
base := "/api/v1/public/shares/" + folder.Token
|
||||
noteBase := "/api/v1/public/shares/" + note.Token
|
||||
guest := newClient()
|
||||
if code := f.status(guest, "GET", base+"?path=子目录/文章.md", "", nil); code != 401 {
|
||||
t.Fatalf("目录内的文章也必须解锁,得到 %d", code)
|
||||
}
|
||||
if code := f.status(guest, "POST", base+"/unlock", `{"password":"访问密码"}`, nil); code != 200 {
|
||||
t.Fatal(code)
|
||||
}
|
||||
type directory struct {
|
||||
Title string
|
||||
Dir bool
|
||||
Entries []vault.Entry
|
||||
}
|
||||
listed := decode[directory](t, f.do(guest, "GET", base, "", nil))
|
||||
if listed.Title != "公开" || !listed.Dir || len(listed.Entries) != 5 {
|
||||
t.Fatalf("应递归列出全部普通文件和子目录: %+v", listed)
|
||||
}
|
||||
for _, e := range listed.Entries {
|
||||
if strings.Contains(e.Path, "公开/") || strings.Contains(e.Path, "私密") || strings.Contains(e.Path, "别名") || strings.Contains(e.Path, ".git") {
|
||||
t.Fatalf("目录列表越界: %s", e.Path)
|
||||
}
|
||||
}
|
||||
f.write("公开/子目录/文章.md", "最新正文")
|
||||
article := decode[map[string]any](t, f.do(guest, "GET", base+"?path=子目录/文章.md", "", nil))
|
||||
if article["content"] != "最新正文" || article["path"] != nil {
|
||||
t.Fatalf("应提供当前正文且不暴露仓库路径: %v", article)
|
||||
}
|
||||
for _, rel := range []string{"../其他/私密.md", ".私密.md", ".git/config", "别名/私密.md"} {
|
||||
if code := f.status(guest, "GET", base+"?path="+url.QueryEscape(rel), "", nil); code != 404 {
|
||||
t.Fatalf("不允许读取 %s,得到 %d", rel, code)
|
||||
}
|
||||
if code := f.status(guest, "GET", base+"/assets/"+url.PathEscape(rel), "", nil); code != 404 {
|
||||
t.Fatalf("资源接口不允许读取 %s,得到 %d", rel, code)
|
||||
}
|
||||
}
|
||||
if code := f.status(guest, "GET", base+"/assets/子目录/文章.assets/图.png", "", nil); code != 200 {
|
||||
t.Fatalf("目录中的图片不可读: %d", code)
|
||||
}
|
||||
res := f.do(guest, "GET", base+"/assets/资料.txt?download=1", "", nil)
|
||||
content, err := io.ReadAll(res.Body)
|
||||
if err != nil || res.StatusCode != 200 || string(content) != "资料" || !strings.HasPrefix(res.Header.Get("Content-Disposition"), "attachment;") {
|
||||
t.Fatalf("普通文件下载失败: %d %v", res.StatusCode, err)
|
||||
}
|
||||
f.write("公开/新增.md", "新内容")
|
||||
listed = decode[directory](t, f.do(guest, "GET", base, "", nil))
|
||||
if len(listed.Entries) != 6 {
|
||||
t.Fatal("新增文件应自动进入目录分享")
|
||||
}
|
||||
if code := f.status(f.admin, "POST", "/api/v1/move", `{"from":"公开","to":"移动后的目录"}`, nil); code != 200 {
|
||||
t.Fatal(code)
|
||||
}
|
||||
listed = decode[directory](t, f.do(guest, "GET", base, "", nil))
|
||||
if listed.Title != "移动后的目录" {
|
||||
t.Fatal("目录主体移动后原分享应跟随")
|
||||
}
|
||||
if code := f.status(f.admin, "POST", "/api/v1/move", `{"from":"移动后的目录/子目录/文章.md","to":"其他/移出.md"}`, nil); code != 200 {
|
||||
t.Fatal(code)
|
||||
}
|
||||
if code := f.status(guest, "GET", base+"?path=子目录/文章.md", "", nil); code != 404 {
|
||||
t.Fatalf("移出的文章不能继续通过目录分享读取: %d", code)
|
||||
}
|
||||
article = decode[map[string]any](t, f.do(guest, "GET", noteBase, "", nil))
|
||||
if article["content"] != "最新正文" || article["title"] != "移出" {
|
||||
t.Fatal("单篇分享应跟随文章移出目录")
|
||||
}
|
||||
file := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", `{"path":"移动后的目录/资料.txt"}`, nil))
|
||||
if code := f.status(guest, "GET", "/api/v1/public/shares/"+file.Token+"/assets/", "", nil); code != 200 {
|
||||
t.Fatalf("应能分享普通文件: %d", code)
|
||||
}
|
||||
if code := f.status(f.admin, "DELETE", "/api/v1/shares/"+folder.Token, "", nil); code != 200 {
|
||||
t.Fatal(code)
|
||||
}
|
||||
if code := f.status(guest, "GET", base+"/assets/资料.txt", "", nil); code != 404 {
|
||||
t.Fatalf("撤销后资源也必须失效: %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShareWithoutPasswordAndExpiry(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.write("a.md", "公开")
|
||||
@@ -414,7 +531,7 @@ func TestShareWithoutPasswordAndExpiry(t *testing.T) {
|
||||
if code := f.status(newClient(), "GET", "/api/v1/public/shares/expired", "", nil); code != 404 {
|
||||
t.Errorf("过期分享应为 404,得到 %d", code)
|
||||
}
|
||||
for _, body := range []string{`{"path":"a.assets/x.png"}`, `{"path":"../a.md"}`, `{"path":"a.md","expiresIn":-1}`} {
|
||||
for _, body := range []string{`{"path":"../a.md"}`, `{"path":"a.md","expiresIn":-1}`} {
|
||||
if code := f.status(f.admin, "POST", "/api/v1/shares", body, nil); code != 400 {
|
||||
t.Errorf("%s 应为 400,得到 %d", body, code)
|
||||
}
|
||||
|
||||
+43
-12
@@ -1046,7 +1046,7 @@
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"summary": "创建只读分享",
|
||||
"summary": "创建文件或文件夹的只读分享",
|
||||
"tags": [
|
||||
"分享"
|
||||
],
|
||||
@@ -1063,7 +1063,8 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"description": "仓库内的文件或文件夹;文件夹递归公开当前所有普通文件和子目录,含后续新增或移入的内容"
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
@@ -1083,7 +1084,7 @@
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "新分享,token 为文件名(不含 .md)加连字符和随机字符,访问地址为 /s/{URL 编码后的 token};文件移动或改名不改变已生成的链接",
|
||||
"description": "新分享,token 为 128 位随机数的 URL 安全编码,访问地址为 /s/{token};移动分享主体不改变链接",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -1093,7 +1094,7 @@
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "不是笔记",
|
||||
"description": "路径或参数无效",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -1314,10 +1315,18 @@
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "path",
|
||||
"in": "query",
|
||||
"description": "文件夹分享中相对主体的子路径;省略时读取主体本身,不允许跳出分享范围",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"get": {
|
||||
"summary": "访客读取分享的笔记",
|
||||
"summary": "访客读取分享主体或其目录中的当前内容",
|
||||
"tags": [
|
||||
"分享(公开)"
|
||||
],
|
||||
@@ -1326,7 +1335,7 @@
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "笔记",
|
||||
"description": "目录返回 dir=true 及递归 entries(路径相对此目录);Markdown 返回 dir=false、content 与 updatedAt;其他文件返回 dir=false,内容经 assets 接口读取或下载",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -1340,6 +1349,15 @@
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "integer"
|
||||
},
|
||||
"dir": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"entries": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Entry"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1365,7 +1383,7 @@
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "分享不存在、已过期或笔记已删除",
|
||||
"description": "分享不存在、已过期、主体已删除或子路径不在分享范围内",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -1472,14 +1490,25 @@
|
||||
"name": "path",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "相对笔记所在目录的图片路径,只能位于该笔记的 .assets 目录",
|
||||
"description": "文件夹分享中相对主体的文件路径;单文件分享仅允许其 .assets 图片,空路径读取该文件本身",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "download",
|
||||
"in": "query",
|
||||
"description": "设为 1 时以附件下载;其他情况下以带沙箱 CSP 的原始文件返回",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"1"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"get": {
|
||||
"summary": "访客读取分享笔记中的图片",
|
||||
"summary": "访客读取或下载分享范围内的普通文件",
|
||||
"tags": [
|
||||
"分享(公开)"
|
||||
],
|
||||
@@ -2163,10 +2192,12 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"token": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"description": "新分享使用完全随机的 URL 安全标识,不包含文件名称或目录"
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"description": "分享主体在仓库中的路径(只在登录后的管理接口返回)"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "integer"
|
||||
@@ -2180,7 +2211,7 @@
|
||||
},
|
||||
"exists": {
|
||||
"type": "boolean",
|
||||
"description": "笔记是否仍存在"
|
||||
"description": "分享的文件或文件夹是否仍存在"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+68
-20
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
@@ -25,7 +26,7 @@ const shareCookieTTL = 30 * 24 * time.Hour
|
||||
type shareView struct {
|
||||
store.Share
|
||||
HasPassword bool `json:"hasPassword"`
|
||||
// 笔记是否仍存在(被删除或在外部改名后为 false)
|
||||
// 分享主体是否仍存在。
|
||||
Exists bool `json:"exists"`
|
||||
}
|
||||
|
||||
@@ -62,8 +63,8 @@ func (s *Server) createShare(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
p, err := vault.CleanPath(body.Path)
|
||||
if err != nil || !vault.IsNote(p) {
|
||||
writeError(w, 400, "只能分享笔记")
|
||||
if err != nil {
|
||||
writeError(w, 400, "分享路径无效")
|
||||
return
|
||||
}
|
||||
if _, err := s.vault.Stat(p); err != nil {
|
||||
@@ -76,7 +77,7 @@ func (s *Server) createShare(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
now := time.Now()
|
||||
sh := store.Share{
|
||||
Token: strings.TrimSuffix(path.Base(p), vault.NoteExt) + "-" + auth.NewShareToken(),
|
||||
Token: auth.NewShareToken(),
|
||||
Path: p,
|
||||
PasswordHash: auth.HashSharePassword(body.Password),
|
||||
CreatedAt: now.UnixMilli(),
|
||||
@@ -147,30 +148,55 @@ func (s *Server) loadShare(token string) (store.Share, bool, error) {
|
||||
return sh, true, nil
|
||||
}
|
||||
|
||||
// publicShare 返回笔记标题与当前内容(不含所在目录,避免泄露仓库结构)
|
||||
// publicShare 只公开主体本身或分享目录内的内容,不返回主体在仓库中的路径。
|
||||
func (s *Server) publicShare(w http.ResponseWriter, r *http.Request) {
|
||||
sh, ok := s.openShare(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, _, err := s.vault.Read(sh.Path)
|
||||
if errors.Is(err, vault.ErrNotFound) {
|
||||
writeError(w, 404, "分享的笔记已不存在")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
info, err := s.vault.Stat(sh.Path)
|
||||
if err != nil {
|
||||
vaultError(w, r, err)
|
||||
return
|
||||
}
|
||||
p := sh.Path
|
||||
if rel := r.URL.Query().Get("path"); rel != "" {
|
||||
if !info.IsDir() {
|
||||
writeError(w, 404, "文件不存在")
|
||||
return
|
||||
}
|
||||
p, err = s.vault.ScopedPath(sh.Path, rel)
|
||||
if err == nil {
|
||||
info, err = s.vault.Stat(p)
|
||||
}
|
||||
if err != nil {
|
||||
vaultError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if info.IsDir() {
|
||||
entries, err := s.vault.TreeAt(p)
|
||||
if err != nil {
|
||||
vaultError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"title": path.Base(p), "dir": true, "entries": entries})
|
||||
return
|
||||
}
|
||||
if !vault.IsNote(p) {
|
||||
writeJSON(w, 200, map[string]any{"title": path.Base(p), "dir": false})
|
||||
return
|
||||
}
|
||||
data, _, err := s.vault.Read(p)
|
||||
if err != nil {
|
||||
vaultError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{
|
||||
"title": strings.TrimSuffix(path.Base(sh.Path), vault.NoteExt),
|
||||
"title": strings.TrimSuffix(path.Base(p), vault.NoteExt),
|
||||
"content": string(data),
|
||||
"updatedAt": info.ModTime().UnixMilli(),
|
||||
"dir": false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -223,18 +249,40 @@ func (s *Server) unlockShare(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// publicShareAsset 输出分享笔记中的图片:路径相对笔记所在目录,且只能是该笔记 .assets 目录内的文件
|
||||
// publicShareAsset 提供主体允许范围内的普通文件;单文件分享额外允许其 .assets。
|
||||
func (s *Server) publicShareAsset(w http.ResponseWriter, r *http.Request) {
|
||||
sh, ok := s.openShare(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rel := path.Join(path.Dir(sh.Path), r.PathValue("path"))
|
||||
if !strings.HasPrefix(rel, vault.AssetsDir(sh.Path)+"/") {
|
||||
writeError(w, 404, "文件不存在")
|
||||
info, err := s.vault.Stat(sh.Path)
|
||||
if err != nil {
|
||||
vaultError(w, r, err)
|
||||
return
|
||||
}
|
||||
s.serveRaw(w, r, rel)
|
||||
rel := r.PathValue("path")
|
||||
var target string
|
||||
if info.IsDir() {
|
||||
target, err = s.vault.ScopedPath(sh.Path, rel)
|
||||
} else if rel == "" {
|
||||
target = sh.Path
|
||||
} else {
|
||||
assets := vault.AssetsDir(sh.Path)
|
||||
prefix := path.Base(assets) + "/"
|
||||
if !vault.IsNote(sh.Path) || !strings.HasPrefix(rel, prefix) {
|
||||
writeError(w, 404, "文件不存在")
|
||||
return
|
||||
}
|
||||
target, err = s.vault.ScopedPath(assets, strings.TrimPrefix(rel, prefix))
|
||||
}
|
||||
if err != nil {
|
||||
vaultError(w, r, err)
|
||||
return
|
||||
}
|
||||
if r.URL.Query().Get("download") == "1" {
|
||||
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": path.Base(target)}))
|
||||
}
|
||||
s.serveRaw(w, r, target)
|
||||
}
|
||||
|
||||
// FollowMoves 让分享跟随在外部(如 Typora)完成的改名与移动;经 API 的移动已在 move 接口中处理,
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
// tree 目录结构变化(新增/删除/移动),客户端重新拉取目录树
|
||||
// changed 笔记内容变化,Rev 为新版本
|
||||
// deleted 笔记被删除
|
||||
// moved 笔记被移动(From -> Path),内容版本不变
|
||||
// moved 文件或目录被移动(From -> Path),目录事件包含其子项的路径映射
|
||||
// settings 应用设置变化,客户端重新读取
|
||||
type Event struct {
|
||||
Type string `json:"type"`
|
||||
|
||||
+102
-25
@@ -49,10 +49,11 @@ type Vault struct {
|
||||
}
|
||||
|
||||
type Entry struct {
|
||||
Path string `json:"path"`
|
||||
Dir bool `json:"dir"`
|
||||
Size int64 `json:"size"`
|
||||
ModTime int64 `json:"mtime"` // 毫秒
|
||||
Path string `json:"path"`
|
||||
Dir bool `json:"dir"`
|
||||
Size int64 `json:"size"`
|
||||
ModTime int64 `json:"mtime"` // 毫秒
|
||||
Info fs.FileInfo `json:"-"`
|
||||
}
|
||||
|
||||
type TrashItem struct {
|
||||
@@ -168,17 +169,14 @@ func (v *Vault) Read(rel string) ([]byte, string, error) {
|
||||
return data, Rev(data), nil
|
||||
}
|
||||
|
||||
// Stat 返回笔记的文件信息;不存在时返回 ErrNotFound
|
||||
// Stat 返回普通文件或目录的信息;不存在时返回 ErrNotFound
|
||||
func (v *Vault) Stat(rel string) (fs.FileInfo, error) {
|
||||
if !IsNote(rel) {
|
||||
return nil, ErrInvalidPath
|
||||
}
|
||||
p, err := v.abs(rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := os.Stat(p)
|
||||
if errors.Is(err, fs.ErrNotExist) || (err == nil && !info.Mode().IsRegular()) {
|
||||
if errors.Is(err, fs.ErrNotExist) || (err == nil && !info.Mode().IsRegular() && !info.IsDir()) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return info, err
|
||||
@@ -335,51 +333,130 @@ func (v *Vault) Tree() ([]Entry, error) {
|
||||
return entries, err
|
||||
}
|
||||
|
||||
// TreeAt 递归列出指定目录中的普通文件和子目录,路径相对此目录;空路径表示仓库根,不公开隐藏条目或符号链接。
|
||||
func (v *Vault) TreeAt(rel string) ([]Entry, error) {
|
||||
root := v.root
|
||||
if rel != "" {
|
||||
var err error
|
||||
root, err = v.abs(rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
entries := []Entry{}
|
||||
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p == root {
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(d.Name(), ".") {
|
||||
if d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if d.Type()&fs.ModeSymlink != 0 {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() && !info.Mode().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
name, err := filepath.Rel(root, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entries = append(entries, Entry{Path: filepath.ToSlash(name), Dir: info.IsDir(), Size: info.Size(), ModTime: info.ModTime().UnixMilli(), Info: info})
|
||||
return nil
|
||||
})
|
||||
return entries, err
|
||||
}
|
||||
|
||||
// ScopedPath 校验公开访问的子路径,并阻止符号链接跳出已分享的目录。
|
||||
func (v *Vault) ScopedPath(root, rel string) (string, error) {
|
||||
if _, err := CleanPath(rel); err != nil {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
base, err := v.abs(root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
base, err = filepath.EvalSymlinks(base)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
p := path.Join(root, rel)
|
||||
full, err := v.abs(p)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
full, err = filepath.EvalSymlinks(full)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if full != filepath.Join(base, filepath.FromSlash(rel)) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// ---- 移动 / 重命名 ----
|
||||
|
||||
// Move 移动或重命名笔记/文件夹。笔记的 .assets 目录随之移动;笔记改名时同步改写正文中指向自身图片目录的链接
|
||||
func (v *Vault) Move(from, to string) (err error) {
|
||||
// Move 移动或重命名笔记/文件夹,返回是否原子改写了正文(改变文件身份)。
|
||||
// 笔记的 .assets 目录随之移动;笔记改名时同步改写正文中指向自身图片目录的链接。
|
||||
func (v *Vault) Move(from, to string) (rewritten bool, err error) {
|
||||
src, err := v.abs(from)
|
||||
if err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
dst, err := v.abs(to)
|
||||
if err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
if from == to || strings.HasPrefix(to, from+"/") {
|
||||
return ErrInvalidPath
|
||||
return false, ErrInvalidPath
|
||||
}
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
info, err := os.Lstat(src)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return ErrNotFound
|
||||
return false, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
isNote := !info.IsDir()
|
||||
if isNote && (!IsNote(from) || !IsNote(to)) {
|
||||
return ErrInvalidPath
|
||||
return false, ErrInvalidPath
|
||||
}
|
||||
if _, err := os.Lstat(dst); err == nil {
|
||||
return ErrExists
|
||||
return false, ErrExists
|
||||
}
|
||||
srcAssets, dstAssets := "", ""
|
||||
if isNote {
|
||||
if st, err := os.Stat(AssetsDir(src)); err == nil && st.IsDir() {
|
||||
srcAssets, dstAssets = AssetsDir(src), AssetsDir(dst)
|
||||
if _, err := os.Lstat(dstAssets); err == nil {
|
||||
return ErrExists
|
||||
return false, ErrExists
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
if err := os.Rename(src, dst); err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
assetsMoved := false
|
||||
defer func() {
|
||||
@@ -397,7 +474,7 @@ func (v *Vault) Move(from, to string) (err error) {
|
||||
}()
|
||||
if srcAssets != "" {
|
||||
if err := os.Rename(srcAssets, dstAssets); err != nil {
|
||||
return fmt.Errorf("移动图片目录失败: %w", err)
|
||||
return false, fmt.Errorf("移动图片目录失败: %w", err)
|
||||
}
|
||||
assetsMoved = true
|
||||
oldBase := strings.TrimSuffix(path.Base(from), NoteExt)
|
||||
@@ -405,14 +482,14 @@ func (v *Vault) Move(from, to string) (err error) {
|
||||
if oldBase != newBase {
|
||||
data, err := os.ReadFile(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
if out := RewriteAssetLinks(data, oldBase, newBase); !bytes.Equal(out, data) {
|
||||
return atomicWrite(dst, out)
|
||||
return true, atomicWrite(dst, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// ---- 回收站 ----
|
||||
|
||||
@@ -70,7 +70,7 @@ func TestMoveRollsBackWhenAssetDestinationIsTooLong(t *testing.T) {
|
||||
}
|
||||
// 笔记名可写入,但派生的 .assets 名超过文件系统的单段长度限制。
|
||||
destination := strings.Repeat("a", 250) + ".md"
|
||||
if err := v.Move("n.md", destination); err == nil {
|
||||
if _, err := v.Move("n.md", destination); err == nil {
|
||||
t.Fatal("图片目录移动应失败")
|
||||
}
|
||||
if content, _, err := v.Read("n.md"); err != nil || string(content) != "原文" {
|
||||
@@ -170,8 +170,8 @@ func TestMoveCarriesAssetsAndRewritesLinks(t *testing.T) {
|
||||
if !strings.HasPrefix(link, "笔记%20A.assets/") {
|
||||
t.Fatalf("资源链接不符: %s", link)
|
||||
}
|
||||
if err := v.Move("dir/笔记 A.md", "other/B.md"); err != nil {
|
||||
t.Fatal(err)
|
||||
if rewritten, err := v.Move("dir/笔记 A.md", "other/B.md"); err != nil || !rewritten {
|
||||
t.Fatalf("应报告正文改写: %v %v", rewritten, err)
|
||||
}
|
||||
data, _, err := v.Read("other/B.md")
|
||||
if err != nil {
|
||||
@@ -184,7 +184,7 @@ func TestMoveCarriesAssetsAndRewritesLinks(t *testing.T) {
|
||||
if _, err := os.Stat(filepath.Join(v.Root(), "other", "B.assets", strings.TrimPrefix(link, "笔记%20A.assets/"))); err != nil {
|
||||
t.Fatal("图片目录未随笔记移动:", err)
|
||||
}
|
||||
if err := v.Move("other", "other/sub"); !errors.Is(err, ErrInvalidPath) {
|
||||
if _, err := v.Move("other", "other/sub"); !errors.Is(err, ErrInvalidPath) {
|
||||
t.Fatal("文件夹移入自身应被拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
+63
-22
@@ -7,6 +7,8 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
@@ -26,6 +28,7 @@ type fileState struct {
|
||||
size int64
|
||||
mtime int64
|
||||
rev string
|
||||
info os.FileInfo
|
||||
}
|
||||
|
||||
type Watcher struct {
|
||||
@@ -102,8 +105,17 @@ func (w *Watcher) rescan() {
|
||||
func notes(snap map[string]fileState) map[string]string {
|
||||
out := make(map[string]string, len(snap))
|
||||
for p, st := range snap {
|
||||
if !st.dir {
|
||||
out[p] = st.rev
|
||||
if !st.dir && vault.IsNote(p) {
|
||||
visible := true
|
||||
for _, segment := range strings.Split(p, "/") {
|
||||
if strings.HasSuffix(segment, vault.AssetsExt) {
|
||||
visible = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if visible {
|
||||
out[p] = st.rev
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
@@ -111,25 +123,27 @@ func notes(snap map[string]fileState) map[string]string {
|
||||
|
||||
// scan 读取当前目录树;内容版本只对新出现或大小/修改时间变化的文件重新计算
|
||||
func (w *Watcher) scan() (map[string]fileState, error) {
|
||||
entries, err := w.v.Tree()
|
||||
entries, err := w.v.TreeAt("")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
next := make(map[string]fileState, len(entries))
|
||||
dirs := map[string]bool{w.v.Root(): true}
|
||||
for _, e := range entries {
|
||||
st := fileState{dir: e.Dir, size: e.Size, mtime: e.ModTime}
|
||||
st := fileState{dir: e.Dir, size: e.Size, mtime: e.Info.ModTime().UnixNano(), info: e.Info}
|
||||
abs := filepath.Join(w.v.Root(), filepath.FromSlash(e.Path))
|
||||
if e.Dir {
|
||||
dirs[abs] = true
|
||||
} else if old, ok := w.snap[e.Path]; ok && !old.dir && old.size == st.size && old.mtime == st.mtime {
|
||||
st.rev = old.rev
|
||||
} else {
|
||||
data, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
continue // 扫描期间被删除,下一轮会处理
|
||||
} else if vault.IsNote(e.Path) {
|
||||
if old, ok := w.snap[e.Path]; ok && !old.dir && old.size == st.size && old.mtime == st.mtime && os.SameFile(old.info, st.info) {
|
||||
st.rev = old.rev
|
||||
} else {
|
||||
data, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
continue // 扫描期间被删除,下一轮会处理
|
||||
}
|
||||
st.rev = vault.Rev(data)
|
||||
}
|
||||
st.rev = vault.Rev(data)
|
||||
}
|
||||
next[e.Path] = st
|
||||
}
|
||||
@@ -150,28 +164,36 @@ func (w *Watcher) scan() (map[string]fileState, error) {
|
||||
return next, nil
|
||||
}
|
||||
|
||||
// diff 比较两次快照:内容相同的"删除 + 新增"视为移动
|
||||
// diff 按文件系统身份识别移动,内容相同不代表同一个分享主体。
|
||||
func diff(prev, next map[string]fileState) []events.Event {
|
||||
var evs []events.Event
|
||||
structural := false
|
||||
removed := map[string][]string{} // rev -> 被删除的路径(内容相同的文件可能有多个)
|
||||
removed := map[string]fileState{}
|
||||
for p, old := range prev {
|
||||
if _, ok := next[p]; ok {
|
||||
continue
|
||||
}
|
||||
structural = true
|
||||
if !old.dir {
|
||||
removed[old.rev] = append(removed[old.rev], p)
|
||||
}
|
||||
removed[p] = old
|
||||
}
|
||||
candidates := map[string]string{} // 新路径 -> 唯一的原路径
|
||||
destinations := map[string]int{}
|
||||
for p, cur := range next {
|
||||
old, existed := prev[p]
|
||||
switch {
|
||||
case !existed:
|
||||
structural = true
|
||||
if froms := removed[cur.rev]; len(froms) > 0 && !cur.dir {
|
||||
removed[cur.rev] = froms[1:]
|
||||
evs = append(evs, events.Event{Type: "moved", From: froms[0], Path: p, Rev: cur.rev})
|
||||
from := ""
|
||||
matches := 0
|
||||
for oldPath, old := range removed {
|
||||
if os.SameFile(old.info, cur.info) {
|
||||
from = oldPath
|
||||
matches++
|
||||
}
|
||||
}
|
||||
if matches == 1 {
|
||||
candidates[p] = from
|
||||
destinations[from]++
|
||||
}
|
||||
case old.dir != cur.dir:
|
||||
structural = true
|
||||
@@ -179,11 +201,30 @@ func diff(prev, next map[string]fileState) []events.Event {
|
||||
evs = append(evs, events.Event{Type: "changed", Path: p, Rev: cur.rev})
|
||||
}
|
||||
}
|
||||
for _, paths := range removed {
|
||||
for _, p := range paths {
|
||||
evs = append(evs, events.Event{Type: "deleted", Path: p})
|
||||
var moves []events.Event
|
||||
for to, from := range candidates {
|
||||
if destinations[from] == 1 {
|
||||
delete(removed, from)
|
||||
moves = append(moves, events.Event{Type: "moved", From: from, Path: to, Rev: next[to].rev})
|
||||
}
|
||||
}
|
||||
sort.Slice(moves, func(i, j int) bool { return len(moves[i].From) < len(moves[j].From) })
|
||||
var emitted []events.Event
|
||||
for _, move := range moves {
|
||||
for _, parent := range emitted {
|
||||
if strings.HasPrefix(move.From, parent.From+"/") {
|
||||
move.From = parent.Path + strings.TrimPrefix(move.From, parent.From)
|
||||
}
|
||||
}
|
||||
// 先应用父目录映射;子项另有改名时,从映射后的路径继续移动。
|
||||
if move.From != move.Path {
|
||||
emitted = append(emitted, move)
|
||||
}
|
||||
}
|
||||
evs = append(emitted, evs...)
|
||||
for p := range removed {
|
||||
evs = append(evs, events.Event{Type: "deleted", Path: p})
|
||||
}
|
||||
if structural {
|
||||
evs = append(evs, events.Event{Type: "tree"})
|
||||
}
|
||||
|
||||
@@ -1,44 +1,61 @@
|
||||
package watch
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDiff(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(root, "d"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, p := range []string{"d/a.md", "b.md", "c.md", "c2.md"} {
|
||||
if err := os.WriteFile(filepath.Join(root, p), []byte("相同内容"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
state := func(p, rev string) fileState {
|
||||
info, err := os.Stat(filepath.Join(root, p))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return fileState{dir: info.IsDir(), rev: rev, info: info}
|
||||
}
|
||||
prev := map[string]fileState{
|
||||
"d": {dir: true},
|
||||
"d/a.md": {rev: "r1"},
|
||||
"b.md": {rev: "r2"},
|
||||
"c.md": {rev: "same"},
|
||||
"c2.md": {rev: "same"},
|
||||
"d": state("d", ""),
|
||||
"d/a.md": state("d/a.md", "r1"),
|
||||
"b.md": state("b.md", "r2"),
|
||||
"c.md": state("c.md", "same"),
|
||||
"c2.md": state("c2.md", "same"),
|
||||
}
|
||||
for from, to := range map[string]string{"d/a.md": "d/x.md", "c2.md": "e.md"} {
|
||||
if err := os.Rename(filepath.Join(root, from), filepath.Join(root, to)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
next := map[string]fileState{
|
||||
"d": {dir: true},
|
||||
"d/x.md": {rev: "r1"}, // 由 d/a.md 移动而来
|
||||
"b.md": {rev: "r3"}, // 内容修改
|
||||
"e.md": {rev: "same"}, // c.md 与 c2.md 内容相同:其一视为移动到此,另一个为删除
|
||||
"d": state("d", ""),
|
||||
"d/x.md": state("d/x.md", "r1"),
|
||||
"b.md": state("b.md", "r3"),
|
||||
"e.md": state("e.md", "same"),
|
||||
}
|
||||
var got []string
|
||||
for _, e := range diff(prev, next) {
|
||||
f := []string{e.Type}
|
||||
if e.From != "" {
|
||||
f = append(f, "c*")
|
||||
if !strings.HasPrefix(e.From, "c") {
|
||||
f[1] = e.From
|
||||
}
|
||||
f = append(f, e.From)
|
||||
}
|
||||
if e.Path != "" {
|
||||
f = append(f, e.Path)
|
||||
if e.Type == "deleted" && strings.HasPrefix(e.Path, "c") {
|
||||
f[1] = "c*"
|
||||
}
|
||||
}
|
||||
got = append(got, strings.Join(f, " "))
|
||||
}
|
||||
sort.Strings(got)
|
||||
want := "changed b.md|deleted c*|moved c* e.md|moved d/a.md d/x.md|tree"
|
||||
want := "changed b.md|deleted c.md|moved c2.md e.md|moved d/a.md d/x.md|tree"
|
||||
if strings.Join(got, "|") != want {
|
||||
t.Fatalf("事件不符:\n got %q\nwant %q", strings.Join(got, "|"), want)
|
||||
}
|
||||
@@ -46,3 +63,53 @@ func TestDiff(t *testing.T) {
|
||||
t.Fatalf("快照相同不应产生事件: %v", evs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectoryMovesCoverChildrenAndEmptyDirectories(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, "from", "empty"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "from", "a.md"), []byte("正文"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot := func(dir string) map[string]fileState {
|
||||
out := map[string]fileState{}
|
||||
for _, rel := range []string{dir, dir + "/empty", dir + "/a.md"} {
|
||||
info, err := os.Stat(filepath.Join(root, rel))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out[rel] = fileState{dir: info.IsDir(), info: info, rev: "same"}
|
||||
}
|
||||
return out
|
||||
}
|
||||
prev := snapshot("from")
|
||||
if err := os.Rename(filepath.Join(root, "from"), filepath.Join(root, "to")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
events := diff(prev, snapshot("to"))
|
||||
if len(events) != 2 || events[0].Type != "moved" || events[0].From != "from" || events[0].Path != "to" || events[1].Type != "tree" {
|
||||
t.Fatalf("目录移动应只产生一次路径映射: %v", events)
|
||||
}
|
||||
next := snapshot("to")
|
||||
if err := os.Rename(filepath.Join(root, "to", "a.md"), filepath.Join(root, "to", "b.md")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
next["to/b.md"] = next["to/a.md"]
|
||||
delete(next, "to/a.md")
|
||||
events = diff(prev, next)
|
||||
if len(events) != 3 || events[0].From != "from" || events[0].Path != "to" || events[1].From != "to/a.md" || events[1].Path != "to/b.md" || events[2].Type != "tree" {
|
||||
t.Fatalf("同一批次内子项改名应接续父目录映射: %v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotesExcludeResourceContents(t *testing.T) {
|
||||
indexed := notes(map[string]fileState{
|
||||
"笔记.md": {rev: "r1"},
|
||||
"笔记.assets/说明.md": {rev: "r2"},
|
||||
"附件.txt": {},
|
||||
})
|
||||
if len(indexed) != 1 || indexed["笔记.md"] != "r1" {
|
||||
t.Fatalf("跟踪普通文件移动不能改变笔记索引范围: %v", indexed)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user