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) {
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
View File
@@ -37,6 +37,33 @@ type fixture struct {
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) {
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
View File
@@ -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
View File
@@ -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 接口中处理,