feat(ui): streamline workspace settings and remove sharing
Unify compact workspace controls and panel transitions, move directory actions into the context menu, and remove backlink UI. Reorganize settings with searchable sections, per-setting resets, shortcut search, and accurate save feedback. Remove sharing UI, APIs, authentication helpers, and storage code; provide a transactional cleanup script with retirement tests. Reject directories during orphan image cleanup and cover mixed-batch preservation.
This commit is contained in:
1 parent
a716794e41
commit
19aab631ec
63 files changed
+1077
-3343
No files matched your search
+2
-12
@@ -54,7 +54,7 @@ const (
|
||||
public access = iota
|
||||
// 登录会话或 API 令牌(只读令牌可用 GET 和只读导出接口)
|
||||
authed
|
||||
// 只允许登录会话:管理分享与 API 令牌,避免令牌给自己扩权
|
||||
// 只允许登录会话:管理 API 令牌,避免令牌给自己扩权
|
||||
sessionOnly
|
||||
)
|
||||
|
||||
@@ -106,17 +106,9 @@ func (s *Server) routes() []route {
|
||||
{"PUT /api/v1/plugins/{id}/data", sessionOnly, s.putPluginData},
|
||||
{"GET /api/v1/themes", authed, s.listThemes},
|
||||
{"GET /api/v1/themes/{id}", authed, s.getTheme},
|
||||
{"GET /api/v1/shares", sessionOnly, s.listShares},
|
||||
{"POST /api/v1/shares", sessionOnly, s.createShare},
|
||||
{"DELETE /api/v1/shares/{token}", sessionOnly, s.deleteShare},
|
||||
{"GET /api/v1/tokens", sessionOnly, s.listTokens},
|
||||
{"POST /api/v1/tokens", sessionOnly, s.createToken},
|
||||
{"DELETE /api/v1/tokens/{id}", sessionOnly, s.deleteToken},
|
||||
|
||||
{"GET /api/v1/public/shares/{token}", public, s.publicShare},
|
||||
{"GET /api/v1/public/shares/{token}/appearance", public, s.publicAppearance},
|
||||
{"POST /api/v1/public/shares/{token}/unlock", public, s.unlockShare},
|
||||
{"GET /api/v1/public/shares/{token}/assets/{path...}", public, s.publicShareAsset},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,9 +347,7 @@ func (s *Server) move(w http.ResponseWriter, r *http.Request) {
|
||||
if !readJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
rewritten, err := s.vault.Move(body.From, body.To, func() error {
|
||||
return s.store.MoveShares(body.From, body.To)
|
||||
})
|
||||
rewritten, err := s.vault.Move(body.From, body.To)
|
||||
if err != nil {
|
||||
vaultError(w, r, err)
|
||||
return
|
||||
|
||||
@@ -3,14 +3,12 @@ package api
|
||||
import (
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
@@ -65,36 +63,6 @@ func TestMoveBroadcastsWhenAssetRewriteReplacesFileIdentity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveRollsBackWhenShareUpdateFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
v, err := vault.Open(filepath.Join(dir, "vault"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := v.Write("from.md", []byte("原文"), vault.Precondition{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, err := store.Open(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := New(v, nil, st, nil, nil)
|
||||
res := httptest.NewRecorder()
|
||||
s.move(res, httptest.NewRequest("POST", "/api/v1/move", strings.NewReader(`{"from":"from.md","to":"to.md"}`)))
|
||||
if res.Code != 500 {
|
||||
t.Fatalf("数据库失败应返回错误: %d", res.Code)
|
||||
}
|
||||
if content, _, err := v.Read("from.md"); err != nil || string(content) != "原文" {
|
||||
t.Fatalf("原笔记未恢复: %q %v", content, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(v.Root(), "to.md")); !os.IsNotExist(err) {
|
||||
t.Fatalf("失败后目标仍存在: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventsRejectRevokedCredentials(t *testing.T) {
|
||||
for _, kind := range []string{"session", "token"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
@@ -282,260 +250,6 @@ func TestCompressedManifestContentType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestShareAccess(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.write("目录/笔记.md", "# 标题\n\n\n")
|
||||
f.write("目录/笔记.assets/图.png", "PNG")
|
||||
f.write("目录/其他.png", "OTHER")
|
||||
f.write("目录/其他.assets/x.png", "X")
|
||||
|
||||
res := f.do(f.admin, "POST", "/api/v1/shares", `{"path":"目录/笔记.md","password":"访问密码","expiresIn":0}`, nil)
|
||||
if res.StatusCode != 200 {
|
||||
t.Fatalf("创建分享: %d", res.StatusCode)
|
||||
}
|
||||
sh := decode[shareView](t, res)
|
||||
if !sh.HasPassword || !sh.Exists || sh.ExpiresAt != 0 || len(sh.Token) < 20 {
|
||||
t.Fatalf("分享信息: %+v", sh)
|
||||
}
|
||||
base := "/api/v1/public/shares/" + sh.Token
|
||||
guest := newClient()
|
||||
if code := f.status(guest, "GET", base, "", nil); code != 401 {
|
||||
t.Errorf("未输入密码应为 401,得到 %d", code)
|
||||
}
|
||||
if code := f.status(guest, "GET", base+"/assets/笔记.assets/图.png", "", nil); code != 401 {
|
||||
t.Errorf("未输入密码读图片应为 401,得到 %d", code)
|
||||
}
|
||||
if code := f.status(guest, "POST", base+"/unlock", `{"password":"错的"}`, nil); code != 401 {
|
||||
t.Errorf("错误密码应为 401,得到 %d", code)
|
||||
}
|
||||
if code := f.status(guest, "POST", base+"/unlock", `{"password":"访问密码"}`, nil); code != 200 {
|
||||
t.Fatalf("正确密码: %d", code)
|
||||
}
|
||||
got := decode[map[string]any](t, f.do(guest, "GET", base, "", nil))
|
||||
if got["title"] != "笔记" || got["content"] != "# 标题\n\n\n" || got["path"] != nil {
|
||||
t.Errorf("分享内容: %v", got)
|
||||
}
|
||||
res = f.do(guest, "GET", base+"/assets/笔记.assets/图.png", "", nil)
|
||||
if b, _ := io.ReadAll(res.Body); res.StatusCode != 200 || string(b) != "PNG" {
|
||||
t.Errorf("本篇图片: %d %q", res.StatusCode, b)
|
||||
}
|
||||
for _, p := range []string{"其他.png", "笔记.assets/../其他.png", "其他.assets/x.png", "../目录/其他.png", "笔记.md"} {
|
||||
if code := f.status(guest, "GET", base+"/assets/"+p, "", nil); code != 404 {
|
||||
t.Errorf("图片目录之外的 %s 应为 404,得到 %d", p, code)
|
||||
}
|
||||
}
|
||||
|
||||
// 改名后分享跟随
|
||||
if code := f.status(f.admin, "POST", "/api/v1/move", `{"from":"目录","to":"新目录"}`, nil); code != 200 {
|
||||
t.Fatalf("移动文件夹: %d", code)
|
||||
}
|
||||
if code := f.status(f.admin, "POST", "/api/v1/move", `{"from":"新目录/笔记.md","to":"新目录/改名.md"}`, nil); code != 200 {
|
||||
t.Fatalf("改名: %d", code)
|
||||
}
|
||||
got = decode[map[string]any](t, f.do(guest, "GET", base, "", nil))
|
||||
if got["title"] != "改名" || !strings.Contains(got["content"].(string), "改名.assets/") {
|
||||
t.Errorf("改名后分享内容: %v", got)
|
||||
}
|
||||
if code := f.status(guest, "GET", base+"/assets/改名.assets/图.png", "", nil); code != 200 {
|
||||
t.Errorf("改名后图片: %d", code)
|
||||
}
|
||||
|
||||
// 其他分享的凭据不能用于本分享
|
||||
res = f.do(f.admin, "POST", "/api/v1/shares", `{"path":"新目录/其他.md"}`, nil)
|
||||
if res.StatusCode != 404 {
|
||||
t.Errorf("分享不存在的笔记应为 404,得到 %d", res.StatusCode)
|
||||
}
|
||||
f.write("新目录/其他.md", "其他")
|
||||
other := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", `{"path":"新目录/其他.md","password":"另一个"}`, nil))
|
||||
if code := f.status(guest, "GET", "/api/v1/public/shares/"+other.Token, "", nil); code != 401 {
|
||||
t.Errorf("凭据只对对应分享有效,得到 %d", code)
|
||||
}
|
||||
|
||||
// 列表、删除笔记、撤销
|
||||
list := decode[struct{ Shares []shareView }](t, f.do(f.admin, "GET", "/api/v1/shares?path=新目录/改名.md", "", nil))
|
||||
if len(list.Shares) != 1 || list.Shares[0].Path != "新目录/改名.md" {
|
||||
t.Errorf("按笔记筛选: %+v", list.Shares)
|
||||
}
|
||||
if code := f.status(f.admin, "DELETE", "/api/v1/files/新目录/改名.md", "", nil); code != 200 {
|
||||
t.Fatal(code)
|
||||
}
|
||||
list = decode[struct{ Shares []shareView }](t, f.do(f.admin, "GET", "/api/v1/shares", "", nil))
|
||||
if len(list.Shares) != 2 || list.Shares[1].Exists {
|
||||
t.Errorf("笔记删除后分享标记为不存在: %+v", list.Shares)
|
||||
}
|
||||
if code := f.status(guest, "GET", base, "", nil); code != 404 {
|
||||
t.Errorf("笔记已删除应为 404,得到 %d", code)
|
||||
}
|
||||
if code := f.status(f.admin, "DELETE", "/api/v1/shares/"+sh.Token, "", nil); code != 200 {
|
||||
t.Fatal(code)
|
||||
}
|
||||
if code := f.status(guest, "POST", base+"/unlock", `{"password":"访问密码"}`, nil); code != 404 {
|
||||
t.Errorf("撤销后应为 404,得到 %d", code)
|
||||
}
|
||||
if code := f.status(newClient(), "GET", "/api/v1/shares", "", nil); code != 401 {
|
||||
t.Errorf("未登录不能管理分享,得到 %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShareRandomToken(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
name := "计划 #1? 50% +;\""
|
||||
p := "目录/" + name + ".md"
|
||||
f.write(p, "正文")
|
||||
f.write("目录/"+name+".assets/图.png", "PNG")
|
||||
body, err := json.Marshal(map[string]string{"path": p, "password": "访问密码"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sh := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", string(body), nil))
|
||||
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 {
|
||||
t.Fatal("同一笔记的分享链接不能重复")
|
||||
}
|
||||
base := "/api/v1/public/shares/" + url.PathEscape(sh.Token)
|
||||
guest := newClient()
|
||||
if code := f.status(guest, "GET", base, "", nil); code != 401 {
|
||||
t.Fatalf("未解锁应拒绝访问,得到 %d", code)
|
||||
}
|
||||
if code := f.status(guest, "POST", base+"/unlock", `{"password":"访问密码"}`, nil); code != 200 {
|
||||
t.Fatalf("解锁失败:%d", code)
|
||||
}
|
||||
if code := f.status(guest, "GET", base, "", nil); code != 200 {
|
||||
t.Fatalf("带保留字符的链接应可读取正文,得到 %d", code)
|
||||
}
|
||||
asset := base + "/assets/" + url.PathEscape(name+".assets") + "/图.png"
|
||||
if code := f.status(guest, "GET", asset, "", nil); code != 200 {
|
||||
t.Fatalf("解锁凭据应覆盖图片路径,得到 %d", code)
|
||||
}
|
||||
if code := f.status(guest, "GET", "/api/v1/public/shares/"+url.PathEscape(other.Token), "", nil); code != 401 {
|
||||
t.Fatalf("凭据不能解锁另一分享,得到 %d", code)
|
||||
}
|
||||
if code := f.status(f.admin, "DELETE", "/api/v1/shares/"+url.PathEscape(sh.Token), "", nil); code != 200 {
|
||||
t.Fatalf("撤销带保留字符的链接失败:%d", code)
|
||||
}
|
||||
if code := f.status(guest, "GET", base, "", nil); code != 404 {
|
||||
t.Fatalf("撤销后应返回 404,得到 %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
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", "公开")
|
||||
sh := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", `{"path":"a.md","expiresIn":3600}`, nil))
|
||||
if sh.HasPassword || sh.ExpiresAt-time.Now().UnixMilli() < 3590_000 {
|
||||
t.Fatalf("分享信息: %+v", sh)
|
||||
}
|
||||
if code := f.status(newClient(), "GET", "/api/v1/public/shares/"+sh.Token, "", nil); code != 200 {
|
||||
t.Errorf("无密码分享直接可读,得到 %d", code)
|
||||
}
|
||||
if err := f.store.CreateShare(store.Share{Token: "expired", Path: "a.md", CreatedAt: 1, ExpiresAt: time.Now().Add(-time.Second).UnixMilli()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPITokenScopes(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.write("a.md", "内容")
|
||||
@@ -561,7 +275,6 @@ func TestAPITokenScopes(t *testing.T) {
|
||||
{"只读令牌不能写", read, "PUT", "/api/v1/files/a.md", "改", 403},
|
||||
{"读写令牌可写", write, "PUT", "/api/v1/files/b.md", "新", 200},
|
||||
{"读写令牌也不能管理令牌", write, "GET", "/api/v1/tokens", "", 403},
|
||||
{"令牌不能管理分享", write, "POST", "/api/v1/shares", `{"path":"a.md"}`, 403},
|
||||
{"无效令牌", "rm_wrong", "GET", "/api/v1/tree", "", 401},
|
||||
{"格式不符的令牌", "abc", "GET", "/api/v1/tree", "", 401},
|
||||
}
|
||||
|
||||
+2
-28
@@ -1,11 +1,6 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"rawmark/internal/vault"
|
||||
"time"
|
||||
)
|
||||
import "net/http"
|
||||
|
||||
func (s *Server) cleanupImages(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
@@ -18,28 +13,7 @@ func (s *Server) cleanupImages(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, 400, "请选择 1~1000 张待清理图片")
|
||||
return
|
||||
}
|
||||
result, err := s.vault.CleanupImages(body.Paths, func() ([]string, error) {
|
||||
shares, err := s.store.Shares()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scopes := []string{}
|
||||
now := time.Now().UnixMilli()
|
||||
for _, share := range shares {
|
||||
if share.ExpiresAt != 0 && share.ExpiresAt <= now {
|
||||
continue
|
||||
}
|
||||
for _, item := range share.Items {
|
||||
if _, err := s.vault.Stat(item.Path); errors.Is(err, vault.ErrNotFound) {
|
||||
continue
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scopes = append(scopes, item.Path)
|
||||
}
|
||||
}
|
||||
return scopes, nil
|
||||
})
|
||||
result, err := s.vault.CleanupImages(body.Paths)
|
||||
if err != nil {
|
||||
vaultError(w, r, err)
|
||||
return
|
||||
|
||||
+30
-17
@@ -1,35 +1,48 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"rawmark/internal/store"
|
||||
"rawmark/internal/vault"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestImageCleanupHonorsShareExpiryAndReferences(t *testing.T) {
|
||||
func TestImageCleanupHonorsReferences(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.write("assets/active.png", "active")
|
||||
f.write("assets/expired.png", "expired")
|
||||
f.write("assets/orphan.png", "orphan")
|
||||
f.write("assets/unused.png", "unused")
|
||||
f.write("assets/used.png", "used")
|
||||
f.write("note.md", "[image](assets/used.png)")
|
||||
for _, share := range []store.Share{
|
||||
{Token: "active", Items: []store.ShareItem{{ID: 0, Path: "assets/active.png"}}, ExpiresAt: time.Now().Add(time.Hour).UnixMilli()},
|
||||
{Token: "expired", Items: []store.ShareItem{{ID: 0, Path: "assets/expired.png"}}, ExpiresAt: time.Now().Add(-time.Hour).UnixMilli()},
|
||||
} {
|
||||
if err := f.store.CreateShare(share); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if code := f.status(newClient(), "POST", "/api/v1/assets/cleanup", `{"paths":["assets/expired.png"]}`, nil); code != http.StatusUnauthorized {
|
||||
if code := f.status(newClient(), "POST", "/api/v1/assets/cleanup", `{"paths":["assets/unused.png"]}`, nil); code != http.StatusUnauthorized {
|
||||
t.Fatal(code)
|
||||
}
|
||||
result := decode[vault.CleanupResult](t, f.do(f.admin, "POST", "/api/v1/assets/cleanup", `{"paths":["assets/active.png","assets/expired.png","assets/used.png"]}`, nil))
|
||||
if len(result.Deleted) != 1 || result.Deleted[0].Path != "assets/expired.png" || len(result.Skipped) != 2 {
|
||||
t.Fatalf("引用或分享保护不符:%+v", result)
|
||||
result := decode[vault.CleanupResult](t, f.do(f.admin, "POST", "/api/v1/assets/cleanup", `{"paths":["assets/orphan.png","assets/unused.png","assets/used.png"]}`, nil))
|
||||
if len(result.Deleted) != 2 || len(result.Skipped) != 1 || result.Skipped[0].Path != "assets/used.png" {
|
||||
t.Fatalf("引用保护不符:%+v", result)
|
||||
}
|
||||
if code := f.status(newClient(), "GET", "/api/v1/metadata", "", nil); code != http.StatusUnauthorized {
|
||||
t.Fatal(code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageCleanupRejectsDirectories(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.write("assets/folder.png/keep.md", "保留目录内的笔记")
|
||||
f.write("assets/orphan.png", "image")
|
||||
res := f.do(f.admin, "POST", "/api/v1/assets/cleanup", `{"paths":["assets/folder.png","assets/orphan.png"]}`, nil)
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Fatalf("清理应逐项汇报:%d", res.StatusCode)
|
||||
}
|
||||
result := decode[vault.CleanupResult](t, res)
|
||||
if len(result.Deleted) != 1 || result.Deleted[0].Path != "assets/orphan.png" || len(result.Skipped) != 1 || result.Skipped[0].Path != "assets/folder.png" || result.Skipped[0].Reason != "不是图片文件" {
|
||||
t.Fatalf("目录应跳过,普通孤立图片仍可清理:%+v", result)
|
||||
}
|
||||
kept := f.do(f.admin, "GET", "/api/v1/files/assets/folder.png/keep.md", "", nil)
|
||||
if kept.StatusCode != http.StatusOK {
|
||||
t.Fatalf("目录内笔记被误移:%d", kept.StatusCode)
|
||||
}
|
||||
got, err := io.ReadAll(kept.Body)
|
||||
if err != nil || string(got) != "保留目录内的笔记" {
|
||||
t.Fatalf("目录内笔记内容被改变:%q %v", got, err)
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
+3
-591
@@ -3,7 +3,7 @@
|
||||
"info": {
|
||||
"title": "RawMark API",
|
||||
"version": "1",
|
||||
"description": "RawMark 的 REST API。自动化调用请在「API 令牌」中创建令牌,以 `Authorization: Bearer <token>` 访问;只读令牌可使用 GET 和 POST /export(只读导出)。分享与令牌的管理接口只接受登录会话。所有错误响应均为 `{\"error\": \"说明\"}`。"
|
||||
"description": "RawMark 的 REST API。自动化调用请在「API 令牌」中创建令牌,以 `Authorization: Bearer <token>` 访问;只读令牌可使用 GET 和 POST /export(只读导出)。令牌的管理接口只接受登录会话。所有错误响应均为 `{\"error\": \"说明\"}`。"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
@@ -617,7 +617,7 @@
|
||||
"tags": [
|
||||
"笔记"
|
||||
],
|
||||
"description": "笔记的 .assets 目录随之移动,改名时改写本篇图片目录链接;图片移动或改名保持扩展名,并更新仓库笔记中的本地引用。分享链接随主体更新。不改写其他笔记指向笔记或文件夹的链接。",
|
||||
"description": "笔记的 .assets 目录随之移动,改名时改写本篇图片目录链接;图片移动或改名保持扩展名,并更新仓库笔记中的本地引用。维护笔记中的本地入链及相对出链。",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
@@ -1114,193 +1114,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/shares": {
|
||||
"get": {
|
||||
"summary": "分享列表",
|
||||
"tags": [
|
||||
"分享"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"cookie": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "path",
|
||||
"in": "query",
|
||||
"description": "只列出该笔记的分享",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "分享",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"shares": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Share"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"summary": "创建文件或文件夹的只读分享",
|
||||
"tags": [
|
||||
"分享"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"cookie": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "仓库内的文件或文件夹;文件夹递归公开当前所有普通文件和子目录,含后续新增或移入的内容"
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"description": "访问密码,留空为不设密码"
|
||||
},
|
||||
"expiresIn": {
|
||||
"type": "integer",
|
||||
"description": "有效时长(秒),0 为永久"
|
||||
},
|
||||
"paths": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "与 path 二选一;重叠路径只保留外层目录"
|
||||
}
|
||||
},
|
||||
"required": [],
|
||||
"oneOf": [
|
||||
{
|
||||
"required": [
|
||||
"path"
|
||||
],
|
||||
"not": {
|
||||
"required": [
|
||||
"paths"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"required": [
|
||||
"paths"
|
||||
],
|
||||
"not": {
|
||||
"required": [
|
||||
"path"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "新分享,token 为 128 位随机数的 URL 安全编码,访问地址为 /s/{token};移动分享主体不改变链接",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Share"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "路径或参数无效",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "笔记不存在",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "创建一个随机只读链接。path 创建单主体分享;paths 创建集合。内容实时读取,主体移动后跟随,部分主体删除不影响其余项。"
|
||||
}
|
||||
},
|
||||
"/shares/{token}": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "token",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"delete": {
|
||||
"summary": "撤销分享",
|
||||
"tags": [
|
||||
"分享"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"cookie": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "成功",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Ok"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "不存在",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tokens": {
|
||||
"get": {
|
||||
"summary": "API 令牌列表",
|
||||
@@ -1446,261 +1259,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/public/shares/{token}": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "token",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "path",
|
||||
"in": "query",
|
||||
"description": "文件夹分享中相对主体的子路径;省略时读取主体本身,不允许跳出分享范围",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"get": {
|
||||
"summary": "访客读取分享主体或其目录中的当前内容",
|
||||
"tags": [
|
||||
"分享(公开)"
|
||||
],
|
||||
"security": [
|
||||
{}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "目录返回 dir=true 及递归 entries(路径相对此目录);Markdown 返回 dir=false、content 与 updatedAt;其他文件返回 dir=false,内容经 assets 接口读取或下载",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"content": {
|
||||
"type": "string"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "integer"
|
||||
},
|
||||
"dir": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"entries": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Entry"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "需要访问密码",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
},
|
||||
"passwordRequired": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "分享不存在、已过期、主体已删除或子路径不在分享范围内",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "item",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
},
|
||||
"description": "集合分享的稳定成员标识;单主体分享可省略。子路径及附件始终限制在该主体原有范围内。"
|
||||
}
|
||||
],
|
||||
"description": "单主体沿用原响应。集合首页未指定 item 时返回 collection=true 及 items(仅含 id、title、dir、exists,不暴露仓库父路径);指定 item 后读取该主体,path 为其内部相对路径。"
|
||||
}
|
||||
},
|
||||
"/public/shares/{token}/unlock": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "token",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"post": {
|
||||
"summary": "输入分享密码,成功后设置访客 Cookie",
|
||||
"tags": [
|
||||
"分享(公开)"
|
||||
],
|
||||
"security": [
|
||||
{}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"password": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"password"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "成功",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Ok"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "密码错误",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "分享不存在或已失效",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "失败次数过多",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/public/shares/{token}/assets/{path}": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "token",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "path",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "文件夹分享中相对主体的文件路径;单文件分享仅允许其 .assets 图片,空路径读取该文件本身",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "download",
|
||||
"in": "query",
|
||||
"description": "设为 1 时以附件下载;其他情况下以带沙箱 CSP 的原始文件返回",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"1"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"get": {
|
||||
"summary": "访客读取或下载分享范围内的普通文件",
|
||||
"tags": [
|
||||
"分享(公开)"
|
||||
],
|
||||
"security": [
|
||||
{}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "文件内容",
|
||||
"content": {
|
||||
"application/octet-stream": {
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "binary"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "不存在或不在允许范围内",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "item",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
},
|
||||
"description": "集合分享的稳定成员标识;单主体分享可省略。子路径及附件始终限制在该主体原有范围内。"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/themes": {
|
||||
"get": {
|
||||
"summary": "列出主题(含内置默认主题)",
|
||||
@@ -1774,98 +1332,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/public/shares/{token}/appearance": {
|
||||
"get": {
|
||||
"summary": "读取有效分享使用的外观配置",
|
||||
"description": "仅返回明暗模式、字号、正文宽度、主题 CSS 和启用的 CSS 片段;密码分享需先解锁,不返回其他设置,不加载插件。",
|
||||
"tags": [
|
||||
"分享"
|
||||
],
|
||||
"security": [
|
||||
{}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "token",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "公开的外观配置",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"colorMode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"system",
|
||||
"light",
|
||||
"dark"
|
||||
]
|
||||
},
|
||||
"uiFontSize": {
|
||||
"type": "integer",
|
||||
"minimum": 14,
|
||||
"maximum": 20,
|
||||
"default": 16,
|
||||
"description": "界面字号,与正文排版字号独立"
|
||||
},
|
||||
"fontSize": {
|
||||
"type": "integer"
|
||||
},
|
||||
"contentWidth": {
|
||||
"type": "integer"
|
||||
},
|
||||
"themeCSS": {
|
||||
"type": "string"
|
||||
},
|
||||
"cssSnippets": {
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
],
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"css": {
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "需要访问密码"
|
||||
},
|
||||
"404": {
|
||||
"description": "分享不存在、已过期或已撤销"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/plugins": {
|
||||
"get": {
|
||||
"summary": "列出已安装插件及启用状态",
|
||||
@@ -2468,7 +1934,7 @@
|
||||
},
|
||||
"/assets/cleanup": {
|
||||
"post": {
|
||||
"summary": "重新核对全库引用和有效分享后,将孤立图片移入回收站",
|
||||
"summary": "重新核对全库引用后,将孤立图片移入回收站",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
@@ -2614,60 +2080,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Share": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"token": {
|
||||
"type": "string",
|
||||
"description": "新分享使用完全随机的 URL 安全标识,不包含文件名称或目录"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "单主体分享的路径;集合分享不返回此字段"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "integer"
|
||||
},
|
||||
"expiresAt": {
|
||||
"type": "integer",
|
||||
"description": "Unix 毫秒,0 为永久"
|
||||
},
|
||||
"hasPassword": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"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",
|
||||
"properties": {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
func TestRemovedShareAPIsReturnNotFound(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
token := decode[struct{ Token string }](t, f.do(f.admin, "POST", "/api/v1/tokens", `{"name":"automation","scope":"write"}`, nil)).Token
|
||||
for _, caller := range []struct {
|
||||
name string
|
||||
client *http.Client
|
||||
headers map[string]string
|
||||
}{
|
||||
{"guest", newClient(), nil},
|
||||
{"session", f.admin, nil},
|
||||
{"token", newClient(), map[string]string{"Authorization": "Bearer " + token}},
|
||||
} {
|
||||
t.Run(caller.name, func(t *testing.T) {
|
||||
for _, endpoint := range []struct{ method, path string }{
|
||||
{"GET", "/api/v1/shares"},
|
||||
{"POST", "/api/v1/shares"},
|
||||
{"DELETE", "/api/v1/shares/old-token"},
|
||||
{"GET", "/api/v1/public/shares/old-token"},
|
||||
{"HEAD", "/api/v1/public/shares/old-token"},
|
||||
{"GET", "/api/v1/public/shares/old-token/appearance"},
|
||||
{"POST", "/api/v1/public/shares/old-token/unlock"},
|
||||
{"GET", "/api/v1/public/shares/old-token/assets/image.png"},
|
||||
} {
|
||||
if code := f.status(caller.client, endpoint.method, endpoint.path, "{}", caller.headers); code != http.StatusNotFound {
|
||||
t.Errorf("%s %s:得到 %d,期望 404", endpoint.method, endpoint.path, code)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemovedSharePagesDoNotFallBackToApplication(t *testing.T) {
|
||||
handler := newStaticHandler(fstest.MapFS{
|
||||
"index.html": {Data: []byte("app entry")},
|
||||
"assets/app.js": {Data: []byte("app script")},
|
||||
})
|
||||
for _, method := range []string{"GET", "HEAD"} {
|
||||
for _, path := range []string{"/s", "/s/", "/s/old-token", "/s/old-token/child", "/share.html"} {
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, httptest.NewRequest(method, path, nil))
|
||||
if recorder.Code != http.StatusNotFound {
|
||||
t.Errorf("%s %s:得到 %d,期望 404", method, path, recorder.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"/", "/assets/app.js"} {
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, httptest.NewRequest("GET", path, nil))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("%s:应用资源无法读取:%d", path, recorder.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,426 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"rawmark/internal/auth"
|
||||
"rawmark/internal/events"
|
||||
"rawmark/internal/store"
|
||||
"rawmark/internal/vault"
|
||||
)
|
||||
|
||||
// 输入分享密码后的凭据 Cookie,按分享限定 Path,同一浏览器可同时打开多个分享
|
||||
const shareCookieName = "rawmark_share"
|
||||
|
||||
// 设有密码的永久分享,访客凭据的有效期
|
||||
const shareCookieTTL = 30 * 24 * time.Hour
|
||||
|
||||
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, 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= 时只列出该笔记的
|
||||
func (s *Server) listShares(w http.ResponseWriter, r *http.Request) {
|
||||
all, err := s.store.Shares()
|
||||
if err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
filter := r.URL.Query().Get("path")
|
||||
out := []shareView{}
|
||||
for _, sh := range all {
|
||||
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})
|
||||
}
|
||||
|
||||
func (s *Server) createShare(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Path string
|
||||
Paths []string
|
||||
Password string
|
||||
// 有效时长(秒),0 为永久
|
||||
ExpiresIn int64
|
||||
}
|
||||
if !readJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if body.Path != "" && len(body.Paths) != 0 {
|
||||
writeError(w, 400, "path 和 paths 不能同时指定")
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
now := time.Now()
|
||||
sh := store.Share{
|
||||
Token: auth.NewShareToken(),
|
||||
Items: items,
|
||||
PasswordHash: auth.HashSharePassword(body.Password),
|
||||
CreatedAt: now.UnixMilli(),
|
||||
}
|
||||
if body.ExpiresIn > 0 {
|
||||
sh.ExpiresAt = now.Add(time.Duration(body.ExpiresIn) * time.Second).UnixMilli()
|
||||
}
|
||||
if err := s.store.CreateShare(sh); err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
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) {
|
||||
ok, err := s.store.DeleteShare(r.PathValue("token"))
|
||||
if err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
writeError(w, 404, "分享不存在")
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// ---- 公开访问(无需登录) ----
|
||||
|
||||
// openShare 取出有效的分享并校验访客凭据;失败时已写好响应
|
||||
func (s *Server) openShare(w http.ResponseWriter, r *http.Request) (store.Share, bool) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("X-Robots-Tag", "noindex")
|
||||
sh, ok, err := s.loadShare(r.PathValue("token"))
|
||||
if err != nil {
|
||||
internalError(w, r, err)
|
||||
return sh, false
|
||||
}
|
||||
if !ok {
|
||||
writeError(w, 404, "分享不存在或已失效")
|
||||
return sh, false
|
||||
}
|
||||
if sh.PasswordHash != "" {
|
||||
valid := false
|
||||
if c, err := r.Cookie(shareCookieName); err == nil {
|
||||
if valid, err = s.auth.ValidShareCookie(c.Value, sh.Token, sh.PasswordHash); err != nil {
|
||||
internalError(w, r, err)
|
||||
return sh, false
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
writeJSON(w, 401, map[string]any{"error": "需要访问密码", "passwordRequired": true})
|
||||
return sh, false
|
||||
}
|
||||
}
|
||||
return sh, true
|
||||
}
|
||||
|
||||
// loadShare 返回未过期的分享
|
||||
func (s *Server) loadShare(token string) (store.Share, bool, error) {
|
||||
sh, ok, err := s.store.GetShare(token)
|
||||
if err != nil || !ok {
|
||||
return sh, false, err
|
||||
}
|
||||
if sh.ExpiresAt != 0 && time.Now().UnixMilli() >= sh.ExpiresAt {
|
||||
return sh, false, nil
|
||||
}
|
||||
return sh, true, nil
|
||||
}
|
||||
|
||||
// publicShare 只公开主体本身或分享目录内的内容,不返回主体在仓库中的路径。
|
||||
func (s *Server) publicShare(w http.ResponseWriter, r *http.Request) {
|
||||
sh, ok := s.openShare(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
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 := subject
|
||||
if rel := r.URL.Query().Get("path"); rel != "" {
|
||||
if !info.IsDir() {
|
||||
writeError(w, 404, "文件不存在")
|
||||
return
|
||||
}
|
||||
p, err = s.vault.ScopedPath(subject, 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(p), vault.NoteExt),
|
||||
"content": string(data),
|
||||
"updatedAt": info.ModTime().UnixMilli(),
|
||||
"dir": false,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) unlockShare(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
var body struct{ Password string }
|
||||
if !readJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
sh, ok, err := s.loadShare(r.PathValue("token"))
|
||||
if err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
writeError(w, 404, "分享不存在或已失效")
|
||||
return
|
||||
}
|
||||
if sh.PasswordHash != "" {
|
||||
switch err := s.auth.CheckSharePassword(clientIP(r), sh.PasswordHash, body.Password); {
|
||||
case errors.Is(err, auth.ErrRateLimited):
|
||||
writeError(w, 429, err.Error())
|
||||
return
|
||||
case errors.Is(err, auth.ErrBadCredentials):
|
||||
writeError(w, 401, "密码错误")
|
||||
return
|
||||
case err != nil:
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
value, err := s.auth.ShareCookie(sh.Token, sh.PasswordHash)
|
||||
if err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
ttl := shareCookieTTL
|
||||
if sh.ExpiresAt != 0 {
|
||||
ttl = time.Until(time.UnixMilli(sh.ExpiresAt))
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: shareCookieName,
|
||||
Value: value,
|
||||
// 默认 Path 为 /unlock 所在目录,避免非 ASCII 文件名违反 Cookie Path 格式。
|
||||
MaxAge: int(ttl / time.Second),
|
||||
HttpOnly: true,
|
||||
Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https",
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
writeJSON(w, 200, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// publicShareAsset 提供主体允许范围内的普通文件;单文件分享额外允许其 .assets。
|
||||
func (s *Server) publicShareAsset(w http.ResponseWriter, r *http.Request) {
|
||||
sh, ok := s.openShare(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
subject, ok := shareSubject(w, r, sh)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
info, err := s.vault.Stat(subject)
|
||||
if err != nil {
|
||||
vaultError(w, r, err)
|
||||
return
|
||||
}
|
||||
rel := r.PathValue("path")
|
||||
var target string
|
||||
if info.IsDir() {
|
||||
target, err = s.vault.ScopedPath(subject, rel)
|
||||
} else if rel == "" {
|
||||
target = subject
|
||||
} else {
|
||||
assets := vault.AssetsDir(subject)
|
||||
prefix := path.Base(assets) + "/"
|
||||
if !vault.IsNote(subject) || !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)
|
||||
}
|
||||
|
||||
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) {
|
||||
for ctx.Err() == nil {
|
||||
ch, cancel := s.hub.Subscribe()
|
||||
s.followMoves(ctx, ch)
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) followMoves(ctx context.Context, ch <-chan []byte) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case msg, ok := <-ch:
|
||||
if !ok {
|
||||
// 处理不及时被断开,重新订阅
|
||||
return
|
||||
}
|
||||
var evs []events.Event
|
||||
if err := json.Unmarshal(msg, &evs); err != nil {
|
||||
slog.Error("解析变更事件失败", "err", err)
|
||||
continue
|
||||
}
|
||||
for _, ev := range evs {
|
||||
if ev.Type == "moved" {
|
||||
if err := s.store.MoveShares(ev.From, ev.Path); err != nil {
|
||||
slog.Warn("更新分享路径失败", "from", ev.From, "to", ev.Path, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,10 +42,10 @@ func (h *staticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
|
||||
// 分享页 /s/{token} 是独立的入口,只加载渲染器
|
||||
share := strings.HasPrefix(name, "s/")
|
||||
if share {
|
||||
name = "share.html"
|
||||
// 已移除的公开入口不能回退到应用首页。
|
||||
if name == "s" || strings.HasPrefix(name, "s/") || name == "share.html" {
|
||||
writeError(w, 404, "页面不存在")
|
||||
return
|
||||
}
|
||||
f, err := h.load(name)
|
||||
if err != nil {
|
||||
@@ -65,9 +65,6 @@ func (h *staticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(name, ".html") {
|
||||
hd.Set("Content-Security-Policy", appCSP)
|
||||
}
|
||||
if share {
|
||||
hd.Set("X-Robots-Tag", "noindex")
|
||||
}
|
||||
if path.Ext(name) == ".webmanifest" {
|
||||
// Go 的 MIME 表未必包含此扩展名,不能让 ServeContent 对压缩后的数据嗅探类型。
|
||||
hd.Set("Content-Type", "application/manifest+json")
|
||||
|
||||
@@ -142,57 +142,3 @@ func (s *Server) getTheme(w http.ResponseWriter, r *http.Request) {
|
||||
CSS string `json:"css"`
|
||||
}{manifest, css})
|
||||
}
|
||||
|
||||
// 只有有效分享的访客可读取外观;不返回账户、插件和其他应用设置。
|
||||
func (s *Server) publicAppearance(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.openShare(w, r); !ok {
|
||||
return
|
||||
}
|
||||
settings, err := s.readSettings()
|
||||
if err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
out := map[string]any{}
|
||||
for _, key := range []string{"colorMode", "uiFontSize", "fontSize", "contentWidth", "textFont", "codeFont", "lineHeight", "paragraphSpacing", "glassOpacity"} {
|
||||
if value, ok := settings[key]; ok {
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
var snippets []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CSS string `json:"css"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if value, ok := settings["cssSnippets"]; ok {
|
||||
if err := json.Unmarshal(value, &snippets); err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
active := snippets[:0]
|
||||
for _, snippet := range snippets {
|
||||
if snippet.Enabled {
|
||||
active = append(active, snippet)
|
||||
}
|
||||
}
|
||||
out["cssSnippets"] = active
|
||||
var theme string
|
||||
if value, ok := settings["theme"]; ok {
|
||||
if err := json.Unmarshal(value, &theme); err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if theme != "" && theme != "default" {
|
||||
_, css, err := s.readTheme(theme)
|
||||
if err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
out["themeCSS"] = css
|
||||
}
|
||||
out["theme"] = theme
|
||||
writeJSON(w, 200, out)
|
||||
}
|
||||
@@ -24,7 +24,6 @@
|
||||
/* 绿色按钮使用深色文字,保持按钮的文字对比度。 */
|
||||
.rm-btn-primary,
|
||||
.rm-btn-primary:hover,
|
||||
.rm-segmented button.on,
|
||||
.rm-share-password button {
|
||||
.rm-segmented button.on {
|
||||
color: #101b16;
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -10,7 +8,7 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestThemeAndShareAppearanceBoundaries(t *testing.T) {
|
||||
func TestThemeBoundaries(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
dir := filepath.Join(filepath.Dir(f.root), "themes", "paper")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
@@ -42,33 +40,6 @@ func TestThemeAndShareAppearanceBoundaries(t *testing.T) {
|
||||
if !strings.Contains(theme["css"], "#faf6ed") {
|
||||
t.Fatal("未读取到主题 CSS")
|
||||
}
|
||||
f.write("笔记.md", "正文")
|
||||
f.do(f.admin, "PATCH", "/api/v1/settings", `{"theme":"paper","colorMode":"dark","fontSize":18,"uiFontSize":20,"privateSetting":"不公开","cssSnippets":[{"id":"a","name":"启用","css":"body { color: red }","enabled":true},{"id":"b","name":"停用","css":"不公开片段","enabled":false}]}`, nil)
|
||||
sh := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", `{"path":"笔记.md","password":"访问密码"}`, nil))
|
||||
base := "/api/v1/public/shares/" + sh.Token
|
||||
if code := f.status(guest, "GET", base+"/appearance", "", nil); code != 401 {
|
||||
t.Fatalf("未解锁不能读取外观,得到 %d", code)
|
||||
}
|
||||
f.do(guest, "POST", base+"/unlock", `{"password":"访问密码"}`, nil)
|
||||
res := f.do(guest, "GET", base+"/appearance", "", nil)
|
||||
raw, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var appearance map[string]any
|
||||
if err := json.Unmarshal(raw, &appearance); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.StatusCode != 200 || appearance["colorMode"] != "dark" || appearance["uiFontSize"] != float64(20) || appearance["fontSize"] != float64(18) || !strings.Contains(appearance["themeCSS"].(string), "#faf6ed") {
|
||||
t.Fatalf("分享外观错误:%s", raw)
|
||||
}
|
||||
if strings.Contains(string(raw), "不公开") || len(appearance["cssSnippets"].([]any)) != 1 {
|
||||
t.Fatalf("分享泄露了其他设置或停用片段:%s", raw)
|
||||
}
|
||||
f.do(f.admin, "DELETE", "/api/v1/shares/"+sh.Token, "", nil)
|
||||
if code := f.status(guest, "GET", base+"/appearance", "", nil); code != 404 {
|
||||
t.Fatalf("撤销分享后不能读取外观,得到 %d", code)
|
||||
}
|
||||
// CSS 符号链接不能读取主题目录之外的应用数据。
|
||||
if err := os.Remove(filepath.Join(dir, "theme.css")); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
+2
-75
@@ -1,8 +1,7 @@
|
||||
// Package auth 实现单用户登录(Argon2id 密码哈希、会话令牌、登录限速),以及分享密码与 API 令牌的校验。
|
||||
// Package auth 实现单用户登录(Argon2id 密码哈希、会话令牌、登录限速),以及 API 令牌的校验。
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
@@ -21,7 +20,6 @@ import (
|
||||
|
||||
const (
|
||||
passwordKey = "auth.password"
|
||||
shareSecretKey = "share.secret"
|
||||
apiTokenPrefix = "rm_"
|
||||
SessionTTL = 30 * 24 * time.Hour
|
||||
MinPasswordLen = 8
|
||||
@@ -32,7 +30,7 @@ const (
|
||||
argonThreads = 4
|
||||
argonKeyLen = 32
|
||||
|
||||
// 同一来源在窗口内累计失败 maxFailures 次(登录与分享密码合计)即封禁 lockout
|
||||
// 同一来源在窗口内累计失败 maxFailures 次即封禁 lockout
|
||||
maxFailures = 30
|
||||
failureWindow = 15 * time.Minute
|
||||
lockout = 15 * time.Minute
|
||||
@@ -149,70 +147,6 @@ func (m *Manager) ChangePassword(ip, keepToken, current, next string) error {
|
||||
return m.store.DeleteOtherSessions(hashToken(keepToken))
|
||||
}
|
||||
|
||||
// ---- 分享 ----
|
||||
|
||||
// HashSharePassword 为分享密码生成哈希;空密码表示不设密码,返回空字符串
|
||||
func HashSharePassword(password string) string {
|
||||
if password == "" {
|
||||
return ""
|
||||
}
|
||||
return hashPassword(password)
|
||||
}
|
||||
|
||||
// CheckSharePassword 校验分享密码,与登录共用按来源的失败限速。
|
||||
// 成功不清零失败计数:否则知道某个分享密码的人可以借此反复重置计数,无限尝试登录密码
|
||||
func (m *Manager) CheckSharePassword(ip, hash, password string) error {
|
||||
if !m.allow(ip) {
|
||||
return ErrRateLimited
|
||||
}
|
||||
ok, err := verifyPassword(hash, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
m.recordFailure(ip)
|
||||
return ErrBadCredentials
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ShareCookie 生成输入分享密码后发给访客的凭据:绑定分享令牌与密码哈希,撤销分享即失效
|
||||
func (m *Manager) ShareCookie(token, passwordHash string) (string, error) {
|
||||
secret, err := m.shareSecret()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
mac := hmac.New(sha256.New, secret)
|
||||
mac.Write([]byte(token + "\x00" + passwordHash))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func (m *Manager) ValidShareCookie(cookie, token, passwordHash string) (bool, error) {
|
||||
want, err := m.ShareCookie(token, passwordHash)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return hmac.Equal([]byte(cookie), []byte(want)), nil
|
||||
}
|
||||
|
||||
// shareSecret 读取签名密钥,首次使用时生成并保存
|
||||
func (m *Manager) shareSecret() ([]byte, error) {
|
||||
v, ok, err := m.store.Setting(shareSecretKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
if _, err := m.store.SetSettingIfAbsent(shareSecretKey, randomToken()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 并发首次生成时以先写入的为准
|
||||
if v, _, err = m.store.Setting(shareSecretKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return base64.RawURLEncoding.DecodeString(v)
|
||||
}
|
||||
|
||||
// ---- API 令牌 ----
|
||||
|
||||
// CreateAPIToken 创建令牌,返回只显示这一次的明文
|
||||
@@ -316,13 +250,6 @@ func randomToken() string {
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// NewShareToken 生成分享链接中的令牌(128 位随机数,比会话令牌短,便于复制)
|
||||
func NewShareToken() string {
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
func hashToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
|
||||
@@ -25,34 +25,20 @@ func newManager(t *testing.T) (*Manager, *time.Time) {
|
||||
return m, &clock
|
||||
}
|
||||
|
||||
// 登录与分享密码合计 30 次失败后封禁 15 分钟,期间正确密码也被拒绝
|
||||
// 登录密码累计 30 次失败后封禁 15 分钟,期间正确密码也被拒绝
|
||||
func TestRateLimit(t *testing.T) {
|
||||
m, clock := newManager(t)
|
||||
share := HashSharePassword("share-pass")
|
||||
for i := 0; i < maxFailures-1; i++ {
|
||||
var err error
|
||||
if i%2 == 0 {
|
||||
_, err = m.Login("1.1.1.1", "wrong")
|
||||
} else {
|
||||
err = m.CheckSharePassword("1.1.1.1", share, "wrong")
|
||||
}
|
||||
if !errors.Is(err, ErrBadCredentials) {
|
||||
if _, err := m.Login("1.1.1.1", "wrong"); !errors.Is(err, ErrBadCredentials) {
|
||||
t.Fatalf("第 %d 次失败: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
// 分享密码正确不清零计数
|
||||
if err := m.CheckSharePassword("1.1.1.1", share, "share-pass"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := m.Login("1.1.1.1", "wrong"); !errors.Is(err, ErrBadCredentials) {
|
||||
t.Fatalf("第 30 次失败: %v", err)
|
||||
}
|
||||
if _, err := m.Login("1.1.1.1", "password123"); !errors.Is(err, ErrRateLimited) {
|
||||
t.Fatalf("封禁期间正确密码也应被拒绝: %v", err)
|
||||
}
|
||||
if err := m.CheckSharePassword("1.1.1.1", share, "share-pass"); !errors.Is(err, ErrRateLimited) {
|
||||
t.Fatalf("封禁同样作用于分享密码: %v", err)
|
||||
}
|
||||
if _, err := m.Login("2.2.2.2", "password123"); err != nil {
|
||||
t.Fatalf("其他来源不受影响: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRemoveSharingPreservesApplicationData(t *testing.T) {
|
||||
script, err := os.ReadFile("../../scripts/remove-sharing.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, layout := range []string{"current", "legacy", "fresh"} {
|
||||
t.Run(layout, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "rawmark.db")
|
||||
st, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
if _, err := st.db.Exec(`
|
||||
INSERT INTO settings VALUES ('auth.password','password-hash'), ('plugin.setting','keep'),
|
||||
('share.secret','old-secret'),
|
||||
('app.settings','{"fontSize":18,"shortcuts":{"note.share":["Alt+S"],"app.shares":[],"note.open":["Ctrl+O"],"plugin.command":[]},"plugin.data":{"keep":true}}');
|
||||
INSERT INTO sessions VALUES ('session-hash',123,456);
|
||||
INSERT INTO api_tokens VALUES ('token-id','automation','token-hash','read',123,456);`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
switch layout {
|
||||
case "current":
|
||||
_, err = st.db.Exec(`CREATE TABLE shares(token TEXT PRIMARY KEY,password_hash TEXT,created_at INTEGER,expires_at INTEGER);
|
||||
CREATE TABLE share_items(share_token TEXT REFERENCES shares(token),item_id INTEGER,path TEXT);
|
||||
CREATE INDEX share_items_path ON share_items(path);
|
||||
INSERT INTO shares VALUES ('old-token','hash',123,0);
|
||||
INSERT INTO share_items VALUES ('old-token',0,'note.md');`)
|
||||
case "legacy":
|
||||
_, err = st.db.Exec(`CREATE TABLE shares(token TEXT PRIMARY KEY,path TEXT,password_hash TEXT,created_at INTEGER,expires_at INTEGER);
|
||||
CREATE INDEX shares_path ON shares(path);
|
||||
INSERT INTO shares VALUES ('old-token','note.md','hash',123,0);`)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for range 2 {
|
||||
if _, err := st.db.Exec(string(script)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := st.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, err = Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var remaining int
|
||||
if err := st.db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE name IN ('shares','share_items','share_items_path','shares_path')`).Scan(&remaining); err != nil || remaining != 0 {
|
||||
t.Fatalf("重启后不应创建分享结构:%d %v", remaining, err)
|
||||
}
|
||||
if _, ok, err := st.Setting("share.secret"); err != nil || ok {
|
||||
t.Fatalf("分享密钥未清除:%v", err)
|
||||
}
|
||||
for key, want := range map[string]string{
|
||||
"auth.password": "password-hash", "plugin.setting": "keep",
|
||||
"app.settings": `{"fontSize":18,"shortcuts":{"note.open":["Ctrl+O"],"plugin.command":[]},"plugin.data":{"keep":true}}`,
|
||||
} {
|
||||
if got, ok, err := st.Setting(key); err != nil || !ok || got != want {
|
||||
t.Fatalf("非分享设置被改变:%s %v", key, err)
|
||||
}
|
||||
}
|
||||
var sessions, tokens int
|
||||
if err := st.db.QueryRow(`SELECT count(*) FROM sessions WHERE token_hash='session-hash' AND created_at=123 AND expires_at=456`).Scan(&sessions); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.db.QueryRow(`SELECT count(*) FROM api_tokens WHERE id='token-id' AND name='automation' AND token_hash='token-hash' AND scope='read' AND created_at=123 AND last_used_at=456`).Scan(&tokens); err != nil || sessions != 1 || tokens != 1 {
|
||||
t.Fatalf("会话或 API 令牌被改变:%d %d %v", sessions, tokens, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveSharingRollsBackOnInvalidSettings(t *testing.T) {
|
||||
st, err := Open(filepath.Join(t.TempDir(), "rawmark.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
if _, err := st.db.Exec(`CREATE TABLE shares(token TEXT); INSERT INTO shares VALUES ('old-token'); INSERT INTO settings VALUES ('share.secret','old-secret'),('app.settings','invalid-json');`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
script, err := os.ReadFile("../../scripts/remove-sharing.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.db.Exec(string(script)); err == nil {
|
||||
t.Fatal("设置损坏时必须报告错误")
|
||||
}
|
||||
if _, err := st.db.Exec("ROLLBACK"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count int
|
||||
if err := st.db.QueryRow(`SELECT count(*) FROM shares WHERE token='old-token'`).Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("失败后分享数据未恢复:%v", err)
|
||||
}
|
||||
if got, ok, err := st.Setting("share.secret"); err != nil || !ok || got != "old-secret" {
|
||||
t.Fatalf("失败后分享密钥未恢复:%v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
+1
-126
@@ -1,4 +1,4 @@
|
||||
// Package store 保存应用自身的数据(设置、会话、分享、API 令牌)。笔记内容不在这里,见 vault。
|
||||
// Package store 保存应用自身的数据(设置、会话、API 令牌)。笔记内容不在这里,见 vault。
|
||||
package store
|
||||
|
||||
import (
|
||||
@@ -22,7 +22,6 @@ CREATE TABLE IF NOT EXISTS sessions (
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
` + shareSchema + `
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
@@ -38,10 +37,6 @@ 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)
|
||||
@@ -108,126 +103,6 @@ func (s *Store) DeleteExpiredSessions() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- 分享 ----
|
||||
|
||||
// Share 为一个只读分享链接,Items 标识各个独立跟随移动的主体。
|
||||
// Path 仅用于已有单主体调用与响应,不单独存储。
|
||||
type Share struct {
|
||||
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 {
|
||||
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()
|
||||
}
|
||||
|
||||
func (s *Store) GetShare(token string) (sh Share, ok bool, err error) {
|
||||
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
|
||||
}
|
||||
|
||||
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, password_hash, created_at, expires_at FROM shares ORDER BY created_at DESC, token`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []Share{}
|
||||
for rows.Next() {
|
||||
var sh Share
|
||||
if err := rows.Scan(&sh.Token, &sh.PasswordHash, &sh.CreatedAt, &sh.ExpiresAt); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, sh)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func (s *Store) DeleteShare(token string) (bool, error) {
|
||||
res, err := s.db.Exec(`DELETE FROM shares WHERE token = ?`, token)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
return n == 1, err
|
||||
}
|
||||
|
||||
func (s *Store) MoveShares(from, to string) error {
|
||||
_, 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
|
||||
}
|
||||
|
||||
// ---- API 令牌 ----
|
||||
|
||||
// APIToken 为自动化调用的访问令牌,只保存哈希。Scope 为 read 或 write
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
import "fmt"
|
||||
|
||||
type CleanupResult struct {
|
||||
Deleted []TrashItem `json:"deleted"`
|
||||
@@ -15,8 +12,7 @@ type CleanupSkip struct {
|
||||
}
|
||||
|
||||
// CleanupImages 在写锁内重新扫描引用;派生索引不作为删除依据。
|
||||
// protected 返回当前有效分享的主体路径,由业务数据拥有者在检查时读取。
|
||||
func (v *Vault) CleanupImages(paths []string, protected func() ([]string, error)) (CleanupResult, error) {
|
||||
func (v *Vault) CleanupImages(paths []string) (CleanupResult, error) {
|
||||
result := CleanupResult{Deleted: []TrashItem{}, Skipped: []CleanupSkip{}}
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
@@ -41,10 +37,6 @@ func (v *Vault) CleanupImages(paths []string, protected func() ([]string, error)
|
||||
references[ref.Target] = true
|
||||
}
|
||||
}
|
||||
scopes, err := protected()
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, p := range paths {
|
||||
if seen[p] {
|
||||
@@ -58,11 +50,11 @@ func (v *Vault) CleanupImages(paths []string, protected func() ([]string, error)
|
||||
case references[p]:
|
||||
reason = "仍被笔记引用"
|
||||
default:
|
||||
for _, scope := range scopes {
|
||||
if p == scope || strings.HasPrefix(p, scope+"/") || IsNote(scope) && strings.HasPrefix(p, AssetsDir(scope)+"/") {
|
||||
reason = "仍在有效分享范围内"
|
||||
break
|
||||
}
|
||||
info, err := v.Stat(p)
|
||||
if err != nil {
|
||||
reason = err.Error()
|
||||
} else if !info.Mode().IsRegular() {
|
||||
reason = "不是图片文件"
|
||||
}
|
||||
}
|
||||
if reason != "" {
|
||||
|
||||
@@ -8,12 +8,12 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCleanupChecksFreshReferencesAndShares(t *testing.T) {
|
||||
func TestCleanupChecksFreshReferences(t *testing.T) {
|
||||
v := newVault(t)
|
||||
if _, err := v.Write("a.md", []byte("[图片](assets/used.png)"), Precondition{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, p := range []string{"assets/used.png", "assets/orphan.png", "public/shared.png", "a.assets/attached.png"} {
|
||||
for _, p := range []string{"assets/used.png", "assets/orphan.png", "public/orphan.png", "a.assets/attached.png"} {
|
||||
full := filepath.Join(v.Root(), p)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -22,8 +22,8 @@ func TestCleanupChecksFreshReferencesAndShares(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
result, err := v.CleanupImages([]string{"assets/used.png", "assets/orphan.png", "public/shared.png", "a.assets/attached.png", "../outside.png"}, func() ([]string, error) { return []string{"public", "a.md"}, nil })
|
||||
if err != nil || len(result.Deleted) != 1 || result.Deleted[0].Path != "assets/orphan.png" || len(result.Skipped) != 4 {
|
||||
result, err := v.CleanupImages([]string{"assets/used.png", "assets/orphan.png", "public/orphan.png", "a.assets/attached.png", "../outside.png"})
|
||||
if err != nil || len(result.Deleted) != 3 || result.Deleted[0].Path != "assets/orphan.png" || len(result.Skipped) != 2 {
|
||||
t.Fatalf("清理结果不符:%+v %v", result, err)
|
||||
}
|
||||
if _, err := v.Restore(result.Deleted[0].ID); err != nil {
|
||||
@@ -32,24 +32,13 @@ func TestCleanupChecksFreshReferencesAndShares(t *testing.T) {
|
||||
if _, err := v.Write("a.md", []byte("---\ntags: [broken\n---"), Precondition{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err = v.CleanupImages([]string{"assets/orphan.png"}, func() ([]string, error) { return nil, nil })
|
||||
result, err = v.CleanupImages([]string{"assets/orphan.png"})
|
||||
if err == nil || len(result.Deleted) != 0 {
|
||||
t.Fatal("不完整的引用分析不能删除图片", result, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(v.Root(), "assets/orphan.png")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := v.Write("a.md", []byte(""), Precondition{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cause := errors.New("分享读取失败")
|
||||
_, err = v.CleanupImages([]string{"assets/orphan.png"}, func() ([]string, error) { return nil, cause })
|
||||
if !errors.Is(err, cause) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(v.Root(), "assets/orphan.png")); err != nil {
|
||||
t.Fatal("分享校验失败后不应删除", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderUploadReusesDeduplication(t *testing.T) {
|
||||
@@ -86,7 +75,7 @@ func TestCleanupProtectsLinkedImages(t *testing.T) {
|
||||
if len(metadata.Assets) != 1 || metadata.Assets[0].Target != link {
|
||||
t.Fatalf("链接内图片未记录引用:%+v", metadata)
|
||||
}
|
||||
result, err := v.CleanupImages([]string{link}, func() ([]string, error) { return nil, nil })
|
||||
result, err := v.CleanupImages([]string{link})
|
||||
if err != nil || len(result.Deleted) != 0 || len(result.Skipped) != 1 || result.Skipped[0].Reason != "仍被笔记引用" {
|
||||
t.Fatalf("链接内图片未受保护:%+v %v", result, err)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ func TestImageMoveUpdatesLocalReferences(t *testing.T) {
|
||||
if _, err := v.Write("notes/a.md", []byte(content), Precondition{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := v.Move("assets/原图.png", "elsewhere/新图 #.png", nil); err != nil {
|
||||
if _, err := v.Move("assets/原图.png", "elsewhere/新图 #.png"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, _, err := v.Read("notes/a.md")
|
||||
@@ -33,7 +33,7 @@ func TestImageMoveUpdatesLocalReferences(t *testing.T) {
|
||||
if string(out) != want {
|
||||
t.Fatalf("链接改写不符:\n%s\n期望:\n%s", out, want)
|
||||
}
|
||||
if _, err := v.Move("elsewhere/新图 #.png", "elsewhere/新图.jpg", nil); !errors.Is(err, ErrInvalidPath) {
|
||||
if _, err := v.Move("elsewhere/新图 #.png", "elsewhere/新图.jpg"); !errors.Is(err, ErrInvalidPath) {
|
||||
t.Fatalf("不能用重命名改变图片格式:%v", err)
|
||||
}
|
||||
item, err := v.Delete("elsewhere/新图 #.png")
|
||||
@@ -62,7 +62,7 @@ func TestImageMovePreservesMultilineMarkdownAndIgnoresLiteralPaths(t *testing.T)
|
||||
if _, err := v.Write("文章.md", []byte(content), Precondition{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := v.Move("old.png", "new.png", nil); err != nil {
|
||||
if _, err := v.Move("old.png", "new.png"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _, err := v.Read("文章.md")
|
||||
@@ -138,42 +138,3 @@ func TestImagesDoNotExposeOrModifySymlinks(t *testing.T) {
|
||||
t.Fatalf("不能替换仓库外文件:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveCommitFailureRestoresExactReferences(t *testing.T) {
|
||||
for _, source := range []string{"from.png", "from.md"} {
|
||||
t.Run(source, func(t *testing.T) {
|
||||
v := newVault(t)
|
||||
content := ""
|
||||
if _, err := v.Write("from.md", []byte(content), Precondition{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(v.Root(), "from.assets"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(v.Root(), "from.png"), []byte("image"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
refs := "\n"
|
||||
if _, err := v.Write("refs.md", []byte(refs), Precondition{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
failure := errors.New("关联数据提交失败")
|
||||
target := strings.Replace(source, "from", "to", 1)
|
||||
if _, err := v.Move(source, target, func() error { return failure }); !errors.Is(err, failure) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, want := range map[string]string{"from.md": content, "refs.md": refs} {
|
||||
got, _, err := v.Read(name)
|
||||
if err != nil || string(got) != want {
|
||||
t.Fatalf("回滚须保留原始正文:%s %q %v", name, got, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(v.Root(), target)); !os.IsNotExist(err) {
|
||||
t.Fatalf("失败后目标仍存在:%v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(v.Root(), "from.assets")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,7 @@ func TestImportZipAssetsAndMove(t *testing.T) {
|
||||
if err := v.Mkdir("移动目标"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := v.Move("知识库/目录/笔记.md", "移动目标/改名.md", nil); err != nil {
|
||||
if _, err := v.Move("知识库/目录/笔记.md", "移动目标/改名.md"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
moved, _, err := v.Read("移动目标/改名.md")
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -42,7 +41,7 @@ func TestMoveMaintainsIncomingAndOutgoingLinks(t *testing.T) {
|
||||
if _, err := v.SaveAsset("dir/a.md", "p.png", strings.NewReader("image"), 100); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := v.Move("dir/a.md", "new/renamed.md", nil); err != nil {
|
||||
if _, err := v.Move("dir/a.md", "new/renamed.md"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _, _ := v.Read("new/renamed.md")
|
||||
@@ -53,25 +52,13 @@ func TestMoveMaintainsIncomingAndOutgoingLinks(t *testing.T) {
|
||||
if string(body) != "[入链](../new/renamed.md?q=1#section)" {
|
||||
t.Fatalf("入链错误:%s", body)
|
||||
}
|
||||
if _, err := v.Move("new", "deep/folder", nil); err != nil {
|
||||
if _, err := v.Move("new", "deep/folder"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _, _ = v.Read("deep/folder/renamed.md")
|
||||
if !strings.Contains(string(body), "../../other.md") {
|
||||
t.Fatalf("目录出链错误:%s", body)
|
||||
}
|
||||
original, _, _ := v.Read("other.md")
|
||||
failure := errors.New("提交失败")
|
||||
if _, err := v.Move("deep/folder", "rollback", func() error { return failure }); !errors.Is(err, failure) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _, _ = v.Read("other.md")
|
||||
if string(body) != string(original) {
|
||||
t.Fatalf("入链未回滚:%s", body)
|
||||
}
|
||||
if _, _, err := v.Read("deep/folder/renamed.md"); err != nil {
|
||||
t.Fatal("移动主体未回滚", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataSkipsMathAndResolvesFootnotes(t *testing.T) {
|
||||
@@ -102,7 +89,7 @@ func TestMovePreservesEntityShapedFilenames(t *testing.T) {
|
||||
}
|
||||
}
|
||||
const target = "a©.md"
|
||||
if _, err := v.Move("target.md", target, nil); err != nil {
|
||||
if _, err := v.Move("target.md", target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _, err := v.Read("source.md")
|
||||
|
||||
+1
-47
@@ -362,46 +362,11 @@ func (v *Vault) TreeAt(rel string) ([]Entry, error) {
|
||||
return entries, err
|
||||
}
|
||||
|
||||
// ScopedPath 校验公开访问的子路径,并阻止符号链接跳出已分享的目录。
|
||||
func (v *Vault) ScopedPath(root, rel string) (string, error) {
|
||||
if _, err := CleanPath(rel); err != nil {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
base, err := v.abs(root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
base, err = filepath.EvalSymlinks(base)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
p := path.Join(root, rel)
|
||||
full, err := v.abs(p)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
full, err = filepath.EvalSymlinks(full)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if full != filepath.Join(base, filepath.FromSlash(rel)) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// ---- 移动 / 重命名 ----
|
||||
|
||||
// Move 移动或重命名笔记、图片或文件夹,返回是否原子改写了移动主体(改变文件身份)。
|
||||
// 笔记的 .assets 目录随之移动;笔记改名时同步改写正文中指向自身图片目录的链接。
|
||||
// commit 用于提交关联元数据;失败时在释放写锁前恢复文件及原始正文,nil 表示无关联元数据。
|
||||
func (v *Vault) Move(from, to string, commit func() error) (rewritten bool, err error) {
|
||||
func (v *Vault) Move(from, to string) (rewritten bool, err error) {
|
||||
src, err := v.abs(from)
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -451,7 +416,6 @@ func (v *Vault) Move(from, to string, commit func() error) (rewritten bool, err
|
||||
if err := os.Rename(src, dst); err != nil {
|
||||
return false, err
|
||||
}
|
||||
assetsMoved := false
|
||||
writtenLinks := 0
|
||||
defer func() {
|
||||
if err == nil {
|
||||
@@ -462,11 +426,6 @@ func (v *Vault) Move(from, to string, commit func() error) (rewritten bool, err
|
||||
err = errors.Join(err, fmt.Errorf("回滚笔记引用失败: %w", rollback))
|
||||
}
|
||||
}
|
||||
if assetsMoved {
|
||||
if rollback := os.Rename(dstAssets, srcAssets); rollback != nil {
|
||||
err = errors.Join(err, fmt.Errorf("回滚图片移动失败: %w", rollback))
|
||||
}
|
||||
}
|
||||
if rollback := os.Rename(dst, src); rollback != nil {
|
||||
err = errors.Join(err, fmt.Errorf("回滚笔记移动失败: %w", rollback))
|
||||
}
|
||||
@@ -484,11 +443,6 @@ func (v *Vault) Move(from, to string, commit func() error) (rewritten bool, err
|
||||
if err := os.Rename(srcAssets, dstAssets); err != nil {
|
||||
return false, fmt.Errorf("移动图片目录失败: %w", err)
|
||||
}
|
||||
assetsMoved = true
|
||||
|
||||
}
|
||||
if commit != nil {
|
||||
err = commit()
|
||||
}
|
||||
return rewritten, err
|
||||
}
|
||||
|
||||
@@ -62,7 +62,12 @@ func TestAssetSymlinkEscapeRejected(t *testing.T) {
|
||||
|
||||
func TestMoveRollsBackWhenAssetDestinationIsTooLong(t *testing.T) {
|
||||
v := newVault(t)
|
||||
if _, err := v.Write("n.md", []byte("原文"), Precondition{}); err != nil {
|
||||
content := "原文 "
|
||||
if _, err := v.Write("n.md", []byte(content), Precondition{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
refs := "[原文](n.md) [目标](" + strings.Repeat("a", 250) + ".md)"
|
||||
if _, err := v.Write("refs.md", []byte(refs), Precondition{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := v.SaveAsset("n.md", "x.png", strings.NewReader("image"), 100); err != nil {
|
||||
@@ -70,11 +75,13 @@ func TestMoveRollsBackWhenAssetDestinationIsTooLong(t *testing.T) {
|
||||
}
|
||||
// 笔记名可写入,但派生的 .assets 名超过文件系统的单段长度限制。
|
||||
destination := strings.Repeat("a", 250) + ".md"
|
||||
if _, err := v.Move("n.md", destination, nil); err == nil {
|
||||
if _, err := v.Move("n.md", destination); err == nil {
|
||||
t.Fatal("图片目录移动应失败")
|
||||
}
|
||||
if content, _, err := v.Read("n.md"); err != nil || string(content) != "原文" {
|
||||
t.Fatalf("原笔记未恢复: %q %v", content, err)
|
||||
for name, want := range map[string]string{"n.md": content, "refs.md": refs} {
|
||||
if got, _, err := v.Read(name); err != nil || string(got) != want {
|
||||
t.Fatalf("原笔记与引用未恢复: %s %q %v", name, got, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(v.Root(), "n.assets")); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -170,7 +177,7 @@ func TestMoveCarriesAssetsAndRewritesLinks(t *testing.T) {
|
||||
if !strings.HasPrefix(link, "笔记%20A.assets/") {
|
||||
t.Fatalf("资源链接不符: %s", link)
|
||||
}
|
||||
if rewritten, err := v.Move("dir/笔记 A.md", "other/B.md", nil); err != nil || !rewritten {
|
||||
if rewritten, err := v.Move("dir/笔记 A.md", "other/B.md"); err != nil || !rewritten {
|
||||
t.Fatalf("应报告正文改写: %v %v", rewritten, err)
|
||||
}
|
||||
data, _, err := v.Read("other/B.md")
|
||||
@@ -184,7 +191,7 @@ func TestMoveCarriesAssetsAndRewritesLinks(t *testing.T) {
|
||||
if _, err := os.Stat(filepath.Join(v.Root(), "other", "B.assets", strings.TrimPrefix(link, "笔记%20A.assets/"))); err != nil {
|
||||
t.Fatal("图片目录未随笔记移动:", err)
|
||||
}
|
||||
if _, err := v.Move("other", "other/sub", nil); !errors.Is(err, ErrInvalidPath) {
|
||||
if _, err := v.Move("other", "other/sub"); !errors.Is(err, ErrInvalidPath) {
|
||||
t.Fatal("文件夹移入自身应被拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ func (w *Watcher) scan() (map[string]fileState, error) {
|
||||
return next, nil
|
||||
}
|
||||
|
||||
// diff 按文件系统身份识别移动,内容相同不代表同一个分享主体。
|
||||
// diff 按文件系统身份识别移动,内容相同不代表同一个文件。
|
||||
func diff(prev, next map[string]fileState) []events.Event {
|
||||
var evs []events.Event
|
||||
structural := false
|
||||
|
||||
Reference in new issue
Block a user