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)