From 5ac01894e610ba169839ab3ce9a7ed77a3b07b45 Mon Sep 17 00:00:00 2001 From: eezd Date: Sat, 17 Oct 2026 15:21:47 +0800 Subject: [PATCH] Add read-only sharing, API tokens and OpenAPI spec - Share a note as a live read-only page at /s/{token} with optional password and expiry - Share page renders with the shared parser (TOC, footnotes, math, Mermaid, code highlighting) and only serves images from the note's own .assets directory - Shares follow renames made through the API or externally - Manage shares and API tokens from the command palette and file tree - Accept read/write API tokens as Bearer credentials; management endpoints require a session - Serve a hand-written openapi.json, checked against the route table in tests --- README.md | 2 + cmd/rawmark/main.go | 3 +- internal/api/api.go | 126 ++- internal/api/api_test.go | 311 +++++++ internal/api/openapi.json | 1523 +++++++++++++++++++++++++++++++++ internal/api/shares.go | 279 ++++++ internal/api/static.go | 8 + internal/api/tokens.go | 63 ++ internal/auth/auth.go | 99 ++- internal/store/store.go | 134 ++- internal/vault/vault.go | 38 +- web/share.html | 15 + web/src/app/Icon.vue | 2 + web/src/app/ShareDialog.vue | 110 +++ web/src/app/TokensDialog.vue | 103 +++ web/src/app/TreeNode.vue | 4 +- web/src/app/Workspace.vue | 4 + web/src/app/api.ts | 33 + web/src/app/app.css | 24 + web/src/app/workspace.ts | 6 + web/src/editor/widgets.ts | 2 +- web/src/markdown/html.test.ts | 4 +- web/src/markdown/html.ts | 16 +- web/src/share/main.ts | 160 ++++ web/src/share/share.css | 112 +++ web/vite.config.ts | 18 +- 26 files changed, 3147 insertions(+), 52 deletions(-) create mode 100644 internal/api/api_test.go create mode 100644 internal/api/openapi.json create mode 100644 internal/api/shares.go create mode 100644 internal/api/tokens.go create mode 100644 web/share.html create mode 100644 web/src/app/ShareDialog.vue create mode 100644 web/src/app/TokensDialog.vue create mode 100644 web/src/share/main.ts create mode 100644 web/src/share/share.css diff --git a/README.md b/README.md index 50d8cd8..3e5198b 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ docker run -d --name rawmark \ - 笔记以普通 `.md` 文件保存在 `数据目录/vault/`,图片按 Typora 习惯存放在同级的 `笔记名.assets/`,可直接用 Typora 等编辑器打开;外部修改会实时同步到网页。 - `--user` 让容器以宿主机用户身份读写数据目录;不指定时容器以 UID 65532 运行,需要该用户对目录有写权限。 - 请通过 HTTPS 反向代理(Caddy、Nginx 等)访问,并转发 WebSocket(`/api/v1/events`)。 +- 分享:命令面板(F1)中「分享当前笔记」生成只读链接 `/s/…`,可设访问密码与有效期;「管理分享链接」查看与撤销。 +- 自动化:「API 令牌」中创建只读或读写令牌,以 `Authorization: Bearer <令牌>` 调用 REST API,接口说明见 `/api/v1/openapi.json`。 | 环境变量 | 默认值 | 说明 | | --- | --- | --- | diff --git a/cmd/rawmark/main.go b/cmd/rawmark/main.go index ec822c2..6b22534 100644 --- a/cmd/rawmark/main.go +++ b/cmd/rawmark/main.go @@ -94,8 +94,9 @@ func run(addr string) error { } go cleanupSessions(ctx, st) - srv := api.New(v, am, hub, idx) + srv := api.New(v, am, st, hub, idx) srv.OnSetup = func() { os.Remove(codeFile) } + 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 3edb024..14cd05b 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -17,6 +17,7 @@ import ( "rawmark/internal/auth" "rawmark/internal/events" "rawmark/internal/search" + "rawmark/internal/store" "rawmark/internal/vault" ) @@ -30,41 +31,82 @@ const ( type Server struct { vault *vault.Vault auth *auth.Manager + store *store.Store hub *events.Hub index *search.Index // OnSetup 在首次初始化完成后调用(用于删除启动码文件) OnSetup func() } -func New(v *vault.Vault, a *auth.Manager, hub *events.Hub, index *search.Index) *Server { - return &Server{vault: v, auth: a, hub: hub, index: index} +func New(v *vault.Vault, a *auth.Manager, st *store.Store, hub *events.Hub, index *search.Index) *Server { + return &Server{vault: v, auth: a, store: st, hub: hub, index: index} +} + +// access 为接口的访问要求 +type access int + +const ( + public access = iota + // 登录会话或 API 令牌(只读令牌只能用 GET) + authed + // 只允许登录会话:管理分享与 API 令牌,避免令牌给自己扩权 + sessionOnly +) + +type route struct { + pattern string + access access + handler http.HandlerFunc +} + +// routes 为全部接口;openapi.json 须与之一致(见 TestOpenAPICoversRoutes) +func (s *Server) routes() []route { + return []route{ + {"GET /api/v1/health", public, func(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]bool{"ok": true}) }}, + {"GET /api/v1/openapi.json", public, serveOpenAPI}, + {"GET /api/v1/auth/state", public, s.authState}, + {"POST /api/v1/auth/setup", public, s.setup}, + {"POST /api/v1/auth/login", public, s.login}, + {"POST /api/v1/auth/logout", public, s.logout}, + + {"GET /api/v1/tree", authed, s.tree}, + {"GET /api/v1/files/{path...}", authed, s.readFile}, + {"PUT /api/v1/files/{path...}", authed, s.writeFile}, + {"DELETE /api/v1/files/{path...}", authed, s.deleteFile}, + {"POST /api/v1/move", authed, s.move}, + {"POST /api/v1/folders", authed, s.mkdir}, + {"POST /api/v1/assets", authed, s.uploadAsset}, + {"GET /api/v1/raw/{path...}", authed, s.raw}, + {"GET /api/v1/trash", authed, s.trashList}, + {"POST /api/v1/trash/{id}/restore", authed, s.trashRestore}, + {"DELETE /api/v1/trash/{id}", authed, s.trashPurge}, + {"GET /api/v1/events", authed, s.events}, + {"GET /api/v1/search", authed, s.search}, + + {"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}, + {"POST /api/v1/public/shares/{token}/unlock", public, s.unlockShare}, + {"GET /api/v1/public/shares/{token}/assets/{path...}", public, s.publicShareAsset}, + } } func (s *Server) Handler(static fs.FS) http.Handler { mux := http.NewServeMux() - mux.HandleFunc("GET /api/v1/health", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]bool{"ok": true}) }) - mux.HandleFunc("GET /api/v1/auth/state", s.authState) - mux.HandleFunc("POST /api/v1/auth/setup", s.setup) - mux.HandleFunc("POST /api/v1/auth/login", s.login) - mux.HandleFunc("POST /api/v1/auth/logout", s.logout) - - protected := map[string]http.HandlerFunc{ - "GET /api/v1/tree": s.tree, - "GET /api/v1/files/{path...}": s.readFile, - "PUT /api/v1/files/{path...}": s.writeFile, - "DELETE /api/v1/files/{path...}": s.deleteFile, - "POST /api/v1/move": s.move, - "POST /api/v1/folders": s.mkdir, - "POST /api/v1/assets": s.uploadAsset, - "GET /api/v1/raw/{path...}": s.raw, - "GET /api/v1/trash": s.trashList, - "POST /api/v1/trash/{id}/restore": s.trashRestore, - "DELETE /api/v1/trash/{id}": s.trashPurge, - "GET /api/v1/events": s.events, - "GET /api/v1/search": s.search, - } - for pattern, h := range protected { - mux.Handle(pattern, s.requireAuth(h)) + for _, rt := range s.routes() { + var h http.Handler = rt.handler + switch rt.access { + case authed: + h = s.requireAuth(h, true) + case sessionOnly: + h = s.requireAuth(h, false) + } + mux.Handle(rt.pattern, h) } mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { writeError(w, 404, "接口不存在") }) mux.Handle("/", newStaticHandler(static)) @@ -99,8 +141,30 @@ func sameOrigin(next http.Handler) http.Handler { }) } -func (s *Server) requireAuth(next http.Handler) http.Handler { +// requireAuth 校验登录会话;allowToken 时也接受 Authorization: Bearer 的 API 令牌 +func (s *Server) requireAuth(next http.Handler, allowToken bool) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if bearer, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer "); ok { + if !allowToken { + writeError(w, 403, "此接口只能在登录后使用,不接受 API 令牌") + return + } + scope, valid, err := s.auth.ValidateAPIToken(strings.TrimSpace(bearer)) + if err != nil { + internalError(w, r, err) + return + } + if !valid { + writeError(w, 401, "API 令牌无效") + return + } + if scope != "write" && r.Method != http.MethodGet && r.Method != http.MethodHead { + writeError(w, 403, "只读令牌不能修改数据") + return + } + next.ServeHTTP(w, r) + return + } c, err := r.Cookie(cookieName) if err != nil { writeError(w, 401, "未登录") @@ -275,6 +339,10 @@ func (s *Server) move(w http.ResponseWriter, r *http.Request) { vaultError(w, r, err) return } + if err := s.store.MoveShares(body.From, body.To); err != nil { + internalError(w, r, err) + return + } writeJSON(w, 200, map[string]bool{"ok": true}) } @@ -309,7 +377,11 @@ func (s *Server) uploadAsset(w http.ResponseWriter, r *http.Request) { // raw 输出仓库内任意非隐藏文件(主要是图片)。以沙箱 CSP 返回,防止 SVG/HTML 中的脚本在本站执行 func (s *Server) raw(w http.ResponseWriter, r *http.Request) { - f, info, err := s.vault.OpenRaw(r.PathValue("path")) + s.serveRaw(w, r, r.PathValue("path")) +} + +func (s *Server) serveRaw(w http.ResponseWriter, r *http.Request, rel string) { + f, info, err := s.vault.OpenRaw(rel) if err != nil { vaultError(w, r, err) return diff --git a/internal/api/api_test.go b/internal/api/api_test.go new file mode 100644 index 0000000..c8974c9 --- /dev/null +++ b/internal/api/api_test.go @@ -0,0 +1,311 @@ +package api + +import ( + "encoding/json" + "io" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + "testing/fstest" + "time" + + "rawmark/internal/auth" + "rawmark/internal/events" + "rawmark/internal/search" + "rawmark/internal/store" + "rawmark/internal/vault" +) + +type fixture struct { + t *testing.T + url string + root string + store *store.Store + // 已登录的客户端 + admin *http.Client +} + +func newFixture(t *testing.T) *fixture { + t.Helper() + dir := t.TempDir() + v, err := vault.Open(filepath.Join(dir, "vault")) + if err != nil { + t.Fatal(err) + } + st, err := store.Open(filepath.Join(dir, "rawmark.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + am, err := auth.New(st) + if err != nil { + t.Fatal(err) + } + if err := am.Setup(am.SetupCode(), "password123"); err != nil { + t.Fatal(err) + } + idx, err := search.Open(filepath.Join(dir, "index.db"), v) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { idx.Close() }) + srv := httptest.NewServer(New(v, am, st, events.NewHub(), idx).Handler(fstest.MapFS{})) + t.Cleanup(srv.Close) + f := &fixture{t: t, url: srv.URL, root: v.Root(), store: st, admin: newClient()} + if res := f.do(f.admin, "POST", "/api/v1/auth/login", `{"password":"password123"}`, nil); res.StatusCode != 200 { + t.Fatalf("登录失败: %d", res.StatusCode) + } + return f +} + +func newClient() *http.Client { + jar, _ := cookiejar.New(nil) + return &http.Client{Jar: jar} +} + +func (f *fixture) write(p, content string) { + abs := filepath.Join(f.root, p) + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + f.t.Fatal(err) + } + if err := os.WriteFile(abs, []byte(content), 0o644); err != nil { + f.t.Fatal(err) + } +} + +func (f *fixture) do(c *http.Client, method, path, body string, headers map[string]string) *http.Response { + f.t.Helper() + req, err := http.NewRequest(method, f.url+path, strings.NewReader(body)) + if err != nil { + f.t.Fatal(err) + } + for k, v := range headers { + req.Header.Set(k, v) + } + res, err := c.Do(req) + if err != nil { + f.t.Fatal(err) + } + f.t.Cleanup(func() { res.Body.Close() }) + return res +} + +func (f *fixture) status(c *http.Client, method, path, body string, headers map[string]string) int { + f.t.Helper() + return f.do(c, method, path, body, headers).StatusCode +} + +func decode[T any](t *testing.T, res *http.Response) T { + t.Helper() + var v T + if err := json.NewDecoder(res.Body).Decode(&v); err != nil { + t.Fatal(err) + } + return v +} + +// 文档与实际接口一一对应:新增接口忘记写文档、文档中残留已删除的接口都会失败 +func TestOpenAPICoversRoutes(t *testing.T) { + var spec struct { + Paths map[string]map[string]json.RawMessage + } + if err := json.Unmarshal(openAPISpec, &spec); err != nil { + t.Fatal(err) + } + documented := map[string]bool{} + for p, ops := range spec.Paths { + for method := range ops { + if method != "parameters" { + documented[strings.ToUpper(method)+" "+p] = true + } + } + } + wildcard := regexp.MustCompile(`\{(\w+)\.\.\.\}`) + s := &Server{} + for _, rt := range s.routes() { + key := strings.Replace(wildcard.ReplaceAllString(rt.pattern, "{$1}"), " /api/v1", " ", 1) + if !documented[key] { + t.Errorf("接口未写入 openapi.json: %s", rt.pattern) + } + delete(documented, key) + } + for key := range documented { + t.Errorf("openapi.json 中的接口不存在: %s", key) + } +} + +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 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.assets/x.png"}`, `{"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", "内容") + create := func(scope string) string { + res := f.do(f.admin, "POST", "/api/v1/tokens", `{"name":"脚本","scope":"`+scope+`"}`, nil) + if res.StatusCode != 200 { + t.Fatalf("创建 %s 令牌: %d", scope, res.StatusCode) + } + return decode[struct{ Token string }](t, res).Token + } + read, write := create("read"), create("write") + if code := f.status(f.admin, "POST", "/api/v1/tokens", `{"name":"x","scope":"admin"}`, nil); code != 400 { + t.Errorf("无效范围应为 400,得到 %d", code) + } + bearer := func(tok string) map[string]string { return map[string]string{"Authorization": "Bearer " + tok} } + anon := &http.Client{} + cases := []struct { + name, token, method, path, body string + want int + }{ + {"只读令牌可读", read, "GET", "/api/v1/files/a.md", "", 200}, + {"只读令牌可搜索", read, "GET", "/api/v1/tree", "", 200}, + {"只读令牌不能写", 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}, + } + for _, c := range cases { + if code := f.status(anon, c.method, c.path, c.body, bearer(c.token)); code != c.want { + t.Errorf("%s: %d,期望 %d", c.name, code, c.want) + } + } + + list := decode[struct{ Tokens []store.APIToken }](t, f.do(f.admin, "GET", "/api/v1/tokens", "", nil)) + if len(list.Tokens) != 2 || list.Tokens[0].LastUsedAt == 0 { + t.Fatalf("令牌列表(应记录使用时间): %+v", list.Tokens) + } + raw, _ := io.ReadAll(f.do(f.admin, "GET", "/api/v1/tokens", "", nil).Body) + if strings.Contains(string(raw), read) || strings.Contains(string(raw), "hash") { + t.Errorf("列表不应包含令牌明文或哈希: %s", raw) + } + for _, tk := range list.Tokens { + if code := f.status(f.admin, "DELETE", "/api/v1/tokens/"+tk.ID, "", nil); code != 200 { + t.Fatal(code) + } + } + if code := f.status(anon, "GET", "/api/v1/tree", "", bearer(read)); code != 401 { + t.Errorf("吊销后应为 401,得到 %d", code) + } +} diff --git a/internal/api/openapi.json b/internal/api/openapi.json new file mode 100644 index 0000000..b9ece2d --- /dev/null +++ b/internal/api/openapi.json @@ -0,0 +1,1523 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "RawMark API", + "version": "1", + "description": "RawMark 的 REST API。自动化调用请在「API 令牌」中创建令牌,以 `Authorization: Bearer ` 访问;只读令牌只能使用 GET。分享与令牌的管理接口只接受登录会话。所有错误响应均为 `{\"error\": \"说明\"}`。" + }, + "servers": [ + { + "url": "/api/v1" + } + ], + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + } + ], + "paths": { + "/health": { + "get": { + "summary": "健康检查", + "tags": [ + "系统" + ], + "security": [ + {} + ], + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Ok" + } + } + } + } + } + } + }, + "/openapi.json": { + "get": { + "summary": "本文档", + "tags": [ + "系统" + ], + "security": [ + {} + ], + "responses": { + "200": { + "description": "OpenAPI 文档", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + } + }, + "/auth/state": { + "get": { + "summary": "是否已初始化、当前是否已登录", + "tags": [ + "登录" + ], + "security": [ + {} + ], + "responses": { + "200": { + "description": "状态", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "setUp": { + "type": "boolean" + }, + "authenticated": { + "type": "boolean" + } + } + } + } + } + } + } + } + }, + "/auth/setup": { + "post": { + "summary": "首次设置登录密码(需服务器数据目录中的启动码)", + "tags": [ + "登录" + ], + "security": [ + {} + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "required": [ + "code", + "password" + ] + } + } + } + }, + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Ok" + } + } + } + }, + "400": { + "description": "启动码错误或密码过短", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "已初始化", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/auth/login": { + "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" + } + } + } + }, + "429": { + "description": "失败次数过多", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/auth/logout": { + "post": { + "summary": "退出登录", + "tags": [ + "登录" + ], + "security": [ + {} + ], + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Ok" + } + } + } + } + } + } + }, + "/tree": { + "get": { + "summary": "全部笔记与文件夹(不含 .assets 图片目录与隐藏文件)", + "tags": [ + "笔记" + ], + "responses": { + "200": { + "description": "目录树", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Entry" + } + } + } + } + } + } + } + } + } + }, + "/files/{path}": { + "parameters": [ + { + "name": "path", + "in": "path", + "required": true, + "description": "仓库内的相对路径,如 `目录/笔记.md`(各段需 URL 编码)", + "schema": { + "type": "string" + } + } + ], + "get": { + "summary": "读取笔记", + "tags": [ + "笔记" + ], + "responses": { + "200": { + "description": "Markdown 原文", + "headers": { + "ETag": { + "description": "内容版本(rev),带双引号", + "schema": { + "type": "string" + } + } + }, + "content": { + "text/markdown": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "不存在", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "put": { + "summary": "写入笔记(不存在的上级目录会自动创建)", + "tags": [ + "笔记" + ], + "description": "请求体整体作为笔记内容。`If-Match: \"rev\"` 要求当前版本一致,否则返回 412 与最新 rev;`If-None-Match: *` 要求笔记尚不存在;都不带则直接覆盖。", + "parameters": [ + { + "name": "If-Match", + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "If-None-Match", + "in": "header", + "schema": { + "type": "string", + "enum": [ + "*" + ] + } + } + ], + "requestBody": { + "required": true, + "content": { + "text/markdown": { + "schema": { + "type": "string" + } + } + } + }, + "responses": { + "200": { + "description": "已写入", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "rev": { + "type": "string" + } + } + } + } + }, + "headers": { + "ETag": { + "description": "内容版本(rev),带双引号", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "路径无效", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "If-None-Match 时已存在", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "412": { + "description": "版本冲突", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "rev": { + "type": "string", + "description": "服务器上的当前版本;为空表示已不存在" + } + } + } + } + } + }, + "413": { + "description": "超过 20 MB", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "summary": "把笔记或文件夹移入回收站(连同 .assets)", + "tags": [ + "笔记" + ], + "responses": { + "200": { + "description": "回收站条目", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TrashItem" + } + } + } + }, + "404": { + "description": "不存在", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/move": { + "post": { + "summary": "移动或重命名笔记/文件夹", + "tags": [ + "笔记" + ], + "description": "笔记的 .assets 目录随之移动,改名时同步改写正文中指向自身图片目录的链接;分享链接随之更新。不改写其他笔记指向它的链接。", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "from": { + "type": "string" + }, + "to": { + "type": "string" + } + }, + "required": [ + "from", + "to" + ] + } + } + } + }, + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Ok" + } + } + } + }, + "404": { + "description": "源不存在", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "目标已存在", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/folders": { + "post": { + "summary": "新建文件夹", + "tags": [ + "笔记" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ] + } + } + } + }, + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Ok" + } + } + } + }, + "409": { + "description": "已存在", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/assets": { + "post": { + "summary": "上传图片到笔记的 .assets 目录", + "tags": [ + "图片" + ], + "parameters": [ + { + "name": "note", + "in": "query", + "required": true, + "description": "所属笔记路径", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "format": "binary" + } + }, + "required": [ + "file" + ] + } + } + } + }, + "responses": { + "200": { + "description": "相对笔记所在目录的链接,可直接写入 Markdown", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "link": { + "type": "string" + } + } + } + } + } + }, + "413": { + "description": "超过 50 MB", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/raw/{path}": { + "parameters": [ + { + "name": "path", + "in": "path", + "required": true, + "description": "仓库内的相对路径,如 `目录/笔记.md`(各段需 URL 编码)", + "schema": { + "type": "string" + } + } + ], + "get": { + "summary": "读取仓库中的原始文件(图片等)", + "tags": [ + "图片" + ], + "responses": { + "200": { + "description": "文件内容", + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "不存在", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/search": { + "get": { + "summary": "全文搜索", + "tags": [ + "笔记" + ], + "description": "空格分隔多个词,全部命中才返回;最多 50 条。", + "parameters": [ + { + "name": "q", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "结果", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SearchResult" + } + } + } + } + } + } + } + } + } + }, + "/trash": { + "get": { + "summary": "回收站列表", + "tags": [ + "回收站" + ], + "responses": { + "200": { + "description": "条目", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TrashItem" + } + } + } + } + } + } + } + } + } + }, + "/trash/{id}/restore": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "post": { + "summary": "从回收站恢复", + "tags": [ + "回收站" + ], + "responses": { + "200": { + "description": "恢复的条目", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TrashItem" + } + } + } + }, + "404": { + "description": "不存在", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "原位置已有同名文件", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/trash/{id}": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "delete": { + "summary": "彻底删除回收站条目", + "tags": [ + "回收站" + ], + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Ok" + } + } + } + }, + "404": { + "description": "不存在", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/events": { + "get": { + "summary": "WebSocket:仓库变更推送", + "tags": [ + "笔记" + ], + "description": "升级为 WebSocket 后,服务器推送 JSON 数组,元素为 Event。客户端不发送消息。", + "responses": { + "101": { + "description": "切换为 WebSocket" + } + } + } + }, + "/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" + }, + "password": { + "type": "string", + "description": "访问密码,留空为不设密码" + }, + "expiresIn": { + "type": "integer", + "description": "有效时长(秒),0 为永久" + } + }, + "required": [ + "path" + ] + } + } + } + }, + "responses": { + "200": { + "description": "新分享,访问地址为 /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" + } + } + } + } + } + } + }, + "/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 令牌列表", + "tags": [ + "API 令牌" + ], + "security": [ + { + "cookie": [] + } + ], + "responses": { + "200": { + "description": "令牌", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tokens": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIToken" + } + } + } + } + } + } + } + } + }, + "post": { + "summary": "创建 API 令牌", + "tags": [ + "API 令牌" + ], + "security": [ + { + "cookie": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "scope": { + "type": "string", + "enum": [ + "read", + "write" + ] + } + }, + "required": [ + "name", + "scope" + ] + } + } + } + }, + "responses": { + "200": { + "description": "token 明文只返回这一次", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "token": { + "type": "string" + }, + "info": { + "$ref": "#/components/schemas/APIToken" + } + } + } + } + } + }, + "400": { + "description": "名称或范围无效", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/tokens/{id}": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "delete": { + "summary": "吊销 API 令牌", + "tags": [ + "API 令牌" + ], + "security": [ + { + "cookie": [] + } + ], + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Ok" + } + } + } + }, + "404": { + "description": "不存在", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/public/shares/{token}": { + "parameters": [ + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "summary": "访客读取分享的笔记", + "tags": [ + "分享(公开)" + ], + "security": [ + {} + ], + "responses": { + "200": { + "description": "笔记", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "content": { + "type": "string" + }, + "updatedAt": { + "type": "integer" + } + } + } + } + } + }, + "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" + } + } + } + } + } + } + }, + "/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" + } + } + ], + "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" + } + } + } + } + } + } + } + }, + "components": { + "securitySchemes": { + "bearer": { + "type": "http", + "scheme": "bearer", + "description": "API 令牌(rm_ 开头)" + }, + "cookie": { + "type": "apiKey", + "in": "cookie", + "name": "rawmark_session", + "description": "浏览器登录会话" + } + }, + "schemas": { + "Error": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + }, + "Ok": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + } + } + }, + "Entry": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "dir": { + "type": "boolean" + }, + "size": { + "type": "integer" + }, + "mtime": { + "type": "integer", + "description": "修改时间(Unix 毫秒)" + } + } + }, + "TrashItem": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "path": { + "type": "string", + "description": "原路径" + }, + "dir": { + "type": "boolean" + }, + "deletedAt": { + "type": "integer", + "description": "Unix 毫秒" + } + } + }, + "SearchResult": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "line": { + "type": "integer", + "description": "第一处匹配所在行(从 1 开始);只有路径匹配时为 0" + }, + "snippet": { + "type": "string" + } + } + }, + "Event": { + "type": "object", + "description": "tree:目录结构变化;changed:内容变化(rev 为新版本);deleted:删除;moved:移动(from → path)", + "properties": { + "type": { + "type": "string", + "enum": [ + "tree", + "changed", + "deleted", + "moved" + ] + }, + "path": { + "type": "string" + }, + "from": { + "type": "string" + }, + "rev": { + "type": "string" + } + } + }, + "Share": { + "type": "object", + "properties": { + "token": { + "type": "string" + }, + "path": { + "type": "string" + }, + "createdAt": { + "type": "integer" + }, + "expiresAt": { + "type": "integer", + "description": "Unix 毫秒,0 为永久" + }, + "hasPassword": { + "type": "boolean" + }, + "exists": { + "type": "boolean", + "description": "笔记是否仍存在" + } + } + }, + "APIToken": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "scope": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "createdAt": { + "type": "integer" + }, + "lastUsedAt": { + "type": "integer", + "description": "Unix 毫秒,0 为从未使用" + } + } + } + } + } +} diff --git a/internal/api/shares.go b/internal/api/shares.go new file mode 100644 index 0000000..bbe4172 --- /dev/null +++ b/internal/api/shares.go @@ -0,0 +1,279 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "net" + "net/http" + "path" + "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 shareView struct { + store.Share + HasPassword bool `json:"hasPassword"` + // 笔记是否仍存在(被删除或在外部改名后为 false) + Exists bool `json:"exists"` +} + +func (s *Server) viewOf(sh store.Share) shareView { + _, err := s.vault.Stat(sh.Path) + return shareView{Share: sh, HasPassword: sh.PasswordHash != "", Exists: err == 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 { + if filter == "" || sh.Path == filter { + out = append(out, s.viewOf(sh)) + } + } + writeJSON(w, 200, map[string]any{"shares": out}) +} + +func (s *Server) createShare(w http.ResponseWriter, r *http.Request) { + var body struct { + Path string + Password string + // 有效时长(秒),0 为永久 + ExpiresIn int64 + } + if !readJSON(w, r, &body) { + return + } + p, err := vault.CleanPath(body.Path) + if err != nil || !vault.IsNote(p) { + writeError(w, 400, "只能分享笔记") + return + } + if _, err := s.vault.Stat(p); err != nil { + vaultError(w, r, err) + return + } + if body.ExpiresIn < 0 { + writeError(w, 400, "有效期无效") + return + } + now := time.Now() + sh := store.Share{ + Token: auth.NewShareToken(), + Path: p, + 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 + } + writeJSON(w, 200, s.viewOf(sh)) +} + +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 + } + data, _, err := s.vault.Read(sh.Path) + if errors.Is(err, vault.ErrNotFound) { + writeError(w, 404, "分享的笔记已不存在") + return + } + if err != nil { + internalError(w, r, err) + return + } + info, err := s.vault.Stat(sh.Path) + if err != nil { + vaultError(w, r, err) + return + } + writeJSON(w, 200, map[string]any{ + "title": strings.TrimSuffix(path.Base(sh.Path), vault.NoteExt), + "content": string(data), + "updatedAt": info.ModTime().UnixMilli(), + }) +} + +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 != "" { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + switch err := s.auth.CheckSharePassword(host, 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: "/api/v1/public/shares/" + sh.Token, + 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 + } + rel := path.Join(path.Dir(sh.Path), r.PathValue("path")) + if !strings.HasPrefix(rel, vault.AssetsDir(sh.Path)+"/") { + writeError(w, 404, "文件不存在") + return + } + s.serveRaw(w, r, rel) +} + +// 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 177914a..715d3b8 100644 --- a/internal/api/static.go +++ b/internal/api/static.go @@ -42,6 +42,11 @@ 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" + } f, err := h.load(name) if err != nil { // 前端路由:未知路径一律返回入口页面 @@ -60,6 +65,9 @@ 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 ct := mime.TypeByExtension(path.Ext(name)); ct != "" { hd.Set("Content-Type", ct) } diff --git a/internal/api/tokens.go b/internal/api/tokens.go new file mode 100644 index 0000000..b19715b --- /dev/null +++ b/internal/api/tokens.go @@ -0,0 +1,63 @@ +package api + +import ( + _ "embed" + "errors" + "net/http" + "strings" + + "rawmark/internal/auth" +) + +func (s *Server) listTokens(w http.ResponseWriter, r *http.Request) { + tokens, err := s.store.APITokens() + if err != nil { + internalError(w, r, err) + return + } + writeJSON(w, 200, map[string]any{"tokens": tokens}) +} + +// createToken 返回的 token 明文只出现这一次,服务器只保存哈希 +func (s *Server) createToken(w http.ResponseWriter, r *http.Request) { + var body struct{ Name, Scope string } + if !readJSON(w, r, &body) { + return + } + name := strings.TrimSpace(body.Name) + if name == "" || len([]rune(name)) > 100 { + writeError(w, 400, "名称不能为空且不超过 100 个字符") + return + } + plain, t, err := s.auth.CreateAPIToken(name, body.Scope) + if errors.Is(err, auth.ErrBadScope) { + writeError(w, 400, err.Error()) + return + } + if err != nil { + internalError(w, r, err) + return + } + writeJSON(w, 200, map[string]any{"token": plain, "info": t}) +} + +func (s *Server) deleteToken(w http.ResponseWriter, r *http.Request) { + ok, err := s.store.DeleteAPIToken(r.PathValue("id")) + if err != nil { + internalError(w, r, err) + return + } + if !ok { + writeError(w, 404, "令牌不存在") + return + } + writeJSON(w, 200, map[string]bool{"ok": true}) +} + +//go:embed openapi.json +var openAPISpec []byte + +func serveOpenAPI(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write(openAPISpec) +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 57ee27e..c51d487 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -1,7 +1,8 @@ -// Package auth 实现单用户登录:Argon2id 密码哈希、会话令牌与登录限速。 +// Package auth 实现单用户登录(Argon2id 密码哈希、会话令牌、登录限速),以及分享密码与 API 令牌的校验。 package auth import ( + "crypto/hmac" "crypto/rand" "crypto/sha256" "crypto/subtle" @@ -20,6 +21,8 @@ import ( const ( passwordKey = "auth.password" + shareSecretKey = "share.secret" + apiTokenPrefix = "rm_" SessionTTL = 30 * 24 * time.Hour MinPasswordLen = 8 @@ -39,6 +42,7 @@ var ( ErrWeakPassword = fmt.Errorf("密码至少 %d 位", MinPasswordLen) ErrBadCredentials = errors.New("密码错误") ErrRateLimited = errors.New("尝试次数过多,请稍后再试") + ErrBadScope = errors.New("权限范围只能是 read 或 write") ) type Manager struct { @@ -135,6 +139,92 @@ func (m *Manager) Validate(token string) (bool, error) { func (m *Manager) Logout(token string) error { return m.store.DeleteSession(hashToken(token)) } +// ---- 分享 ---- + +// 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 创建令牌,返回只显示这一次的明文 +func (m *Manager) CreateAPIToken(name, scope string) (string, store.APIToken, error) { + if scope != "read" && scope != "write" { + return "", store.APIToken{}, ErrBadScope + } + plain := apiTokenPrefix + randomToken() + t := store.APIToken{ID: randomCode(), Name: name, Hash: hashToken(plain), Scope: scope, CreatedAt: time.Now().UnixMilli()} + if err := m.store.CreateAPIToken(t); err != nil { + return "", store.APIToken{}, err + } + return plain, t, nil +} + +// ValidateAPIToken 返回令牌的权限范围;无效时 ok 为 false +func (m *Manager) ValidateAPIToken(plain string) (scope string, ok bool, err error) { + if !strings.HasPrefix(plain, apiTokenPrefix) { + return "", false, nil + } + return m.store.UseAPIToken(hashToken(plain), time.Now().UnixMilli()) +} + // ---- 限速:同一来源在时间窗口内失败过多则拒绝 ---- func (m *Manager) allow(ip string) bool { @@ -204,6 +294,13 @@ 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/store/store.go b/internal/store/store.go index 03b6408..ace5392 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -1,4 +1,4 @@ -// Package store 保存应用自身的数据(设置、会话)。笔记内容不在这里,见 vault。 +// Package store 保存应用自身的数据(设置、会话、分享、API 令牌)。笔记内容不在这里,见 vault。 package store import ( @@ -22,6 +22,23 @@ CREATE TABLE IF NOT EXISTS sessions ( created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL ); +-- 分享链接中的令牌明文保存:管理列表需要随时复制链接,令牌本身即访问凭据,与笔记同等敏感 +CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS shares_path ON shares(path); +CREATE TABLE IF NOT EXISTS api_tokens ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + scope TEXT NOT NULL, + created_at INTEGER NOT NULL, + last_used_at INTEGER NOT NULL DEFAULT 0 +); ` func Open(path string) (*Store, error) { @@ -93,3 +110,118 @@ func (s *Store) DeleteExpiredSessions() error { _, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at <= ?`, time.Now().Unix()) return err } + +// ---- 分享 ---- + +// Share 为一个只读分享链接。时间均为 Unix 毫秒,ExpiresAt 为 0 表示永久有效 +type Share struct { + Token string `json:"token"` + Path string `json:"path"` + PasswordHash string `json:"-"` + CreatedAt int64 `json:"createdAt"` + ExpiresAt int64 `json:"expiresAt"` +} + +func (s *Store) CreateShare(sh Share) error { + _, err := s.db.Exec(`INSERT INTO shares (token, path, password_hash, created_at, expires_at) VALUES (?, ?, ?, ?, ?)`, + sh.Token, sh.Path, sh.PasswordHash, sh.CreatedAt, sh.ExpiresAt) + return err +} + +// GetShare 按令牌读取分享;不存在时 ok 为 false +func (s *Store) GetShare(token string) (sh Share, ok bool, err error) { + err = s.db.QueryRow(`SELECT token, path, password_hash, created_at, expires_at FROM shares WHERE token = ?`, token). + Scan(&sh.Token, &sh.Path, &sh.PasswordHash, &sh.CreatedAt, &sh.ExpiresAt) + if errors.Is(err, sql.ErrNoRows) { + return Share{}, false, nil + } + return sh, err == nil, err +} + +// Shares 列出全部分享,最新的在前 +func (s *Store) Shares() ([]Share, error) { + rows, err := s.db.Query(`SELECT token, path, password_hash, created_at, expires_at FROM shares ORDER BY created_at DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + out := []Share{} + for rows.Next() { + var sh Share + if err := rows.Scan(&sh.Token, &sh.Path, &sh.PasswordHash, &sh.CreatedAt, &sh.ExpiresAt); err != nil { + return nil, err + } + out = append(out, sh) + } + return out, rows.Err() +} + +// DeleteShare 返回是否删除了记录 +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 +} + +// MoveShares 让分享跟随笔记移动:from 为笔记时只改它自己,为文件夹时改其下所有笔记 +func (s *Store) MoveShares(from, to string) error { + _, err := s.db.Exec(`UPDATE shares 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 +type APIToken struct { + ID string `json:"id"` + Name string `json:"name"` + Hash string `json:"-"` + Scope string `json:"scope"` + CreatedAt int64 `json:"createdAt"` + LastUsedAt int64 `json:"lastUsedAt"` +} + +func (s *Store) CreateAPIToken(t APIToken) error { + _, err := s.db.Exec(`INSERT INTO api_tokens (id, name, token_hash, scope, created_at) VALUES (?, ?, ?, ?, ?)`, + t.ID, t.Name, t.Hash, t.Scope, t.CreatedAt) + return err +} + +func (s *Store) APITokens() ([]APIToken, error) { + rows, err := s.db.Query(`SELECT id, name, scope, created_at, last_used_at FROM api_tokens ORDER BY created_at DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + out := []APIToken{} + for rows.Next() { + var t APIToken + if err := rows.Scan(&t.ID, &t.Name, &t.Scope, &t.CreatedAt, &t.LastUsedAt); err != nil { + return nil, err + } + out = append(out, t) + } + return out, rows.Err() +} + +// UseAPIToken 按哈希查找令牌并记录使用时间;不存在时 ok 为 false +func (s *Store) UseAPIToken(hash string, now int64) (scope string, ok bool, err error) { + err = s.db.QueryRow(`UPDATE api_tokens SET last_used_at = ? WHERE token_hash = ? RETURNING scope`, now, hash).Scan(&scope) + if errors.Is(err, sql.ErrNoRows) { + return "", false, nil + } + return scope, err == nil, err +} + +func (s *Store) DeleteAPIToken(id string) (bool, error) { + res, err := s.db.Exec(`DELETE FROM api_tokens WHERE id = ?`, id) + if err != nil { + return false, err + } + n, err := res.RowsAffected() + return n == 1, err +} diff --git a/internal/vault/vault.go b/internal/vault/vault.go index 19dc554..a827840 100644 --- a/internal/vault/vault.go +++ b/internal/vault/vault.go @@ -107,8 +107,8 @@ func CleanPath(p string) (string, error) { func IsNote(p string) bool { return strings.HasSuffix(p, NoteExt) && len(p) > len(NoteExt) } -// assetsDir 返回笔记对应的 Typora 风格图片目录:a.md -> a.assets -func assetsDir(note string) string { return strings.TrimSuffix(note, NoteExt) + AssetsExt } +// AssetsDir 返回笔记对应的 Typora 风格图片目录:a.md -> a.assets +func AssetsDir(note string) string { return strings.TrimSuffix(note, NoteExt) + AssetsExt } func (v *Vault) abs(rel string) (string, error) { rel, err := CleanPath(rel) @@ -168,6 +168,22 @@ func (v *Vault) Read(rel string) ([]byte, string, error) { return data, Rev(data), nil } +// Stat 返回笔记的文件信息;不存在时返回 ErrNotFound +func (v *Vault) Stat(rel string) (fs.FileInfo, error) { + if !IsNote(rel) { + return nil, ErrInvalidPath + } + p, err := v.abs(rel) + if err != nil { + return nil, err + } + info, err := os.Stat(p) + if errors.Is(err, fs.ErrNotExist) || (err == nil && !info.Mode().IsRegular()) { + return nil, ErrNotFound + } + return info, err +} + // Precondition 对应 HTTP 条件请求:IfMatch 要求当前版本一致,IfNoneMatch 要求文件尚不存在;都为空则无条件覆盖 type Precondition struct { IfMatch string @@ -352,8 +368,8 @@ func (v *Vault) Move(from, to string) error { } srcAssets, dstAssets := "", "" if isNote { - if st, err := os.Stat(assetsDir(src)); err == nil && st.IsDir() { - srcAssets, dstAssets = assetsDir(src), assetsDir(dst) + if st, err := os.Stat(AssetsDir(src)); err == nil && st.IsDir() { + srcAssets, dstAssets = AssetsDir(src), AssetsDir(dst) if _, err := os.Lstat(dstAssets); err == nil { return ErrExists } @@ -422,9 +438,9 @@ func (v *Vault) Delete(rel string) (TrashItem, error) { return TrashItem{}, err } if !item.Dir { - if st, err := os.Stat(assetsDir(p)); err == nil && st.IsDir() { - item.Assets = filepath.Base(assetsDir(p)) - if err := os.Rename(assetsDir(p), filepath.Join(dir, item.Assets)); err != nil { + if st, err := os.Stat(AssetsDir(p)); err == nil && st.IsDir() { + item.Assets = filepath.Base(AssetsDir(p)) + if err := os.Rename(AssetsDir(p), filepath.Join(dir, item.Assets)); err != nil { return TrashItem{}, fmt.Errorf("移动图片目录到回收站失败: %w", err) } } @@ -486,7 +502,7 @@ func (v *Vault) Restore(id string) (TrashItem, error) { return TrashItem{}, ErrExists } if item.Assets != "" { - if _, err := os.Lstat(assetsDir(dst)); err == nil { + if _, err := os.Lstat(AssetsDir(dst)); err == nil { return TrashItem{}, ErrExists } } @@ -498,7 +514,7 @@ func (v *Vault) Restore(id string) (TrashItem, error) { return TrashItem{}, err } if item.Assets != "" { - if err := os.Rename(filepath.Join(dir, item.Assets), assetsDir(dst)); err != nil { + if err := os.Rename(filepath.Join(dir, item.Assets), AssetsDir(dst)); err != nil { return TrashItem{}, fmt.Errorf("恢复图片目录失败: %w", err) } } @@ -539,7 +555,7 @@ func (v *Vault) SaveAsset(note, origName string, r io.Reader, limit int64) (stri } sum := sha256.Sum256(data) name := hex.EncodeToString(sum[:8]) + ext - dir := assetsDir(p) + dir := AssetsDir(p) v.mu.Lock() defer v.mu.Unlock() if err := os.MkdirAll(dir, 0o755); err != nil { @@ -553,7 +569,7 @@ func (v *Vault) SaveAsset(note, origName string, r io.Reader, limit int64) (stri } else if err != nil { return "", err } - return EscapeLinkPath(path.Base(assetsDir(note))) + "/" + name, nil + return EscapeLinkPath(path.Base(AssetsDir(note))) + "/" + name, nil } func sniffExt(data []byte) string { diff --git a/web/share.html b/web/share.html new file mode 100644 index 0000000..4c7768a --- /dev/null +++ b/web/share.html @@ -0,0 +1,15 @@ + + + + + + + + + RawMark + + +
+ + + diff --git a/web/src/app/Icon.vue b/web/src/app/Icon.vue index 2712354..dd22f5a 100644 --- a/web/src/app/Icon.vue +++ b/web/src/app/Icon.vue @@ -14,6 +14,8 @@ const paths = { restore: 'M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5', edit: 'M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z', search: 'M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM21 21l-4.35-4.35', + link: 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71', + key: 'M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.78 7.78 5.5 5.5 0 0 1 7.78-7.78zM15.5 7.5l3 3L22 7l-3-3', command: 'M18 3a3 3 0 0 0-3 3v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 0 0 0-6z', } defineProps<{ name: keyof typeof paths; size?: number }>() diff --git a/web/src/app/ShareDialog.vue b/web/src/app/ShareDialog.vue new file mode 100644 index 0000000..8fea7e1 --- /dev/null +++ b/web/src/app/ShareDialog.vue @@ -0,0 +1,110 @@ + + + diff --git a/web/src/app/TokensDialog.vue b/web/src/app/TokensDialog.vue new file mode 100644 index 0000000..2704e2d --- /dev/null +++ b/web/src/app/TokensDialog.vue @@ -0,0 +1,103 @@ + + + diff --git a/web/src/app/TreeNode.vue b/web/src/app/TreeNode.vue index 6eecde5..06ef92e 100644 --- a/web/src/app/TreeNode.vue +++ b/web/src/app/TreeNode.vue @@ -41,13 +41,14 @@ function commitRename() { if (target && target !== props.node.path) void move(props.node.path, target) } -function runMenu(action: 'note' | 'folder' | 'rename' | 'delete') { +function runMenu(action: 'note' | 'folder' | 'rename' | 'share' | 'delete') { menuOpen.value = false const path = props.node.path if (action === 'note' || action === 'folder') { expanded.add(path) void (action === 'note' ? createNote(path) : createFolder(path)) } else if (action === 'rename') ws.renaming = path + else if (action === 'share') ws.shareDialog = { path } else void remove(path) } @@ -114,6 +115,7 @@ function onDrop(e: DragEvent) { +
diff --git a/web/src/app/Workspace.vue b/web/src/app/Workspace.vue index 6701a9e..f1e3815 100644 --- a/web/src/app/Workspace.vue +++ b/web/src/app/Workspace.vue @@ -6,6 +6,8 @@ import EditorPane from './EditorPane.vue' import Icon from './Icon.vue' import NoteBanner from './NoteBanner.vue' import QuickSwitcher from './QuickSwitcher.vue' +import ShareDialog from './ShareDialog.vue' +import TokensDialog from './TokensDialog.vue' import { noteTitle } from './paths' import Sidebar from './Sidebar.vue' import TabBar from './TabBar.vue' @@ -67,5 +69,7 @@ onBeforeUnmount(() => { + +
diff --git a/web/src/app/api.ts b/web/src/app/api.ts index a0a6430..b39c9e4 100644 --- a/web/src/app/api.ts +++ b/web/src/app/api.ts @@ -21,6 +21,26 @@ export interface SearchResult { snippet: string } +export interface Share { + token: string + path: string + createdAt: number + // 0 为永久 + expiresAt: number + hasPassword: boolean + // 笔记是否仍存在 + exists: boolean +} + +export interface ApiToken { + id: string + name: string + scope: 'read' | 'write' + createdAt: number + // 0 为从未使用 + lastUsedAt: number +} + export class ApiError extends Error { constructor( readonly status: number, @@ -106,6 +126,19 @@ export const api = { return (await json<{ link: string }>(request('POST', '/api/v1/assets?note=' + encodeURIComponent(note), { body: form }))).link }, + shares: async (path?: string) => + (await json<{ shares: Share[] }>(request('GET', '/api/v1/shares' + (path ? '?path=' + encodeURIComponent(path) : '')))).shares, + // expiresIn 为有效时长(秒),0 为永久 + createShare: (path: string, password: string, expiresIn: number) => + json(request('POST', '/api/v1/shares', { json: { path, password, expiresIn } })), + deleteShare: (token: string) => request('DELETE', '/api/v1/shares/' + encodeURIComponent(token)), + + tokens: async () => (await json<{ tokens: ApiToken[] }>(request('GET', '/api/v1/tokens'))).tokens, + // 返回的 token 明文只有这一次 + createToken: (name: string, scope: ApiToken['scope']) => + json<{ token: string; info: ApiToken }>(request('POST', '/api/v1/tokens', { json: { name, scope } })), + deleteToken: (id: string) => request('DELETE', '/api/v1/tokens/' + encodeURIComponent(id)), + trash: async () => (await json<{ items: TrashItem[] }>(request('GET', '/api/v1/trash'))).items, restore: (id: string) => json(request('POST', `/api/v1/trash/${id}/restore`)), purge: (id: string) => request('DELETE', `/api/v1/trash/${id}`), diff --git a/web/src/app/app.css b/web/src/app/app.css index 1701c61..2cf53de 100644 --- a/web/src/app/app.css +++ b/web/src/app/app.css @@ -354,6 +354,30 @@ button { } .rm-dialog-head { display: flex; align-items: center; padding: 14px 16px 8px 20px; } .rm-dialog-head h2 { flex: 1; margin: 0; font-size: 16px; } +.rm-share-dialog { max-width: 640px; } +.rm-share-form { display: flex; gap: 8px; padding: 4px 20px 0; } +.rm-share-form input, +.rm-share-form select { + height: 34px; + padding: 0 10px; + border: 1px solid var(--rm-divider); + border-radius: 6px; + background: var(--rm-bg); + color: var(--rm-text); + font-size: 14px; + outline: none; +} +.rm-share-form input { flex: 1; min-width: 0; } +.rm-share-form input:focus, +.rm-share-form select:focus { border-color: var(--rm-accent); } +.rm-share-note { margin: 10px 20px 4px; font-size: 12px; color: var(--rm-text-muted); line-height: 1.6; } +.rm-share-note a { color: var(--rm-accent-strong); } +.rm-share-link { font-family: var(--rm-font-mono); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.rm-share-bad { color: var(--rm-danger); } +.rm-token-created { margin: 12px 20px 0; padding: 10px 12px; border-radius: 8px; background: var(--rm-selection); font-size: 13px; } +.rm-token-created p { margin: 0 0 6px; } +.rm-token-value { display: flex; align-items: center; gap: 8px; } +.rm-token-value code { flex: 1; min-width: 0; overflow-wrap: anywhere; font-family: var(--rm-font-mono); font-size: 12px; user-select: all; } .rm-trash-list { list-style: none; margin: 0; padding: 0 12px 12px; overflow: auto; } .rm-trash-list li { display: flex; align-items: center; gap: 10px; padding: 8px; border-radius: 8px; color: var(--rm-text-muted); } .rm-trash-list li:hover { background: var(--rm-hover); } diff --git a/web/src/app/workspace.ts b/web/src/app/workspace.ts index b62ddde..a4a50c9 100644 --- a/web/src/app/workspace.ts +++ b/web/src/app/workspace.ts @@ -46,6 +46,9 @@ export const ws = reactive({ // 快速切换/命令面板的初始输入,null 为关闭;以 > 开头时列出命令 switcher: null as string | null, trashOpen: false, + // 分享对话框:path 为笔记路径,null 为全部分享的管理列表 + shareDialog: null as { path: string | null } | null, + tokensOpen: false, // 最近打开的笔记,最新的在前 recent: JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as string[], toasts: [] as Toast[], @@ -758,6 +761,9 @@ function registerBuiltinCommands() { // 浏览器不允许网页覆盖 Ctrl+W { id: 'tab.close', title: '关闭当前标签页', when: hasNote, run: () => void closeTab(ws.active!) }, { id: 'app.trash', title: '打开回收站', run: () => (ws.trashOpen = true) }, + { id: 'note.share', title: '分享当前笔记', when: hasNote, run: () => (ws.shareDialog = { path: ws.active }) }, + { id: 'app.shares', title: '管理分享链接', run: () => (ws.shareDialog = { path: null }) }, + { id: 'app.tokens', title: 'API 令牌', run: () => (ws.tokensOpen = true) }, ]) { registerCommand(cmd) } diff --git a/web/src/editor/widgets.ts b/web/src/editor/widgets.ts index ce17ddc..115066f 100644 --- a/web/src/editor/widgets.ts +++ b/web/src/editor/widgets.ts @@ -52,7 +52,7 @@ export class HorizontalRuleWidget extends WidgetType { } // 写入剪贴板;Clipboard API 只在 HTTPS/localhost 可用,局域网 HTTP 访问时退回 execCommand -async function copyText(text: string): Promise { +export async function copyText(text: string): Promise { if (navigator.clipboard && window.isSecureContext) { try { await navigator.clipboard.writeText(text) diff --git a/web/src/markdown/html.test.ts b/web/src/markdown/html.test.ts index b37100c..147df46 100644 --- a/web/src/markdown/html.test.ts +++ b/web/src/markdown/html.test.ts @@ -58,7 +58,7 @@ describe('安全模式', () => { describe('Typora 扩展', () => { it('高亮、上下标、脚注引用', () => { - expect(renderMarkdown('==a== x^2^ H~2~O[^1]')).toBe('

a x2 H2O1

\n') + expect(renderMarkdown('==a== x^2^ H~2~O[^1]')).toBe('

a x2 H2O1

\n') }) it('公式与 emoji 输出待填充的占位元素,内容被转义', () => { expect(renderMarkdown('$aa<b :smile:

\n') @@ -67,7 +67,7 @@ describe('Typora 扩展', () => { ) }) it('脚注定义、front matter 与 [TOC]', () => { - expect(renderMarkdown('---\ntitle: x\n---\n[TOC]\n\n[^1]: 说明 **重点**')).toBe('
1 说明 重点
\n') + expect(renderMarkdown('---\ntitle: x\n---\n[TOC]\n\n[^1]: 说明 **重点**')).toBe('\n
1 说明 重点
\n') }) }) diff --git a/web/src/markdown/html.ts b/web/src/markdown/html.ts index cb543f3..d927013 100644 --- a/web/src/markdown/html.ts +++ b/web/src/markdown/html.ts @@ -19,6 +19,8 @@ interface LinkDefinition { const BLOCK_CONTAINERS = new Set(['Blockquote', 'ListItem']) +const footnoteId = (label: string) => 'fn-' + label.toLowerCase().replace(/\s+/g, '-') + export function escapeHtml(s: string): string { return s.replace(/[&<>"]/g, (c) => (c === '&' ? '&' : c === '<' ? '<' : c === '>' ? '>' : '"')) } @@ -275,8 +277,10 @@ export function renderMarkdown(src: string, { parser = markdownParser, unsafe = const raw = text(node.from, node.to) return `${escapeHtml(raw)}` } - case 'FootnoteReference': - return `${escapeHtml(text(node.from + 2, node.to - 1))}` + case 'FootnoteReference': { + const label = text(node.from + 2, node.to - 1) + return `${escapeHtml(label)}` + } case 'InlineCode': { const marks = node.getChildren('CodeMark') let code = text(marks[0].to, marks[marks.length - 1].from).replace(/\n/g, ' ') @@ -439,9 +443,13 @@ export function renderMarkdown(src: string, { parser = markdownParser, unsafe = } case 'FootnoteDefinition': { const label = node.getChild('FootnoteLabel')! - return `
${escapeHtml(text(label.from + 2, label.to - 2))} ${inline(node, label.to, node.to).trim()}
\n` + const name = text(label.from + 2, label.to - 2) + return `
${escapeHtml(name)} ${inline(node, label.to, node.to).trim()}
\n` } - // front matter 是元数据,不输出;[TOC] 在只读分享页面(M4)中生成 + // 目录占位,由分享页根据标题填充 + case 'TableOfContents': + return '\n' + // front matter 是元数据,不输出 default: return '' } diff --git a/web/src/share/main.ts b/web/src/share/main.ts new file mode 100644 index 0000000..c4a2e08 --- /dev/null +++ b/web/src/share/main.ts @@ -0,0 +1,160 @@ +// 只读分享页(/s/{token}):取回笔记内容,用与编辑器同一套解析器渲染为 HTML。不加载编辑器 +import { requestTokens } from '../highlight/client' +import { renderMarkdown } from '../markdown/html' +import { hydrate, renderMermaid } from '../markdown/render' +import '../themes/base.css' +import './share.css' + +interface SharedNote { + title: string + content: string + updatedAt: number +} + +const root = document.getElementById('share')! +const token = decodeURIComponent(location.pathname.split('/')[2] ?? '') +const api = '/api/v1/public/shares/' + encodeURIComponent(token) + +function message(text: string) { + root.replaceChildren(Object.assign(document.createElement('p'), { className: 'rm-share-message', textContent: text })) +} + +async function load() { + let res: Response + try { + res = await fetch(api, { credentials: 'same-origin' }) + } catch { + message('网络错误,请检查连接后刷新') + return + } + if (res.ok) render((await res.json()) as SharedNote) + else if (res.status === 401) askPassword() + else if (res.status === 404) message('分享不存在、已过期或已被撤销') + else message(`加载失败(${res.status})`) +} + +function askPassword(error = '') { + const form = document.createElement('form') + form.className = 'rm-share-password' + form.innerHTML = ` +

这篇分享需要访问密码

+ + + ` + const input = form.querySelector('input')! + const button = form.querySelector('button')! + form.querySelector('.rm-share-error')!.textContent = error + form.addEventListener('submit', async (e) => { + e.preventDefault() + button.disabled = true + try { + const res = await fetch(api + '/unlock', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: input.value }), + }) + if (res.ok) return void load() + if (res.status === 404) return message('分享不存在、已过期或已被撤销') + askPassword(res.status === 401 ? '密码错误' : res.status === 429 ? '尝试次数过多,请稍后再试' : `验证失败(${res.status})`) + } catch { + askPassword('网络错误,请重试') + } + }) + root.replaceChildren(form) + input.focus() +} + +const dateFormat = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeStyle: 'short' }) + +function render(note: SharedNote) { + document.title = note.title + const header = document.createElement('header') + header.className = 'rm-share-header' + header.append( + Object.assign(document.createElement('span'), { className: 'rm-share-title', textContent: note.title }), + Object.assign(document.createElement('span'), { className: 'rm-share-time', textContent: '更新于 ' + dateFormat.format(note.updatedAt) }), + ) + const article = document.createElement('article') + article.className = 'rm-share-content' + // 安全模式:原始 HTML 被转义,危险协议的链接被过滤 + article.innerHTML = renderMarkdown(note.content) + root.replaceChildren(header, article) + + resolveImages(article) + buildToc(article) + hydrate(article) + for (const code of article.querySelectorAll('pre > code')) void decorateCode(code) +} + +// 相对地址的图片(笔记的 .assets)经分享接口读取;服务器只允许读取本篇的图片目录 +function resolveImages(article: HTMLElement) { + for (const img of article.querySelectorAll('img')) { + const src = img.getAttribute('src') ?? '' + if (src && !/^[a-z][a-z\d+.-]*:/i.test(src) && !src.startsWith('/') && !src.startsWith('#')) { + img.src = api + '/assets/' + src.replace(/^(\.\/)+/, '') + } + img.loading = 'lazy' + } +} + +function buildToc(article: HTMLElement) { + const headings = [...article.querySelectorAll('h1, h2, h3, h4, h5, h6')] + const used = new Set() + for (const h of headings) { + const base = (h.textContent ?? '').trim().toLowerCase().replace(/\s+/g, '-') || 'section' + let id = base + for (let i = 1; used.has(id) || document.getElementById(id); i++) id = `${base}-${i}` + used.add(id) + h.id = id + } + const navs = article.querySelectorAll('nav.toc') + if (!navs.length) return + const top = Math.min(...headings.map((h) => Number(h.tagName[1]))) + const list = document.createElement('ul') + for (const h of headings) { + const li = document.createElement('li') + li.style.paddingLeft = `${(Number(h.tagName[1]) - top) * 1.2}em` + li.append(Object.assign(document.createElement('a'), { href: '#' + h.id, textContent: h.textContent })) + list.append(li) + } + for (const nav of navs) nav.replaceChildren(list.cloneNode(true)) +} + +async function decorateCode(code: HTMLElement) { + const lang = /language-(\S+)/.exec(code.className)?.[1]?.toLowerCase() + if (!lang) return + const text = (code.textContent ?? '').replace(/\n$/, '') + if (lang === 'mermaid') { + const figure = document.createElement('div') + figure.className = 'rm-share-mermaid' + try { + figure.innerHTML = await renderMermaid(text) + code.parentElement!.replaceWith(figure) + } catch { + // 语法错误时保留源码 + } + return + } + const run = await requestTokens(lang, text) + if (!run) return + const out = document.createDocumentFragment() + let at = 0 + for (let i = 0; i < run.length; i += 4) { + const from = run[i] as number + const to = from + (run[i + 1] as number) + if (from > at) out.append(text.slice(at, from)) + const span = document.createElement('span') + const fontStyle = run[i + 3] as number + if (run[i + 2]) span.style.color = run[i + 2] as string + if (fontStyle & 1) span.style.fontStyle = 'italic' + if (fontStyle & 2) span.style.fontWeight = 'bold' + if (fontStyle & 4) span.style.textDecoration = 'underline' + span.textContent = text.slice(from, to) + out.append(span) + at = to + } + out.append(text.slice(at)) + code.replaceChildren(out) +} + +void load() diff --git a/web/src/share/share.css b/web/src/share/share.css new file mode 100644 index 0000000..f6b1f84 --- /dev/null +++ b/web/src/share/share.css @@ -0,0 +1,112 @@ +/* 只读分享页:排版与编辑器渲染效果保持一致(同一套 --rm-* 变量) */ +.rm-share { + max-width: var(--rm-content-width); + margin: 0 auto; + padding: 24px 24px 80px; + font-size: var(--rm-font-size); + line-height: var(--rm-line-height); + overflow-wrap: break-word; +} + +.rm-share-message { margin-top: 30vh; text-align: center; color: var(--rm-text-muted); } + +.rm-share-password { display: flex; flex-direction: column; gap: 12px; max-width: 300px; margin: 25vh auto 0; } +.rm-share-password p { margin: 0; } +.rm-share-password input { + height: 40px; + padding: 0 12px; + border: 1px solid var(--rm-divider); + border-radius: 8px; + background: var(--rm-bg); + color: var(--rm-text); + font-size: 15px; + outline: none; +} +.rm-share-password input:focus { border-color: var(--rm-accent); box-shadow: 0 0 0 3px var(--rm-selection); } +.rm-share-password button { + height: 40px; + border: 0; + border-radius: 8px; + background: var(--rm-accent); + color: #fff; + font-size: 15px; + cursor: pointer; +} +.rm-share-password button:disabled { opacity: 0.6; } +.rm-share-error { min-height: 1.2em; color: #e5484d; font-size: 13px; } + +.rm-share-header { + display: flex; + justify-content: space-between; + gap: 16px; + padding-bottom: 12px; + border-bottom: 1px solid var(--rm-divider); + color: var(--rm-text-muted); + font-size: 13px; +} +.rm-share-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.rm-share-time { flex: none; } + +.rm-share-content h1 { font-size: 2em; line-height: 1.3; margin: 0.9em 0 0.4em; } +.rm-share-content h2 { font-size: 1.6em; line-height: 1.35; margin: 0.9em 0 0.4em; } +.rm-share-content h3 { font-size: 1.3em; line-height: 1.4; margin: 0.8em 0 0.4em; } +.rm-share-content h4 { font-size: 1.15em; margin: 0.8em 0 0.4em; } +.rm-share-content h5, +.rm-share-content h6 { font-size: 1em; margin: 0.8em 0 0.4em; } +.rm-share-content h6 { color: var(--rm-text-muted); } +.rm-share-content p { margin: 0.6em 0; } +.rm-share-content a { color: var(--rm-accent-strong); text-decoration: none; } +.rm-share-content a:hover { text-decoration: underline; } +.rm-share-content img { max-width: 100%; border-radius: 4px; } +.rm-share-content mark { background: var(--rm-highlight-bg); color: inherit; padding: 0 2px; border-radius: 2px; } +.rm-share-content hr { border: 0; border-top: 1px solid var(--rm-divider); margin: 1.5em 0; } +.rm-share-content blockquote { margin: 0.8em 0; padding-left: 16px; border-left: 3px solid var(--rm-divider); color: var(--rm-text-muted); } +.rm-share-content ul, +.rm-share-content ol { padding-left: 1.6em; } +.rm-share-content li > input[type='checkbox'] { margin: 0 0.4em 0 -1.4em; accent-color: var(--rm-accent); vertical-align: -0.1em; } +.rm-share-content li:has(> input[type='checkbox']) { list-style: none; } + +.rm-share-content code { + font-family: var(--rm-font-mono); + font-size: 0.875em; + background: var(--rm-inline-code-bg); + color: var(--rm-inline-code-text); + padding: 0.15em 0.4em; + border-radius: 4px; +} +.rm-share-content pre { + margin: 0.8em 0; + padding: 12px 24px; + border-radius: 8px; + background: var(--rm-code-bg); + overflow-x: auto; + line-height: 1.5; + tab-size: 4; +} +.rm-share-content pre code { + padding: 0; + background: none; + color: var(--rm-code-text); + font-size: var(--rm-code-font-size); +} + +.rm-share-content table { border-collapse: collapse; display: block; max-width: 100%; width: fit-content; overflow-x: auto; margin: 0.8em 0; font-size: 0.95em; line-height: 1.5; } +.rm-share-content th, +.rm-share-content td { border: 1px solid var(--rm-divider); padding: 6px 12px; } +.rm-share-content th { font-weight: 600; background: var(--rm-inline-code-bg); } + +.rm-share-content div[data-math] { margin: 0.8em 0; overflow-x: auto; } +.rm-share-mermaid { margin: 0.8em 0; text-align: center; overflow-x: auto; } + +.rm-share-content nav.toc { margin: 0.8em 0; padding: 8px 16px; border-left: 3px solid var(--rm-divider); } +.rm-share-content nav.toc ul { list-style: none; margin: 0; padding: 0; } +.rm-share-content nav.toc li { margin: 2px 0; } + +.rm-share-content .footnote-ref a { text-decoration: none; } +.rm-share-content .footnote { margin-top: 0.4em; font-size: 0.9em; color: var(--rm-text-muted); } +.rm-share-content .footnote:first-of-type { margin-top: 2em; padding-top: 1em; border-top: 1px solid var(--rm-divider); } + +@media (max-width: 600px) { + .rm-share { padding: 16px 16px 60px; } + .rm-share-time { display: none; } +} diff --git a/web/vite.config.ts b/web/vite.config.ts index 6ba7ca8..5f830ad 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -2,11 +2,23 @@ import vue from '@vitejs/plugin-vue' import { defineConfig } from 'vitest/config' export default defineConfig({ - plugins: [vue()], + plugins: [ + vue(), + // 开发服务器中把分享地址 /s/{token} 交给分享页入口(生产环境由 Go 服务处理) + { + name: 'rawmark-share-route', + configureServer(server) { + server.middlewares.use((req, _res, next) => { + if (req.url?.startsWith('/s/')) req.url = '/share.html' + next() + }) + }, + }, + ], build: { rolldownOptions: { - // demo.html 为免登录的编辑器演示页,供自动化性能/交互测试使用 - input: { index: 'index.html', demo: 'demo.html' }, + // demo.html 为免登录的编辑器演示页,供自动化性能/交互测试使用;share.html 为只读分享页(/s/{token}) + input: { index: 'index.html', demo: 'demo.html', share: 'share.html' }, }, }, worker: { format: 'es' },