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

+1 -1
View File
@@ -52,7 +52,7 @@ type access int
const (
public access = iota
// 登录会话或 API 令牌(只读令牌只能用 GET)
// 登录会话或 API 令牌(只读令牌可用 GET 和只读导出接口)
authed
// 只允许登录会话:管理分享与 API 令牌,避免令牌给自己扩权
sessionOnly
+94
View File
@@ -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)
}
}
+84 -7
View File
@@ -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",
+128 -24
View File
@@ -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) {