Support live collection shares with stable member scopes
This commit is contained in:
1 parent
ffc95d6dbc
commit
828d50f242
13 files changed
+551
-84
No files matched your search
+1
-1
@@ -52,7 +52,7 @@ type access int
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
public access = iota
|
public access = iota
|
||||||
// 登录会话或 API 令牌(只读令牌只能用 GET)
|
// 登录会话或 API 令牌(只读令牌可用 GET 和只读导出接口)
|
||||||
authed
|
authed
|
||||||
// 只允许登录会话:管理分享与 API 令牌,避免令牌给自己扩权
|
// 只允许登录会话:管理分享与 API 令牌,避免令牌给自己扩权
|
||||||
sessionOnly
|
sessionOnly
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1175,10 +1175,38 @@
|
|||||||
"expiresIn": {
|
"expiresIn": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "有效时长(秒),0 为永久"
|
"description": "有效时长(秒),0 为永久"
|
||||||
|
},
|
||||||
|
"paths": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "与 path 二选一;重叠路径只保留外层目录"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [],
|
||||||
"path"
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"required": [
|
||||||
|
"path"
|
||||||
|
],
|
||||||
|
"not": {
|
||||||
|
"required": [
|
||||||
|
"paths"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"required": [
|
||||||
|
"paths"
|
||||||
|
],
|
||||||
|
"not": {
|
||||||
|
"required": [
|
||||||
|
"path"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1215,7 +1243,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"description": "创建一个随机只读链接。path 创建单主体分享;paths 创建集合。内容实时读取,主体移动后跟随,部分主体删除不影响其余项。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/shares/{token}": {
|
"/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": {
|
"/public/shares/{token}/unlock": {
|
||||||
@@ -1639,7 +1679,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "item",
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"description": "集合分享的稳定成员标识;单主体分享可省略。子路径及附件始终限制在该主体原有范围内。"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/themes": {
|
"/themes": {
|
||||||
@@ -2503,7 +2553,7 @@
|
|||||||
},
|
},
|
||||||
"path": {
|
"path": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "分享主体在仓库中的路径(只在登录后的管理接口返回)"
|
"description": "单主体分享的路径;集合分享不返回此字段"
|
||||||
},
|
},
|
||||||
"createdAt": {
|
"createdAt": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
@@ -2518,8 +2568,35 @@
|
|||||||
"exists": {
|
"exists": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "分享的文件或文件夹是否仍存在"
|
"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": {
|
"APIToken": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
+128
-24
@@ -8,6 +8,8 @@ import (
|
|||||||
"mime"
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -23,16 +25,29 @@ const shareCookieName = "rawmark_share"
|
|||||||
// 设有密码的永久分享,访客凭据的有效期
|
// 设有密码的永久分享,访客凭据的有效期
|
||||||
const shareCookieTTL = 30 * 24 * time.Hour
|
const shareCookieTTL = 30 * 24 * time.Hour
|
||||||
|
|
||||||
type shareView struct {
|
type shareItemView struct {
|
||||||
store.Share
|
store.ShareItem
|
||||||
HasPassword bool `json:"hasPassword"`
|
|
||||||
// 分享主体是否仍存在。
|
|
||||||
Exists bool `json:"exists"`
|
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 {
|
func (s *Server) viewOf(sh store.Share) (shareView, error) {
|
||||||
_, err := s.vault.Stat(sh.Path)
|
view := shareView{Share: sh, HasPassword: sh.PasswordHash != "", Items: []shareItemView{}}
|
||||||
return shareView{Share: sh, HasPassword: sh.PasswordHash != "", Exists: err == nil}
|
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= 时只列出该笔记的
|
// listShares 列出全部分享;带 ?path= 时只列出该笔记的
|
||||||
@@ -45,8 +60,19 @@ func (s *Server) listShares(w http.ResponseWriter, r *http.Request) {
|
|||||||
filter := r.URL.Query().Get("path")
|
filter := r.URL.Query().Get("path")
|
||||||
out := []shareView{}
|
out := []shareView{}
|
||||||
for _, sh := range all {
|
for _, sh := range all {
|
||||||
if filter == "" || sh.Path == filter {
|
matches := filter == ""
|
||||||
out = append(out, s.viewOf(sh))
|
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})
|
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) {
|
func (s *Server) createShare(w http.ResponseWriter, r *http.Request) {
|
||||||
var body struct {
|
var body struct {
|
||||||
Path string
|
Path string
|
||||||
|
Paths []string
|
||||||
Password string
|
Password string
|
||||||
// 有效时长(秒),0 为永久
|
// 有效时长(秒),0 为永久
|
||||||
ExpiresIn int64
|
ExpiresIn int64
|
||||||
@@ -62,15 +89,42 @@ func (s *Server) createShare(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !readJSON(w, r, &body) {
|
if !readJSON(w, r, &body) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
p, err := vault.CleanPath(body.Path)
|
if body.Path != "" && len(body.Paths) != 0 {
|
||||||
if err != nil {
|
writeError(w, 400, "path 和 paths 不能同时指定")
|
||||||
writeError(w, 400, "分享路径无效")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := s.vault.Stat(p); err != nil {
|
paths := body.Paths
|
||||||
vaultError(w, r, err)
|
if body.Path != "" {
|
||||||
|
paths = []string{body.Path}
|
||||||
|
}
|
||||||
|
if len(paths) == 0 {
|
||||||
|
writeError(w, 400, "请选择分享主体")
|
||||||
return
|
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 {
|
if body.ExpiresIn < 0 {
|
||||||
writeError(w, 400, "有效期无效")
|
writeError(w, 400, "有效期无效")
|
||||||
return
|
return
|
||||||
@@ -78,7 +132,7 @@ func (s *Server) createShare(w http.ResponseWriter, r *http.Request) {
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
sh := store.Share{
|
sh := store.Share{
|
||||||
Token: auth.NewShareToken(),
|
Token: auth.NewShareToken(),
|
||||||
Path: p,
|
Items: items,
|
||||||
PasswordHash: auth.HashSharePassword(body.Password),
|
PasswordHash: auth.HashSharePassword(body.Password),
|
||||||
CreatedAt: now.UnixMilli(),
|
CreatedAt: now.UnixMilli(),
|
||||||
}
|
}
|
||||||
@@ -89,7 +143,15 @@ func (s *Server) createShare(w http.ResponseWriter, r *http.Request) {
|
|||||||
internalError(w, r, err)
|
internalError(w, r, err)
|
||||||
return
|
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) {
|
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 {
|
if !ok {
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
vaultError(w, r, err)
|
vaultError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
p := sh.Path
|
p := subject
|
||||||
if rel := r.URL.Query().Get("path"); rel != "" {
|
if rel := r.URL.Query().Get("path"); rel != "" {
|
||||||
if !info.IsDir() {
|
if !info.IsDir() {
|
||||||
writeError(w, 404, "文件不存在")
|
writeError(w, 404, "文件不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
p, err = s.vault.ScopedPath(sh.Path, rel)
|
p, err = s.vault.ScopedPath(subject, rel)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
info, err = s.vault.Stat(p)
|
info, err = s.vault.Stat(p)
|
||||||
}
|
}
|
||||||
@@ -255,7 +338,11 @@ func (s *Server) publicShareAsset(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
vaultError(w, r, err)
|
vaultError(w, r, err)
|
||||||
return
|
return
|
||||||
@@ -263,13 +350,13 @@ func (s *Server) publicShareAsset(w http.ResponseWriter, r *http.Request) {
|
|||||||
rel := r.PathValue("path")
|
rel := r.PathValue("path")
|
||||||
var target string
|
var target string
|
||||||
if info.IsDir() {
|
if info.IsDir() {
|
||||||
target, err = s.vault.ScopedPath(sh.Path, rel)
|
target, err = s.vault.ScopedPath(subject, rel)
|
||||||
} else if rel == "" {
|
} else if rel == "" {
|
||||||
target = sh.Path
|
target = subject
|
||||||
} else {
|
} else {
|
||||||
assets := vault.AssetsDir(sh.Path)
|
assets := vault.AssetsDir(subject)
|
||||||
prefix := path.Base(assets) + "/"
|
prefix := path.Base(assets) + "/"
|
||||||
if !vault.IsNote(sh.Path) || !strings.HasPrefix(rel, prefix) {
|
if !vault.IsNote(subject) || !strings.HasPrefix(rel, prefix) {
|
||||||
writeError(w, 404, "文件不存在")
|
writeError(w, 404, "文件不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -285,6 +372,23 @@ func (s *Server) publicShareAsset(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.serveRaw(w, r, target)
|
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 接口中处理,
|
// FollowMoves 让分享跟随在外部(如 Typora)完成的改名与移动;经 API 的移动已在 move 接口中处理,
|
||||||
// 这里再次处理时原路径已无分享,不会重复修改
|
// 这里再次处理时原路径已无分享,不会重复修改
|
||||||
func (s *Server) FollowMoves(ctx context.Context) {
|
func (s *Server) FollowMoves(ctx context.Context) {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
@@ -22,15 +22,7 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
expires_at INTEGER NOT NULL
|
expires_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
-- 分享链接中的令牌明文保存:管理列表需要随时复制链接,令牌本身即访问凭据,与笔记同等敏感
|
` + shareSchema + `
|
||||||
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);
|
|
||||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
@@ -46,6 +38,10 @@ func Open(path string) (*Store, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := migrateShares(db); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, fmt.Errorf("迁移分享数据失败: %w", err)
|
||||||
|
}
|
||||||
if _, err := db.Exec(schema); err != nil {
|
if _, err := db.Exec(schema); err != nil {
|
||||||
db.Close()
|
db.Close()
|
||||||
return nil, fmt.Errorf("初始化数据库失败: %w", err)
|
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 {
|
type Share struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path,omitempty"`
|
||||||
PasswordHash string `json:"-"`
|
Items []ShareItem `json:"items"`
|
||||||
CreatedAt int64 `json:"createdAt"`
|
PasswordHash string `json:"-"`
|
||||||
ExpiresAt int64 `json:"expiresAt"`
|
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 {
|
func (s *Store) CreateShare(sh Share) error {
|
||||||
_, err := s.db.Exec(`INSERT INTO shares (token, path, password_hash, created_at, expires_at) VALUES (?, ?, ?, ?, ?)`,
|
items := sh.Items
|
||||||
sh.Token, sh.Path, sh.PasswordHash, sh.CreatedAt, sh.ExpiresAt)
|
if len(items) == 0 && sh.Path != "" {
|
||||||
return err
|
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) {
|
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).
|
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)
|
||||||
Scan(&sh.Token, &sh.Path, &sh.PasswordHash, &sh.CreatedAt, &sh.ExpiresAt)
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return Share{}, false, nil
|
return Share{}, false, nil
|
||||||
}
|
}
|
||||||
|
if err != nil {
|
||||||
|
return sh, false, err
|
||||||
|
}
|
||||||
|
err = s.shareItems(&sh)
|
||||||
return sh, err == nil, err
|
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) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
|
||||||
out := []Share{}
|
out := []Share{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var sh Share
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
out = append(out, sh)
|
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) {
|
func (s *Store) DeleteShare(token string) (bool, error) {
|
||||||
res, err := s.db.Exec(`DELETE FROM shares WHERE token = ?`, token)
|
res, err := s.db.Exec(`DELETE FROM shares WHERE token = ?`, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -167,10 +222,9 @@ func (s *Store) DeleteShare(token string) (bool, error) {
|
|||||||
return n == 1, err
|
return n == 1, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// MoveShares 让分享跟随笔记移动:from 为笔记时只改它自己,为文件夹时改其下所有笔记
|
|
||||||
func (s *Store) MoveShares(from, to string) error {
|
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
|
_, 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)
|
WHERE path = ?1 OR substr(path,1,length(?1)+1) = ?1 || '/'`, from, to)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
import Icon from './Icon.vue'
|
import Icon from './Icon.vue'
|
||||||
import { basename } from './paths'
|
import { basename } from './paths'
|
||||||
import SharePanel from './SharePanel.vue'
|
import SharePanel from './SharePanel.vue'
|
||||||
|
|
||||||
defineProps<{ path: string }>()
|
const props = defineProps<{ path: string | string[] }>()
|
||||||
|
const title = computed(() => typeof props.path === 'string' ? basename(props.path) : `${props.path.length} 项内容`)
|
||||||
const emit = defineEmits<{ close: [] }>()
|
const emit = defineEmits<{ close: [] }>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<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="`分享“${basename(path)}”`">
|
<div class="rm-dialog rm-share-dialog" role="dialog" :aria-label="`分享“${title}”`">
|
||||||
<div class="rm-dialog-head">
|
<div class="rm-dialog-head">
|
||||||
<h2>分享“{{ basename(path) }}”</h2>
|
<h2>分享“{{ title }}”</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" />
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ 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 | string[] | null }>()
|
||||||
|
|
||||||
const EXPIRY_OPTIONS = [
|
const EXPIRY_OPTIONS = [
|
||||||
{ label: '永久有效', seconds: 0 },
|
{ label: '永久有效', seconds: 0 },
|
||||||
@@ -31,7 +31,9 @@ function errorText(err: unknown) {
|
|||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
shares.value = await api.shares(props.path ?? undefined)
|
const selected = props.path
|
||||||
|
const all = await api.shares(typeof selected === 'string' ? selected : undefined)
|
||||||
|
shares.value = Array.isArray(selected) ? all.filter((share) => share.items.length === selected.length && share.items.every((item) => selected.includes(item.path))) : all
|
||||||
} catch {
|
} catch {
|
||||||
failed.value = true
|
failed.value = true
|
||||||
}
|
}
|
||||||
@@ -77,20 +79,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">分享只读并显示最新内容。分享文件夹会公开其中所有文件和子目录,包括以后新增或移入的内容;移出的内容不再公开。分享主体移动后链接保持有效。</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 }}</span>
|
<span v-if="!path" class="rm-trash-path">{{ s.items.map((item) => item.path).join('、') }}</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 && s.items.some((item) => !item.exists)"> · <span class="rm-share-bad">部分主体已不存在</span></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>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { computed, nextTick, ref, watch } from 'vue'
|
|||||||
import Icon from './Icon.vue'
|
import Icon from './Icon.vue'
|
||||||
import { basename, inAssetDir, isImage, joinPath } from './paths'
|
import { basename, inAssetDir, isImage, joinPath } from './paths'
|
||||||
import { contextSelection, expanded, selectedPaths, selectPath, type TreeNode } from './tree'
|
import { contextSelection, expanded, selectedPaths, selectPath, type TreeNode } from './tree'
|
||||||
|
import { minimalPaths } from './selection'
|
||||||
import { batchFiles, exportFiles } from './batch'
|
import { batchFiles, exportFiles } from './batch'
|
||||||
import { createFolder, createNote, move, openNote, renameTarget, ws } from './workspace'
|
import { createFolder, createNote, move, openNote, renameTarget, ws } from './workspace'
|
||||||
|
|
||||||
@@ -86,7 +87,7 @@ function runMenu(action: 'note' | 'folder' | 'import' | 'images' | 'rename' | 'm
|
|||||||
} else if (action === 'import') ws.importing = path
|
} else if (action === 'import') ws.importing = path
|
||||||
else if (action === 'images') ws.images = path
|
else if (action === 'images') ws.images = path
|
||||||
else if (action === 'rename') ws.renaming = path
|
else if (action === 'rename') ws.renaming = path
|
||||||
else if (action === 'share') ws.sharing = path
|
else if (action === 'share') ws.sharing = minimalPaths(selectedPaths)
|
||||||
else if (action === 'move') ws.moving = [...selectedPaths]
|
else if (action === 'move') ws.moving = [...selectedPaths]
|
||||||
else if (action === 'export') void exportFiles([...selectedPaths])
|
else if (action === 'export') void exportFiles([...selectedPaths])
|
||||||
else void batchFiles('delete', [...selectedPaths])
|
else void batchFiles('delete', [...selectedPaths])
|
||||||
@@ -171,7 +172,7 @@ function onDrop(e: DragEvent) {
|
|||||||
<button v-if="image && selectedPaths.size === 1" @click="menuOpen = false; ws.image = node.path"><Icon name="image" />预览 / 替换图片</button>
|
<button v-if="image && selectedPaths.size === 1" @click="menuOpen = false; ws.image = node.path"><Icon name="image" />预览 / 替换图片</button>
|
||||||
<button v-if="!assetDir && selectedPaths.size === 1" @click="runMenu('rename')"><Icon name="edit" />重命名</button>
|
<button v-if="!assetDir && selectedPaths.size === 1" @click="runMenu('rename')"><Icon name="edit" />重命名</button>
|
||||||
<button v-if="!assetDir" @click="runMenu('move')"><Icon name="folder" />移动到…</button>
|
<button v-if="!assetDir" @click="runMenu('move')"><Icon name="folder" />移动到…</button>
|
||||||
<button v-if="!image && selectedPaths.size === 1" @click="runMenu('share')"><Icon name="link" />分享</button>
|
<button @click="runMenu('share')"><Icon name="link" />分享</button>
|
||||||
<button @click="runMenu('export')"><Icon name="note" />导出 Markdown</button>
|
<button @click="runMenu('export')"><Icon name="note" />导出 Markdown</button>
|
||||||
<button v-if="!assetDir" class="danger" @click="runMenu('delete')"><Icon name="trash" />删除</button>
|
<button v-if="!assetDir" class="danger" @click="runMenu('delete')"><Icon name="trash" />删除</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+4
-3
@@ -44,8 +44,9 @@ export interface SearchResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Share {
|
export interface Share {
|
||||||
|
items: { id: number; path: string; exists: boolean }[]
|
||||||
token: string
|
token: string
|
||||||
path: string
|
path?: string
|
||||||
createdAt: number
|
createdAt: number
|
||||||
// 0 为永久
|
// 0 为永久
|
||||||
expiresAt: number
|
expiresAt: number
|
||||||
@@ -188,8 +189,8 @@ export const api = {
|
|||||||
shares: async (path?: string) =>
|
shares: async (path?: string) =>
|
||||||
(await json<{ shares: Share[] }>(request('GET', '/api/v1/shares' + (path ? '?path=' + encodeURIComponent(path) : '')))).shares,
|
(await json<{ shares: Share[] }>(request('GET', '/api/v1/shares' + (path ? '?path=' + encodeURIComponent(path) : '')))).shares,
|
||||||
// expiresIn 为有效时长(秒),0 为永久
|
// expiresIn 为有效时长(秒),0 为永久
|
||||||
createShare: (path: string, password: string, expiresIn: number) =>
|
createShare: (path: string | string[], password: string, expiresIn: number) =>
|
||||||
json<Share>(request('POST', '/api/v1/shares', { json: { path, password, expiresIn } })),
|
json<Share>(request('POST', '/api/v1/shares', { json: { ...(typeof path === 'string' ? { path } : { paths: path }), password, expiresIn } })),
|
||||||
deleteShare: (token: string) => request('DELETE', '/api/v1/shares/' + encodeURIComponent(token)),
|
deleteShare: (token: string) => request('DELETE', '/api/v1/shares/' + encodeURIComponent(token)),
|
||||||
|
|
||||||
tokens: async () => (await json<{ tokens: ApiToken[] }>(request('GET', '/api/v1/tokens'))).tokens,
|
tokens: async () => (await json<{ tokens: ApiToken[] }>(request('GET', '/api/v1/tokens'))).tokens,
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export const ws = reactive({
|
|||||||
// 打开的设置分组,null 为关闭;index 为从通用入口打开(手机上先显示分组列表,电脑上显示第一组)
|
// 打开的设置分组,null 为关闭;index 为从通用入口打开(手机上先显示分组列表,电脑上显示第一组)
|
||||||
settings: (pluginsPaused ? 'plugins' : appearancePaused ? 'appearance' : null) as SettingsSection | 'index' | null,
|
settings: (pluginsPaused ? 'plugins' : appearancePaused ? 'appearance' : null) as SettingsSection | 'index' | null,
|
||||||
// 分享当前笔记的对话框(笔记路径)
|
// 分享当前笔记的对话框(笔记路径)
|
||||||
sharing: null as string | null,
|
sharing: null as string | string[] | null,
|
||||||
// 「移动到…」对话框(笔记或文件夹路径)
|
// 「移动到…」对话框(笔记或文件夹路径)
|
||||||
moving: null as string | string[] | null,
|
moving: null as string | string[] | null,
|
||||||
// 导入目标目录,空字符串为根目录
|
// 导入目标目录,空字符串为根目录
|
||||||
|
|||||||
+47
-9
@@ -24,13 +24,30 @@ interface SharedFile {
|
|||||||
dir: false
|
dir: false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SharedCollection {
|
||||||
|
collection: true
|
||||||
|
title: string
|
||||||
|
items: { id: number; title: string; exists: boolean; dir: boolean }[]
|
||||||
|
}
|
||||||
|
|
||||||
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 selectedItem = new URL(location.href).searchParams.get('item')
|
||||||
const selectedPath = new URL(location.href).searchParams.get('path') ?? ''
|
const selectedPath = new URL(location.href).searchParams.get('path') ?? ''
|
||||||
const encodePath = (path: string) => path.split('/').map(encodeURIComponent).join('/')
|
const encodePath = (path: string) => path.split('/').map(encodeURIComponent).join('/')
|
||||||
const pageUrl = (path: string) => location.pathname + (path ? '?path=' + encodeURIComponent(path) : '')
|
function pageUrl(path: string, item: string | null = selectedItem) {
|
||||||
const downloadUrl = () => api + '/assets/' + encodePath(selectedPath) + '?download=1'
|
const query = new URLSearchParams()
|
||||||
|
if (item !== null) query.set('item', item)
|
||||||
|
if (path) query.set('path', path)
|
||||||
|
return location.pathname + (query.size ? '?' + query : '')
|
||||||
|
}
|
||||||
|
function downloadUrl() {
|
||||||
|
const url = new URL(api + '/assets/' + encodePath(selectedPath), location.origin)
|
||||||
|
url.searchParams.set('download', '1')
|
||||||
|
if (selectedItem !== null) url.searchParams.set('item', selectedItem)
|
||||||
|
return url.href
|
||||||
|
}
|
||||||
|
|
||||||
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 }))
|
||||||
@@ -39,13 +56,16 @@ function message(text: string) {
|
|||||||
async function load() {
|
async function load() {
|
||||||
let res: Response
|
let res: Response
|
||||||
try {
|
try {
|
||||||
res = await fetch(api + (selectedPath ? '?path=' + encodeURIComponent(selectedPath) : ''), { credentials: 'same-origin', cache: 'no-store' })
|
const query = new URLSearchParams()
|
||||||
|
if (selectedPath) query.set('path', selectedPath)
|
||||||
|
if (selectedItem !== null) query.set('item', selectedItem)
|
||||||
|
res = await fetch(api + (query.size ? '?' + query : ''), { credentials: 'same-origin', cache: 'no-store' })
|
||||||
} catch {
|
} catch {
|
||||||
message('网络错误,请检查连接后刷新')
|
message('网络错误,请检查连接后刷新')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const content = (await res.json()) as SharedNote | SharedDirectory | SharedFile
|
const content = (await res.json()) as SharedNote | SharedDirectory | SharedFile | SharedCollection
|
||||||
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' })
|
||||||
@@ -58,7 +78,8 @@ async function load() {
|
|||||||
} catch {
|
} catch {
|
||||||
appearanceError = true
|
appearanceError = true
|
||||||
}
|
}
|
||||||
if (content.dir) renderDirectory(content)
|
if ('collection' in content) renderCollection(content)
|
||||||
|
else if (content.dir) renderDirectory(content)
|
||||||
else if ('content' in content) render(content)
|
else if ('content' in content) render(content)
|
||||||
else {
|
else {
|
||||||
document.title = content.title
|
document.title = content.title
|
||||||
@@ -69,8 +90,8 @@ async function load() {
|
|||||||
}
|
}
|
||||||
else if (res.status === 401) askPassword()
|
else if (res.status === 401) askPassword()
|
||||||
else if (res.status === 404) {
|
else if (res.status === 404) {
|
||||||
message(selectedPath ? '此文件或文件夹已不存在,或不在分享范围内' : '分享不存在、已过期或已被撤销')
|
message(selectedPath || selectedItem !== null ? '此文件或文件夹已不存在,或不在分享范围内' : '分享不存在、已过期或已被撤销')
|
||||||
if (selectedPath) root.prepend(navigation())
|
if (selectedPath || selectedItem !== null) root.prepend(navigation())
|
||||||
}
|
}
|
||||||
else message(`加载失败(${res.status})`)
|
else message(`加载失败(${res.status})`)
|
||||||
}
|
}
|
||||||
@@ -112,7 +133,8 @@ function navigation() {
|
|||||||
const nav = document.createElement('nav')
|
const nav = document.createElement('nav')
|
||||||
nav.className = 'rm-share-nav'
|
nav.className = 'rm-share-nav'
|
||||||
nav.setAttribute('aria-label', '分享导航')
|
nav.setAttribute('aria-label', '分享导航')
|
||||||
nav.append(Object.assign(document.createElement('a'), { href: pageUrl(''), textContent: '分享首页' }))
|
nav.append(Object.assign(document.createElement('a'), { href: pageUrl('', null), textContent: '分享首页' }))
|
||||||
|
if (selectedItem !== null) nav.append(' / ', Object.assign(document.createElement('a'), { href: pageUrl(''), textContent: '主体首页' }))
|
||||||
const parts = selectedPath.split('/').filter(Boolean)
|
const parts = selectedPath.split('/').filter(Boolean)
|
||||||
for (let i = 0; i < parts.length; i++) {
|
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] }))
|
nav.append(' / ', Object.assign(document.createElement('a'), { href: pageUrl(parts.slice(0, i + 1).join('/')), textContent: parts[i] }))
|
||||||
@@ -120,6 +142,21 @@ function navigation() {
|
|||||||
return nav
|
return nav
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderCollection(collection: SharedCollection) {
|
||||||
|
document.title = collection.title
|
||||||
|
const heading = Object.assign(document.createElement('h1'), { textContent: collection.title })
|
||||||
|
const list = document.createElement('ul')
|
||||||
|
list.className = 'rm-share-files'
|
||||||
|
for (const item of collection.items) {
|
||||||
|
const row = document.createElement('li')
|
||||||
|
row.append(item.exists
|
||||||
|
? Object.assign(document.createElement('a'), { href: pageUrl('', String(item.id)), textContent: item.title + (item.dir ? '/' : '') })
|
||||||
|
: Object.assign(document.createElement('span'), { textContent: item.title + '(已不可用)' }))
|
||||||
|
list.append(row)
|
||||||
|
}
|
||||||
|
root.replaceChildren(heading, list)
|
||||||
|
}
|
||||||
|
|
||||||
function renderDirectory(directory: SharedDirectory) {
|
function renderDirectory(directory: SharedDirectory) {
|
||||||
document.title = directory.title
|
document.title = directory.title
|
||||||
const heading = Object.assign(document.createElement('h1'), { textContent: directory.title })
|
const heading = Object.assign(document.createElement('h1'), { textContent: directory.title })
|
||||||
@@ -148,7 +185,7 @@ 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(...(selectedPath ? [navigation()] : []), header, article)
|
root.replaceChildren(...(selectedPath || selectedItem !== null ? [navigation()] : []), header, article)
|
||||||
|
|
||||||
resolveImages(article)
|
resolveImages(article)
|
||||||
resolveLinks(article)
|
resolveLinks(article)
|
||||||
@@ -165,6 +202,7 @@ function resourceUrl(src: string): URL | null {
|
|||||||
try {
|
try {
|
||||||
const prefix = new URL(api + '/assets/', location.origin)
|
const prefix = new URL(api + '/assets/', location.origin)
|
||||||
const target = new URL(src, new URL(encodePath(selectedPath), prefix))
|
const target = new URL(src, new URL(encodePath(selectedPath), prefix))
|
||||||
|
if (selectedItem !== null) target.searchParams.set('item', selectedItem)
|
||||||
return target.origin === prefix.origin && target.pathname.startsWith(prefix.pathname) ? target : null
|
return target.origin === prefix.origin && target.pathname.startsWith(prefix.pathname) ? target : null
|
||||||
} catch { return null }
|
} catch { return null }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user