diff --git a/README.md b/README.md index ef0ff83..e5deaf4 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ docker compose --env-file /docker/main/.env up -d --build --wait ``` 持久化数据位于 `deploy/data/`,与 `.dev-data/` 独立。首次打开网页时直接设置登录密码。 +升级已有实例移除分享时,先备份数据与旧镜像、停止旧服务,再对 `rawmark.db` 执行一次 `scripts/remove-sharing.sql`,然后启动新版本。脚本删除分享表、专用密钥及分享快捷键;不会删除笔记或图片。旧分享页面和接口返回 404。回滚必须配套恢复数据库和旧镜像,不能只恢复旧程序。 宿主机 Nginx 配置为 `deploy/nginx/raw.134333.xyz.conf`,复用现有通配证书及 WebSocket 升级映射;安装到 `/etc/nginx/conf.d/134333.xyz/` 后,先执行 `nginx -t` 再重载。 域名 `raw.134333.xyz` 在 DNSPod 设置 CNAME 指向 `cdn.134333.xyz`(与 AlphaPulse 一致);还需在 Cloudflare for SaaS 的 `unino.top` 区域添加该自定义域名,并等待域名与托管证书均为 `active`。仅设置 CNAME 或旧区域中的 A 记录不足以完成 CDN 接入。访问地址为 `https://raw.134333.xyz`。修改端口时需同步端口登记和 Nginx 上游。 @@ -31,16 +32,17 @@ docker run -d --name rawmark \ - 首次打开直接设置登录密码,之后凭密码登录;请在将新实例开放给他人访问前完成初始化。 - 笔记以普通 `.md` 文件保存在 `数据目录/vault/`,图片按 Typora 习惯存放在同级的 `笔记名.assets/`,可直接用 Typora 等编辑器打开;外部修改会实时同步到网页。 -- 知识工具:标准 Markdown 链接构成图谱和反向链接,支持正文 `#标签` 与 YAML `tags`。笔记或文件夹在应用内改名、移动时维护入链和相对出链;公式和代码示例不参与关系提取。 +- 知识工具:标准 Markdown 链接构成图谱,支持正文 `#标签` 与 YAML `tags`。笔记或文件夹在应用内改名、移动时维护入链和相对出链;公式和代码示例不参与关系提取。 - 日记与模板:默认日记路径为 `Daily/YYYY-MM-DD.md`(本地日期),已有日记直接打开;模板为 `Templates/` 下的普通 Markdown,支持 `{{date}}`、`{{time}}`、`{{title}}`,资源链接按新笔记位置调整。可在设置中修改目录和日记模板。 -- 外观:浅色玻璃工作台与深色模式,字体资源随应用本地提供;界面字号默认 16px,可输入 14–20px 并单独重置,与编辑器及阅读字号独立;排版、玻璃透明度、选中文本工具栏均可在设置中调整并即时保存。 -- 图片管理:侧栏默认隐藏 `assets` 和 `笔记名.assets`;右键文件夹选择「图片管理」,查看当前目录及直属附件目录的图片,支持检索、排序、网格/列表、批量上传、引用查看及预览、重命名、移动、替换。可切换整个知识库;清理孤立图片须确认候选,服务端再次核对全库引用与有效分享后移入回收站,可恢复。上传至所选目录的 `assets/`,相同内容自动去重。根目录可在侧栏空白处右键或通过命令面板打开。图片改名/移动会更新笔记中的本地引用;替换保留路径,选择相同扩展名的图片(最大 50 MB),旧图片不进回收站。 +- 外观:浅色玻璃工作台与深色模式,字体资源随应用本地提供;界面字号默认 14px,可输入 14–20px 并单独重置,与编辑器及阅读字号独立;排版、玻璃透明度、选中文本工具栏均可在设置中调整并即时保存。 +- 图片管理:侧栏默认隐藏 `assets` 和 `笔记名.assets`;右键文件夹选择「图片管理」,查看当前目录及直属附件目录的图片,支持检索、排序、网格/列表、批量上传、引用查看及预览、重命名、移动、替换。可切换整个知识库;清理孤立图片须确认候选,服务端再次核对全库引用后移入回收站,可恢复。上传至所选目录的 `assets/`,相同内容自动去重。根目录可在侧栏空白处右键或通过命令面板打开。图片改名/移动会更新笔记中的本地引用;替换保留路径,选择相同扩展名的图片(最大 50 MB),旧图片不进回收站。 - `--user` 让容器以宿主机用户身份读写数据目录;不指定时容器以 UID 65532 运行,需要该用户对目录有写权限。 - 请通过 HTTPS 反向代理(Caddy、Nginx 等)访问,并转发 WebSocket(`/api/v1/events`)。 -- 分享:文件或文件夹右键「分享」,也可通过命令面板(F1)分享当前笔记;多选生成一个合集链接。链接 `/s/…` 的标识完全随机,展示源文件最新内容并跟随分享主体移动;文件夹递归包含当前内容。可设密码与有效期,在设置中管理与撤销。公开阅读页仅提供分享范围内的导航、大纲、阅读进度、复制与打印,不加载编辑器。 -- 多选与导出:Ctrl/Cmd 点击切换选择,Shift 点击选择可见范围;右键批量分享、删除、移动或导出 Markdown。单篇无附件直接下载 `.md`,其他情况下载包含 Markdown 与本地附件的 ZIP;重名目标不会被覆盖,批量操作会汇报单项失败。 -- 快捷键:设置中支持添加、逐条移除和单项/全部重置,并拒绝冲突绑定。默认 Ctrl+Alt+1~6 设置对应级别标题,同级再次触发恢复正文;字号可直接输入。 -- 导入:侧栏顶部或文件夹右键菜单选择「导入」,支持多选 UTF-8 `.md` 文件或单个 ZIP(总上传上限 100 MB)。ZIP 新建同名文件夹并保留层级,本地图片转换为笔记独立附件,原附件保留;同名内容不覆盖,缺失的本地链接会提示,外部图片不会自动下载。 +- 多选与导出:Ctrl/Cmd 点击切换选择,Shift 点击选择可见范围;右键批量删除、移动或导出 Markdown。单篇无附件直接下载 `.md`,其他情况下载包含 Markdown 与本地附件的 ZIP;重名目标不会被覆盖,批量操作会汇报单项失败。 +- 设置:桌面使用居中窗口,手机使用分类与内容两级布局;支持搜索设置并定位、常用排版项单独重置,自动保存状态与失败提示可见。外观、编辑器和日记与模板分别管理,安装入口位于「关于」。 +- 快捷键:支持按命令名称、ID 或组合键搜索,添加、逐条移除和单项/全部重置,并拒绝冲突绑定。默认 Ctrl+Alt+1~6 设置对应级别标题,同级再次触发恢复正文;字号可直接输入。 +- 文件目录操作:右键「文件目录」标题或目录空白处,可搜索、打开标签索引、新建笔记、新建目录、导入文档及管理图片;也可点击标题打开菜单。文件夹右键操作以所选文件夹为目标。 +- 导入:文件目录或文件夹右键菜单选择「导入」,支持多选 UTF-8 `.md` 文件或单个 ZIP(总上传上限 100 MB)。ZIP 新建同名文件夹并保留层级,本地图片转换为笔记独立附件,原附件保留;同名内容不覆盖,缺失的本地链接会提示,外部图片不会自动下载。 - 自动化:「API 令牌」中创建只读或读写令牌,以 `Authorization: Bearer <令牌>` 调用 REST API,接口说明见 `/api/v1/openapi.json`。 | 环境变量 | 默认值 | 说明 | diff --git a/cmd/rawmark/main.go b/cmd/rawmark/main.go index 7072f85..262d730 100644 --- a/cmd/rawmark/main.go +++ b/cmd/rawmark/main.go @@ -83,7 +83,6 @@ func run(addr string) error { srv := api.New(v, am, st, hub, idx) srv.ThemesDir = filepath.Join(dataDir, "themes") srv.PluginsDir = filepath.Join(dataDir, "plugins") - go srv.FollowMoves(ctx) httpServer := &http.Server{ Addr: addr, Handler: srv.Handler(web.Dist()), diff --git a/internal/api/api.go b/internal/api/api.go index db8adff..22d9719 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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 diff --git a/internal/api/api_test.go b/internal/api/api_test.go index cf0b0ff..0823d3c 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -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}, } diff --git a/internal/api/assets.go b/internal/api/assets.go index 8c3218a..da4f66a 100644 --- a/internal/api/assets.go +++ b/internal/api/assets.go @@ -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 diff --git a/internal/api/assets_test.go b/internal/api/assets_test.go index 53d24f3..d17ca2b 100644 --- a/internal/api/assets_test.go +++ b/internal/api/assets_test.go @@ -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) + } +} diff --git a/internal/api/collections_test.go b/internal/api/collections_test.go deleted file mode 100644 index 2bbd7b2..0000000 --- a/internal/api/collections_test.go +++ /dev/null @@ -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) - } -} diff --git a/internal/api/openapi.json b/internal/api/openapi.json index 00895ce..9b34c47 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "RawMark API", "version": "1", - "description": "RawMark 的 REST API。自动化调用请在「API 令牌」中创建令牌,以 `Authorization: Bearer ` 访问;只读令牌可使用 GET 和 POST /export(只读导出)。分享与令牌的管理接口只接受登录会话。所有错误响应均为 `{\"error\": \"说明\"}`。" + "description": "RawMark 的 REST API。自动化调用请在「API 令牌」中创建令牌,以 `Authorization: Bearer ` 访问;只读令牌可使用 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": { diff --git a/internal/api/removed_sharing_test.go b/internal/api/removed_sharing_test.go new file mode 100644 index 0000000..8f3dc6b --- /dev/null +++ b/internal/api/removed_sharing_test.go @@ -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) + } + } +} diff --git a/internal/api/shares.go b/internal/api/shares.go deleted file mode 100644 index 8a822db..0000000 --- a/internal/api/shares.go +++ /dev/null @@ -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) - } - } - } - } - } -} diff --git a/internal/api/static.go b/internal/api/static.go index a639948..6eeef5a 100644 --- a/internal/api/static.go +++ b/internal/api/static.go @@ -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") diff --git a/internal/api/themes.go b/internal/api/themes.go index be61e17..f707516 100644 --- a/internal/api/themes.go +++ b/internal/api/themes.go @@ -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) -} diff --git a/internal/api/themes/black.css b/internal/api/themes/black.css index 8273ec1..b5a91b9 100644 --- a/internal/api/themes/black.css +++ b/internal/api/themes/black.css @@ -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; } diff --git a/internal/api/themes_test.go b/internal/api/themes_test.go index 8b3193c..de6d8a6 100644 --- a/internal/api/themes_test.go +++ b/internal/api/themes_test.go @@ -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) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index aabc408..f0ba751 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -1,8 +1,7 @@ -// Package auth 实现单用户登录(Argon2id 密码哈希、会话令牌、登录限速),以及分享密码与 API 令牌的校验。 +// Package auth 实现单用户登录(Argon2id 密码哈希、会话令牌、登录限速),以及 API 令牌的校验。 package auth import ( - "crypto/hmac" "crypto/rand" "crypto/sha256" "crypto/subtle" @@ -21,7 +20,6 @@ import ( const ( passwordKey = "auth.password" - shareSecretKey = "share.secret" apiTokenPrefix = "rm_" SessionTTL = 30 * 24 * time.Hour MinPasswordLen = 8 @@ -32,7 +30,7 @@ const ( argonThreads = 4 argonKeyLen = 32 - // 同一来源在窗口内累计失败 maxFailures 次(登录与分享密码合计)即封禁 lockout + // 同一来源在窗口内累计失败 maxFailures 次即封禁 lockout maxFailures = 30 failureWindow = 15 * time.Minute lockout = 15 * time.Minute @@ -149,70 +147,6 @@ func (m *Manager) ChangePassword(ip, keepToken, current, next string) error { return m.store.DeleteOtherSessions(hashToken(keepToken)) } -// ---- 分享 ---- - -// HashSharePassword 为分享密码生成哈希;空密码表示不设密码,返回空字符串 -func HashSharePassword(password string) string { - if password == "" { - return "" - } - return hashPassword(password) -} - -// CheckSharePassword 校验分享密码,与登录共用按来源的失败限速。 -// 成功不清零失败计数:否则知道某个分享密码的人可以借此反复重置计数,无限尝试登录密码 -func (m *Manager) CheckSharePassword(ip, hash, password string) error { - if !m.allow(ip) { - return ErrRateLimited - } - ok, err := verifyPassword(hash, password) - if err != nil { - return err - } - if !ok { - m.recordFailure(ip) - return ErrBadCredentials - } - return nil -} - -// ShareCookie 生成输入分享密码后发给访客的凭据:绑定分享令牌与密码哈希,撤销分享即失效 -func (m *Manager) ShareCookie(token, passwordHash string) (string, error) { - secret, err := m.shareSecret() - if err != nil { - return "", err - } - mac := hmac.New(sha256.New, secret) - mac.Write([]byte(token + "\x00" + passwordHash)) - return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil -} - -func (m *Manager) ValidShareCookie(cookie, token, passwordHash string) (bool, error) { - want, err := m.ShareCookie(token, passwordHash) - if err != nil { - return false, err - } - return hmac.Equal([]byte(cookie), []byte(want)), nil -} - -// shareSecret 读取签名密钥,首次使用时生成并保存 -func (m *Manager) shareSecret() ([]byte, error) { - v, ok, err := m.store.Setting(shareSecretKey) - if err != nil { - return nil, err - } - if !ok { - if _, err := m.store.SetSettingIfAbsent(shareSecretKey, randomToken()); err != nil { - return nil, err - } - // 并发首次生成时以先写入的为准 - if v, _, err = m.store.Setting(shareSecretKey); err != nil { - return nil, err - } - } - return base64.RawURLEncoding.DecodeString(v) -} - // ---- API 令牌 ---- // CreateAPIToken 创建令牌,返回只显示这一次的明文 @@ -316,13 +250,6 @@ func randomToken() string { return base64.RawURLEncoding.EncodeToString(b) } -// NewShareToken 生成分享链接中的令牌(128 位随机数,比会话令牌短,便于复制) -func NewShareToken() string { - b := make([]byte, 16) - rand.Read(b) - return base64.RawURLEncoding.EncodeToString(b) -} - func hashToken(token string) string { sum := sha256.Sum256([]byte(token)) return hex.EncodeToString(sum[:]) diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index f408714..9c665ac 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -25,34 +25,20 @@ func newManager(t *testing.T) (*Manager, *time.Time) { return m, &clock } -// 登录与分享密码合计 30 次失败后封禁 15 分钟,期间正确密码也被拒绝 +// 登录密码累计 30 次失败后封禁 15 分钟,期间正确密码也被拒绝 func TestRateLimit(t *testing.T) { m, clock := newManager(t) - share := HashSharePassword("share-pass") for i := 0; i < maxFailures-1; i++ { - var err error - if i%2 == 0 { - _, err = m.Login("1.1.1.1", "wrong") - } else { - err = m.CheckSharePassword("1.1.1.1", share, "wrong") - } - if !errors.Is(err, ErrBadCredentials) { + if _, err := m.Login("1.1.1.1", "wrong"); !errors.Is(err, ErrBadCredentials) { t.Fatalf("第 %d 次失败: %v", i+1, err) } } - // 分享密码正确不清零计数 - if err := m.CheckSharePassword("1.1.1.1", share, "share-pass"); err != nil { - t.Fatal(err) - } if _, err := m.Login("1.1.1.1", "wrong"); !errors.Is(err, ErrBadCredentials) { t.Fatalf("第 30 次失败: %v", err) } if _, err := m.Login("1.1.1.1", "password123"); !errors.Is(err, ErrRateLimited) { t.Fatalf("封禁期间正确密码也应被拒绝: %v", err) } - if err := m.CheckSharePassword("1.1.1.1", share, "share-pass"); !errors.Is(err, ErrRateLimited) { - t.Fatalf("封禁同样作用于分享密码: %v", err) - } if _, err := m.Login("2.2.2.2", "password123"); err != nil { t.Fatalf("其他来源不受影响: %v", err) } diff --git a/internal/store/remove_sharing_test.go b/internal/store/remove_sharing_test.go new file mode 100644 index 0000000..1c26ea8 --- /dev/null +++ b/internal/store/remove_sharing_test.go @@ -0,0 +1,109 @@ +package store + +import ( + "os" + "path/filepath" + "testing" +) + +func TestRemoveSharingPreservesApplicationData(t *testing.T) { + script, err := os.ReadFile("../../scripts/remove-sharing.sql") + if err != nil { + t.Fatal(err) + } + for _, layout := range []string{"current", "legacy", "fresh"} { + t.Run(layout, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "rawmark.db") + st, err := Open(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + if _, err := st.db.Exec(` +INSERT INTO settings VALUES ('auth.password','password-hash'), ('plugin.setting','keep'), + ('share.secret','old-secret'), + ('app.settings','{"fontSize":18,"shortcuts":{"note.share":["Alt+S"],"app.shares":[],"note.open":["Ctrl+O"],"plugin.command":[]},"plugin.data":{"keep":true}}'); +INSERT INTO sessions VALUES ('session-hash',123,456); +INSERT INTO api_tokens VALUES ('token-id','automation','token-hash','read',123,456);`); err != nil { + t.Fatal(err) + } + switch layout { + case "current": + _, err = st.db.Exec(`CREATE TABLE shares(token TEXT PRIMARY KEY,password_hash TEXT,created_at INTEGER,expires_at INTEGER); +CREATE TABLE share_items(share_token TEXT REFERENCES shares(token),item_id INTEGER,path TEXT); +CREATE INDEX share_items_path ON share_items(path); +INSERT INTO shares VALUES ('old-token','hash',123,0); +INSERT INTO share_items VALUES ('old-token',0,'note.md');`) + case "legacy": + _, err = st.db.Exec(`CREATE TABLE shares(token TEXT PRIMARY KEY,path TEXT,password_hash TEXT,created_at INTEGER,expires_at INTEGER); +CREATE INDEX shares_path ON shares(path); +INSERT INTO shares VALUES ('old-token','note.md','hash',123,0);`) + } + if err != nil { + t.Fatal(err) + } + for range 2 { + if _, err := st.db.Exec(string(script)); err != nil { + t.Fatal(err) + } + } + if err := st.Close(); err != nil { + t.Fatal(err) + } + st, err = Open(path) + if err != nil { + t.Fatal(err) + } + var remaining int + if err := st.db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE name IN ('shares','share_items','share_items_path','shares_path')`).Scan(&remaining); err != nil || remaining != 0 { + t.Fatalf("重启后不应创建分享结构:%d %v", remaining, err) + } + if _, ok, err := st.Setting("share.secret"); err != nil || ok { + t.Fatalf("分享密钥未清除:%v", err) + } + for key, want := range map[string]string{ + "auth.password": "password-hash", "plugin.setting": "keep", + "app.settings": `{"fontSize":18,"shortcuts":{"note.open":["Ctrl+O"],"plugin.command":[]},"plugin.data":{"keep":true}}`, + } { + if got, ok, err := st.Setting(key); err != nil || !ok || got != want { + t.Fatalf("非分享设置被改变:%s %v", key, err) + } + } + var sessions, tokens int + if err := st.db.QueryRow(`SELECT count(*) FROM sessions WHERE token_hash='session-hash' AND created_at=123 AND expires_at=456`).Scan(&sessions); err != nil { + t.Fatal(err) + } + if err := st.db.QueryRow(`SELECT count(*) FROM api_tokens WHERE id='token-id' AND name='automation' AND token_hash='token-hash' AND scope='read' AND created_at=123 AND last_used_at=456`).Scan(&tokens); err != nil || sessions != 1 || tokens != 1 { + t.Fatalf("会话或 API 令牌被改变:%d %d %v", sessions, tokens, err) + } + }) + } +} + +func TestRemoveSharingRollsBackOnInvalidSettings(t *testing.T) { + st, err := Open(filepath.Join(t.TempDir(), "rawmark.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if _, err := st.db.Exec(`CREATE TABLE shares(token TEXT); INSERT INTO shares VALUES ('old-token'); INSERT INTO settings VALUES ('share.secret','old-secret'),('app.settings','invalid-json');`); err != nil { + t.Fatal(err) + } + script, err := os.ReadFile("../../scripts/remove-sharing.sql") + if err != nil { + t.Fatal(err) + } + if _, err := st.db.Exec(string(script)); err == nil { + t.Fatal("设置损坏时必须报告错误") + } + if _, err := st.db.Exec("ROLLBACK"); err != nil { + t.Fatal(err) + } + var count int + if err := st.db.QueryRow(`SELECT count(*) FROM shares WHERE token='old-token'`).Scan(&count); err != nil || count != 1 { + t.Fatalf("失败后分享数据未恢复:%v", err) + } + if got, ok, err := st.Setting("share.secret"); err != nil || !ok || got != "old-secret" { + t.Fatalf("失败后分享密钥未恢复:%v", err) + } +} diff --git a/internal/store/shares.go b/internal/store/shares.go deleted file mode 100644 index a8c6b74..0000000 --- a/internal/store/shares.go +++ /dev/null @@ -1,43 +0,0 @@ -package store - -import "database/sql" - -const shareSchema = ` --- 分享令牌是可复制的访问凭据,与笔记同等敏感。 -CREATE TABLE IF NOT EXISTS shares ( - token TEXT PRIMARY KEY, - password_hash TEXT NOT NULL DEFAULT '', - created_at INTEGER NOT NULL, - expires_at INTEGER NOT NULL DEFAULT 0 -); -CREATE TABLE IF NOT EXISTS share_items ( - share_token TEXT NOT NULL REFERENCES shares(token) ON DELETE CASCADE, - item_id INTEGER NOT NULL, - path TEXT NOT NULL, - PRIMARY KEY (share_token,item_id) -); -CREATE INDEX IF NOT EXISTS share_items_path ON share_items(path); -` - -// 仅迁移项目已有的单路径表;事务保证中断或失败时仍保留原格式。 -func migrateShares(db *sql.DB) error { - var legacy bool - if err := db.QueryRow(`SELECT EXISTS(SELECT 1 FROM pragma_table_info('shares') WHERE name = 'path')`).Scan(&legacy); err != nil { - return err - } - if !legacy { - return nil - } - tx, err := db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if _, err = tx.Exec(`ALTER TABLE shares RENAME TO shares_legacy;` + shareSchema + ` - INSERT INTO shares(token,password_hash,created_at,expires_at) SELECT token,password_hash,created_at,expires_at FROM shares_legacy; - INSERT INTO share_items(share_token,item_id,path) SELECT token,0,path FROM shares_legacy; - DROP TABLE shares_legacy;`); err != nil { - return err - } - return tx.Commit() -} diff --git a/internal/store/shares_test.go b/internal/store/shares_test.go deleted file mode 100644 index 4f8431c..0000000 --- a/internal/store/shares_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package store - -import ( - "database/sql" - "path/filepath" - "testing" -) - -func TestMigrateSingleSharesAndPreserveLinks(t *testing.T) { - file := filepath.Join(t.TempDir(), "app.db") - legacy, err := sql.Open("sqlite", file) - if err != nil { - t.Fatal(err) - } - _, err = legacy.Exec(`CREATE TABLE shares(token TEXT PRIMARY KEY,path TEXT NOT NULL,password_hash TEXT NOT NULL DEFAULT '',created_at INTEGER NOT NULL,expires_at INTEGER NOT NULL DEFAULT 0); - CREATE INDEX shares_path ON shares(path); - INSERT INTO shares VALUES ('old-token','资料/原文.md','hash',123,456);`) - if err != nil { - t.Fatal(err) - } - legacy.Close() - st, err := Open(file) - if err != nil { - t.Fatal(err) - } - sh, ok, err := st.GetShare("old-token") - if err != nil || !ok || sh.Path != "资料/原文.md" || len(sh.Items) != 1 || sh.Items[0].ID != 0 || sh.PasswordHash != "hash" || sh.CreatedAt != 123 || sh.ExpiresAt != 456 { - t.Fatalf("迁移改变了旧分享:%+v %v", sh, err) - } - if err := st.MoveShares("资料", "归档"); err != nil { - t.Fatal(err) - } - st.Close() - st, err = Open(file) - if err != nil { - t.Fatal(err) - } - defer st.Close() - sh, ok, err = st.GetShare("old-token") - if err != nil || !ok || sh.Path != "归档/原文.md" { - t.Fatalf("重启或移动未保留链接:%+v %v", sh, err) - } - if _, err := st.DeleteShare(sh.Token); err != nil { - t.Fatal(err) - } - var count int - if err := st.db.QueryRow(`SELECT count(*) FROM share_items`).Scan(&count); err != nil || count != 0 { - t.Fatalf("撤销未清理主体:%d %v", count, err) - } -} diff --git a/internal/store/store.go b/internal/store/store.go index a6717fb..0c01d3b 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -1,4 +1,4 @@ -// Package store 保存应用自身的数据(设置、会话、分享、API 令牌)。笔记内容不在这里,见 vault。 +// Package store 保存应用自身的数据(设置、会话、API 令牌)。笔记内容不在这里,见 vault。 package store import ( @@ -22,7 +22,6 @@ CREATE TABLE IF NOT EXISTS sessions ( created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL ); -` + shareSchema + ` CREATE TABLE IF NOT EXISTS api_tokens ( id TEXT PRIMARY KEY, name TEXT NOT NULL, @@ -38,10 +37,6 @@ func Open(path string) (*Store, error) { if err != nil { return nil, err } - if err := migrateShares(db); err != nil { - db.Close() - return nil, fmt.Errorf("迁移分享数据失败: %w", err) - } if _, err := db.Exec(schema); err != nil { db.Close() return nil, fmt.Errorf("初始化数据库失败: %w", err) @@ -108,126 +103,6 @@ func (s *Store) DeleteExpiredSessions() error { return err } -// ---- 分享 ---- - -// Share 为一个只读分享链接,Items 标识各个独立跟随移动的主体。 -// Path 仅用于已有单主体调用与响应,不单独存储。 -type Share struct { - Token string `json:"token"` - Path string `json:"path,omitempty"` - Items []ShareItem `json:"items"` - PasswordHash string `json:"-"` - CreatedAt int64 `json:"createdAt"` - ExpiresAt int64 `json:"expiresAt"` -} -type ShareItem struct { - ID int `json:"id"` - Path string `json:"path"` -} - -func (s *Store) CreateShare(sh Share) error { - items := sh.Items - if len(items) == 0 && sh.Path != "" { - items = []ShareItem{{ID: 0, Path: sh.Path}} - } - if len(items) == 0 { - return fmt.Errorf("分享缺少主体") - } - tx, err := s.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if _, err = tx.Exec(`INSERT INTO shares (token, password_hash, created_at, expires_at) VALUES (?, ?, ?, ?)`, sh.Token, sh.PasswordHash, sh.CreatedAt, sh.ExpiresAt); err != nil { - return err - } - for _, item := range items { - if _, err = tx.Exec(`INSERT INTO share_items (share_token, item_id, path) VALUES (?, ?, ?)`, sh.Token, item.ID, item.Path); err != nil { - return err - } - } - return tx.Commit() -} - -func (s *Store) GetShare(token string) (sh Share, ok bool, err error) { - err = s.db.QueryRow(`SELECT token, password_hash, created_at, expires_at FROM shares WHERE token = ?`, token).Scan(&sh.Token, &sh.PasswordHash, &sh.CreatedAt, &sh.ExpiresAt) - if errors.Is(err, sql.ErrNoRows) { - return Share{}, false, nil - } - if err != nil { - return sh, false, err - } - err = s.shareItems(&sh) - return sh, err == nil, err -} - -func (s *Store) shareItems(sh *Share) error { - rows, err := s.db.Query(`SELECT item_id, path FROM share_items WHERE share_token = ? ORDER BY item_id`, sh.Token) - if err != nil { - return err - } - defer rows.Close() - for rows.Next() { - var item ShareItem - if err := rows.Scan(&item.ID, &item.Path); err != nil { - return err - } - sh.Items = append(sh.Items, item) - } - if err := rows.Err(); err != nil { - return err - } - if len(sh.Items) == 0 { - return fmt.Errorf("分享缺少主体") - } - if len(sh.Items) == 1 { - sh.Path = sh.Items[0].Path - } - return nil -} - -func (s *Store) Shares() ([]Share, error) { - rows, err := s.db.Query(`SELECT token, password_hash, created_at, expires_at FROM shares ORDER BY created_at DESC, token`) - if err != nil { - return nil, err - } - out := []Share{} - for rows.Next() { - var sh Share - if err := rows.Scan(&sh.Token, &sh.PasswordHash, &sh.CreatedAt, &sh.ExpiresAt); err != nil { - rows.Close() - return nil, err - } - out = append(out, sh) - } - err = rows.Err() - rows.Close() - if err != nil { - return nil, err - } - for i := range out { - if err := s.shareItems(&out[i]); err != nil { - return nil, err - } - } - return out, nil -} - -func (s *Store) DeleteShare(token string) (bool, error) { - res, err := s.db.Exec(`DELETE FROM shares WHERE token = ?`, token) - if err != nil { - return false, err - } - n, err := res.RowsAffected() - return n == 1, err -} - -func (s *Store) MoveShares(from, to string) error { - _, err := s.db.Exec(`UPDATE share_items SET path = CASE WHEN path = ?1 THEN ?2 ELSE ?2 || substr(path, length(?1) + 1) END - WHERE path = ?1 OR substr(path,1,length(?1)+1) = ?1 || '/'`, from, to) - return err -} - // ---- API 令牌 ---- // APIToken 为自动化调用的访问令牌,只保存哈希。Scope 为 read 或 write diff --git a/internal/vault/cleanup.go b/internal/vault/cleanup.go index 735c806..3d4f614 100644 --- a/internal/vault/cleanup.go +++ b/internal/vault/cleanup.go @@ -1,9 +1,6 @@ package vault -import ( - "fmt" - "strings" -) +import "fmt" type CleanupResult struct { Deleted []TrashItem `json:"deleted"` @@ -15,8 +12,7 @@ type CleanupSkip struct { } // CleanupImages 在写锁内重新扫描引用;派生索引不作为删除依据。 -// protected 返回当前有效分享的主体路径,由业务数据拥有者在检查时读取。 -func (v *Vault) CleanupImages(paths []string, protected func() ([]string, error)) (CleanupResult, error) { +func (v *Vault) CleanupImages(paths []string) (CleanupResult, error) { result := CleanupResult{Deleted: []TrashItem{}, Skipped: []CleanupSkip{}} v.mu.Lock() defer v.mu.Unlock() @@ -41,10 +37,6 @@ func (v *Vault) CleanupImages(paths []string, protected func() ([]string, error) references[ref.Target] = true } } - scopes, err := protected() - if err != nil { - return result, err - } seen := map[string]bool{} for _, p := range paths { if seen[p] { @@ -58,11 +50,11 @@ func (v *Vault) CleanupImages(paths []string, protected func() ([]string, error) case references[p]: reason = "仍被笔记引用" default: - for _, scope := range scopes { - if p == scope || strings.HasPrefix(p, scope+"/") || IsNote(scope) && strings.HasPrefix(p, AssetsDir(scope)+"/") { - reason = "仍在有效分享范围内" - break - } + info, err := v.Stat(p) + if err != nil { + reason = err.Error() + } else if !info.Mode().IsRegular() { + reason = "不是图片文件" } } if reason != "" { diff --git a/internal/vault/cleanup_test.go b/internal/vault/cleanup_test.go index b84a621..9058194 100644 --- a/internal/vault/cleanup_test.go +++ b/internal/vault/cleanup_test.go @@ -8,12 +8,12 @@ import ( "testing" ) -func TestCleanupChecksFreshReferencesAndShares(t *testing.T) { +func TestCleanupChecksFreshReferences(t *testing.T) { v := newVault(t) if _, err := v.Write("a.md", []byte("[图片](assets/used.png)"), Precondition{}); err != nil { t.Fatal(err) } - for _, p := range []string{"assets/used.png", "assets/orphan.png", "public/shared.png", "a.assets/attached.png"} { + for _, p := range []string{"assets/used.png", "assets/orphan.png", "public/orphan.png", "a.assets/attached.png"} { full := filepath.Join(v.Root(), p) if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil { t.Fatal(err) @@ -22,8 +22,8 @@ func TestCleanupChecksFreshReferencesAndShares(t *testing.T) { t.Fatal(err) } } - result, err := v.CleanupImages([]string{"assets/used.png", "assets/orphan.png", "public/shared.png", "a.assets/attached.png", "../outside.png"}, func() ([]string, error) { return []string{"public", "a.md"}, nil }) - if err != nil || len(result.Deleted) != 1 || result.Deleted[0].Path != "assets/orphan.png" || len(result.Skipped) != 4 { + result, err := v.CleanupImages([]string{"assets/used.png", "assets/orphan.png", "public/orphan.png", "a.assets/attached.png", "../outside.png"}) + if err != nil || len(result.Deleted) != 3 || result.Deleted[0].Path != "assets/orphan.png" || len(result.Skipped) != 2 { t.Fatalf("清理结果不符:%+v %v", result, err) } if _, err := v.Restore(result.Deleted[0].ID); err != nil { @@ -32,24 +32,13 @@ func TestCleanupChecksFreshReferencesAndShares(t *testing.T) { if _, err := v.Write("a.md", []byte("---\ntags: [broken\n---"), Precondition{}); err != nil { t.Fatal(err) } - result, err = v.CleanupImages([]string{"assets/orphan.png"}, func() ([]string, error) { return nil, nil }) + result, err = v.CleanupImages([]string{"assets/orphan.png"}) if err == nil || len(result.Deleted) != 0 { t.Fatal("不完整的引用分析不能删除图片", result, err) } if _, err := os.Stat(filepath.Join(v.Root(), "assets/orphan.png")); err != nil { t.Fatal(err) } - if _, err := v.Write("a.md", []byte(""), Precondition{}); err != nil { - t.Fatal(err) - } - cause := errors.New("分享读取失败") - _, err = v.CleanupImages([]string{"assets/orphan.png"}, func() ([]string, error) { return nil, cause }) - if !errors.Is(err, cause) { - t.Fatal(err) - } - if _, err := os.Stat(filepath.Join(v.Root(), "assets/orphan.png")); err != nil { - t.Fatal("分享校验失败后不应删除", err) - } } func TestFolderUploadReusesDeduplication(t *testing.T) { @@ -86,7 +75,7 @@ func TestCleanupProtectsLinkedImages(t *testing.T) { if len(metadata.Assets) != 1 || metadata.Assets[0].Target != link { t.Fatalf("链接内图片未记录引用:%+v", metadata) } - result, err := v.CleanupImages([]string{link}, func() ([]string, error) { return nil, nil }) + result, err := v.CleanupImages([]string{link}) if err != nil || len(result.Deleted) != 0 || len(result.Skipped) != 1 || result.Skipped[0].Reason != "仍被笔记引用" { t.Fatalf("链接内图片未受保护:%+v %v", result, err) } diff --git a/internal/vault/images_test.go b/internal/vault/images_test.go index fb01c8f..e9cff45 100644 --- a/internal/vault/images_test.go +++ b/internal/vault/images_test.go @@ -20,7 +20,7 @@ func TestImageMoveUpdatesLocalReferences(t *testing.T) { if _, err := v.Write("notes/a.md", []byte(content), Precondition{}); err != nil { t.Fatal(err) } - if _, err := v.Move("assets/原图.png", "elsewhere/新图 #.png", nil); err != nil { + if _, err := v.Move("assets/原图.png", "elsewhere/新图 #.png"); err != nil { t.Fatal(err) } out, _, err := v.Read("notes/a.md") @@ -33,7 +33,7 @@ func TestImageMoveUpdatesLocalReferences(t *testing.T) { if string(out) != want { t.Fatalf("链接改写不符:\n%s\n期望:\n%s", out, want) } - if _, err := v.Move("elsewhere/新图 #.png", "elsewhere/新图.jpg", nil); !errors.Is(err, ErrInvalidPath) { + if _, err := v.Move("elsewhere/新图 #.png", "elsewhere/新图.jpg"); !errors.Is(err, ErrInvalidPath) { t.Fatalf("不能用重命名改变图片格式:%v", err) } item, err := v.Delete("elsewhere/新图 #.png") @@ -62,7 +62,7 @@ func TestImageMovePreservesMultilineMarkdownAndIgnoresLiteralPaths(t *testing.T) if _, err := v.Write("文章.md", []byte(content), Precondition{}); err != nil { t.Fatal(err) } - if _, err := v.Move("old.png", "new.png", nil); err != nil { + if _, err := v.Move("old.png", "new.png"); err != nil { t.Fatal(err) } body, _, err := v.Read("文章.md") @@ -138,42 +138,3 @@ func TestImagesDoNotExposeOrModifySymlinks(t *testing.T) { t.Fatalf("不能替换仓库外文件:%v", err) } } - -func TestMoveCommitFailureRestoresExactReferences(t *testing.T) { - for _, source := range []string{"from.png", "from.md"} { - t.Run(source, func(t *testing.T) { - v := newVault(t) - content := "![](from.assets/a.png)" - if _, err := v.Write("from.md", []byte(content), Precondition{}); err != nil { - t.Fatal(err) - } - if err := os.Mkdir(filepath.Join(v.Root(), "from.assets"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(v.Root(), "from.png"), []byte("image"), 0o644); err != nil { - t.Fatal(err) - } - refs := "![](from.png)\n![](to.png)" - if _, err := v.Write("refs.md", []byte(refs), Precondition{}); err != nil { - t.Fatal(err) - } - failure := errors.New("关联数据提交失败") - target := strings.Replace(source, "from", "to", 1) - if _, err := v.Move(source, target, func() error { return failure }); !errors.Is(err, failure) { - t.Fatal(err) - } - for name, want := range map[string]string{"from.md": content, "refs.md": refs} { - got, _, err := v.Read(name) - if err != nil || string(got) != want { - t.Fatalf("回滚须保留原始正文:%s %q %v", name, got, err) - } - } - if _, err := os.Stat(filepath.Join(v.Root(), target)); !os.IsNotExist(err) { - t.Fatalf("失败后目标仍存在:%v", err) - } - if _, err := os.Stat(filepath.Join(v.Root(), "from.assets")); err != nil { - t.Fatal(err) - } - }) - } -} diff --git a/internal/vault/import_test.go b/internal/vault/import_test.go index 994f7d2..3906e08 100644 --- a/internal/vault/import_test.go +++ b/internal/vault/import_test.go @@ -70,7 +70,7 @@ func TestImportZipAssetsAndMove(t *testing.T) { if err := v.Mkdir("移动目标"); err != nil { t.Fatal(err) } - if _, err := v.Move("知识库/目录/笔记.md", "移动目标/改名.md", nil); err != nil { + if _, err := v.Move("知识库/目录/笔记.md", "移动目标/改名.md"); err != nil { t.Fatal(err) } moved, _, err := v.Read("移动目标/改名.md") diff --git a/internal/vault/metadata_test.go b/internal/vault/metadata_test.go index 0293bec..dc2d3e0 100644 --- a/internal/vault/metadata_test.go +++ b/internal/vault/metadata_test.go @@ -1,7 +1,6 @@ package vault import ( - "errors" "reflect" "strings" "testing" @@ -42,7 +41,7 @@ func TestMoveMaintainsIncomingAndOutgoingLinks(t *testing.T) { if _, err := v.SaveAsset("dir/a.md", "p.png", strings.NewReader("image"), 100); err != nil { t.Fatal(err) } - if _, err := v.Move("dir/a.md", "new/renamed.md", nil); err != nil { + if _, err := v.Move("dir/a.md", "new/renamed.md"); err != nil { t.Fatal(err) } body, _, _ := v.Read("new/renamed.md") @@ -53,25 +52,13 @@ func TestMoveMaintainsIncomingAndOutgoingLinks(t *testing.T) { if string(body) != "[入链](../new/renamed.md?q=1#section)" { t.Fatalf("入链错误:%s", body) } - if _, err := v.Move("new", "deep/folder", nil); err != nil { + if _, err := v.Move("new", "deep/folder"); err != nil { t.Fatal(err) } body, _, _ = v.Read("deep/folder/renamed.md") if !strings.Contains(string(body), "../../other.md") { t.Fatalf("目录出链错误:%s", body) } - original, _, _ := v.Read("other.md") - failure := errors.New("提交失败") - if _, err := v.Move("deep/folder", "rollback", func() error { return failure }); !errors.Is(err, failure) { - t.Fatal(err) - } - body, _, _ = v.Read("other.md") - if string(body) != string(original) { - t.Fatalf("入链未回滚:%s", body) - } - if _, _, err := v.Read("deep/folder/renamed.md"); err != nil { - t.Fatal("移动主体未回滚", err) - } } func TestMetadataSkipsMathAndResolvesFootnotes(t *testing.T) { @@ -102,7 +89,7 @@ func TestMovePreservesEntityShapedFilenames(t *testing.T) { } } const target = "a©.md" - if _, err := v.Move("target.md", target, nil); err != nil { + if _, err := v.Move("target.md", target); err != nil { t.Fatal(err) } body, _, err := v.Read("source.md") diff --git a/internal/vault/vault.go b/internal/vault/vault.go index ff16a68..dc130bc 100644 --- a/internal/vault/vault.go +++ b/internal/vault/vault.go @@ -362,46 +362,11 @@ func (v *Vault) TreeAt(rel string) ([]Entry, error) { return entries, err } -// ScopedPath 校验公开访问的子路径,并阻止符号链接跳出已分享的目录。 -func (v *Vault) ScopedPath(root, rel string) (string, error) { - if _, err := CleanPath(rel); err != nil { - return "", ErrNotFound - } - base, err := v.abs(root) - if err != nil { - return "", err - } - base, err = filepath.EvalSymlinks(base) - if errors.Is(err, fs.ErrNotExist) { - return "", ErrNotFound - } - if err != nil { - return "", err - } - p := path.Join(root, rel) - full, err := v.abs(p) - if err != nil { - return "", err - } - full, err = filepath.EvalSymlinks(full) - if errors.Is(err, fs.ErrNotExist) { - return "", ErrNotFound - } - if err != nil { - return "", err - } - if full != filepath.Join(base, filepath.FromSlash(rel)) { - return "", ErrNotFound - } - return p, nil -} - // ---- 移动 / 重命名 ---- // Move 移动或重命名笔记、图片或文件夹,返回是否原子改写了移动主体(改变文件身份)。 // 笔记的 .assets 目录随之移动;笔记改名时同步改写正文中指向自身图片目录的链接。 -// commit 用于提交关联元数据;失败时在释放写锁前恢复文件及原始正文,nil 表示无关联元数据。 -func (v *Vault) Move(from, to string, commit func() error) (rewritten bool, err error) { +func (v *Vault) Move(from, to string) (rewritten bool, err error) { src, err := v.abs(from) if err != nil { return false, err @@ -451,7 +416,6 @@ func (v *Vault) Move(from, to string, commit func() error) (rewritten bool, err if err := os.Rename(src, dst); err != nil { return false, err } - assetsMoved := false writtenLinks := 0 defer func() { if err == nil { @@ -462,11 +426,6 @@ func (v *Vault) Move(from, to string, commit func() error) (rewritten bool, err err = errors.Join(err, fmt.Errorf("回滚笔记引用失败: %w", rollback)) } } - if assetsMoved { - if rollback := os.Rename(dstAssets, srcAssets); rollback != nil { - err = errors.Join(err, fmt.Errorf("回滚图片移动失败: %w", rollback)) - } - } if rollback := os.Rename(dst, src); rollback != nil { err = errors.Join(err, fmt.Errorf("回滚笔记移动失败: %w", rollback)) } @@ -484,11 +443,6 @@ func (v *Vault) Move(from, to string, commit func() error) (rewritten bool, err if err := os.Rename(srcAssets, dstAssets); err != nil { return false, fmt.Errorf("移动图片目录失败: %w", err) } - assetsMoved = true - - } - if commit != nil { - err = commit() } return rewritten, err } diff --git a/internal/vault/vault_test.go b/internal/vault/vault_test.go index 2d0d2e0..3e37f59 100644 --- a/internal/vault/vault_test.go +++ b/internal/vault/vault_test.go @@ -62,7 +62,12 @@ func TestAssetSymlinkEscapeRejected(t *testing.T) { func TestMoveRollsBackWhenAssetDestinationIsTooLong(t *testing.T) { v := newVault(t) - if _, err := v.Write("n.md", []byte("原文"), Precondition{}); err != nil { + content := "原文 ![](n.assets/x.png)" + if _, err := v.Write("n.md", []byte(content), Precondition{}); err != nil { + t.Fatal(err) + } + refs := "[原文](n.md) [目标](" + strings.Repeat("a", 250) + ".md)" + if _, err := v.Write("refs.md", []byte(refs), Precondition{}); err != nil { t.Fatal(err) } if _, err := v.SaveAsset("n.md", "x.png", strings.NewReader("image"), 100); err != nil { @@ -70,11 +75,13 @@ func TestMoveRollsBackWhenAssetDestinationIsTooLong(t *testing.T) { } // 笔记名可写入,但派生的 .assets 名超过文件系统的单段长度限制。 destination := strings.Repeat("a", 250) + ".md" - if _, err := v.Move("n.md", destination, nil); err == nil { + if _, err := v.Move("n.md", destination); err == nil { t.Fatal("图片目录移动应失败") } - if content, _, err := v.Read("n.md"); err != nil || string(content) != "原文" { - t.Fatalf("原笔记未恢复: %q %v", content, err) + for name, want := range map[string]string{"n.md": content, "refs.md": refs} { + if got, _, err := v.Read(name); err != nil || string(got) != want { + t.Fatalf("原笔记与引用未恢复: %s %q %v", name, got, err) + } } if _, err := os.Stat(filepath.Join(v.Root(), "n.assets")); err != nil { t.Fatal(err) @@ -170,7 +177,7 @@ func TestMoveCarriesAssetsAndRewritesLinks(t *testing.T) { if !strings.HasPrefix(link, "笔记%20A.assets/") { t.Fatalf("资源链接不符: %s", link) } - if rewritten, err := v.Move("dir/笔记 A.md", "other/B.md", nil); err != nil || !rewritten { + if rewritten, err := v.Move("dir/笔记 A.md", "other/B.md"); err != nil || !rewritten { t.Fatalf("应报告正文改写: %v %v", rewritten, err) } data, _, err := v.Read("other/B.md") @@ -184,7 +191,7 @@ func TestMoveCarriesAssetsAndRewritesLinks(t *testing.T) { if _, err := os.Stat(filepath.Join(v.Root(), "other", "B.assets", strings.TrimPrefix(link, "笔记%20A.assets/"))); err != nil { t.Fatal("图片目录未随笔记移动:", err) } - if _, err := v.Move("other", "other/sub", nil); !errors.Is(err, ErrInvalidPath) { + if _, err := v.Move("other", "other/sub"); !errors.Is(err, ErrInvalidPath) { t.Fatal("文件夹移入自身应被拒绝") } } diff --git a/internal/watch/watch.go b/internal/watch/watch.go index 411927a..3e59bf7 100644 --- a/internal/watch/watch.go +++ b/internal/watch/watch.go @@ -170,7 +170,7 @@ func (w *Watcher) scan() (map[string]fileState, error) { return next, nil } -// diff 按文件系统身份识别移动,内容相同不代表同一个分享主体。 +// diff 按文件系统身份识别移动,内容相同不代表同一个文件。 func diff(prev, next map[string]fileState) []events.Event { var evs []events.Event structural := false diff --git a/scripts/remove-sharing.sql b/scripts/remove-sharing.sql new file mode 100644 index 0000000..5fbfa6f --- /dev/null +++ b/scripts/remove-sharing.sql @@ -0,0 +1,12 @@ +-- 一次性部署清理:先停止旧服务,并保留数据库与镜像配套的回滚备份。 +-- 不由应用启动流程执行;原始笔记和图片不在此脚本的清理范围内。 +BEGIN IMMEDIATE; +DROP TABLE IF EXISTS share_items; +DROP TABLE IF EXISTS shares; +DELETE FROM settings WHERE key = 'share.secret'; +UPDATE settings +SET value = json_remove(value, '$.shortcuts."note.share"', '$.shortcuts."app.shares"') +WHERE key = 'app.settings' + AND (json_type(value, '$.shortcuts."note.share"') IS NOT NULL + OR json_type(value, '$.shortcuts."app.shares"') IS NOT NULL); +COMMIT; diff --git a/web/share.html b/web/share.html deleted file mode 100644 index 4c7768a..0000000 --- a/web/share.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - RawMark - - -
- - - diff --git a/web/src/app/AppHeader.vue b/web/src/app/AppHeader.vue deleted file mode 100644 index 537e622..0000000 --- a/web/src/app/AppHeader.vue +++ /dev/null @@ -1,19 +0,0 @@ - - - diff --git a/web/src/app/AppearanceSettings.vue b/web/src/app/AppearanceSettings.vue index 682c982..19600a0 100644 --- a/web/src/app/AppearanceSettings.vue +++ b/web/src/app/AppearanceSettings.vue @@ -3,7 +3,8 @@ import { computed, onMounted, ref } from 'vue' import { api, ApiError, type Theme } from './api' import { DISPLAY_DEFAULTS } from '../themes/typography' import { appearancePaused, fixedThemeMode, type CssSnippet } from '../themes/appearance' -import { loadSettings, reloadTheme, settings, updateSettings, type Settings } from './settings' +import { loadSettings, reloadTheme, settings, SettingsAppearanceError, updateSettings, type Settings } from './settings' +import SettingRow from './SettingRow.vue' import { toast } from './workspace' const themes = ref([{ id: 'default', name: '默认', version: '1' }]) @@ -30,6 +31,7 @@ async function change(patch: Partial): Promise { await updateSettings(patch) return true } catch (err) { + if (err instanceof SettingsAppearanceError) { toast(err.message); return true } toast('保存外观失败:' + (err instanceof ApiError ? err.message : '网络错误,请检查连接')) void loadSettings().catch((err) => toast('重新读取外观失败:' + (err instanceof ApiError ? err.message : '网络错误'))) return false @@ -68,43 +70,39 @@ async function remove(snippet: CssSnippet) { async function reset() { if (!await change({ colorMode: 'system', theme: 'default', ...DISPLAY_DEFAULTS, cssSnippets: settings.cssSnippets.map((s) => ({ ...s, enabled: false })) })) return - toast('已恢复默认外观,CSS 片段已停用并保留') + toast('已恢复默认外观与排版,CSS 片段已停用并保留') }