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\n")
+ f.write("目录/笔记.assets/图.png", "PNG")
+ f.write("目录/其他.png", "OTHER")
+ f.write("目录/其他.assets/x.png", "X")
+
+ res := f.do(f.admin, "POST", "/api/v1/shares", `{"path":"目录/笔记.md","password":"访问密码","expiresIn":0}`, nil)
+ if res.StatusCode != 200 {
+ t.Fatalf("创建分享: %d", res.StatusCode)
+ }
+ sh := decode[shareView](t, res)
+ if !sh.HasPassword || !sh.Exists || sh.ExpiresAt != 0 || len(sh.Token) < 20 {
+ t.Fatalf("分享信息: %+v", sh)
+ }
+ base := "/api/v1/public/shares/" + sh.Token
+ guest := newClient()
+ if code := f.status(guest, "GET", base, "", nil); code != 401 {
+ t.Errorf("未输入密码应为 401,得到 %d", code)
+ }
+ if code := f.status(guest, "GET", base+"/assets/笔记.assets/图.png", "", nil); code != 401 {
+ t.Errorf("未输入密码读图片应为 401,得到 %d", code)
+ }
+ if code := f.status(guest, "POST", base+"/unlock", `{"password":"错的"}`, nil); code != 401 {
+ t.Errorf("错误密码应为 401,得到 %d", code)
+ }
+ if code := f.status(guest, "POST", base+"/unlock", `{"password":"访问密码"}`, nil); code != 200 {
+ t.Fatalf("正确密码: %d", code)
+ }
+ got := decode[map[string]any](t, f.do(guest, "GET", base, "", nil))
+ if got["title"] != "笔记" || got["content"] != "# 标题\n\n\n" || got["path"] != nil {
+ t.Errorf("分享内容: %v", got)
+ }
+ res = f.do(guest, "GET", base+"/assets/笔记.assets/图.png", "", nil)
+ if b, _ := io.ReadAll(res.Body); res.StatusCode != 200 || string(b) != "PNG" {
+ t.Errorf("本篇图片: %d %q", res.StatusCode, b)
+ }
+ for _, p := range []string{"其他.png", "笔记.assets/../其他.png", "其他.assets/x.png", "../目录/其他.png", "笔记.md"} {
+ if code := f.status(guest, "GET", base+"/assets/"+p, "", nil); code != 404 {
+ t.Errorf("图片目录之外的 %s 应为 404,得到 %d", p, code)
+ }
+ }
+
+ // 改名后分享跟随
+ if code := f.status(f.admin, "POST", "/api/v1/move", `{"from":"目录","to":"新目录"}`, nil); code != 200 {
+ t.Fatalf("移动文件夹: %d", code)
+ }
+ if code := f.status(f.admin, "POST", "/api/v1/move", `{"from":"新目录/笔记.md","to":"新目录/改名.md"}`, nil); code != 200 {
+ t.Fatalf("改名: %d", code)
+ }
+ got = decode[map[string]any](t, f.do(guest, "GET", base, "", nil))
+ if got["title"] != "改名" || !strings.Contains(got["content"].(string), "改名.assets/") {
+ t.Errorf("改名后分享内容: %v", got)
+ }
+ if code := f.status(guest, "GET", base+"/assets/改名.assets/图.png", "", nil); code != 200 {
+ t.Errorf("改名后图片: %d", code)
+ }
+
+ // 其他分享的凭据不能用于本分享
+ res = f.do(f.admin, "POST", "/api/v1/shares", `{"path":"新目录/其他.md"}`, nil)
+ if res.StatusCode != 404 {
+ t.Errorf("分享不存在的笔记应为 404,得到 %d", res.StatusCode)
+ }
+ f.write("新目录/其他.md", "其他")
+ other := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", `{"path":"新目录/其他.md","password":"另一个"}`, nil))
+ if code := f.status(guest, "GET", "/api/v1/public/shares/"+other.Token, "", nil); code != 401 {
+ t.Errorf("凭据只对对应分享有效,得到 %d", code)
+ }
+
+ // 列表、删除笔记、撤销
+ list := decode[struct{ Shares []shareView }](t, f.do(f.admin, "GET", "/api/v1/shares?path=新目录/改名.md", "", nil))
+ if len(list.Shares) != 1 || list.Shares[0].Path != "新目录/改名.md" {
+ t.Errorf("按笔记筛选: %+v", list.Shares)
+ }
+ if code := f.status(f.admin, "DELETE", "/api/v1/files/新目录/改名.md", "", nil); code != 200 {
+ t.Fatal(code)
+ }
+ list = decode[struct{ Shares []shareView }](t, f.do(f.admin, "GET", "/api/v1/shares", "", nil))
+ if len(list.Shares) != 2 || list.Shares[1].Exists {
+ t.Errorf("笔记删除后分享标记为不存在: %+v", list.Shares)
+ }
+ if code := f.status(guest, "GET", base, "", nil); code != 404 {
+ t.Errorf("笔记已删除应为 404,得到 %d", code)
+ }
+ if code := f.status(f.admin, "DELETE", "/api/v1/shares/"+sh.Token, "", nil); code != 200 {
+ t.Fatal(code)
+ }
+ if code := f.status(guest, "POST", base+"/unlock", `{"password":"访问密码"}`, nil); code != 404 {
+ t.Errorf("撤销后应为 404,得到 %d", code)
+ }
+ if code := f.status(newClient(), "GET", "/api/v1/shares", "", nil); code != 401 {
+ t.Errorf("未登录不能管理分享,得到 %d", code)
+ }
+}
+
+func 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 @@
+
+
+
+
+
+
+
{{ title }}
+
+
+
+
分享页只读,显示笔记的当前内容及其 .assets 中的图片;拿到链接的人都能访问。
+
{{ path ? '还没有分享链接' : '还没有分享任何笔记' }}
+
+
+
+
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 @@
+
+
+
+
+
+
+
API 令牌
+
+
+
+
+
令牌只显示这一次,请立即保存:
+
+ {{ created }}
+
+
+
+
+ 调用时带上请求头 Authorization: Bearer 令牌;只读令牌只能读取。接口说明见
+ openapi.json。
+
+
还没有 API 令牌
+
+
+
+
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 H2O
\n')
+ expect(renderMarkdown('==a== x^2^ H~2~O[^1]')).toBe('a x2 H2O
\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('\n')
+ expect(renderMarkdown('---\ntitle: x\n---\n[TOC]\n\n[^1]: 说明 **重点**')).toBe('\n\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 ``
+ case 'FootnoteReference': {
+ const label = text(node.from + 2, node.to - 1)
+ return ``
+ }
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 `\n`
+ const name = text(label.from + 2, label.to - 2)
+ return `\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' },