feat(ui): streamline workspace settings and remove sharing
Unify compact workspace controls and panel transitions, move directory actions into the context menu, and remove backlink UI. Reorganize settings with searchable sections, per-setting resets, shortcut search, and accurate save feedback. Remove sharing UI, APIs, authentication helpers, and storage code; provide a transactional cleanup script with retirement tests. Reject directories during orphan image cleanup and cover mixed-batch preservation.
This commit is contained in:
1 parent
a716794e41
commit
19aab631ec
63 files changed
+1077
-3343
No files matched your search
+2
-12
@@ -54,7 +54,7 @@ const (
|
||||
public access = iota
|
||||
// 登录会话或 API 令牌(只读令牌可用 GET 和只读导出接口)
|
||||
authed
|
||||
// 只允许登录会话:管理分享与 API 令牌,避免令牌给自己扩权
|
||||
// 只允许登录会话:管理 API 令牌,避免令牌给自己扩权
|
||||
sessionOnly
|
||||
)
|
||||
|
||||
@@ -106,17 +106,9 @@ func (s *Server) routes() []route {
|
||||
{"PUT /api/v1/plugins/{id}/data", sessionOnly, s.putPluginData},
|
||||
{"GET /api/v1/themes", authed, s.listThemes},
|
||||
{"GET /api/v1/themes/{id}", authed, s.getTheme},
|
||||
{"GET /api/v1/shares", sessionOnly, s.listShares},
|
||||
{"POST /api/v1/shares", sessionOnly, s.createShare},
|
||||
{"DELETE /api/v1/shares/{token}", sessionOnly, s.deleteShare},
|
||||
{"GET /api/v1/tokens", sessionOnly, s.listTokens},
|
||||
{"POST /api/v1/tokens", sessionOnly, s.createToken},
|
||||
{"DELETE /api/v1/tokens/{id}", sessionOnly, s.deleteToken},
|
||||
|
||||
{"GET /api/v1/public/shares/{token}", public, s.publicShare},
|
||||
{"GET /api/v1/public/shares/{token}/appearance", public, s.publicAppearance},
|
||||
{"POST /api/v1/public/shares/{token}/unlock", public, s.unlockShare},
|
||||
{"GET /api/v1/public/shares/{token}/assets/{path...}", public, s.publicShareAsset},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,9 +347,7 @@ func (s *Server) move(w http.ResponseWriter, r *http.Request) {
|
||||
if !readJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
rewritten, err := s.vault.Move(body.From, body.To, func() error {
|
||||
return s.store.MoveShares(body.From, body.To)
|
||||
})
|
||||
rewritten, err := s.vault.Move(body.From, body.To)
|
||||
if err != nil {
|
||||
vaultError(w, r, err)
|
||||
return
|
||||
|
||||
@@ -3,14 +3,12 @@ package api
|
||||
import (
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
@@ -65,36 +63,6 @@ func TestMoveBroadcastsWhenAssetRewriteReplacesFileIdentity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveRollsBackWhenShareUpdateFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
v, err := vault.Open(filepath.Join(dir, "vault"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := v.Write("from.md", []byte("原文"), vault.Precondition{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, err := store.Open(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := New(v, nil, st, nil, nil)
|
||||
res := httptest.NewRecorder()
|
||||
s.move(res, httptest.NewRequest("POST", "/api/v1/move", strings.NewReader(`{"from":"from.md","to":"to.md"}`)))
|
||||
if res.Code != 500 {
|
||||
t.Fatalf("数据库失败应返回错误: %d", res.Code)
|
||||
}
|
||||
if content, _, err := v.Read("from.md"); err != nil || string(content) != "原文" {
|
||||
t.Fatalf("原笔记未恢复: %q %v", content, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(v.Root(), "to.md")); !os.IsNotExist(err) {
|
||||
t.Fatalf("失败后目标仍存在: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventsRejectRevokedCredentials(t *testing.T) {
|
||||
for _, kind := range []string{"session", "token"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
@@ -282,260 +250,6 @@ func TestCompressedManifestContentType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestShareAccess(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.write("目录/笔记.md", "# 标题\n\n\n")
|
||||
f.write("目录/笔记.assets/图.png", "PNG")
|
||||
f.write("目录/其他.png", "OTHER")
|
||||
f.write("目录/其他.assets/x.png", "X")
|
||||
|
||||
res := f.do(f.admin, "POST", "/api/v1/shares", `{"path":"目录/笔记.md","password":"访问密码","expiresIn":0}`, nil)
|
||||
if res.StatusCode != 200 {
|
||||
t.Fatalf("创建分享: %d", res.StatusCode)
|
||||
}
|
||||
sh := decode[shareView](t, res)
|
||||
if !sh.HasPassword || !sh.Exists || sh.ExpiresAt != 0 || len(sh.Token) < 20 {
|
||||
t.Fatalf("分享信息: %+v", sh)
|
||||
}
|
||||
base := "/api/v1/public/shares/" + sh.Token
|
||||
guest := newClient()
|
||||
if code := f.status(guest, "GET", base, "", nil); code != 401 {
|
||||
t.Errorf("未输入密码应为 401,得到 %d", code)
|
||||
}
|
||||
if code := f.status(guest, "GET", base+"/assets/笔记.assets/图.png", "", nil); code != 401 {
|
||||
t.Errorf("未输入密码读图片应为 401,得到 %d", code)
|
||||
}
|
||||
if code := f.status(guest, "POST", base+"/unlock", `{"password":"错的"}`, nil); code != 401 {
|
||||
t.Errorf("错误密码应为 401,得到 %d", code)
|
||||
}
|
||||
if code := f.status(guest, "POST", base+"/unlock", `{"password":"访问密码"}`, nil); code != 200 {
|
||||
t.Fatalf("正确密码: %d", code)
|
||||
}
|
||||
got := decode[map[string]any](t, f.do(guest, "GET", base, "", nil))
|
||||
if got["title"] != "笔记" || got["content"] != "# 标题\n\n\n" || got["path"] != nil {
|
||||
t.Errorf("分享内容: %v", got)
|
||||
}
|
||||
res = f.do(guest, "GET", base+"/assets/笔记.assets/图.png", "", nil)
|
||||
if b, _ := io.ReadAll(res.Body); res.StatusCode != 200 || string(b) != "PNG" {
|
||||
t.Errorf("本篇图片: %d %q", res.StatusCode, b)
|
||||
}
|
||||
for _, p := range []string{"其他.png", "笔记.assets/../其他.png", "其他.assets/x.png", "../目录/其他.png", "笔记.md"} {
|
||||
if code := f.status(guest, "GET", base+"/assets/"+p, "", nil); code != 404 {
|
||||
t.Errorf("图片目录之外的 %s 应为 404,得到 %d", p, code)
|
||||
}
|
||||
}
|
||||
|
||||
// 改名后分享跟随
|
||||
if code := f.status(f.admin, "POST", "/api/v1/move", `{"from":"目录","to":"新目录"}`, nil); code != 200 {
|
||||
t.Fatalf("移动文件夹: %d", code)
|
||||
}
|
||||
if code := f.status(f.admin, "POST", "/api/v1/move", `{"from":"新目录/笔记.md","to":"新目录/改名.md"}`, nil); code != 200 {
|
||||
t.Fatalf("改名: %d", code)
|
||||
}
|
||||
got = decode[map[string]any](t, f.do(guest, "GET", base, "", nil))
|
||||
if got["title"] != "改名" || !strings.Contains(got["content"].(string), "改名.assets/") {
|
||||
t.Errorf("改名后分享内容: %v", got)
|
||||
}
|
||||
if code := f.status(guest, "GET", base+"/assets/改名.assets/图.png", "", nil); code != 200 {
|
||||
t.Errorf("改名后图片: %d", code)
|
||||
}
|
||||
|
||||
// 其他分享的凭据不能用于本分享
|
||||
res = f.do(f.admin, "POST", "/api/v1/shares", `{"path":"新目录/其他.md"}`, nil)
|
||||
if res.StatusCode != 404 {
|
||||
t.Errorf("分享不存在的笔记应为 404,得到 %d", res.StatusCode)
|
||||
}
|
||||
f.write("新目录/其他.md", "其他")
|
||||
other := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", `{"path":"新目录/其他.md","password":"另一个"}`, nil))
|
||||
if code := f.status(guest, "GET", "/api/v1/public/shares/"+other.Token, "", nil); code != 401 {
|
||||
t.Errorf("凭据只对对应分享有效,得到 %d", code)
|
||||
}
|
||||
|
||||
// 列表、删除笔记、撤销
|
||||
list := decode[struct{ Shares []shareView }](t, f.do(f.admin, "GET", "/api/v1/shares?path=新目录/改名.md", "", nil))
|
||||
if len(list.Shares) != 1 || list.Shares[0].Path != "新目录/改名.md" {
|
||||
t.Errorf("按笔记筛选: %+v", list.Shares)
|
||||
}
|
||||
if code := f.status(f.admin, "DELETE", "/api/v1/files/新目录/改名.md", "", nil); code != 200 {
|
||||
t.Fatal(code)
|
||||
}
|
||||
list = decode[struct{ Shares []shareView }](t, f.do(f.admin, "GET", "/api/v1/shares", "", nil))
|
||||
if len(list.Shares) != 2 || list.Shares[1].Exists {
|
||||
t.Errorf("笔记删除后分享标记为不存在: %+v", list.Shares)
|
||||
}
|
||||
if code := f.status(guest, "GET", base, "", nil); code != 404 {
|
||||
t.Errorf("笔记已删除应为 404,得到 %d", code)
|
||||
}
|
||||
if code := f.status(f.admin, "DELETE", "/api/v1/shares/"+sh.Token, "", nil); code != 200 {
|
||||
t.Fatal(code)
|
||||
}
|
||||
if code := f.status(guest, "POST", base+"/unlock", `{"password":"访问密码"}`, nil); code != 404 {
|
||||
t.Errorf("撤销后应为 404,得到 %d", code)
|
||||
}
|
||||
if code := f.status(newClient(), "GET", "/api/v1/shares", "", nil); code != 401 {
|
||||
t.Errorf("未登录不能管理分享,得到 %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShareRandomToken(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
name := "计划 #1? 50% +;\""
|
||||
p := "目录/" + name + ".md"
|
||||
f.write(p, "正文")
|
||||
f.write("目录/"+name+".assets/图.png", "PNG")
|
||||
body, err := json.Marshal(map[string]string{"path": p, "password": "访问密码"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sh := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", string(body), nil))
|
||||
random, err := base64.RawURLEncoding.DecodeString(sh.Token)
|
||||
if err != nil || len(random) != 16 {
|
||||
t.Fatal("分享标识应为 128 位随机字符,不包含文件名称或目录")
|
||||
}
|
||||
other := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", string(body), nil))
|
||||
if other.Token == sh.Token {
|
||||
t.Fatal("同一笔记的分享链接不能重复")
|
||||
}
|
||||
base := "/api/v1/public/shares/" + url.PathEscape(sh.Token)
|
||||
guest := newClient()
|
||||
if code := f.status(guest, "GET", base, "", nil); code != 401 {
|
||||
t.Fatalf("未解锁应拒绝访问,得到 %d", code)
|
||||
}
|
||||
if code := f.status(guest, "POST", base+"/unlock", `{"password":"访问密码"}`, nil); code != 200 {
|
||||
t.Fatalf("解锁失败:%d", code)
|
||||
}
|
||||
if code := f.status(guest, "GET", base, "", nil); code != 200 {
|
||||
t.Fatalf("带保留字符的链接应可读取正文,得到 %d", code)
|
||||
}
|
||||
asset := base + "/assets/" + url.PathEscape(name+".assets") + "/图.png"
|
||||
if code := f.status(guest, "GET", asset, "", nil); code != 200 {
|
||||
t.Fatalf("解锁凭据应覆盖图片路径,得到 %d", code)
|
||||
}
|
||||
if code := f.status(guest, "GET", "/api/v1/public/shares/"+url.PathEscape(other.Token), "", nil); code != 401 {
|
||||
t.Fatalf("凭据不能解锁另一分享,得到 %d", code)
|
||||
}
|
||||
if code := f.status(f.admin, "DELETE", "/api/v1/shares/"+url.PathEscape(sh.Token), "", nil); code != 200 {
|
||||
t.Fatalf("撤销带保留字符的链接失败:%d", code)
|
||||
}
|
||||
if code := f.status(guest, "GET", base, "", nil); code != 404 {
|
||||
t.Fatalf("撤销后应返回 404,得到 %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderShareTracksItsCurrentContentsAndSubject(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.write("公开/子目录/文章.md", "第一版")
|
||||
f.write("公开/子目录/文章.assets/图.png", "PNG")
|
||||
f.write("公开/资料.txt", "资料")
|
||||
f.write("公开/.私密.md", "不能公开")
|
||||
f.write("公开/.git/config", "不能公开")
|
||||
f.write("其他/私密.md", "不能公开")
|
||||
if err := os.Symlink(filepath.Join(f.root, "其他"), filepath.Join(f.root, "公开", "别名")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
folder := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", `{"path":"公开","password":"访问密码"}`, nil))
|
||||
note := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", `{"path":"公开/子目录/文章.md"}`, nil))
|
||||
base := "/api/v1/public/shares/" + folder.Token
|
||||
noteBase := "/api/v1/public/shares/" + note.Token
|
||||
guest := newClient()
|
||||
if code := f.status(guest, "GET", base+"?path=子目录/文章.md", "", nil); code != 401 {
|
||||
t.Fatalf("目录内的文章也必须解锁,得到 %d", code)
|
||||
}
|
||||
if code := f.status(guest, "POST", base+"/unlock", `{"password":"访问密码"}`, nil); code != 200 {
|
||||
t.Fatal(code)
|
||||
}
|
||||
type directory struct {
|
||||
Title string
|
||||
Dir bool
|
||||
Entries []vault.Entry
|
||||
}
|
||||
listed := decode[directory](t, f.do(guest, "GET", base, "", nil))
|
||||
if listed.Title != "公开" || !listed.Dir || len(listed.Entries) != 5 {
|
||||
t.Fatalf("应递归列出全部普通文件和子目录: %+v", listed)
|
||||
}
|
||||
for _, e := range listed.Entries {
|
||||
if strings.Contains(e.Path, "公开/") || strings.Contains(e.Path, "私密") || strings.Contains(e.Path, "别名") || strings.Contains(e.Path, ".git") {
|
||||
t.Fatalf("目录列表越界: %s", e.Path)
|
||||
}
|
||||
}
|
||||
f.write("公开/子目录/文章.md", "最新正文")
|
||||
article := decode[map[string]any](t, f.do(guest, "GET", base+"?path=子目录/文章.md", "", nil))
|
||||
if article["content"] != "最新正文" || article["path"] != nil {
|
||||
t.Fatalf("应提供当前正文且不暴露仓库路径: %v", article)
|
||||
}
|
||||
for _, rel := range []string{"../其他/私密.md", ".私密.md", ".git/config", "别名/私密.md"} {
|
||||
if code := f.status(guest, "GET", base+"?path="+url.QueryEscape(rel), "", nil); code != 404 {
|
||||
t.Fatalf("不允许读取 %s,得到 %d", rel, code)
|
||||
}
|
||||
if code := f.status(guest, "GET", base+"/assets/"+url.PathEscape(rel), "", nil); code != 404 {
|
||||
t.Fatalf("资源接口不允许读取 %s,得到 %d", rel, code)
|
||||
}
|
||||
}
|
||||
if code := f.status(guest, "GET", base+"/assets/子目录/文章.assets/图.png", "", nil); code != 200 {
|
||||
t.Fatalf("目录中的图片不可读: %d", code)
|
||||
}
|
||||
res := f.do(guest, "GET", base+"/assets/资料.txt?download=1", "", nil)
|
||||
content, err := io.ReadAll(res.Body)
|
||||
if err != nil || res.StatusCode != 200 || string(content) != "资料" || !strings.HasPrefix(res.Header.Get("Content-Disposition"), "attachment;") {
|
||||
t.Fatalf("普通文件下载失败: %d %v", res.StatusCode, err)
|
||||
}
|
||||
f.write("公开/新增.md", "新内容")
|
||||
listed = decode[directory](t, f.do(guest, "GET", base, "", nil))
|
||||
if len(listed.Entries) != 6 {
|
||||
t.Fatal("新增文件应自动进入目录分享")
|
||||
}
|
||||
if code := f.status(f.admin, "POST", "/api/v1/move", `{"from":"公开","to":"移动后的目录"}`, nil); code != 200 {
|
||||
t.Fatal(code)
|
||||
}
|
||||
listed = decode[directory](t, f.do(guest, "GET", base, "", nil))
|
||||
if listed.Title != "移动后的目录" {
|
||||
t.Fatal("目录主体移动后原分享应跟随")
|
||||
}
|
||||
if code := f.status(f.admin, "POST", "/api/v1/move", `{"from":"移动后的目录/子目录/文章.md","to":"其他/移出.md"}`, nil); code != 200 {
|
||||
t.Fatal(code)
|
||||
}
|
||||
if code := f.status(guest, "GET", base+"?path=子目录/文章.md", "", nil); code != 404 {
|
||||
t.Fatalf("移出的文章不能继续通过目录分享读取: %d", code)
|
||||
}
|
||||
article = decode[map[string]any](t, f.do(guest, "GET", noteBase, "", nil))
|
||||
if article["content"] != "最新正文" || article["title"] != "移出" {
|
||||
t.Fatal("单篇分享应跟随文章移出目录")
|
||||
}
|
||||
file := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", `{"path":"移动后的目录/资料.txt"}`, nil))
|
||||
if code := f.status(guest, "GET", "/api/v1/public/shares/"+file.Token+"/assets/", "", nil); code != 200 {
|
||||
t.Fatalf("应能分享普通文件: %d", code)
|
||||
}
|
||||
if code := f.status(f.admin, "DELETE", "/api/v1/shares/"+folder.Token, "", nil); code != 200 {
|
||||
t.Fatal(code)
|
||||
}
|
||||
if code := f.status(guest, "GET", base+"/assets/资料.txt", "", nil); code != 404 {
|
||||
t.Fatalf("撤销后资源也必须失效: %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShareWithoutPasswordAndExpiry(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.write("a.md", "公开")
|
||||
sh := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", `{"path":"a.md","expiresIn":3600}`, nil))
|
||||
if sh.HasPassword || sh.ExpiresAt-time.Now().UnixMilli() < 3590_000 {
|
||||
t.Fatalf("分享信息: %+v", sh)
|
||||
}
|
||||
if code := f.status(newClient(), "GET", "/api/v1/public/shares/"+sh.Token, "", nil); code != 200 {
|
||||
t.Errorf("无密码分享直接可读,得到 %d", code)
|
||||
}
|
||||
if err := f.store.CreateShare(store.Share{Token: "expired", Path: "a.md", CreatedAt: 1, ExpiresAt: time.Now().Add(-time.Second).UnixMilli()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code := f.status(newClient(), "GET", "/api/v1/public/shares/expired", "", nil); code != 404 {
|
||||
t.Errorf("过期分享应为 404,得到 %d", code)
|
||||
}
|
||||
for _, body := range []string{`{"path":"../a.md"}`, `{"path":"a.md","expiresIn":-1}`} {
|
||||
if code := f.status(f.admin, "POST", "/api/v1/shares", body, nil); code != 400 {
|
||||
t.Errorf("%s 应为 400,得到 %d", body, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPITokenScopes(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.write("a.md", "内容")
|
||||
@@ -561,7 +275,6 @@ func TestAPITokenScopes(t *testing.T) {
|
||||
{"只读令牌不能写", read, "PUT", "/api/v1/files/a.md", "改", 403},
|
||||
{"读写令牌可写", write, "PUT", "/api/v1/files/b.md", "新", 200},
|
||||
{"读写令牌也不能管理令牌", write, "GET", "/api/v1/tokens", "", 403},
|
||||
{"令牌不能管理分享", write, "POST", "/api/v1/shares", `{"path":"a.md"}`, 403},
|
||||
{"无效令牌", "rm_wrong", "GET", "/api/v1/tree", "", 401},
|
||||
{"格式不符的令牌", "abc", "GET", "/api/v1/tree", "", 401},
|
||||
}
|
||||
|
||||
+2
-28
@@ -1,11 +1,6 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"rawmark/internal/vault"
|
||||
"time"
|
||||
)
|
||||
import "net/http"
|
||||
|
||||
func (s *Server) cleanupImages(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
@@ -18,28 +13,7 @@ func (s *Server) cleanupImages(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, 400, "请选择 1~1000 张待清理图片")
|
||||
return
|
||||
}
|
||||
result, err := s.vault.CleanupImages(body.Paths, func() ([]string, error) {
|
||||
shares, err := s.store.Shares()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scopes := []string{}
|
||||
now := time.Now().UnixMilli()
|
||||
for _, share := range shares {
|
||||
if share.ExpiresAt != 0 && share.ExpiresAt <= now {
|
||||
continue
|
||||
}
|
||||
for _, item := range share.Items {
|
||||
if _, err := s.vault.Stat(item.Path); errors.Is(err, vault.ErrNotFound) {
|
||||
continue
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scopes = append(scopes, item.Path)
|
||||
}
|
||||
}
|
||||
return scopes, nil
|
||||
})
|
||||
result, err := s.vault.CleanupImages(body.Paths)
|
||||
if err != nil {
|
||||
vaultError(w, r, err)
|
||||
return
|
||||
|
||||
+30
-17
@@ -1,35 +1,48 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"rawmark/internal/store"
|
||||
"rawmark/internal/vault"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestImageCleanupHonorsShareExpiryAndReferences(t *testing.T) {
|
||||
func TestImageCleanupHonorsReferences(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.write("assets/active.png", "active")
|
||||
f.write("assets/expired.png", "expired")
|
||||
f.write("assets/orphan.png", "orphan")
|
||||
f.write("assets/unused.png", "unused")
|
||||
f.write("assets/used.png", "used")
|
||||
f.write("note.md", "[image](assets/used.png)")
|
||||
for _, share := range []store.Share{
|
||||
{Token: "active", Items: []store.ShareItem{{ID: 0, Path: "assets/active.png"}}, ExpiresAt: time.Now().Add(time.Hour).UnixMilli()},
|
||||
{Token: "expired", Items: []store.ShareItem{{ID: 0, Path: "assets/expired.png"}}, ExpiresAt: time.Now().Add(-time.Hour).UnixMilli()},
|
||||
} {
|
||||
if err := f.store.CreateShare(share); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if code := f.status(newClient(), "POST", "/api/v1/assets/cleanup", `{"paths":["assets/expired.png"]}`, nil); code != http.StatusUnauthorized {
|
||||
if code := f.status(newClient(), "POST", "/api/v1/assets/cleanup", `{"paths":["assets/unused.png"]}`, nil); code != http.StatusUnauthorized {
|
||||
t.Fatal(code)
|
||||
}
|
||||
result := decode[vault.CleanupResult](t, f.do(f.admin, "POST", "/api/v1/assets/cleanup", `{"paths":["assets/active.png","assets/expired.png","assets/used.png"]}`, nil))
|
||||
if len(result.Deleted) != 1 || result.Deleted[0].Path != "assets/expired.png" || len(result.Skipped) != 2 {
|
||||
t.Fatalf("引用或分享保护不符:%+v", result)
|
||||
result := decode[vault.CleanupResult](t, f.do(f.admin, "POST", "/api/v1/assets/cleanup", `{"paths":["assets/orphan.png","assets/unused.png","assets/used.png"]}`, nil))
|
||||
if len(result.Deleted) != 2 || len(result.Skipped) != 1 || result.Skipped[0].Path != "assets/used.png" {
|
||||
t.Fatalf("引用保护不符:%+v", result)
|
||||
}
|
||||
if code := f.status(newClient(), "GET", "/api/v1/metadata", "", nil); code != http.StatusUnauthorized {
|
||||
t.Fatal(code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageCleanupRejectsDirectories(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.write("assets/folder.png/keep.md", "保留目录内的笔记")
|
||||
f.write("assets/orphan.png", "image")
|
||||
res := f.do(f.admin, "POST", "/api/v1/assets/cleanup", `{"paths":["assets/folder.png","assets/orphan.png"]}`, nil)
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Fatalf("清理应逐项汇报:%d", res.StatusCode)
|
||||
}
|
||||
result := decode[vault.CleanupResult](t, res)
|
||||
if len(result.Deleted) != 1 || result.Deleted[0].Path != "assets/orphan.png" || len(result.Skipped) != 1 || result.Skipped[0].Path != "assets/folder.png" || result.Skipped[0].Reason != "不是图片文件" {
|
||||
t.Fatalf("目录应跳过,普通孤立图片仍可清理:%+v", result)
|
||||
}
|
||||
kept := f.do(f.admin, "GET", "/api/v1/files/assets/folder.png/keep.md", "", nil)
|
||||
if kept.StatusCode != http.StatusOK {
|
||||
t.Fatalf("目录内笔记被误移:%d", kept.StatusCode)
|
||||
}
|
||||
got, err := io.ReadAll(kept.Body)
|
||||
if err != nil || string(got) != "保留目录内的笔记" {
|
||||
t.Fatalf("目录内笔记内容被改变:%q %v", got, err)
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCollectionShareFollowsEachSubjectAndKeepsItsScope(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.write("私有/文章.md", "最新正文")
|
||||
f.write("私有/秘密.md", "不能公开")
|
||||
f.write("文件夹/内部.md", "内部正文")
|
||||
sh := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", `{"paths":["文件夹/内部.md","私有/文章.md","文件夹"]}`, nil))
|
||||
if len(sh.Items) != 2 || sh.Items[0].Path != "文件夹" {
|
||||
t.Fatalf("未消除重叠:%+v", sh)
|
||||
}
|
||||
base := "/api/v1/public/shares/" + sh.Token
|
||||
guest := newClient()
|
||||
root := decode[struct {
|
||||
Collection bool
|
||||
Items []struct {
|
||||
ID int
|
||||
Title string
|
||||
Exists bool
|
||||
}
|
||||
}](t, f.do(guest, "GET", base, "", nil))
|
||||
if !root.Collection || len(root.Items) != 2 {
|
||||
t.Fatalf("集合首页不符:%+v", root)
|
||||
}
|
||||
for _, item := range root.Items {
|
||||
if strings.Contains(item.Title, "私有/") {
|
||||
t.Fatal("公开了主体的父目录")
|
||||
}
|
||||
}
|
||||
for _, endpoint := range []string{base + "?path=秘密.md", base + "?item=99", base + "?item=1&path=秘密.md", base + "/assets/../秘密.md?item=1", base + "/assets/秘密.md?item=1"} {
|
||||
response := f.do(guest, "GET", endpoint, "", nil)
|
||||
response.Body.Close()
|
||||
if response.StatusCode == http.StatusOK {
|
||||
t.Fatalf("越权访问成功:%s", endpoint)
|
||||
}
|
||||
}
|
||||
if code := f.status(f.admin, "POST", "/api/v1/move", `{"from":"私有/文章.md","to":"移动后.md"}`, nil); code != 200 {
|
||||
t.Fatalf("移动失败:%d", code)
|
||||
}
|
||||
content := decode[struct{ Content string }](t, f.do(guest, "GET", base+"?item=1", "", nil))
|
||||
if content.Content != "最新正文" {
|
||||
t.Fatalf("未跟随主体:%+v", content)
|
||||
}
|
||||
// 内部文件不是独立主体,移出文件夹后不能继续从该主体读取。
|
||||
f.do(f.admin, "POST", "/api/v1/move", `{"from":"文件夹/内部.md","to":"移出.md"}`, nil).Body.Close()
|
||||
if code := f.status(guest, "GET", base+"?item=0&path=内部.md", "", nil); code != 404 {
|
||||
t.Fatalf("移出后仍公开:%d", code)
|
||||
}
|
||||
f.do(f.admin, "DELETE", "/api/v1/files/移动后.md", "", nil).Body.Close()
|
||||
root = decode[struct {
|
||||
Collection bool
|
||||
Items []struct {
|
||||
ID int
|
||||
Title string
|
||||
Exists bool
|
||||
}
|
||||
}](t, f.do(guest, "GET", base, "", nil))
|
||||
if !root.Items[0].Exists || root.Items[1].Exists {
|
||||
t.Fatalf("部分删除状态不符:%+v", root)
|
||||
}
|
||||
if code := f.status(guest, "GET", base+"?item=0", "", nil); code != 200 {
|
||||
t.Fatalf("部分删除影响其余主体:%d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionPasswordAssetsAndRevocation(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.write("a.md", "文章")
|
||||
f.write("目录/b.md", "文章二")
|
||||
sh := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", `{"paths":["a.md","目录"],"password":"访问密码","expiresIn":86400}`, nil))
|
||||
base := "/api/v1/public/shares/" + sh.Token
|
||||
guest := newClient()
|
||||
if code := f.status(guest, "GET", base, "", nil); code != 401 {
|
||||
t.Fatal(code)
|
||||
}
|
||||
f.do(guest, "POST", base+"/unlock", `{"password":"访问密码"}`, nil).Body.Close()
|
||||
if code := f.status(guest, "GET", base+"?item=1&path=b.md", "", nil); code != 200 {
|
||||
t.Fatal(code)
|
||||
}
|
||||
if code := f.status(guest, "GET", base+"/assets/b.md?item=1", "", nil); code != 200 {
|
||||
t.Fatal(code)
|
||||
}
|
||||
f.do(f.admin, "DELETE", "/api/v1/shares/"+sh.Token, "", nil).Body.Close()
|
||||
if code := f.status(guest, "GET", fmt.Sprintf("%s?item=1", base), "", nil); code != 404 {
|
||||
t.Fatal(code)
|
||||
}
|
||||
}
|
||||
+3
-591
@@ -3,7 +3,7 @@
|
||||
"info": {
|
||||
"title": "RawMark API",
|
||||
"version": "1",
|
||||
"description": "RawMark 的 REST API。自动化调用请在「API 令牌」中创建令牌,以 `Authorization: Bearer <token>` 访问;只读令牌可使用 GET 和 POST /export(只读导出)。分享与令牌的管理接口只接受登录会话。所有错误响应均为 `{\"error\": \"说明\"}`。"
|
||||
"description": "RawMark 的 REST API。自动化调用请在「API 令牌」中创建令牌,以 `Authorization: Bearer <token>` 访问;只读令牌可使用 GET 和 POST /export(只读导出)。令牌的管理接口只接受登录会话。所有错误响应均为 `{\"error\": \"说明\"}`。"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
@@ -617,7 +617,7 @@
|
||||
"tags": [
|
||||
"笔记"
|
||||
],
|
||||
"description": "笔记的 .assets 目录随之移动,改名时改写本篇图片目录链接;图片移动或改名保持扩展名,并更新仓库笔记中的本地引用。分享链接随主体更新。不改写其他笔记指向笔记或文件夹的链接。",
|
||||
"description": "笔记的 .assets 目录随之移动,改名时改写本篇图片目录链接;图片移动或改名保持扩展名,并更新仓库笔记中的本地引用。维护笔记中的本地入链及相对出链。",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
@@ -1114,193 +1114,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/shares": {
|
||||
"get": {
|
||||
"summary": "分享列表",
|
||||
"tags": [
|
||||
"分享"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"cookie": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "path",
|
||||
"in": "query",
|
||||
"description": "只列出该笔记的分享",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "分享",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"shares": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Share"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"summary": "创建文件或文件夹的只读分享",
|
||||
"tags": [
|
||||
"分享"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"cookie": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "仓库内的文件或文件夹;文件夹递归公开当前所有普通文件和子目录,含后续新增或移入的内容"
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"description": "访问密码,留空为不设密码"
|
||||
},
|
||||
"expiresIn": {
|
||||
"type": "integer",
|
||||
"description": "有效时长(秒),0 为永久"
|
||||
},
|
||||
"paths": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "与 path 二选一;重叠路径只保留外层目录"
|
||||
}
|
||||
},
|
||||
"required": [],
|
||||
"oneOf": [
|
||||
{
|
||||
"required": [
|
||||
"path"
|
||||
],
|
||||
"not": {
|
||||
"required": [
|
||||
"paths"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"required": [
|
||||
"paths"
|
||||
],
|
||||
"not": {
|
||||
"required": [
|
||||
"path"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "新分享,token 为 128 位随机数的 URL 安全编码,访问地址为 /s/{token};移动分享主体不改变链接",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Share"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "路径或参数无效",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "笔记不存在",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "创建一个随机只读链接。path 创建单主体分享;paths 创建集合。内容实时读取,主体移动后跟随,部分主体删除不影响其余项。"
|
||||
}
|
||||
},
|
||||
"/shares/{token}": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "token",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"delete": {
|
||||
"summary": "撤销分享",
|
||||
"tags": [
|
||||
"分享"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"cookie": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "成功",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Ok"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "不存在",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tokens": {
|
||||
"get": {
|
||||
"summary": "API 令牌列表",
|
||||
@@ -1446,261 +1259,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/public/shares/{token}": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "token",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "path",
|
||||
"in": "query",
|
||||
"description": "文件夹分享中相对主体的子路径;省略时读取主体本身,不允许跳出分享范围",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"get": {
|
||||
"summary": "访客读取分享主体或其目录中的当前内容",
|
||||
"tags": [
|
||||
"分享(公开)"
|
||||
],
|
||||
"security": [
|
||||
{}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "目录返回 dir=true 及递归 entries(路径相对此目录);Markdown 返回 dir=false、content 与 updatedAt;其他文件返回 dir=false,内容经 assets 接口读取或下载",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"content": {
|
||||
"type": "string"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "integer"
|
||||
},
|
||||
"dir": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"entries": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Entry"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "需要访问密码",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
},
|
||||
"passwordRequired": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "分享不存在、已过期、主体已删除或子路径不在分享范围内",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "item",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
},
|
||||
"description": "集合分享的稳定成员标识;单主体分享可省略。子路径及附件始终限制在该主体原有范围内。"
|
||||
}
|
||||
],
|
||||
"description": "单主体沿用原响应。集合首页未指定 item 时返回 collection=true 及 items(仅含 id、title、dir、exists,不暴露仓库父路径);指定 item 后读取该主体,path 为其内部相对路径。"
|
||||
}
|
||||
},
|
||||
"/public/shares/{token}/unlock": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "token",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"post": {
|
||||
"summary": "输入分享密码,成功后设置访客 Cookie",
|
||||
"tags": [
|
||||
"分享(公开)"
|
||||
],
|
||||
"security": [
|
||||
{}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"password": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"password"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "成功",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Ok"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "密码错误",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "分享不存在或已失效",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "失败次数过多",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/public/shares/{token}/assets/{path}": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "token",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "path",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "文件夹分享中相对主体的文件路径;单文件分享仅允许其 .assets 图片,空路径读取该文件本身",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "download",
|
||||
"in": "query",
|
||||
"description": "设为 1 时以附件下载;其他情况下以带沙箱 CSP 的原始文件返回",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"1"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"get": {
|
||||
"summary": "访客读取或下载分享范围内的普通文件",
|
||||
"tags": [
|
||||
"分享(公开)"
|
||||
],
|
||||
"security": [
|
||||
{}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "文件内容",
|
||||
"content": {
|
||||
"application/octet-stream": {
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "binary"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "不存在或不在允许范围内",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "item",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
},
|
||||
"description": "集合分享的稳定成员标识;单主体分享可省略。子路径及附件始终限制在该主体原有范围内。"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/themes": {
|
||||
"get": {
|
||||
"summary": "列出主题(含内置默认主题)",
|
||||
@@ -1774,98 +1332,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/public/shares/{token}/appearance": {
|
||||
"get": {
|
||||
"summary": "读取有效分享使用的外观配置",
|
||||
"description": "仅返回明暗模式、字号、正文宽度、主题 CSS 和启用的 CSS 片段;密码分享需先解锁,不返回其他设置,不加载插件。",
|
||||
"tags": [
|
||||
"分享"
|
||||
],
|
||||
"security": [
|
||||
{}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "token",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "公开的外观配置",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"colorMode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"system",
|
||||
"light",
|
||||
"dark"
|
||||
]
|
||||
},
|
||||
"uiFontSize": {
|
||||
"type": "integer",
|
||||
"minimum": 14,
|
||||
"maximum": 20,
|
||||
"default": 16,
|
||||
"description": "界面字号,与正文排版字号独立"
|
||||
},
|
||||
"fontSize": {
|
||||
"type": "integer"
|
||||
},
|
||||
"contentWidth": {
|
||||
"type": "integer"
|
||||
},
|
||||
"themeCSS": {
|
||||
"type": "string"
|
||||
},
|
||||
"cssSnippets": {
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
],
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"css": {
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "需要访问密码"
|
||||
},
|
||||
"404": {
|
||||
"description": "分享不存在、已过期或已撤销"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/plugins": {
|
||||
"get": {
|
||||
"summary": "列出已安装插件及启用状态",
|
||||
@@ -2468,7 +1934,7 @@
|
||||
},
|
||||
"/assets/cleanup": {
|
||||
"post": {
|
||||
"summary": "重新核对全库引用和有效分享后,将孤立图片移入回收站",
|
||||
"summary": "重新核对全库引用后,将孤立图片移入回收站",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
@@ -2614,60 +2080,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Share": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"token": {
|
||||
"type": "string",
|
||||
"description": "新分享使用完全随机的 URL 安全标识,不包含文件名称或目录"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "单主体分享的路径;集合分享不返回此字段"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "integer"
|
||||
},
|
||||
"expiresAt": {
|
||||
"type": "integer",
|
||||
"description": "Unix 毫秒,0 为永久"
|
||||
},
|
||||
"hasPassword": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"exists": {
|
||||
"type": "boolean",
|
||||
"description": "分享的文件或文件夹是否仍存在"
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"path",
|
||||
"exists"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"description": "稳定的分享成员标识"
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
"exists": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"items"
|
||||
]
|
||||
},
|
||||
"APIToken": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
func TestRemovedShareAPIsReturnNotFound(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
token := decode[struct{ Token string }](t, f.do(f.admin, "POST", "/api/v1/tokens", `{"name":"automation","scope":"write"}`, nil)).Token
|
||||
for _, caller := range []struct {
|
||||
name string
|
||||
client *http.Client
|
||||
headers map[string]string
|
||||
}{
|
||||
{"guest", newClient(), nil},
|
||||
{"session", f.admin, nil},
|
||||
{"token", newClient(), map[string]string{"Authorization": "Bearer " + token}},
|
||||
} {
|
||||
t.Run(caller.name, func(t *testing.T) {
|
||||
for _, endpoint := range []struct{ method, path string }{
|
||||
{"GET", "/api/v1/shares"},
|
||||
{"POST", "/api/v1/shares"},
|
||||
{"DELETE", "/api/v1/shares/old-token"},
|
||||
{"GET", "/api/v1/public/shares/old-token"},
|
||||
{"HEAD", "/api/v1/public/shares/old-token"},
|
||||
{"GET", "/api/v1/public/shares/old-token/appearance"},
|
||||
{"POST", "/api/v1/public/shares/old-token/unlock"},
|
||||
{"GET", "/api/v1/public/shares/old-token/assets/image.png"},
|
||||
} {
|
||||
if code := f.status(caller.client, endpoint.method, endpoint.path, "{}", caller.headers); code != http.StatusNotFound {
|
||||
t.Errorf("%s %s:得到 %d,期望 404", endpoint.method, endpoint.path, code)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemovedSharePagesDoNotFallBackToApplication(t *testing.T) {
|
||||
handler := newStaticHandler(fstest.MapFS{
|
||||
"index.html": {Data: []byte("app entry")},
|
||||
"assets/app.js": {Data: []byte("app script")},
|
||||
})
|
||||
for _, method := range []string{"GET", "HEAD"} {
|
||||
for _, path := range []string{"/s", "/s/", "/s/old-token", "/s/old-token/child", "/share.html"} {
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, httptest.NewRequest(method, path, nil))
|
||||
if recorder.Code != http.StatusNotFound {
|
||||
t.Errorf("%s %s:得到 %d,期望 404", method, path, recorder.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"/", "/assets/app.js"} {
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, httptest.NewRequest("GET", path, nil))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("%s:应用资源无法读取:%d", path, recorder.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,426 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"rawmark/internal/auth"
|
||||
"rawmark/internal/events"
|
||||
"rawmark/internal/store"
|
||||
"rawmark/internal/vault"
|
||||
)
|
||||
|
||||
// 输入分享密码后的凭据 Cookie,按分享限定 Path,同一浏览器可同时打开多个分享
|
||||
const shareCookieName = "rawmark_share"
|
||||
|
||||
// 设有密码的永久分享,访客凭据的有效期
|
||||
const shareCookieTTL = 30 * 24 * time.Hour
|
||||
|
||||
type shareItemView struct {
|
||||
store.ShareItem
|
||||
Exists bool `json:"exists"`
|
||||
}
|
||||
type shareView struct {
|
||||
store.Share
|
||||
Items []shareItemView `json:"items"`
|
||||
HasPassword bool `json:"hasPassword"`
|
||||
Exists bool `json:"exists"`
|
||||
}
|
||||
|
||||
func (s *Server) viewOf(sh store.Share) (shareView, error) {
|
||||
view := shareView{Share: sh, HasPassword: sh.PasswordHash != "", Items: []shareItemView{}}
|
||||
for _, item := range sh.Items {
|
||||
_, err := s.vault.Stat(item.Path)
|
||||
if err != nil && !errors.Is(err, vault.ErrNotFound) {
|
||||
return view, err
|
||||
}
|
||||
exists := err == nil
|
||||
view.Exists = view.Exists || exists
|
||||
view.Items = append(view.Items, shareItemView{ShareItem: item, Exists: exists})
|
||||
}
|
||||
return view, nil
|
||||
}
|
||||
|
||||
// listShares 列出全部分享;带 ?path= 时只列出该笔记的
|
||||
func (s *Server) listShares(w http.ResponseWriter, r *http.Request) {
|
||||
all, err := s.store.Shares()
|
||||
if err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
filter := r.URL.Query().Get("path")
|
||||
out := []shareView{}
|
||||
for _, sh := range all {
|
||||
matches := filter == ""
|
||||
for _, item := range sh.Items {
|
||||
if item.Path == filter {
|
||||
matches = true
|
||||
}
|
||||
}
|
||||
if matches {
|
||||
view, err := s.viewOf(sh)
|
||||
if err != nil {
|
||||
vaultError(w, r, err)
|
||||
return
|
||||
}
|
||||
out = append(out, view)
|
||||
}
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"shares": out})
|
||||
}
|
||||
|
||||
func (s *Server) createShare(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Path string
|
||||
Paths []string
|
||||
Password string
|
||||
// 有效时长(秒),0 为永久
|
||||
ExpiresIn int64
|
||||
}
|
||||
if !readJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if body.Path != "" && len(body.Paths) != 0 {
|
||||
writeError(w, 400, "path 和 paths 不能同时指定")
|
||||
return
|
||||
}
|
||||
paths := body.Paths
|
||||
if body.Path != "" {
|
||||
paths = []string{body.Path}
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
writeError(w, 400, "请选择分享主体")
|
||||
return
|
||||
}
|
||||
sort.Strings(paths)
|
||||
roots := map[string]bool{}
|
||||
items := []store.ShareItem{}
|
||||
for _, p := range paths {
|
||||
if _, err := vault.CleanPath(p); err != nil {
|
||||
writeError(w, 400, "分享路径无效")
|
||||
return
|
||||
}
|
||||
if _, err := s.vault.Stat(p); err != nil {
|
||||
vaultError(w, r, err)
|
||||
return
|
||||
}
|
||||
covered := roots[p]
|
||||
for parent := path.Dir(p); parent != "."; parent = path.Dir(parent) {
|
||||
if roots[parent] {
|
||||
covered = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !covered {
|
||||
roots[p] = true
|
||||
items = append(items, store.ShareItem{ID: len(items), Path: p})
|
||||
}
|
||||
}
|
||||
if body.ExpiresIn < 0 {
|
||||
writeError(w, 400, "有效期无效")
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
sh := store.Share{
|
||||
Token: auth.NewShareToken(),
|
||||
Items: items,
|
||||
PasswordHash: auth.HashSharePassword(body.Password),
|
||||
CreatedAt: now.UnixMilli(),
|
||||
}
|
||||
if body.ExpiresIn > 0 {
|
||||
sh.ExpiresAt = now.Add(time.Duration(body.ExpiresIn) * time.Second).UnixMilli()
|
||||
}
|
||||
if err := s.store.CreateShare(sh); err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
if len(sh.Items) == 1 {
|
||||
sh.Path = sh.Items[0].Path
|
||||
}
|
||||
view, err := s.viewOf(sh)
|
||||
if err != nil {
|
||||
vaultError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, view)
|
||||
}
|
||||
|
||||
func (s *Server) deleteShare(w http.ResponseWriter, r *http.Request) {
|
||||
ok, err := s.store.DeleteShare(r.PathValue("token"))
|
||||
if err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
writeError(w, 404, "分享不存在")
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// ---- 公开访问(无需登录) ----
|
||||
|
||||
// openShare 取出有效的分享并校验访客凭据;失败时已写好响应
|
||||
func (s *Server) openShare(w http.ResponseWriter, r *http.Request) (store.Share, bool) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("X-Robots-Tag", "noindex")
|
||||
sh, ok, err := s.loadShare(r.PathValue("token"))
|
||||
if err != nil {
|
||||
internalError(w, r, err)
|
||||
return sh, false
|
||||
}
|
||||
if !ok {
|
||||
writeError(w, 404, "分享不存在或已失效")
|
||||
return sh, false
|
||||
}
|
||||
if sh.PasswordHash != "" {
|
||||
valid := false
|
||||
if c, err := r.Cookie(shareCookieName); err == nil {
|
||||
if valid, err = s.auth.ValidShareCookie(c.Value, sh.Token, sh.PasswordHash); err != nil {
|
||||
internalError(w, r, err)
|
||||
return sh, false
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
writeJSON(w, 401, map[string]any{"error": "需要访问密码", "passwordRequired": true})
|
||||
return sh, false
|
||||
}
|
||||
}
|
||||
return sh, true
|
||||
}
|
||||
|
||||
// loadShare 返回未过期的分享
|
||||
func (s *Server) loadShare(token string) (store.Share, bool, error) {
|
||||
sh, ok, err := s.store.GetShare(token)
|
||||
if err != nil || !ok {
|
||||
return sh, false, err
|
||||
}
|
||||
if sh.ExpiresAt != 0 && time.Now().UnixMilli() >= sh.ExpiresAt {
|
||||
return sh, false, nil
|
||||
}
|
||||
return sh, true, nil
|
||||
}
|
||||
|
||||
// publicShare 只公开主体本身或分享目录内的内容,不返回主体在仓库中的路径。
|
||||
func (s *Server) publicShare(w http.ResponseWriter, r *http.Request) {
|
||||
sh, ok := s.openShare(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if len(sh.Items) > 1 && r.URL.Query().Get("item") == "" {
|
||||
if r.URL.Query().Get("path") != "" {
|
||||
writeError(w, 400, "请选择分享主体")
|
||||
return
|
||||
}
|
||||
items := []map[string]any{}
|
||||
for _, item := range sh.Items {
|
||||
info, err := s.vault.Stat(item.Path)
|
||||
if err != nil && !errors.Is(err, vault.ErrNotFound) {
|
||||
vaultError(w, r, err)
|
||||
return
|
||||
}
|
||||
items = append(items, map[string]any{"id": item.ID, "title": path.Base(item.Path), "exists": err == nil, "dir": err == nil && info.IsDir()})
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"title": "分享的文件", "collection": true, "items": items})
|
||||
return
|
||||
}
|
||||
subject, ok := shareSubject(w, r, sh)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
info, err := s.vault.Stat(subject)
|
||||
if err != nil {
|
||||
vaultError(w, r, err)
|
||||
return
|
||||
}
|
||||
p := subject
|
||||
if rel := r.URL.Query().Get("path"); rel != "" {
|
||||
if !info.IsDir() {
|
||||
writeError(w, 404, "文件不存在")
|
||||
return
|
||||
}
|
||||
p, err = s.vault.ScopedPath(subject, rel)
|
||||
if err == nil {
|
||||
info, err = s.vault.Stat(p)
|
||||
}
|
||||
if err != nil {
|
||||
vaultError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if info.IsDir() {
|
||||
entries, err := s.vault.TreeAt(p)
|
||||
if err != nil {
|
||||
vaultError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"title": path.Base(p), "dir": true, "entries": entries})
|
||||
return
|
||||
}
|
||||
if !vault.IsNote(p) {
|
||||
writeJSON(w, 200, map[string]any{"title": path.Base(p), "dir": false})
|
||||
return
|
||||
}
|
||||
data, _, err := s.vault.Read(p)
|
||||
if err != nil {
|
||||
vaultError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{
|
||||
"title": strings.TrimSuffix(path.Base(p), vault.NoteExt),
|
||||
"content": string(data),
|
||||
"updatedAt": info.ModTime().UnixMilli(),
|
||||
"dir": false,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) unlockShare(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
var body struct{ Password string }
|
||||
if !readJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
sh, ok, err := s.loadShare(r.PathValue("token"))
|
||||
if err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
writeError(w, 404, "分享不存在或已失效")
|
||||
return
|
||||
}
|
||||
if sh.PasswordHash != "" {
|
||||
switch err := s.auth.CheckSharePassword(clientIP(r), sh.PasswordHash, body.Password); {
|
||||
case errors.Is(err, auth.ErrRateLimited):
|
||||
writeError(w, 429, err.Error())
|
||||
return
|
||||
case errors.Is(err, auth.ErrBadCredentials):
|
||||
writeError(w, 401, "密码错误")
|
||||
return
|
||||
case err != nil:
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
value, err := s.auth.ShareCookie(sh.Token, sh.PasswordHash)
|
||||
if err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
ttl := shareCookieTTL
|
||||
if sh.ExpiresAt != 0 {
|
||||
ttl = time.Until(time.UnixMilli(sh.ExpiresAt))
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: shareCookieName,
|
||||
Value: value,
|
||||
// 默认 Path 为 /unlock 所在目录,避免非 ASCII 文件名违反 Cookie Path 格式。
|
||||
MaxAge: int(ttl / time.Second),
|
||||
HttpOnly: true,
|
||||
Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https",
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
writeJSON(w, 200, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// publicShareAsset 提供主体允许范围内的普通文件;单文件分享额外允许其 .assets。
|
||||
func (s *Server) publicShareAsset(w http.ResponseWriter, r *http.Request) {
|
||||
sh, ok := s.openShare(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
subject, ok := shareSubject(w, r, sh)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
info, err := s.vault.Stat(subject)
|
||||
if err != nil {
|
||||
vaultError(w, r, err)
|
||||
return
|
||||
}
|
||||
rel := r.PathValue("path")
|
||||
var target string
|
||||
if info.IsDir() {
|
||||
target, err = s.vault.ScopedPath(subject, rel)
|
||||
} else if rel == "" {
|
||||
target = subject
|
||||
} else {
|
||||
assets := vault.AssetsDir(subject)
|
||||
prefix := path.Base(assets) + "/"
|
||||
if !vault.IsNote(subject) || !strings.HasPrefix(rel, prefix) {
|
||||
writeError(w, 404, "文件不存在")
|
||||
return
|
||||
}
|
||||
target, err = s.vault.ScopedPath(assets, strings.TrimPrefix(rel, prefix))
|
||||
}
|
||||
if err != nil {
|
||||
vaultError(w, r, err)
|
||||
return
|
||||
}
|
||||
if r.URL.Query().Get("download") == "1" {
|
||||
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": path.Base(target)}))
|
||||
}
|
||||
s.serveRaw(w, r, target)
|
||||
}
|
||||
|
||||
func shareSubject(w http.ResponseWriter, r *http.Request, sh store.Share) (string, bool) {
|
||||
raw := r.URL.Query().Get("item")
|
||||
if raw == "" && len(sh.Items) == 1 {
|
||||
return sh.Items[0].Path, true
|
||||
}
|
||||
id, err := strconv.Atoi(raw)
|
||||
if err == nil {
|
||||
for _, item := range sh.Items {
|
||||
if item.ID == id {
|
||||
return item.Path, true
|
||||
}
|
||||
}
|
||||
}
|
||||
writeError(w, 404, "分享主体不存在")
|
||||
return "", false
|
||||
}
|
||||
|
||||
// FollowMoves 让分享跟随在外部(如 Typora)完成的改名与移动;经 API 的移动已在 move 接口中处理,
|
||||
// 这里再次处理时原路径已无分享,不会重复修改
|
||||
func (s *Server) FollowMoves(ctx context.Context) {
|
||||
for ctx.Err() == nil {
|
||||
ch, cancel := s.hub.Subscribe()
|
||||
s.followMoves(ctx, ch)
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) followMoves(ctx context.Context, ch <-chan []byte) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case msg, ok := <-ch:
|
||||
if !ok {
|
||||
// 处理不及时被断开,重新订阅
|
||||
return
|
||||
}
|
||||
var evs []events.Event
|
||||
if err := json.Unmarshal(msg, &evs); err != nil {
|
||||
slog.Error("解析变更事件失败", "err", err)
|
||||
continue
|
||||
}
|
||||
for _, ev := range evs {
|
||||
if ev.Type == "moved" {
|
||||
if err := s.store.MoveShares(ev.From, ev.Path); err != nil {
|
||||
slog.Warn("更新分享路径失败", "from", ev.From, "to", ev.Path, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,10 +42,10 @@ func (h *staticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
|
||||
// 分享页 /s/{token} 是独立的入口,只加载渲染器
|
||||
share := strings.HasPrefix(name, "s/")
|
||||
if share {
|
||||
name = "share.html"
|
||||
// 已移除的公开入口不能回退到应用首页。
|
||||
if name == "s" || strings.HasPrefix(name, "s/") || name == "share.html" {
|
||||
writeError(w, 404, "页面不存在")
|
||||
return
|
||||
}
|
||||
f, err := h.load(name)
|
||||
if err != nil {
|
||||
@@ -65,9 +65,6 @@ func (h *staticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(name, ".html") {
|
||||
hd.Set("Content-Security-Policy", appCSP)
|
||||
}
|
||||
if share {
|
||||
hd.Set("X-Robots-Tag", "noindex")
|
||||
}
|
||||
if path.Ext(name) == ".webmanifest" {
|
||||
// Go 的 MIME 表未必包含此扩展名,不能让 ServeContent 对压缩后的数据嗅探类型。
|
||||
hd.Set("Content-Type", "application/manifest+json")
|
||||
|
||||
@@ -142,57 +142,3 @@ func (s *Server) getTheme(w http.ResponseWriter, r *http.Request) {
|
||||
CSS string `json:"css"`
|
||||
}{manifest, css})
|
||||
}
|
||||
|
||||
// 只有有效分享的访客可读取外观;不返回账户、插件和其他应用设置。
|
||||
func (s *Server) publicAppearance(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.openShare(w, r); !ok {
|
||||
return
|
||||
}
|
||||
settings, err := s.readSettings()
|
||||
if err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
out := map[string]any{}
|
||||
for _, key := range []string{"colorMode", "uiFontSize", "fontSize", "contentWidth", "textFont", "codeFont", "lineHeight", "paragraphSpacing", "glassOpacity"} {
|
||||
if value, ok := settings[key]; ok {
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
var snippets []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CSS string `json:"css"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if value, ok := settings["cssSnippets"]; ok {
|
||||
if err := json.Unmarshal(value, &snippets); err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
active := snippets[:0]
|
||||
for _, snippet := range snippets {
|
||||
if snippet.Enabled {
|
||||
active = append(active, snippet)
|
||||
}
|
||||
}
|
||||
out["cssSnippets"] = active
|
||||
var theme string
|
||||
if value, ok := settings["theme"]; ok {
|
||||
if err := json.Unmarshal(value, &theme); err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if theme != "" && theme != "default" {
|
||||
_, css, err := s.readTheme(theme)
|
||||
if err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
out["themeCSS"] = css
|
||||
}
|
||||
out["theme"] = theme
|
||||
writeJSON(w, 200, out)
|
||||
}
|
||||
@@ -24,7 +24,6 @@
|
||||
/* 绿色按钮使用深色文字,保持按钮的文字对比度。 */
|
||||
.rm-btn-primary,
|
||||
.rm-btn-primary:hover,
|
||||
.rm-segmented button.on,
|
||||
.rm-share-password button {
|
||||
.rm-segmented button.on {
|
||||
color: #101b16;
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -10,7 +8,7 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestThemeAndShareAppearanceBoundaries(t *testing.T) {
|
||||
func TestThemeBoundaries(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
dir := filepath.Join(filepath.Dir(f.root), "themes", "paper")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
@@ -42,33 +40,6 @@ func TestThemeAndShareAppearanceBoundaries(t *testing.T) {
|
||||
if !strings.Contains(theme["css"], "#faf6ed") {
|
||||
t.Fatal("未读取到主题 CSS")
|
||||
}
|
||||
f.write("笔记.md", "正文")
|
||||
f.do(f.admin, "PATCH", "/api/v1/settings", `{"theme":"paper","colorMode":"dark","fontSize":18,"uiFontSize":20,"privateSetting":"不公开","cssSnippets":[{"id":"a","name":"启用","css":"body { color: red }","enabled":true},{"id":"b","name":"停用","css":"不公开片段","enabled":false}]}`, nil)
|
||||
sh := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", `{"path":"笔记.md","password":"访问密码"}`, nil))
|
||||
base := "/api/v1/public/shares/" + sh.Token
|
||||
if code := f.status(guest, "GET", base+"/appearance", "", nil); code != 401 {
|
||||
t.Fatalf("未解锁不能读取外观,得到 %d", code)
|
||||
}
|
||||
f.do(guest, "POST", base+"/unlock", `{"password":"访问密码"}`, nil)
|
||||
res := f.do(guest, "GET", base+"/appearance", "", nil)
|
||||
raw, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var appearance map[string]any
|
||||
if err := json.Unmarshal(raw, &appearance); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.StatusCode != 200 || appearance["colorMode"] != "dark" || appearance["uiFontSize"] != float64(20) || appearance["fontSize"] != float64(18) || !strings.Contains(appearance["themeCSS"].(string), "#faf6ed") {
|
||||
t.Fatalf("分享外观错误:%s", raw)
|
||||
}
|
||||
if strings.Contains(string(raw), "不公开") || len(appearance["cssSnippets"].([]any)) != 1 {
|
||||
t.Fatalf("分享泄露了其他设置或停用片段:%s", raw)
|
||||
}
|
||||
f.do(f.admin, "DELETE", "/api/v1/shares/"+sh.Token, "", nil)
|
||||
if code := f.status(guest, "GET", base+"/appearance", "", nil); code != 404 {
|
||||
t.Fatalf("撤销分享后不能读取外观,得到 %d", code)
|
||||
}
|
||||
// CSS 符号链接不能读取主题目录之外的应用数据。
|
||||
if err := os.Remove(filepath.Join(dir, "theme.css")); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
Reference in new issue
Block a user