From 828d50f2421a68cd06a4f80b42416f1cbd195223 Mon Sep 17 00:00:00 2001 From: eezd Date: Wed, 21 Oct 2026 16:16:53 +0800 Subject: [PATCH] Support live collection shares with stable member scopes --- internal/api/api.go | 2 +- internal/api/collections_test.go | 94 +++++++++++++++++++ internal/api/openapi.json | 91 ++++++++++++++++-- internal/api/shares.go | 152 ++++++++++++++++++++++++++----- internal/store/shares.go | 43 +++++++++ internal/store/shares_test.go | 50 ++++++++++ internal/store/store.go | 114 +++++++++++++++++------ web/src/app/ShareDialog.vue | 8 +- web/src/app/SharePanel.vue | 11 ++- web/src/app/TreeNode.vue | 5 +- web/src/app/api.ts | 7 +- web/src/app/workspace.ts | 2 +- web/src/share/main.ts | 56 ++++++++++-- 13 files changed, 551 insertions(+), 84 deletions(-) create mode 100644 internal/api/collections_test.go create mode 100644 internal/store/shares.go create mode 100644 internal/store/shares_test.go diff --git a/internal/api/api.go b/internal/api/api.go index ad17d0c..a0f22c8 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -52,7 +52,7 @@ type access int const ( public access = iota - // 登录会话或 API 令牌(只读令牌只能用 GET) + // 登录会话或 API 令牌(只读令牌可用 GET 和只读导出接口) authed // 只允许登录会话:管理分享与 API 令牌,避免令牌给自己扩权 sessionOnly diff --git a/internal/api/collections_test.go b/internal/api/collections_test.go new file mode 100644 index 0000000..2bbd7b2 --- /dev/null +++ b/internal/api/collections_test.go @@ -0,0 +1,94 @@ +package api + +import ( + "fmt" + "net/http" + "strings" + "testing" +) + +func TestCollectionShareFollowsEachSubjectAndKeepsItsScope(t *testing.T) { + f := newFixture(t) + f.write("私有/文章.md", "最新正文") + f.write("私有/秘密.md", "不能公开") + f.write("文件夹/内部.md", "内部正文") + sh := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", `{"paths":["文件夹/内部.md","私有/文章.md","文件夹"]}`, nil)) + if len(sh.Items) != 2 || sh.Items[0].Path != "文件夹" { + t.Fatalf("未消除重叠:%+v", sh) + } + base := "/api/v1/public/shares/" + sh.Token + guest := newClient() + root := decode[struct { + Collection bool + Items []struct { + ID int + Title string + Exists bool + } + }](t, f.do(guest, "GET", base, "", nil)) + if !root.Collection || len(root.Items) != 2 { + t.Fatalf("集合首页不符:%+v", root) + } + for _, item := range root.Items { + if strings.Contains(item.Title, "私有/") { + t.Fatal("公开了主体的父目录") + } + } + for _, endpoint := range []string{base + "?path=秘密.md", base + "?item=99", base + "?item=1&path=秘密.md", base + "/assets/../秘密.md?item=1", base + "/assets/秘密.md?item=1"} { + response := f.do(guest, "GET", endpoint, "", nil) + response.Body.Close() + if response.StatusCode == http.StatusOK { + t.Fatalf("越权访问成功:%s", endpoint) + } + } + if code := f.status(f.admin, "POST", "/api/v1/move", `{"from":"私有/文章.md","to":"移动后.md"}`, nil); code != 200 { + t.Fatalf("移动失败:%d", code) + } + content := decode[struct{ Content string }](t, f.do(guest, "GET", base+"?item=1", "", nil)) + if content.Content != "最新正文" { + t.Fatalf("未跟随主体:%+v", content) + } + // 内部文件不是独立主体,移出文件夹后不能继续从该主体读取。 + f.do(f.admin, "POST", "/api/v1/move", `{"from":"文件夹/内部.md","to":"移出.md"}`, nil).Body.Close() + if code := f.status(guest, "GET", base+"?item=0&path=内部.md", "", nil); code != 404 { + t.Fatalf("移出后仍公开:%d", code) + } + f.do(f.admin, "DELETE", "/api/v1/files/移动后.md", "", nil).Body.Close() + root = decode[struct { + Collection bool + Items []struct { + ID int + Title string + Exists bool + } + }](t, f.do(guest, "GET", base, "", nil)) + if !root.Items[0].Exists || root.Items[1].Exists { + t.Fatalf("部分删除状态不符:%+v", root) + } + if code := f.status(guest, "GET", base+"?item=0", "", nil); code != 200 { + t.Fatalf("部分删除影响其余主体:%d", code) + } +} + +func TestCollectionPasswordAssetsAndRevocation(t *testing.T) { + f := newFixture(t) + f.write("a.md", "文章") + f.write("目录/b.md", "文章二") + sh := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", `{"paths":["a.md","目录"],"password":"访问密码","expiresIn":86400}`, nil)) + base := "/api/v1/public/shares/" + sh.Token + guest := newClient() + if code := f.status(guest, "GET", base, "", nil); code != 401 { + t.Fatal(code) + } + f.do(guest, "POST", base+"/unlock", `{"password":"访问密码"}`, nil).Body.Close() + if code := f.status(guest, "GET", base+"?item=1&path=b.md", "", nil); code != 200 { + t.Fatal(code) + } + if code := f.status(guest, "GET", base+"/assets/b.md?item=1", "", nil); code != 200 { + t.Fatal(code) + } + f.do(f.admin, "DELETE", "/api/v1/shares/"+sh.Token, "", nil).Body.Close() + if code := f.status(guest, "GET", fmt.Sprintf("%s?item=1", base), "", nil); code != 404 { + t.Fatal(code) + } +} diff --git a/internal/api/openapi.json b/internal/api/openapi.json index 2615d5c..c218dcf 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -1175,10 +1175,38 @@ "expiresIn": { "type": "integer", "description": "有效时长(秒),0 为永久" + }, + "paths": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + }, + "description": "与 path 二选一;重叠路径只保留外层目录" } }, - "required": [ - "path" + "required": [], + "oneOf": [ + { + "required": [ + "path" + ], + "not": { + "required": [ + "paths" + ] + } + }, + { + "required": [ + "paths" + ], + "not": { + "required": [ + "path" + ] + } + } ] } } @@ -1215,7 +1243,8 @@ } } } - } + }, + "description": "创建一个随机只读链接。path 创建单主体分享;paths 创建集合。内容实时读取,主体移动后跟随,部分主体删除不影响其余项。" } }, "/shares/{token}": { @@ -1494,7 +1523,18 @@ } } } - } + }, + "parameters": [ + { + "name": "item", + "in": "query", + "schema": { + "type": "integer" + }, + "description": "集合分享的稳定成员标识;单主体分享可省略。子路径及附件始终限制在该主体原有范围内。" + } + ], + "description": "单主体沿用原响应。集合首页未指定 item 时返回 collection=true 及 items(仅含 id、title、dir、exists,不暴露仓库父路径);指定 item 后读取该主体,path 为其内部相对路径。" } }, "/public/shares/{token}/unlock": { @@ -1639,7 +1679,17 @@ } } } - } + }, + "parameters": [ + { + "name": "item", + "in": "query", + "schema": { + "type": "integer" + }, + "description": "集合分享的稳定成员标识;单主体分享可省略。子路径及附件始终限制在该主体原有范围内。" + } + ] } }, "/themes": { @@ -2503,7 +2553,7 @@ }, "path": { "type": "string", - "description": "分享主体在仓库中的路径(只在登录后的管理接口返回)" + "description": "单主体分享的路径;集合分享不返回此字段" }, "createdAt": { "type": "integer" @@ -2518,8 +2568,35 @@ "exists": { "type": "boolean", "description": "分享的文件或文件夹是否仍存在" + }, + "items": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "id", + "path", + "exists" + ], + "properties": { + "id": { + "type": "integer", + "description": "稳定的分享成员标识" + }, + "path": { + "type": "string" + }, + "exists": { + "type": "boolean" + } + } + } } - } + }, + "required": [ + "items" + ] }, "APIToken": { "type": "object", diff --git a/internal/api/shares.go b/internal/api/shares.go index fa1e769..8a822db 100644 --- a/internal/api/shares.go +++ b/internal/api/shares.go @@ -8,6 +8,8 @@ import ( "mime" "net/http" "path" + "sort" + "strconv" "strings" "time" @@ -23,16 +25,29 @@ const shareCookieName = "rawmark_share" // 设有密码的永久分享,访客凭据的有效期 const shareCookieTTL = 30 * 24 * time.Hour -type shareView struct { - store.Share - HasPassword bool `json:"hasPassword"` - // 分享主体是否仍存在。 +type shareItemView struct { + store.ShareItem Exists bool `json:"exists"` } +type shareView struct { + store.Share + Items []shareItemView `json:"items"` + HasPassword bool `json:"hasPassword"` + Exists bool `json:"exists"` +} -func (s *Server) viewOf(sh store.Share) shareView { - _, err := s.vault.Stat(sh.Path) - return shareView{Share: sh, HasPassword: sh.PasswordHash != "", Exists: err == nil} +func (s *Server) viewOf(sh store.Share) (shareView, error) { + view := shareView{Share: sh, HasPassword: sh.PasswordHash != "", Items: []shareItemView{}} + for _, item := range sh.Items { + _, err := s.vault.Stat(item.Path) + if err != nil && !errors.Is(err, vault.ErrNotFound) { + return view, err + } + exists := err == nil + view.Exists = view.Exists || exists + view.Items = append(view.Items, shareItemView{ShareItem: item, Exists: exists}) + } + return view, nil } // listShares 列出全部分享;带 ?path= 时只列出该笔记的 @@ -45,8 +60,19 @@ func (s *Server) listShares(w http.ResponseWriter, r *http.Request) { filter := r.URL.Query().Get("path") out := []shareView{} for _, sh := range all { - if filter == "" || sh.Path == filter { - out = append(out, s.viewOf(sh)) + matches := filter == "" + for _, item := range sh.Items { + if item.Path == filter { + matches = true + } + } + if matches { + view, err := s.viewOf(sh) + if err != nil { + vaultError(w, r, err) + return + } + out = append(out, view) } } writeJSON(w, 200, map[string]any{"shares": out}) @@ -55,6 +81,7 @@ func (s *Server) listShares(w http.ResponseWriter, r *http.Request) { func (s *Server) createShare(w http.ResponseWriter, r *http.Request) { var body struct { Path string + Paths []string Password string // 有效时长(秒),0 为永久 ExpiresIn int64 @@ -62,15 +89,42 @@ func (s *Server) createShare(w http.ResponseWriter, r *http.Request) { if !readJSON(w, r, &body) { return } - p, err := vault.CleanPath(body.Path) - if err != nil { - writeError(w, 400, "分享路径无效") + if body.Path != "" && len(body.Paths) != 0 { + writeError(w, 400, "path 和 paths 不能同时指定") return } - if _, err := s.vault.Stat(p); err != nil { - vaultError(w, r, err) + paths := body.Paths + if body.Path != "" { + paths = []string{body.Path} + } + if len(paths) == 0 { + writeError(w, 400, "请选择分享主体") return } + sort.Strings(paths) + roots := map[string]bool{} + items := []store.ShareItem{} + for _, p := range paths { + if _, err := vault.CleanPath(p); err != nil { + writeError(w, 400, "分享路径无效") + return + } + if _, err := s.vault.Stat(p); err != nil { + vaultError(w, r, err) + return + } + covered := roots[p] + for parent := path.Dir(p); parent != "."; parent = path.Dir(parent) { + if roots[parent] { + covered = true + break + } + } + if !covered { + roots[p] = true + items = append(items, store.ShareItem{ID: len(items), Path: p}) + } + } if body.ExpiresIn < 0 { writeError(w, 400, "有效期无效") return @@ -78,7 +132,7 @@ func (s *Server) createShare(w http.ResponseWriter, r *http.Request) { now := time.Now() sh := store.Share{ Token: auth.NewShareToken(), - Path: p, + Items: items, PasswordHash: auth.HashSharePassword(body.Password), CreatedAt: now.UnixMilli(), } @@ -89,7 +143,15 @@ func (s *Server) createShare(w http.ResponseWriter, r *http.Request) { internalError(w, r, err) return } - writeJSON(w, 200, s.viewOf(sh)) + if len(sh.Items) == 1 { + sh.Path = sh.Items[0].Path + } + view, err := s.viewOf(sh) + if err != nil { + vaultError(w, r, err) + return + } + writeJSON(w, 200, view) } func (s *Server) deleteShare(w http.ResponseWriter, r *http.Request) { @@ -154,18 +216,39 @@ func (s *Server) publicShare(w http.ResponseWriter, r *http.Request) { if !ok { return } - info, err := s.vault.Stat(sh.Path) + if len(sh.Items) > 1 && r.URL.Query().Get("item") == "" { + if r.URL.Query().Get("path") != "" { + writeError(w, 400, "请选择分享主体") + return + } + items := []map[string]any{} + for _, item := range sh.Items { + info, err := s.vault.Stat(item.Path) + if err != nil && !errors.Is(err, vault.ErrNotFound) { + vaultError(w, r, err) + return + } + items = append(items, map[string]any{"id": item.ID, "title": path.Base(item.Path), "exists": err == nil, "dir": err == nil && info.IsDir()}) + } + writeJSON(w, 200, map[string]any{"title": "分享的文件", "collection": true, "items": items}) + return + } + subject, ok := shareSubject(w, r, sh) + if !ok { + return + } + info, err := s.vault.Stat(subject) if err != nil { vaultError(w, r, err) return } - p := sh.Path + p := subject if rel := r.URL.Query().Get("path"); rel != "" { if !info.IsDir() { writeError(w, 404, "文件不存在") return } - p, err = s.vault.ScopedPath(sh.Path, rel) + p, err = s.vault.ScopedPath(subject, rel) if err == nil { info, err = s.vault.Stat(p) } @@ -255,7 +338,11 @@ func (s *Server) publicShareAsset(w http.ResponseWriter, r *http.Request) { if !ok { return } - info, err := s.vault.Stat(sh.Path) + subject, ok := shareSubject(w, r, sh) + if !ok { + return + } + info, err := s.vault.Stat(subject) if err != nil { vaultError(w, r, err) return @@ -263,13 +350,13 @@ func (s *Server) publicShareAsset(w http.ResponseWriter, r *http.Request) { rel := r.PathValue("path") var target string if info.IsDir() { - target, err = s.vault.ScopedPath(sh.Path, rel) + target, err = s.vault.ScopedPath(subject, rel) } else if rel == "" { - target = sh.Path + target = subject } else { - assets := vault.AssetsDir(sh.Path) + assets := vault.AssetsDir(subject) prefix := path.Base(assets) + "/" - if !vault.IsNote(sh.Path) || !strings.HasPrefix(rel, prefix) { + if !vault.IsNote(subject) || !strings.HasPrefix(rel, prefix) { writeError(w, 404, "文件不存在") return } @@ -285,6 +372,23 @@ func (s *Server) publicShareAsset(w http.ResponseWriter, r *http.Request) { s.serveRaw(w, r, target) } +func shareSubject(w http.ResponseWriter, r *http.Request, sh store.Share) (string, bool) { + raw := r.URL.Query().Get("item") + if raw == "" && len(sh.Items) == 1 { + return sh.Items[0].Path, true + } + id, err := strconv.Atoi(raw) + if err == nil { + for _, item := range sh.Items { + if item.ID == id { + return item.Path, true + } + } + } + writeError(w, 404, "分享主体不存在") + return "", false +} + // FollowMoves 让分享跟随在外部(如 Typora)完成的改名与移动;经 API 的移动已在 move 接口中处理, // 这里再次处理时原路径已无分享,不会重复修改 func (s *Server) FollowMoves(ctx context.Context) { diff --git a/internal/store/shares.go b/internal/store/shares.go new file mode 100644 index 0000000..a8c6b74 --- /dev/null +++ b/internal/store/shares.go @@ -0,0 +1,43 @@ +package store + +import "database/sql" + +const shareSchema = ` +-- 分享令牌是可复制的访问凭据,与笔记同等敏感。 +CREATE TABLE IF NOT EXISTS shares ( + token TEXT PRIMARY KEY, + password_hash TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS share_items ( + share_token TEXT NOT NULL REFERENCES shares(token) ON DELETE CASCADE, + item_id INTEGER NOT NULL, + path TEXT NOT NULL, + PRIMARY KEY (share_token,item_id) +); +CREATE INDEX IF NOT EXISTS share_items_path ON share_items(path); +` + +// 仅迁移项目已有的单路径表;事务保证中断或失败时仍保留原格式。 +func migrateShares(db *sql.DB) error { + var legacy bool + if err := db.QueryRow(`SELECT EXISTS(SELECT 1 FROM pragma_table_info('shares') WHERE name = 'path')`).Scan(&legacy); err != nil { + return err + } + if !legacy { + return nil + } + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err = tx.Exec(`ALTER TABLE shares RENAME TO shares_legacy;` + shareSchema + ` + INSERT INTO shares(token,password_hash,created_at,expires_at) SELECT token,password_hash,created_at,expires_at FROM shares_legacy; + INSERT INTO share_items(share_token,item_id,path) SELECT token,0,path FROM shares_legacy; + DROP TABLE shares_legacy;`); err != nil { + return err + } + return tx.Commit() +} diff --git a/internal/store/shares_test.go b/internal/store/shares_test.go new file mode 100644 index 0000000..4f8431c --- /dev/null +++ b/internal/store/shares_test.go @@ -0,0 +1,50 @@ +package store + +import ( + "database/sql" + "path/filepath" + "testing" +) + +func TestMigrateSingleSharesAndPreserveLinks(t *testing.T) { + file := filepath.Join(t.TempDir(), "app.db") + legacy, err := sql.Open("sqlite", file) + if err != nil { + t.Fatal(err) + } + _, err = legacy.Exec(`CREATE TABLE shares(token TEXT PRIMARY KEY,path TEXT NOT NULL,password_hash TEXT NOT NULL DEFAULT '',created_at INTEGER NOT NULL,expires_at INTEGER NOT NULL DEFAULT 0); + CREATE INDEX shares_path ON shares(path); + INSERT INTO shares VALUES ('old-token','资料/原文.md','hash',123,456);`) + if err != nil { + t.Fatal(err) + } + legacy.Close() + st, err := Open(file) + if err != nil { + t.Fatal(err) + } + sh, ok, err := st.GetShare("old-token") + if err != nil || !ok || sh.Path != "资料/原文.md" || len(sh.Items) != 1 || sh.Items[0].ID != 0 || sh.PasswordHash != "hash" || sh.CreatedAt != 123 || sh.ExpiresAt != 456 { + t.Fatalf("迁移改变了旧分享:%+v %v", sh, err) + } + if err := st.MoveShares("资料", "归档"); err != nil { + t.Fatal(err) + } + st.Close() + st, err = Open(file) + if err != nil { + t.Fatal(err) + } + defer st.Close() + sh, ok, err = st.GetShare("old-token") + if err != nil || !ok || sh.Path != "归档/原文.md" { + t.Fatalf("重启或移动未保留链接:%+v %v", sh, err) + } + if _, err := st.DeleteShare(sh.Token); err != nil { + t.Fatal(err) + } + var count int + if err := st.db.QueryRow(`SELECT count(*) FROM share_items`).Scan(&count); err != nil || count != 0 { + t.Fatalf("撤销未清理主体:%d %v", count, err) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index ebddba4..a6717fb 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -22,15 +22,7 @@ CREATE TABLE IF NOT EXISTS sessions ( created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL ); --- 分享链接中的令牌明文保存:管理列表需要随时复制链接,令牌本身即访问凭据,与笔记同等敏感 -CREATE TABLE IF NOT EXISTS shares ( - token TEXT PRIMARY KEY, - path TEXT NOT NULL, - password_hash TEXT NOT NULL DEFAULT '', - created_at INTEGER NOT NULL, - expires_at INTEGER NOT NULL DEFAULT 0 -); -CREATE INDEX IF NOT EXISTS shares_path ON shares(path); +` + shareSchema + ` CREATE TABLE IF NOT EXISTS api_tokens ( id TEXT PRIMARY KEY, name TEXT NOT NULL, @@ -46,6 +38,10 @@ func Open(path string) (*Store, error) { if err != nil { return nil, err } + if err := migrateShares(db); err != nil { + db.Close() + return nil, fmt.Errorf("迁移分享数据失败: %w", err) + } if _, err := db.Exec(schema); err != nil { db.Close() return nil, fmt.Errorf("初始化数据库失败: %w", err) @@ -114,50 +110,109 @@ func (s *Store) DeleteExpiredSessions() error { // ---- 分享 ---- -// Share 为一个只读分享链接。时间均为 Unix 毫秒,ExpiresAt 为 0 表示永久有效 +// Share 为一个只读分享链接,Items 标识各个独立跟随移动的主体。 +// Path 仅用于已有单主体调用与响应,不单独存储。 type Share struct { - Token string `json:"token"` - Path string `json:"path"` - PasswordHash string `json:"-"` - CreatedAt int64 `json:"createdAt"` - ExpiresAt int64 `json:"expiresAt"` + Token string `json:"token"` + Path string `json:"path,omitempty"` + Items []ShareItem `json:"items"` + PasswordHash string `json:"-"` + CreatedAt int64 `json:"createdAt"` + ExpiresAt int64 `json:"expiresAt"` +} +type ShareItem struct { + ID int `json:"id"` + Path string `json:"path"` } func (s *Store) CreateShare(sh Share) error { - _, err := s.db.Exec(`INSERT INTO shares (token, path, password_hash, created_at, expires_at) VALUES (?, ?, ?, ?, ?)`, - sh.Token, sh.Path, sh.PasswordHash, sh.CreatedAt, sh.ExpiresAt) - return err + items := sh.Items + if len(items) == 0 && sh.Path != "" { + items = []ShareItem{{ID: 0, Path: sh.Path}} + } + if len(items) == 0 { + return fmt.Errorf("分享缺少主体") + } + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err = tx.Exec(`INSERT INTO shares (token, password_hash, created_at, expires_at) VALUES (?, ?, ?, ?)`, sh.Token, sh.PasswordHash, sh.CreatedAt, sh.ExpiresAt); err != nil { + return err + } + for _, item := range items { + if _, err = tx.Exec(`INSERT INTO share_items (share_token, item_id, path) VALUES (?, ?, ?)`, sh.Token, item.ID, item.Path); err != nil { + return err + } + } + return tx.Commit() } -// GetShare 按令牌读取分享;不存在时 ok 为 false func (s *Store) GetShare(token string) (sh Share, ok bool, err error) { - err = s.db.QueryRow(`SELECT token, path, password_hash, created_at, expires_at FROM shares WHERE token = ?`, token). - Scan(&sh.Token, &sh.Path, &sh.PasswordHash, &sh.CreatedAt, &sh.ExpiresAt) + err = s.db.QueryRow(`SELECT token, password_hash, created_at, expires_at FROM shares WHERE token = ?`, token).Scan(&sh.Token, &sh.PasswordHash, &sh.CreatedAt, &sh.ExpiresAt) if errors.Is(err, sql.ErrNoRows) { return Share{}, false, nil } + if err != nil { + return sh, false, err + } + err = s.shareItems(&sh) return sh, err == nil, err } -// Shares 列出全部分享,最新的在前 +func (s *Store) shareItems(sh *Share) error { + rows, err := s.db.Query(`SELECT item_id, path FROM share_items WHERE share_token = ? ORDER BY item_id`, sh.Token) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var item ShareItem + if err := rows.Scan(&item.ID, &item.Path); err != nil { + return err + } + sh.Items = append(sh.Items, item) + } + if err := rows.Err(); err != nil { + return err + } + if len(sh.Items) == 0 { + return fmt.Errorf("分享缺少主体") + } + if len(sh.Items) == 1 { + sh.Path = sh.Items[0].Path + } + return nil +} + func (s *Store) Shares() ([]Share, error) { - rows, err := s.db.Query(`SELECT token, path, password_hash, created_at, expires_at FROM shares ORDER BY created_at DESC`) + rows, err := s.db.Query(`SELECT token, password_hash, created_at, expires_at FROM shares ORDER BY created_at DESC, token`) if err != nil { return nil, err } - defer rows.Close() out := []Share{} for rows.Next() { var sh Share - if err := rows.Scan(&sh.Token, &sh.Path, &sh.PasswordHash, &sh.CreatedAt, &sh.ExpiresAt); err != nil { + if err := rows.Scan(&sh.Token, &sh.PasswordHash, &sh.CreatedAt, &sh.ExpiresAt); err != nil { + rows.Close() return nil, err } out = append(out, sh) } - return out, rows.Err() + err = rows.Err() + rows.Close() + if err != nil { + return nil, err + } + for i := range out { + if err := s.shareItems(&out[i]); err != nil { + return nil, err + } + } + return out, nil } -// DeleteShare 返回是否删除了记录 func (s *Store) DeleteShare(token string) (bool, error) { res, err := s.db.Exec(`DELETE FROM shares WHERE token = ?`, token) if err != nil { @@ -167,10 +222,9 @@ func (s *Store) DeleteShare(token string) (bool, error) { return n == 1, err } -// MoveShares 让分享跟随笔记移动:from 为笔记时只改它自己,为文件夹时改其下所有笔记 func (s *Store) MoveShares(from, to string) error { - _, err := s.db.Exec(`UPDATE shares SET path = CASE WHEN path = ?1 THEN ?2 ELSE ?2 || substr(path, length(?1) + 1) END - WHERE path = ?1 OR substr(path, 1, length(?1) + 1) = ?1 || '/'`, from, to) + _, err := s.db.Exec(`UPDATE share_items SET path = CASE WHEN path = ?1 THEN ?2 ELSE ?2 || substr(path, length(?1) + 1) END + WHERE path = ?1 OR substr(path,1,length(?1)+1) = ?1 || '/'`, from, to) return err } diff --git a/web/src/app/ShareDialog.vue b/web/src/app/ShareDialog.vue index 77f58f7..319d0b3 100644 --- a/web/src/app/ShareDialog.vue +++ b/web/src/app/ShareDialog.vue @@ -1,17 +1,19 @@