Support live collection shares with stable member scopes

This commit is contained in:
eezd committed 2026-10-21 16:16:53 +08:00
1 parent ffc95d6dbc
commit 828d50f242
13 files changed
+551 -84

No files matched your search

+43
View File
@@ -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()
}
+50
View File
@@ -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)
}
}
+84 -30
View File
@@ -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
}