Support live recursive folder shares and follow subject moves

This commit is contained in:
eezd committed 2026-10-20 17:20:23 +08:00
1 parent 89472fe6fd
commit 84e89c8fe4
17 files changed
+620 -127

No files matched your search

+7 -2
View File
@@ -355,17 +355,22 @@ func (s *Server) move(w http.ResponseWriter, r *http.Request) {
if !readJSON(w, r, &body) { if !readJSON(w, r, &body) {
return 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) vaultError(w, r, err)
return return
} }
if err := s.store.MoveShares(body.From, body.To); err != nil { 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)) err = errors.Join(err, fmt.Errorf("回滚笔记移动失败: %w", rollback))
} }
internalError(w, r, err) internalError(w, r, err)
return 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}) writeJSON(w, 200, map[string]bool{"ok": true})
} }
+123 -6
View File
@@ -37,6 +37,33 @@ type fixture struct {
admin *http.Client admin *http.Client
} }
func TestMoveBroadcastsWhenAssetRewriteReplacesFileIdentity(t *testing.T) {
f := newFixture(t)
f.write("from.md", "![](from.assets/x.png)")
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) { func TestMoveRollsBackWhenShareUpdateFails(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
v, err := vault.Open(filepath.Join(dir, "vault")) 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) f := newFixture(t)
name := "计划 #1? 50% +;\"" name := "计划 #1? 50% +;\""
p := "目录/" + name + ".md" p := "目录/" + name + ".md"
@@ -363,10 +390,9 @@ func TestShareFilenameToken(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
sh := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", string(body), nil)) 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(sh.Token)
random, err := base64.RawURLEncoding.DecodeString(suffix) if err != nil || len(random) != 16 {
if !ok || err != nil || len(random) != 16 { t.Fatal("分享标识应为 128 位随机字符,不包含文件名称或目录")
t.Fatal("分享应包含完整文件名和 128 位随机字符,不包含目录")
} }
other := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", string(body), nil)) other := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", string(body), nil))
if other.Token == sh.Token { 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) { func TestShareWithoutPasswordAndExpiry(t *testing.T) {
f := newFixture(t) f := newFixture(t)
f.write("a.md", "公开") 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 { if code := f.status(newClient(), "GET", "/api/v1/public/shares/expired", "", nil); code != 404 {
t.Errorf("过期分享应为 404,得到 %d", code) 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 { if code := f.status(f.admin, "POST", "/api/v1/shares", body, nil); code != 400 {
t.Errorf("%s 应为 400,得到 %d", body, code) t.Errorf("%s 应为 400,得到 %d", body, code)
} }
+43 -12
View File
@@ -1046,7 +1046,7 @@
} }
}, },
"post": { "post": {
"summary": "创建只读分享", "summary": "创建文件或文件夹的只读分享",
"tags": [ "tags": [
"分享" "分享"
], ],
@@ -1063,7 +1063,8 @@
"type": "object", "type": "object",
"properties": { "properties": {
"path": { "path": {
"type": "string" "type": "string",
"description": "仓库内的文件或文件夹;文件夹递归公开当前所有普通文件和子目录,含后续新增或移入的内容"
}, },
"password": { "password": {
"type": "string", "type": "string",
@@ -1083,7 +1084,7 @@
}, },
"responses": { "responses": {
"200": { "200": {
"description": "新分享,token 为文件名(不含 .md)加连字符和随机字符,访问地址为 /s/{URL 编码后的 token};文件移动或改名不改变已生成的链接", "description": "新分享,token 为 128 位随机数的 URL 安全编码,访问地址为 /s/{token};移动分享主体不改变链接",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
@@ -1093,7 +1094,7 @@
} }
}, },
"400": { "400": {
"description": "不是笔记", "description": "路径或参数无效",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
@@ -1314,10 +1315,18 @@
"schema": { "schema": {
"type": "string" "type": "string"
} }
},
{
"name": "path",
"in": "query",
"description": "文件夹分享中相对主体的子路径;省略时读取主体本身,不允许跳出分享范围",
"schema": {
"type": "string"
}
} }
], ],
"get": { "get": {
"summary": "访客读取分享的笔记", "summary": "访客读取分享主体或其目录中的当前内容",
"tags": [ "tags": [
"分享(公开)" "分享(公开)"
], ],
@@ -1326,7 +1335,7 @@
], ],
"responses": { "responses": {
"200": { "200": {
"description": "笔记", "description": "目录返回 dir=true 及递归 entries(路径相对此目录);Markdown 返回 dir=false、content 与 updatedAt;其他文件返回 dir=false,内容经 assets 接口读取或下载",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
@@ -1340,6 +1349,15 @@
}, },
"updatedAt": { "updatedAt": {
"type": "integer" "type": "integer"
},
"dir": {
"type": "boolean"
},
"entries": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Entry"
}
} }
} }
} }
@@ -1365,7 +1383,7 @@
} }
}, },
"404": { "404": {
"description": "分享不存在、已过期或笔记已删除", "description": "分享不存在、已过期、主体已删除或子路径不在分享范围内",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
@@ -1472,14 +1490,25 @@
"name": "path", "name": "path",
"in": "path", "in": "path",
"required": true, "required": true,
"description": "相对笔记所在目录的图片路径,只能位于该笔记的 .assets 目录", "description": "文件夹分享中相对主体的文件路径;单文件分享仅允许其 .assets 图片,空路径读取该文件本身",
"schema": { "schema": {
"type": "string" "type": "string"
} }
},
{
"name": "download",
"in": "query",
"description": "设为 1 时以附件下载;其他情况下以带沙箱 CSP 的原始文件返回",
"schema": {
"type": "string",
"enum": [
"1"
]
}
} }
], ],
"get": { "get": {
"summary": "访客读取分享笔记中的图片", "summary": "访客读取或下载分享范围内的普通文件",
"tags": [ "tags": [
"分享(公开)" "分享(公开)"
], ],
@@ -2163,10 +2192,12 @@
"type": "object", "type": "object",
"properties": { "properties": {
"token": { "token": {
"type": "string" "type": "string",
"description": "新分享使用完全随机的 URL 安全标识,不包含文件名称或目录"
}, },
"path": { "path": {
"type": "string" "type": "string",
"description": "分享主体在仓库中的路径(只在登录后的管理接口返回)"
}, },
"createdAt": { "createdAt": {
"type": "integer" "type": "integer"
@@ -2180,7 +2211,7 @@
}, },
"exists": { "exists": {
"type": "boolean", "type": "boolean",
"description": "笔记是否仍存在" "description": "分享的文件或文件夹是否仍存在"
} }
} }
}, },
+68 -20
View File
@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"log/slog" "log/slog"
"mime"
"net/http" "net/http"
"path" "path"
"strings" "strings"
@@ -25,7 +26,7 @@ const shareCookieTTL = 30 * 24 * time.Hour
type shareView struct { type shareView struct {
store.Share store.Share
HasPassword bool `json:"hasPassword"` HasPassword bool `json:"hasPassword"`
// 笔记是否仍存在(被删除或在外部改名后为 false) // 分享主体是否仍存在。
Exists bool `json:"exists"` Exists bool `json:"exists"`
} }
@@ -62,8 +63,8 @@ func (s *Server) createShare(w http.ResponseWriter, r *http.Request) {
return return
} }
p, err := vault.CleanPath(body.Path) p, err := vault.CleanPath(body.Path)
if err != nil || !vault.IsNote(p) { if err != nil {
writeError(w, 400, "只能分享笔记") writeError(w, 400, "分享路径无效")
return return
} }
if _, err := s.vault.Stat(p); err != nil { 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() now := time.Now()
sh := store.Share{ sh := store.Share{
Token: strings.TrimSuffix(path.Base(p), vault.NoteExt) + "-" + auth.NewShareToken(), Token: auth.NewShareToken(),
Path: p, Path: p,
PasswordHash: auth.HashSharePassword(body.Password), PasswordHash: auth.HashSharePassword(body.Password),
CreatedAt: now.UnixMilli(), CreatedAt: now.UnixMilli(),
@@ -147,30 +148,55 @@ func (s *Server) loadShare(token string) (store.Share, bool, error) {
return sh, true, nil return sh, true, nil
} }
// publicShare 返回笔记标题与当前内容(不含所在目录,避免泄露仓库结构) // publicShare 只公开主体本身或分享目录内的内容,不返回主体在仓库中的路径。
func (s *Server) publicShare(w http.ResponseWriter, r *http.Request) { func (s *Server) publicShare(w http.ResponseWriter, r *http.Request) {
sh, ok := s.openShare(w, r) sh, ok := s.openShare(w, r)
if !ok { if !ok {
return 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) info, err := s.vault.Stat(sh.Path)
if err != nil { if err != nil {
vaultError(w, r, err) vaultError(w, r, err)
return 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{ 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), "content": string(data),
"updatedAt": info.ModTime().UnixMilli(), "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}) writeJSON(w, 200, map[string]bool{"ok": true})
} }
// publicShareAsset 输出分享笔记中的图片:路径相对笔记所在目录,且只能是该笔记 .assets 目录内的文件 // publicShareAsset 提供主体允许范围内的普通文件;单文件分享额外允许其 .assets。
func (s *Server) publicShareAsset(w http.ResponseWriter, r *http.Request) { func (s *Server) publicShareAsset(w http.ResponseWriter, r *http.Request) {
sh, ok := s.openShare(w, r) sh, ok := s.openShare(w, r)
if !ok { if !ok {
return return
} }
rel := path.Join(path.Dir(sh.Path), r.PathValue("path")) info, err := s.vault.Stat(sh.Path)
if !strings.HasPrefix(rel, vault.AssetsDir(sh.Path)+"/") { if err != nil {
writeError(w, 404, "文件不存在") vaultError(w, r, err)
return 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 接口中处理, // FollowMoves 让分享跟随在外部(如 Typora)完成的改名与移动;经 API 的移动已在 move 接口中处理,
+1 -1
View File
@@ -12,7 +12,7 @@ import (
// tree 目录结构变化(新增/删除/移动),客户端重新拉取目录树 // tree 目录结构变化(新增/删除/移动),客户端重新拉取目录树
// changed 笔记内容变化,Rev 为新版本 // changed 笔记内容变化,Rev 为新版本
// deleted 笔记被删除 // deleted 笔记被删除
// moved 笔记被移动(From -> Path),内容版本不变 // moved 文件或目录被移动(From -> Path),目录事件包含其子项的路径映射
// settings 应用设置变化,客户端重新读取 // settings 应用设置变化,客户端重新读取
type Event struct { type Event struct {
Type string `json:"type"` Type string `json:"type"`
+102 -25
View File
@@ -49,10 +49,11 @@ type Vault struct {
} }
type Entry struct { type Entry struct {
Path string `json:"path"` Path string `json:"path"`
Dir bool `json:"dir"` Dir bool `json:"dir"`
Size int64 `json:"size"` Size int64 `json:"size"`
ModTime int64 `json:"mtime"` // 毫秒 ModTime int64 `json:"mtime"` // 毫秒
Info fs.FileInfo `json:"-"`
} }
type TrashItem struct { type TrashItem struct {
@@ -168,17 +169,14 @@ func (v *Vault) Read(rel string) ([]byte, string, error) {
return data, Rev(data), nil return data, Rev(data), nil
} }
// Stat 返回笔记的文件信息;不存在时返回 ErrNotFound // Stat 返回普通文件或目录的信息;不存在时返回 ErrNotFound
func (v *Vault) Stat(rel string) (fs.FileInfo, error) { func (v *Vault) Stat(rel string) (fs.FileInfo, error) {
if !IsNote(rel) {
return nil, ErrInvalidPath
}
p, err := v.abs(rel) p, err := v.abs(rel)
if err != nil { if err != nil {
return nil, err return nil, err
} }
info, err := os.Stat(p) 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 nil, ErrNotFound
} }
return info, err return info, err
@@ -335,51 +333,130 @@ func (v *Vault) Tree() ([]Entry, error) {
return entries, err 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 目录随之移动;笔记改名时同步改写正文中指向自身图片目录的链接 // Move 移动或重命名笔记/文件夹,返回是否原子改写了正文(改变文件身份)。
func (v *Vault) Move(from, to string) (err error) { // 笔记的 .assets 目录随之移动;笔记改名时同步改写正文中指向自身图片目录的链接。
func (v *Vault) Move(from, to string) (rewritten bool, err error) {
src, err := v.abs(from) src, err := v.abs(from)
if err != nil { if err != nil {
return err return false, err
} }
dst, err := v.abs(to) dst, err := v.abs(to)
if err != nil { if err != nil {
return err return false, err
} }
if from == to || strings.HasPrefix(to, from+"/") { if from == to || strings.HasPrefix(to, from+"/") {
return ErrInvalidPath return false, ErrInvalidPath
} }
v.mu.Lock() v.mu.Lock()
defer v.mu.Unlock() defer v.mu.Unlock()
info, err := os.Lstat(src) info, err := os.Lstat(src)
if errors.Is(err, fs.ErrNotExist) { if errors.Is(err, fs.ErrNotExist) {
return ErrNotFound return false, ErrNotFound
} }
if err != nil { if err != nil {
return err return false, err
} }
isNote := !info.IsDir() isNote := !info.IsDir()
if isNote && (!IsNote(from) || !IsNote(to)) { if isNote && (!IsNote(from) || !IsNote(to)) {
return ErrInvalidPath return false, ErrInvalidPath
} }
if _, err := os.Lstat(dst); err == nil { if _, err := os.Lstat(dst); err == nil {
return ErrExists return false, ErrExists
} }
srcAssets, dstAssets := "", "" srcAssets, dstAssets := "", ""
if isNote { if isNote {
if st, err := os.Stat(AssetsDir(src)); err == nil && st.IsDir() { if st, err := os.Stat(AssetsDir(src)); err == nil && st.IsDir() {
srcAssets, dstAssets = AssetsDir(src), AssetsDir(dst) srcAssets, dstAssets = AssetsDir(src), AssetsDir(dst)
if _, err := os.Lstat(dstAssets); err == nil { if _, err := os.Lstat(dstAssets); err == nil {
return ErrExists return false, ErrExists
} }
} }
} }
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err return false, err
} }
if err := os.Rename(src, dst); err != nil { if err := os.Rename(src, dst); err != nil {
return err return false, err
} }
assetsMoved := false assetsMoved := false
defer func() { defer func() {
@@ -397,7 +474,7 @@ func (v *Vault) Move(from, to string) (err error) {
}() }()
if srcAssets != "" { if srcAssets != "" {
if err := os.Rename(srcAssets, dstAssets); err != nil { if err := os.Rename(srcAssets, dstAssets); err != nil {
return fmt.Errorf("移动图片目录失败: %w", err) return false, fmt.Errorf("移动图片目录失败: %w", err)
} }
assetsMoved = true assetsMoved = true
oldBase := strings.TrimSuffix(path.Base(from), NoteExt) oldBase := strings.TrimSuffix(path.Base(from), NoteExt)
@@ -405,14 +482,14 @@ func (v *Vault) Move(from, to string) (err error) {
if oldBase != newBase { if oldBase != newBase {
data, err := os.ReadFile(dst) data, err := os.ReadFile(dst)
if err != nil { if err != nil {
return err return false, err
} }
if out := RewriteAssetLinks(data, oldBase, newBase); !bytes.Equal(out, data) { if out := RewriteAssetLinks(data, oldBase, newBase); !bytes.Equal(out, data) {
return atomicWrite(dst, out) return true, atomicWrite(dst, out)
} }
} }
} }
return nil return false, nil
} }
// ---- 回收站 ---- // ---- 回收站 ----
+4 -4
View File
@@ -70,7 +70,7 @@ func TestMoveRollsBackWhenAssetDestinationIsTooLong(t *testing.T) {
} }
// 笔记名可写入,但派生的 .assets 名超过文件系统的单段长度限制。 // 笔记名可写入,但派生的 .assets 名超过文件系统的单段长度限制。
destination := strings.Repeat("a", 250) + ".md" 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("图片目录移动应失败") t.Fatal("图片目录移动应失败")
} }
if content, _, err := v.Read("n.md"); err != nil || string(content) != "原文" { 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/") { if !strings.HasPrefix(link, "笔记%20A.assets/") {
t.Fatalf("资源链接不符: %s", link) t.Fatalf("资源链接不符: %s", link)
} }
if err := v.Move("dir/笔记 A.md", "other/B.md"); err != nil { if rewritten, err := v.Move("dir/笔记 A.md", "other/B.md"); err != nil || !rewritten {
t.Fatal(err) t.Fatalf("应报告正文改写: %v %v", rewritten, err)
} }
data, _, err := v.Read("other/B.md") data, _, err := v.Read("other/B.md")
if err != nil { 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 { if _, err := os.Stat(filepath.Join(v.Root(), "other", "B.assets", strings.TrimPrefix(link, "笔记%20A.assets/"))); err != nil {
t.Fatal("图片目录未随笔记移动:", err) 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("文件夹移入自身应被拒绝") t.Fatal("文件夹移入自身应被拒绝")
} }
} }
+63 -22
View File
@@ -7,6 +7,8 @@ import (
"log/slog" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings"
"time" "time"
"github.com/fsnotify/fsnotify" "github.com/fsnotify/fsnotify"
@@ -26,6 +28,7 @@ type fileState struct {
size int64 size int64
mtime int64 mtime int64
rev string rev string
info os.FileInfo
} }
type Watcher struct { type Watcher struct {
@@ -102,8 +105,17 @@ func (w *Watcher) rescan() {
func notes(snap map[string]fileState) map[string]string { func notes(snap map[string]fileState) map[string]string {
out := make(map[string]string, len(snap)) out := make(map[string]string, len(snap))
for p, st := range snap { for p, st := range snap {
if !st.dir { if !st.dir && vault.IsNote(p) {
out[p] = st.rev 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 return out
@@ -111,25 +123,27 @@ func notes(snap map[string]fileState) map[string]string {
// scan 读取当前目录树;内容版本只对新出现或大小/修改时间变化的文件重新计算 // scan 读取当前目录树;内容版本只对新出现或大小/修改时间变化的文件重新计算
func (w *Watcher) scan() (map[string]fileState, error) { func (w *Watcher) scan() (map[string]fileState, error) {
entries, err := w.v.Tree() entries, err := w.v.TreeAt("")
if err != nil { if err != nil {
return nil, err return nil, err
} }
next := make(map[string]fileState, len(entries)) next := make(map[string]fileState, len(entries))
dirs := map[string]bool{w.v.Root(): true} dirs := map[string]bool{w.v.Root(): true}
for _, e := range entries { 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)) abs := filepath.Join(w.v.Root(), filepath.FromSlash(e.Path))
if e.Dir { if e.Dir {
dirs[abs] = true dirs[abs] = true
} else if old, ok := w.snap[e.Path]; ok && !old.dir && old.size == st.size && old.mtime == st.mtime { } else if vault.IsNote(e.Path) {
st.rev = old.rev if old, ok := w.snap[e.Path]; ok && !old.dir && old.size == st.size && old.mtime == st.mtime && os.SameFile(old.info, st.info) {
} else { st.rev = old.rev
data, err := os.ReadFile(abs) } else {
if err != nil { data, err := os.ReadFile(abs)
continue // 扫描期间被删除,下一轮会处理 if err != nil {
continue // 扫描期间被删除,下一轮会处理
}
st.rev = vault.Rev(data)
} }
st.rev = vault.Rev(data)
} }
next[e.Path] = st next[e.Path] = st
} }
@@ -150,28 +164,36 @@ func (w *Watcher) scan() (map[string]fileState, error) {
return next, nil return next, nil
} }
// diff 比较两次快照:内容相同的"删除 + 新增"视为移动 // diff 按文件系统身份识别移动,内容相同不代表同一个分享主体。
func diff(prev, next map[string]fileState) []events.Event { func diff(prev, next map[string]fileState) []events.Event {
var evs []events.Event var evs []events.Event
structural := false structural := false
removed := map[string][]string{} // rev -> 被删除的路径(内容相同的文件可能有多个) removed := map[string]fileState{}
for p, old := range prev { for p, old := range prev {
if _, ok := next[p]; ok { if _, ok := next[p]; ok {
continue continue
} }
structural = true structural = true
if !old.dir { removed[p] = old
removed[old.rev] = append(removed[old.rev], p)
}
} }
candidates := map[string]string{} // 新路径 -> 唯一的原路径
destinations := map[string]int{}
for p, cur := range next { for p, cur := range next {
old, existed := prev[p] old, existed := prev[p]
switch { switch {
case !existed: case !existed:
structural = true structural = true
if froms := removed[cur.rev]; len(froms) > 0 && !cur.dir { from := ""
removed[cur.rev] = froms[1:] matches := 0
evs = append(evs, events.Event{Type: "moved", From: froms[0], Path: p, Rev: cur.rev}) 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: case old.dir != cur.dir:
structural = true 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}) evs = append(evs, events.Event{Type: "changed", Path: p, Rev: cur.rev})
} }
} }
for _, paths := range removed { var moves []events.Event
for _, p := range paths { for to, from := range candidates {
evs = append(evs, events.Event{Type: "deleted", Path: p}) 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 { if structural {
evs = append(evs, events.Event{Type: "tree"}) evs = append(evs, events.Event{Type: "tree"})
} }
+84 -17
View File
@@ -1,44 +1,61 @@
package watch package watch
import ( import (
"os"
"path/filepath"
"sort" "sort"
"strings" "strings"
"testing" "testing"
) )
func TestDiff(t *testing.T) { 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{ prev := map[string]fileState{
"d": {dir: true}, "d": state("d", ""),
"d/a.md": {rev: "r1"}, "d/a.md": state("d/a.md", "r1"),
"b.md": {rev: "r2"}, "b.md": state("b.md", "r2"),
"c.md": {rev: "same"}, "c.md": state("c.md", "same"),
"c2.md": {rev: "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{ next := map[string]fileState{
"d": {dir: true}, "d": state("d", ""),
"d/x.md": {rev: "r1"}, // 由 d/a.md 移动而来 "d/x.md": state("d/x.md", "r1"),
"b.md": {rev: "r3"}, // 内容修改 "b.md": state("b.md", "r3"),
"e.md": {rev: "same"}, // c.md 与 c2.md 内容相同:其一视为移动到此,另一个为删除 "e.md": state("e.md", "same"),
} }
var got []string var got []string
for _, e := range diff(prev, next) { for _, e := range diff(prev, next) {
f := []string{e.Type} f := []string{e.Type}
if e.From != "" { if e.From != "" {
f = append(f, "c*") f = append(f, e.From)
if !strings.HasPrefix(e.From, "c") {
f[1] = e.From
}
} }
if e.Path != "" { if e.Path != "" {
f = append(f, 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, " ")) got = append(got, strings.Join(f, " "))
} }
sort.Strings(got) 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 { if strings.Join(got, "|") != want {
t.Fatalf("事件不符:\n got %q\nwant %q", 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) 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)
}
}
+3 -3
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import Icon from './Icon.vue' import Icon from './Icon.vue'
import { noteTitle } from './paths' import { basename } from './paths'
import SharePanel from './SharePanel.vue' import SharePanel from './SharePanel.vue'
defineProps<{ path: string }>() defineProps<{ path: string }>()
@@ -9,9 +9,9 @@ const emit = defineEmits<{ close: [] }>()
<template> <template>
<div class="rm-dialog-backdrop" @click.self="emit('close')"> <div class="rm-dialog-backdrop" @click.self="emit('close')">
<div class="rm-dialog rm-share-dialog" role="dialog" :aria-label="`分享“${noteTitle(path)}”`"> <div class="rm-dialog rm-share-dialog" role="dialog" :aria-label="`分享“${basename(path)}”`">
<div class="rm-dialog-head"> <div class="rm-dialog-head">
<h2>分享“{{ noteTitle(path) }}”</h2> <h2>分享“{{ basename(path) }}”</h2>
<button class="rm-icon-btn" title="关闭" @click="emit('close')"><Icon name="close" /></button> <button class="rm-icon-btn" title="关闭" @click="emit('close')"><Icon name="close" /></button>
</div> </div>
<SharePanel :path="path" /> <SharePanel :path="path" />
+5 -5
View File
@@ -5,7 +5,7 @@ import { api, ApiError, type Share } from './api'
import Icon from './Icon.vue' import Icon from './Icon.vue'
import { toast } from './workspace' import { toast } from './workspace'
// path 为空时列出全部分享(管理列表),否则管理这一篇笔记的分享 // path 为空时列出全部分享,否则管理此文件或文件夹的分享。
const props = defineProps<{ path: string | null }>() const props = defineProps<{ path: string | null }>()
const EXPIRY_OPTIONS = [ const EXPIRY_OPTIONS = [
@@ -77,21 +77,21 @@ onMounted(load)
</select> </select>
<button class="rm-btn rm-btn-primary" type="submit" :disabled="busy">创建链接</button> <button class="rm-btn rm-btn-primary" type="submit" :disabled="busy">创建链接</button>
</form> </form>
<p class="rm-share-note">分享页只读,显示笔记的当前内容及其 .assets 中的图片;拿到链接的人都能访问。</p> <p class="rm-share-note">分享只读并显示最新内容。分享文件夹会公开其中所有文件和子目录,包括以后新增或移入的内容;移出的内容不再公开。分享主体移动后链接保持有效。</p>
<p v-if="failed" class="rm-tree-empty">读取分享失败</p> <p v-if="failed" class="rm-tree-empty">读取分享失败</p>
<p v-else-if="shares && !shares.length" class="rm-tree-empty">{{ path ? '还没有分享链接' : '还没有分享任何笔记' }}</p> <p v-else-if="shares && !shares.length" class="rm-tree-empty">{{ path ? '还没有分享链接' : '还没有分享任何文件或文件夹' }}</p>
<ul v-else class="rm-trash-list"> <ul v-else class="rm-trash-list">
<li v-for="s in shares ?? []" :key="s.token"> <li v-for="s in shares ?? []" :key="s.token">
<Icon name="link" /> <Icon name="link" />
<div class="rm-trash-info"> <div class="rm-trash-info">
<span v-if="!path" class="rm-trash-path">{{ s.path.replace(/\.md$/, '') }}</span> <span v-if="!path" class="rm-trash-path">{{ s.path }}</span>
<span class="rm-share-link" :class="{ 'rm-trash-path': path }">{{ linkOf(s) }}</span> <span class="rm-share-link" :class="{ 'rm-trash-path': path }">{{ linkOf(s) }}</span>
<span class="rm-trash-time"> <span class="rm-trash-time">
{{ fmt.format(s.createdAt) }} 创建 {{ fmt.format(s.createdAt) }} 创建
<template v-if="s.hasPassword"> · 有密码</template> <template v-if="s.hasPassword"> · 有密码</template>
<template v-if="expired(s)"> · <span class="rm-share-bad">已过期</span></template> <template v-if="expired(s)"> · <span class="rm-share-bad">已过期</span></template>
<template v-else-if="s.expiresAt"> · {{ fmt.format(s.expiresAt) }} 到期</template> <template v-else-if="s.expiresAt"> · {{ fmt.format(s.expiresAt) }} 到期</template>
<template v-if="!s.exists"> · <span class="rm-share-bad">笔记不存在</span></template> <template v-if="!s.exists"> · <span class="rm-share-bad">文件或文件夹不存在</span></template>
</span> </span>
</div> </div>
<button class="rm-btn" @click="copy(s)">复制链接</button> <button class="rm-btn" @click="copy(s)">复制链接</button>
+1 -1
View File
@@ -150,7 +150,7 @@ function onDrop(e: DragEvent) {
</template> </template>
<button @click="runMenu('rename')"><Icon name="edit" />重命名</button> <button @click="runMenu('rename')"><Icon name="edit" />重命名</button>
<button @click="runMenu('move')"><Icon name="folder" />移动到…</button> <button @click="runMenu('move')"><Icon name="folder" />移动到…</button>
<button v-if="!node.dir" @click="runMenu('share')"><Icon name="link" />分享</button> <button @click="runMenu('share')"><Icon name="link" />分享</button>
<button class="danger" @click="runMenu('delete')"><Icon name="trash" />删除</button> <button class="danger" @click="runMenu('delete')"><Icon name="trash" />删除</button>
</div> </div>
<div v-if="menuOpen" class="rm-menu-backdrop" @click.stop="menuOpen = false" @contextmenu.prevent.stop="menuOpen = false" /> <div v-if="menuOpen" class="rm-menu-backdrop" @click.stop="menuOpen = false" @contextmenu.prevent.stop="menuOpen = false" />
+1 -1
View File
@@ -43,7 +43,7 @@ export interface Share {
// 0 为永久 // 0 为永久
expiresAt: number expiresAt: number
hasPassword: boolean hasPassword: boolean
// 笔记是否仍存在 // 分享的文件或文件夹是否仍存在
exists: boolean exists: boolean
} }
+22
View File
@@ -20,6 +20,28 @@ afterEach(() => {
}) })
describe('事件同步', () => { describe('事件同步', () => {
it('目录移动映射整个已打开的子树,并把未保存输入写入新路径', async () => {
const tree = vi.spyOn(api, 'tree').mockResolvedValue([{ path: 'from/note.md', dir: false, size: 0, mtime: 0 }])
vi.spyOn(api, 'settings').mockResolvedValue({})
vi.spyOn(api, 'plugins').mockResolvedValue([])
vi.spyOn(drafts, 'all').mockResolvedValue([])
vi.spyOn(drafts, 'put').mockImplementation(async (draft) => draft.path)
const saved = new Map<string, string>()
vi.spyOn(api, 'writeFile').mockImplementation(async (path, content) => { saved.set(path, content); return 'r2' })
await startWorkspace()
const v = await edit('from/note.md')
v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: '尚未保存的输入' } })
tree.mockResolvedValue([{ path: 'to/note.md', dir: false, size: 0, mtime: 0 }])
vi.mocked(connectEvents).mock.lastCall![0].onEvents([{ type: 'moved', from: 'from', path: 'to' }, { type: 'tree' }])
await vi.waitFor(() => expect(ws.active).toBe('to/note.md'))
expect(editorStateOf('from/note.md')).toBeUndefined()
expect(editorStateOf('to/note.md')!.doc.toString()).toBe('尚未保存的输入')
await flush('to/note.md')
expect(saved.get('to/note.md')).toBe('尚未保存的输入')
expect(saved.has('from/note.md')).toBe(false)
await closeTab('to/note.md')
})
it('正文同步后才通知插件,同步失败时不广播成功且不阻塞后续批次', async () => { it('正文同步后才通知插件,同步失败时不广播成功且不阻塞后续批次', async () => {
const entries = [{ path: 'sync.md', dir: false, size: 0, mtime: 0 }] const entries = [{ path: 'sync.md', dir: false, size: 0, mtime: 0 }]
vi.spyOn(api, 'tree').mockResolvedValue(entries) vi.spyOn(api, 'tree').mockResolvedValue(entries)
+1 -1
View File
@@ -783,7 +783,7 @@ async function handleEvents(evs: VaultEvent[]) {
// 自己保存产生的事件 rev 与本地一致;有本地修改时不覆盖,交由下次保存的冲突检测处理 // 自己保存产生的事件 rev 与本地一致;有本地修改时不覆盖,交由下次保存的冲突检测处理
if (tab && tab.rev !== ev.rev) await syncOpenTab(tab) if (tab && tab.rev !== ev.rev) await syncOpenTab(tab)
} else if (ev.type === 'moved') { } else if (ev.type === 'moved') {
if (tabOf(ev.from!) && !tabOf(ev.path!)) remapOpenNotes(ev.from!, ev.path!) if (affected(ev.from!).length && !affected(ev.path!).length) remapOpenNotes(ev.from!, ev.path!)
remapRecent(ev.from!, ev.path!) remapRecent(ev.from!, ev.path!)
} else if (ev.type === 'deleted') { } else if (ev.type === 'deleted') {
const tab = tabOf(ev.path!) const tab = tabOf(ev.path!)
+88 -7
View File
@@ -8,13 +8,29 @@ import './share.css'
interface SharedNote { interface SharedNote {
title: string title: string
dir: false
content: string content: string
updatedAt: number updatedAt: number
} }
interface SharedDirectory {
title: string
dir: true
entries: { path: string; dir: boolean }[]
}
interface SharedFile {
title: string
dir: false
}
const root = document.getElementById('share')! const root = document.getElementById('share')!
const token = decodeURIComponent(location.pathname.split('/')[2] ?? '') const token = decodeURIComponent(location.pathname.split('/')[2] ?? '')
const api = '/api/v1/public/shares/' + encodeURIComponent(token) const api = '/api/v1/public/shares/' + encodeURIComponent(token)
const selectedPath = new URL(location.href).searchParams.get('path') ?? ''
const encodePath = (path: string) => path.split('/').map(encodeURIComponent).join('/')
const pageUrl = (path: string) => location.pathname + (path ? '?path=' + encodeURIComponent(path) : '')
const downloadUrl = () => api + '/assets/' + encodePath(selectedPath) + '?download=1'
function message(text: string) { function message(text: string) {
root.replaceChildren(Object.assign(document.createElement('p'), { className: 'rm-share-message', textContent: text })) root.replaceChildren(Object.assign(document.createElement('p'), { className: 'rm-share-message', textContent: text }))
@@ -23,13 +39,13 @@ function message(text: string) {
async function load() { async function load() {
let res: Response let res: Response
try { try {
res = await fetch(api, { credentials: 'same-origin', cache: 'no-store' }) res = await fetch(api + (selectedPath ? '?path=' + encodeURIComponent(selectedPath) : ''), { credentials: 'same-origin', cache: 'no-store' })
} catch { } catch {
message('网络错误,请检查连接后刷新') message('网络错误,请检查连接后刷新')
return return
} }
if (res.ok) { if (res.ok) {
const note = (await res.json()) as SharedNote const content = (await res.json()) as SharedNote | SharedDirectory | SharedFile
let appearanceError = false let appearanceError = false
try { try {
const response = await fetch(api + '/appearance', { cache: 'no-store' }) const response = await fetch(api + '/appearance', { cache: 'no-store' })
@@ -42,11 +58,20 @@ async function load() {
} catch { } catch {
appearanceError = true appearanceError = true
} }
render(note) if (content.dir) renderDirectory(content)
else if ('content' in content) render(content)
else {
document.title = content.title
const link = Object.assign(document.createElement('a'), { href: downloadUrl(), download: content.title, textContent: '下载 ' + content.title })
root.replaceChildren(navigation(), link)
}
if (appearanceError) root.prepend(Object.assign(document.createElement('p'), { className: 'rm-share-error', textContent: '外观加载失败,可刷新重试' })) if (appearanceError) root.prepend(Object.assign(document.createElement('p'), { className: 'rm-share-error', textContent: '外观加载失败,可刷新重试' }))
} }
else if (res.status === 401) askPassword() else if (res.status === 401) askPassword()
else if (res.status === 404) message('分享不存在、已过期或已被撤销') else if (res.status === 404) {
message(selectedPath ? '此文件或文件夹已不存在,或不在分享范围内' : '分享不存在、已过期或已被撤销')
if (selectedPath) root.prepend(navigation())
}
else message(`加载失败(${res.status})`) else message(`加载失败(${res.status})`)
} }
@@ -83,6 +108,34 @@ function askPassword(error = '') {
const dateFormat = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeStyle: 'short' }) const dateFormat = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeStyle: 'short' })
function navigation() {
const nav = document.createElement('nav')
nav.className = 'rm-share-nav'
nav.setAttribute('aria-label', '分享导航')
nav.append(Object.assign(document.createElement('a'), { href: pageUrl(''), textContent: '分享首页' }))
const parts = selectedPath.split('/').filter(Boolean)
for (let i = 0; i < parts.length; i++) {
nav.append(' / ', Object.assign(document.createElement('a'), { href: pageUrl(parts.slice(0, i + 1).join('/')), textContent: parts[i] }))
}
return nav
}
function renderDirectory(directory: SharedDirectory) {
document.title = directory.title
const heading = Object.assign(document.createElement('h1'), { textContent: directory.title })
const list = document.createElement('ul')
list.className = 'rm-share-files'
for (const entry of directory.entries) {
const li = document.createElement('li')
li.style.paddingLeft = (entry.path.split('/').length - 1) * 16 + 'px'
const path = selectedPath ? selectedPath + '/' + entry.path : entry.path
li.append(Object.assign(document.createElement('a'), { href: pageUrl(path), textContent: entry.path.split('/').at(-1) + (entry.dir ? '/' : '') }))
list.append(li)
}
root.replaceChildren(navigation(), heading, list)
if (!directory.entries.length) root.append(Object.assign(document.createElement('p'), { textContent: '此文件夹为空' }))
}
function render(note: SharedNote) { function render(note: SharedNote) {
document.title = note.title document.title = note.title
const header = document.createElement('header') const header = document.createElement('header')
@@ -95,9 +148,10 @@ function render(note: SharedNote) {
article.className = 'rm-share-content' article.className = 'rm-share-content'
// 安全模式:原始 HTML 被转义,危险协议的链接被过滤 // 安全模式:原始 HTML 被转义,危险协议的链接被过滤
article.innerHTML = renderMarkdown(note.content) article.innerHTML = renderMarkdown(note.content)
root.replaceChildren(header, article) root.replaceChildren(...(selectedPath ? [navigation()] : []), header, article)
resolveImages(article) resolveImages(article)
resolveLinks(article)
buildToc(article) buildToc(article)
hydrate(article) hydrate(article)
for (const code of article.querySelectorAll<HTMLElement>('pre > code')) void decorateCode(code).catch((error) => { for (const code of article.querySelectorAll<HTMLElement>('pre > code')) void decorateCode(code).catch((error) => {
@@ -106,17 +160,44 @@ function render(note: SharedNote) {
}) })
} }
// 相对地址的图片(笔记的 .assets)经分享接口读取;服务器只允许读取本篇的图片目录 // 相对引用始终经当前分享读取,范围由服务器按分享主体校验。
function resourceUrl(src: string): URL | null {
try {
const prefix = new URL(api + '/assets/', location.origin)
const target = new URL(src, new URL(encodePath(selectedPath), prefix))
return target.origin === prefix.origin && target.pathname.startsWith(prefix.pathname) ? target : null
} catch { return null }
}
function resolveImages(article: HTMLElement) { function resolveImages(article: HTMLElement) {
for (const img of article.querySelectorAll('img')) { for (const img of article.querySelectorAll('img')) {
const src = img.getAttribute('src') ?? '' const src = img.getAttribute('src') ?? ''
if (src && !/^[a-z][a-z\d+.-]*:/i.test(src) && !src.startsWith('/') && !src.startsWith('#')) { if (src && !/^[a-z][a-z\d+.-]*:/i.test(src) && !src.startsWith('/') && !src.startsWith('#')) {
img.src = api + '/assets/' + src.replace(/^(\.\/)+/, '') img.src = resourceUrl(src)?.href ?? '#'
} }
img.loading = 'lazy' img.loading = 'lazy'
} }
} }
function resolveLinks(article: HTMLElement) {
for (const link of article.querySelectorAll<HTMLAnchorElement>('a[href]')) {
const href = link.getAttribute('href')!
if (!href || /^[a-z][a-z\d+.-]*:/i.test(href) || href.startsWith('/') || href.startsWith('#')) continue
const target = resourceUrl(href)
if (!target) { link.href = '#'; continue }
if (selectedPath) {
try {
const path = decodeURIComponent(target.pathname.slice((api + '/assets/').length)).replace(/\/$/, '')
link.href = pageUrl(path) + target.hash
} catch { link.href = '#' }
} else {
target.searchParams.set('download', '1')
link.href = target.href
link.download = ''
}
}
}
function buildToc(article: HTMLElement) { function buildToc(article: HTMLElement) {
const headings = [...article.querySelectorAll<HTMLElement>('h1, h2, h3, h4, h5, h6')] const headings = [...article.querySelectorAll<HTMLElement>('h1, h2, h3, h4, h5, h6')]
const used = new Set<string>() const used = new Set<string>()
+4
View File
@@ -9,6 +9,10 @@
} }
.rm-share-message { margin-top: 30vh; text-align: center; color: var(--rm-text-muted); } .rm-share-message { margin-top: 30vh; text-align: center; color: var(--rm-text-muted); }
.rm-share-nav { margin-bottom: 16px; color: var(--rm-text-muted); }
.rm-share-nav a, .rm-share-files a { color: var(--rm-accent-strong); }
.rm-share-files { list-style: none; padding: 0; }
.rm-share-files li { margin: 8px 0; }
.rm-share-password { display: flex; flex-direction: column; gap: 12px; max-width: 300px; margin: 25vh auto 0; } .rm-share-password { display: flex; flex-direction: column; gap: 12px; max-width: 300px; margin: 25vh auto 0; }
.rm-share-password p { margin: 0; } .rm-share-password p { margin: 0; }