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:
eezd committed 2026-10-24 12:32:23 +08:00
1 parent a716794e41
commit 19aab631ec
63 files changed
+1077 -3343

No files matched your search

+2 -12
View File
@@ -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
-287
View File
@@ -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![](笔记.assets/图.png)\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![](笔记.assets/图.png)\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
View File
@@ -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
View File
@@ -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)
}
}
-94
View File
@@ -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
View File
@@ -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": {
+62
View File
@@ -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)
}
}
}
-426
View File
@@ -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)
}
}
}
}
}
}
+4 -7
View File
@@ -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")
-54
View File
@@ -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)
}
+1 -2
View File
@@ -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 -30
View File
@@ -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
View File
@@ -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[:])
+2 -16
View File
@@ -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)
}
+109
View File
@@ -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)
}
}
-43
View File
@@ -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()
}
-50
View File
@@ -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
View File
@@ -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
+7 -15
View File
@@ -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 != "" {
+6 -17
View File
@@ -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)
}
+3 -42
View File
@@ -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 := "![](from.assets/a.png)"
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 := "![](from.png)\n![](to.png)"
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)
}
})
}
}
+1 -1
View File
@@ -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")
+3 -16
View File
@@ -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&copy;.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
View File
@@ -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
}
+13 -6
View File
@@ -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 := "原文 ![](n.assets/x.png)"
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("文件夹移入自身应被拒绝")
}
}
+1 -1
View File
@@ -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