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
This commit is contained in:
eezd committed 2026-10-17 15:21:47 +08:00
1 parent 7b076346a4
commit 5ac01894e6
26 files changed
+3147 -52

No files matched your search

+2
View File
@@ -19,6 +19,8 @@ docker run -d --name rawmark \
- 笔记以普通 `.md` 文件保存在 `数据目录/vault/`,图片按 Typora 习惯存放在同级的 `笔记名.assets/`,可直接用 Typora 等编辑器打开;外部修改会实时同步到网页。 - 笔记以普通 `.md` 文件保存在 `数据目录/vault/`,图片按 Typora 习惯存放在同级的 `笔记名.assets/`,可直接用 Typora 等编辑器打开;外部修改会实时同步到网页。
- `--user` 让容器以宿主机用户身份读写数据目录;不指定时容器以 UID 65532 运行,需要该用户对目录有写权限。 - `--user` 让容器以宿主机用户身份读写数据目录;不指定时容器以 UID 65532 运行,需要该用户对目录有写权限。
- 请通过 HTTPS 反向代理(Caddy、Nginx 等)访问,并转发 WebSocket(`/api/v1/events`)。 - 请通过 HTTPS 反向代理(Caddy、Nginx 等)访问,并转发 WebSocket(`/api/v1/events`)。
- 分享:命令面板(F1)中「分享当前笔记」生成只读链接 `/s/…`,可设访问密码与有效期;「管理分享链接」查看与撤销。
- 自动化:「API 令牌」中创建只读或读写令牌,以 `Authorization: Bearer <令牌>` 调用 REST API,接口说明见 `/api/v1/openapi.json`。
| 环境变量 | 默认值 | 说明 | | 环境变量 | 默认值 | 说明 |
| --- | --- | --- | | --- | --- | --- |
+2 -1
View File
@@ -94,8 +94,9 @@ func run(addr string) error {
} }
go cleanupSessions(ctx, st) 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) } srv.OnSetup = func() { os.Remove(codeFile) }
go srv.FollowMoves(ctx)
httpServer := &http.Server{ httpServer := &http.Server{
Addr: addr, Addr: addr,
Handler: srv.Handler(web.Dist()), Handler: srv.Handler(web.Dist()),
+99 -27
View File
@@ -17,6 +17,7 @@ import (
"rawmark/internal/auth" "rawmark/internal/auth"
"rawmark/internal/events" "rawmark/internal/events"
"rawmark/internal/search" "rawmark/internal/search"
"rawmark/internal/store"
"rawmark/internal/vault" "rawmark/internal/vault"
) )
@@ -30,41 +31,82 @@ const (
type Server struct { type Server struct {
vault *vault.Vault vault *vault.Vault
auth *auth.Manager auth *auth.Manager
store *store.Store
hub *events.Hub hub *events.Hub
index *search.Index index *search.Index
// OnSetup 在首次初始化完成后调用(用于删除启动码文件) // OnSetup 在首次初始化完成后调用(用于删除启动码文件)
OnSetup func() OnSetup func()
} }
func New(v *vault.Vault, a *auth.Manager, hub *events.Hub, index *search.Index) *Server { func New(v *vault.Vault, a *auth.Manager, st *store.Store, hub *events.Hub, index *search.Index) *Server {
return &Server{vault: v, auth: a, hub: hub, index: index} 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 { func (s *Server) Handler(static fs.FS) http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/health", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]bool{"ok": true}) }) for _, rt := range s.routes() {
mux.HandleFunc("GET /api/v1/auth/state", s.authState) var h http.Handler = rt.handler
mux.HandleFunc("POST /api/v1/auth/setup", s.setup) switch rt.access {
mux.HandleFunc("POST /api/v1/auth/login", s.login) case authed:
mux.HandleFunc("POST /api/v1/auth/logout", s.logout) h = s.requireAuth(h, true)
case sessionOnly:
protected := map[string]http.HandlerFunc{ h = s.requireAuth(h, false)
"GET /api/v1/tree": s.tree, }
"GET /api/v1/files/{path...}": s.readFile, mux.Handle(rt.pattern, h)
"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))
} }
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { writeError(w, 404, "接口不存在") }) mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { writeError(w, 404, "接口不存在") })
mux.Handle("/", newStaticHandler(static)) 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) { 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) c, err := r.Cookie(cookieName)
if err != nil { if err != nil {
writeError(w, 401, "未登录") writeError(w, 401, "未登录")
@@ -275,6 +339,10 @@ func (s *Server) move(w http.ResponseWriter, r *http.Request) {
vaultError(w, r, err) vaultError(w, r, err)
return 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}) 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 中的脚本在本站执行 // raw 输出仓库内任意非隐藏文件(主要是图片)。以沙箱 CSP 返回,防止 SVG/HTML 中的脚本在本站执行
func (s *Server) raw(w http.ResponseWriter, r *http.Request) { 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 { if err != nil {
vaultError(w, r, err) vaultError(w, r, err)
return return
+311
View File
@@ -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)
}
}
File diff suppressed because it is too large. Load diff
+279
View File
@@ -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)
}
}
}
}
}
}
+8
View File
@@ -42,6 +42,11 @@ func (h *staticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return return
} }
name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/") 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) f, err := h.load(name)
if err != nil { if err != nil {
// 前端路由:未知路径一律返回入口页面 // 前端路由:未知路径一律返回入口页面
@@ -60,6 +65,9 @@ func (h *staticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(name, ".html") { if strings.HasSuffix(name, ".html") {
hd.Set("Content-Security-Policy", appCSP) hd.Set("Content-Security-Policy", appCSP)
} }
if share {
hd.Set("X-Robots-Tag", "noindex")
}
if ct := mime.TypeByExtension(path.Ext(name)); ct != "" { if ct := mime.TypeByExtension(path.Ext(name)); ct != "" {
hd.Set("Content-Type", ct) hd.Set("Content-Type", ct)
} }
+63
View File
@@ -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)
}
+98 -1
View File
@@ -1,7 +1,8 @@
// Package auth 实现单用户登录:Argon2id 密码哈希、会话令牌与登录限速。 // Package auth 实现单用户登录(Argon2id 密码哈希、会话令牌、登录限速),以及分享密码与 API 令牌的校验。
package auth package auth
import ( import (
"crypto/hmac"
"crypto/rand" "crypto/rand"
"crypto/sha256" "crypto/sha256"
"crypto/subtle" "crypto/subtle"
@@ -20,6 +21,8 @@ import (
const ( const (
passwordKey = "auth.password" passwordKey = "auth.password"
shareSecretKey = "share.secret"
apiTokenPrefix = "rm_"
SessionTTL = 30 * 24 * time.Hour SessionTTL = 30 * 24 * time.Hour
MinPasswordLen = 8 MinPasswordLen = 8
@@ -39,6 +42,7 @@ var (
ErrWeakPassword = fmt.Errorf("密码至少 %d 位", MinPasswordLen) ErrWeakPassword = fmt.Errorf("密码至少 %d 位", MinPasswordLen)
ErrBadCredentials = errors.New("密码错误") ErrBadCredentials = errors.New("密码错误")
ErrRateLimited = errors.New("尝试次数过多,请稍后再试") ErrRateLimited = errors.New("尝试次数过多,请稍后再试")
ErrBadScope = errors.New("权限范围只能是 read 或 write")
) )
type Manager struct { 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)) } 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 { func (m *Manager) allow(ip string) bool {
@@ -204,6 +294,13 @@ func randomToken() string {
return base64.RawURLEncoding.EncodeToString(b) 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 { func hashToken(token string) string {
sum := sha256.Sum256([]byte(token)) sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:]) return hex.EncodeToString(sum[:])
+133 -1
View File
@@ -1,4 +1,4 @@
// Package store 保存应用自身的数据(设置、会话)。笔记内容不在这里,见 vault。 // Package store 保存应用自身的数据(设置、会话、分享、API 令牌)。笔记内容不在这里,见 vault。
package store package store
import ( import (
@@ -22,6 +22,23 @@ CREATE TABLE IF NOT EXISTS sessions (
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
expires_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) { 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()) _, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at <= ?`, time.Now().Unix())
return err 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
}
+27 -11
View File
@@ -107,8 +107,8 @@ func CleanPath(p string) (string, error) {
func IsNote(p string) bool { return strings.HasSuffix(p, NoteExt) && len(p) > len(NoteExt) } func IsNote(p string) bool { return strings.HasSuffix(p, NoteExt) && len(p) > len(NoteExt) }
// assetsDir 返回笔记对应的 Typora 风格图片目录:a.md -> a.assets // AssetsDir 返回笔记对应的 Typora 风格图片目录:a.md -> a.assets
func assetsDir(note string) string { return strings.TrimSuffix(note, NoteExt) + AssetsExt } func AssetsDir(note string) string { return strings.TrimSuffix(note, NoteExt) + AssetsExt }
func (v *Vault) abs(rel string) (string, error) { func (v *Vault) abs(rel string) (string, error) {
rel, err := CleanPath(rel) rel, err := CleanPath(rel)
@@ -168,6 +168,22 @@ func (v *Vault) Read(rel string) ([]byte, string, error) {
return data, Rev(data), nil 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 要求文件尚不存在;都为空则无条件覆盖 // Precondition 对应 HTTP 条件请求:IfMatch 要求当前版本一致,IfNoneMatch 要求文件尚不存在;都为空则无条件覆盖
type Precondition struct { type Precondition struct {
IfMatch string IfMatch string
@@ -352,8 +368,8 @@ func (v *Vault) Move(from, to string) error {
} }
srcAssets, dstAssets := "", "" srcAssets, dstAssets := "", ""
if isNote { if isNote {
if st, err := os.Stat(assetsDir(src)); err == nil && st.IsDir() { if st, err := os.Stat(AssetsDir(src)); err == nil && st.IsDir() {
srcAssets, dstAssets = assetsDir(src), assetsDir(dst) srcAssets, dstAssets = AssetsDir(src), AssetsDir(dst)
if _, err := os.Lstat(dstAssets); err == nil { if _, err := os.Lstat(dstAssets); err == nil {
return ErrExists return ErrExists
} }
@@ -422,9 +438,9 @@ func (v *Vault) Delete(rel string) (TrashItem, error) {
return TrashItem{}, err return TrashItem{}, err
} }
if !item.Dir { if !item.Dir {
if st, err := os.Stat(assetsDir(p)); err == nil && st.IsDir() { if st, err := os.Stat(AssetsDir(p)); err == nil && st.IsDir() {
item.Assets = filepath.Base(assetsDir(p)) item.Assets = filepath.Base(AssetsDir(p))
if err := os.Rename(assetsDir(p), filepath.Join(dir, item.Assets)); err != nil { if err := os.Rename(AssetsDir(p), filepath.Join(dir, item.Assets)); err != nil {
return TrashItem{}, fmt.Errorf("移动图片目录到回收站失败: %w", err) return TrashItem{}, fmt.Errorf("移动图片目录到回收站失败: %w", err)
} }
} }
@@ -486,7 +502,7 @@ func (v *Vault) Restore(id string) (TrashItem, error) {
return TrashItem{}, ErrExists return TrashItem{}, ErrExists
} }
if item.Assets != "" { if item.Assets != "" {
if _, err := os.Lstat(assetsDir(dst)); err == nil { if _, err := os.Lstat(AssetsDir(dst)); err == nil {
return TrashItem{}, ErrExists return TrashItem{}, ErrExists
} }
} }
@@ -498,7 +514,7 @@ func (v *Vault) Restore(id string) (TrashItem, error) {
return TrashItem{}, err return TrashItem{}, err
} }
if item.Assets != "" { 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) 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) sum := sha256.Sum256(data)
name := hex.EncodeToString(sum[:8]) + ext name := hex.EncodeToString(sum[:8]) + ext
dir := assetsDir(p) dir := AssetsDir(p)
v.mu.Lock() v.mu.Lock()
defer v.mu.Unlock() defer v.mu.Unlock()
if err := os.MkdirAll(dir, 0o755); err != nil { 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 { } else if err != nil {
return "", err return "", err
} }
return EscapeLinkPath(path.Base(assetsDir(note))) + "/" + name, nil return EscapeLinkPath(path.Base(AssetsDir(note))) + "/" + name, nil
} }
func sniffExt(data []byte) string { func sniffExt(data []byte) string {
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="robots" content="noindex" />
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#1a1a1a" media="(prefers-color-scheme: dark)" />
<title>RawMark</title>
</head>
<body>
<main id="share" class="rm-share"></main>
<script type="module" src="/src/share/main.ts"></script>
</body>
</html>
+2
View File
@@ -14,6 +14,8 @@ const paths = {
restore: 'M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5', 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', 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', 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', 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 }>() defineProps<{ name: keyof typeof paths; size?: number }>()
+110
View File
@@ -0,0 +1,110 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { copyText } from '../editor/widgets'
import { api, ApiError, type Share } from './api'
import Icon from './Icon.vue'
import { noteTitle } from './paths'
import { toast } from './workspace'
// path 为空时列出全部分享(管理列表),否则管理这一篇笔记的分享
const props = defineProps<{ path: string | null }>()
const emit = defineEmits<{ close: [] }>()
const EXPIRY_OPTIONS = [
{ label: '永久有效', seconds: 0 },
{ label: '1 天', seconds: 86400 },
{ label: '7 天', seconds: 7 * 86400 },
{ label: '30 天', seconds: 30 * 86400 },
]
const shares = ref<Share[] | null>(null)
const password = ref('')
const expiresIn = ref(0)
const busy = ref(false)
const title = computed(() => (props.path ? `分享“${noteTitle(props.path)}”` : '所有分享'))
const fmt = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'short' })
const linkOf = (s: Share) => `${location.origin}/s/${s.token}`
const expired = (s: Share) => s.expiresAt !== 0 && s.expiresAt <= Date.now()
function errorText(err: unknown) {
return err instanceof ApiError ? err.message : '网络错误,请检查连接'
}
async function load() {
try {
shares.value = await api.shares(props.path ?? undefined)
} catch (err) {
toast('读取分享失败:' + errorText(err))
emit('close')
}
}
async function create() {
busy.value = true
try {
const s = await api.createShare(props.path!, password.value, expiresIn.value)
shares.value = [s, ...(shares.value ?? [])]
password.value = ''
await copy(s)
} catch (err) {
toast('创建分享失败:' + errorText(err))
} finally {
busy.value = false
}
}
async function copy(s: Share) {
toast((await copyText(linkOf(s))) ? '链接已复制' : '复制失败,请手动复制链接')
}
async function revoke(s: Share) {
if (!confirm('撤销后该链接立即失效,确定撤销?')) return
try {
await api.deleteShare(s.token)
shares.value = shares.value!.filter((x) => x.token !== s.token)
} catch (err) {
toast('撤销失败:' + errorText(err))
}
}
onMounted(load)
</script>
<template>
<div class="rm-dialog-backdrop" @click.self="emit('close')">
<div class="rm-dialog rm-share-dialog" role="dialog" :aria-label="title">
<div class="rm-dialog-head">
<h2>{{ title }}</h2>
<button class="rm-icon-btn" title="关闭" @click="emit('close')"><Icon name="close" /></button>
</div>
<form v-if="path" class="rm-share-form" @submit.prevent="create">
<input v-model="password" type="text" autocomplete="off" placeholder="访问密码(可选)" aria-label="访问密码" />
<select v-model="expiresIn" aria-label="有效期">
<option v-for="o in EXPIRY_OPTIONS" :key="o.seconds" :value="o.seconds">{{ o.label }}</option>
</select>
<button class="rm-btn rm-btn-primary" type="submit" :disabled="busy">创建链接</button>
</form>
<p class="rm-share-note">分享页只读,显示笔记的当前内容及其 .assets 中的图片;拿到链接的人都能访问。</p>
<p v-if="shares && !shares.length" class="rm-tree-empty">{{ path ? '还没有分享链接' : '还没有分享任何笔记' }}</p>
<ul v-else class="rm-trash-list">
<li v-for="s in shares ?? []" :key="s.token">
<Icon name="link" />
<div class="rm-trash-info">
<span v-if="!path" class="rm-trash-path">{{ s.path.replace(/\.md$/, '') }}</span>
<span class="rm-share-link" :class="{ 'rm-trash-path': path }">{{ linkOf(s) }}</span>
<span class="rm-trash-time">
{{ fmt.format(s.createdAt) }} 创建
<template v-if="s.hasPassword"> · 有密码</template>
<template v-if="expired(s)"> · <span class="rm-share-bad">已过期</span></template>
<template v-else-if="s.expiresAt"> · {{ fmt.format(s.expiresAt) }} 到期</template>
<template v-if="!s.exists"> · <span class="rm-share-bad">笔记不存在</span></template>
</span>
</div>
<button class="rm-btn" @click="copy(s)">复制链接</button>
<button class="rm-btn rm-btn-danger" @click="revoke(s)">撤销</button>
</li>
</ul>
</div>
</div>
</template>
+103
View File
@@ -0,0 +1,103 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { copyText } from '../editor/widgets'
import { api, ApiError, type ApiToken } from './api'
import Icon from './Icon.vue'
import { toast } from './workspace'
const emit = defineEmits<{ close: [] }>()
const tokens = ref<ApiToken[] | null>(null)
const name = ref('')
const scope = ref<ApiToken['scope']>('read')
const busy = ref(false)
// 刚创建的令牌明文,只在本次对话框中显示
const created = ref<string | null>(null)
const fmt = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'short' })
function errorText(err: unknown) {
return err instanceof ApiError ? err.message : '网络错误,请检查连接'
}
async function load() {
try {
tokens.value = await api.tokens()
} catch (err) {
toast('读取 API 令牌失败:' + errorText(err))
emit('close')
}
}
async function create() {
busy.value = true
try {
const res = await api.createToken(name.value.trim(), scope.value)
tokens.value = [res.info, ...(tokens.value ?? [])]
created.value = res.token
name.value = ''
} catch (err) {
toast('创建失败:' + errorText(err))
} finally {
busy.value = false
}
}
async function copy() {
toast((await copyText(created.value!)) ? '令牌已复制' : '复制失败,请手动选中复制')
}
async function revoke(t: ApiToken) {
if (!confirm(`吊销“${t.name}”?使用它的脚本将立即无法访问。`)) return
try {
await api.deleteToken(t.id)
tokens.value = tokens.value!.filter((x) => x.id !== t.id)
} catch (err) {
toast('吊销失败:' + errorText(err))
}
}
onMounted(load)
</script>
<template>
<div class="rm-dialog-backdrop" @click.self="emit('close')">
<div class="rm-dialog rm-share-dialog" role="dialog" aria-label="API 令牌">
<div class="rm-dialog-head">
<h2>API 令牌</h2>
<button class="rm-icon-btn" title="关闭" @click="emit('close')"><Icon name="close" /></button>
</div>
<form class="rm-share-form" @submit.prevent="create">
<input v-model="name" type="text" autocomplete="off" placeholder="名称,如:备份脚本" aria-label="名称" required maxlength="100" />
<select v-model="scope" aria-label="权限">
<option value="read">只读</option>
<option value="write">读写</option>
</select>
<button class="rm-btn rm-btn-primary" type="submit" :disabled="busy">创建</button>
</form>
<div v-if="created" class="rm-token-created">
<p>令牌只显示这一次,请立即保存:</p>
<div class="rm-token-value">
<code>{{ created }}</code>
<button class="rm-btn" @click="copy">复制</button>
</div>
</div>
<p class="rm-share-note">
调用时带上请求头 <code>Authorization: Bearer 令牌</code>;只读令牌只能读取。接口说明见
<a href="/api/v1/openapi.json" target="_blank" rel="noopener">openapi.json</a>。
</p>
<p v-if="tokens && !tokens.length" class="rm-tree-empty">还没有 API 令牌</p>
<ul v-else class="rm-trash-list">
<li v-for="t in tokens ?? []" :key="t.id">
<Icon name="key" />
<div class="rm-trash-info">
<span class="rm-trash-path">{{ t.name }} · {{ t.scope === 'write' ? '读写' : '只读' }}</span>
<span class="rm-trash-time">
{{ fmt.format(t.createdAt) }} 创建 · {{ t.lastUsedAt ? fmt.format(t.lastUsedAt) + ' 最近使用' : '从未使用' }}
</span>
</div>
<button class="rm-btn rm-btn-danger" @click="revoke(t)">吊销</button>
</li>
</ul>
</div>
</div>
</template>
+3 -1
View File
@@ -41,13 +41,14 @@ function commitRename() {
if (target && target !== props.node.path) void move(props.node.path, target) 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 menuOpen.value = false
const path = props.node.path const path = props.node.path
if (action === 'note' || action === 'folder') { if (action === 'note' || action === 'folder') {
expanded.add(path) expanded.add(path)
void (action === 'note' ? createNote(path) : createFolder(path)) void (action === 'note' ? createNote(path) : createFolder(path))
} else if (action === 'rename') ws.renaming = path } else if (action === 'rename') ws.renaming = path
else if (action === 'share') ws.shareDialog = { path }
else void remove(path) else void remove(path)
} }
@@ -114,6 +115,7 @@ function onDrop(e: DragEvent) {
<button @click="runMenu('folder')"><Icon name="folderAdd" />新建文件夹</button> <button @click="runMenu('folder')"><Icon name="folderAdd" />新建文件夹</button>
</template> </template>
<button @click="runMenu('rename')"><Icon name="edit" />重命名</button> <button @click="runMenu('rename')"><Icon name="edit" />重命名</button>
<button v-if="!node.dir" @click="runMenu('share')"><Icon name="link" />分享</button>
<button class="danger" @click="runMenu('delete')"><Icon name="trash" />删除</button> <button class="danger" @click="runMenu('delete')"><Icon name="trash" />删除</button>
</div> </div>
<div v-if="menuOpen" class="rm-menu-backdrop" @click.stop="menuOpen = false" @contextmenu.prevent.stop="menuOpen = false" /> <div v-if="menuOpen" class="rm-menu-backdrop" @click.stop="menuOpen = false" @contextmenu.prevent.stop="menuOpen = false" />
+4
View File
@@ -6,6 +6,8 @@ import EditorPane from './EditorPane.vue'
import Icon from './Icon.vue' import Icon from './Icon.vue'
import NoteBanner from './NoteBanner.vue' import NoteBanner from './NoteBanner.vue'
import QuickSwitcher from './QuickSwitcher.vue' import QuickSwitcher from './QuickSwitcher.vue'
import ShareDialog from './ShareDialog.vue'
import TokensDialog from './TokensDialog.vue'
import { noteTitle } from './paths' import { noteTitle } from './paths'
import Sidebar from './Sidebar.vue' import Sidebar from './Sidebar.vue'
import TabBar from './TabBar.vue' import TabBar from './TabBar.vue'
@@ -67,5 +69,7 @@ onBeforeUnmount(() => {
</main> </main>
<QuickSwitcher v-if="ws.switcher !== null" /> <QuickSwitcher v-if="ws.switcher !== null" />
<TrashDialog v-if="ws.trashOpen" @close="ws.trashOpen = false" /> <TrashDialog v-if="ws.trashOpen" @close="ws.trashOpen = false" />
<ShareDialog v-if="ws.shareDialog" :path="ws.shareDialog.path" @close="ws.shareDialog = null" />
<TokensDialog v-if="ws.tokensOpen" @close="ws.tokensOpen = false" />
</div> </div>
</template> </template>
+33
View File
@@ -21,6 +21,26 @@ export interface SearchResult {
snippet: string 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 { export class ApiError extends Error {
constructor( constructor(
readonly status: number, 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 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<Share>(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, trash: async () => (await json<{ items: TrashItem[] }>(request('GET', '/api/v1/trash'))).items,
restore: (id: string) => json<TrashItem>(request('POST', `/api/v1/trash/${id}/restore`)), restore: (id: string) => json<TrashItem>(request('POST', `/api/v1/trash/${id}/restore`)),
purge: (id: string) => request('DELETE', `/api/v1/trash/${id}`), purge: (id: string) => request('DELETE', `/api/v1/trash/${id}`),
+24
View File
@@ -354,6 +354,30 @@ button {
} }
.rm-dialog-head { display: flex; align-items: center; padding: 14px 16px 8px 20px; } .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-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 { 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 { 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); } .rm-trash-list li:hover { background: var(--rm-hover); }
+6
View File
@@ -46,6 +46,9 @@ export const ws = reactive({
// 快速切换/命令面板的初始输入,null 为关闭;以 > 开头时列出命令 // 快速切换/命令面板的初始输入,null 为关闭;以 > 开头时列出命令
switcher: null as string | null, switcher: null as string | null,
trashOpen: false, trashOpen: false,
// 分享对话框:path 为笔记路径,null 为全部分享的管理列表
shareDialog: null as { path: string | null } | null,
tokensOpen: false,
// 最近打开的笔记,最新的在前 // 最近打开的笔记,最新的在前
recent: JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as string[], recent: JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as string[],
toasts: [] as Toast[], toasts: [] as Toast[],
@@ -758,6 +761,9 @@ function registerBuiltinCommands() {
// 浏览器不允许网页覆盖 Ctrl+W // 浏览器不允许网页覆盖 Ctrl+W
{ id: 'tab.close', title: '关闭当前标签页', when: hasNote, run: () => void closeTab(ws.active!) }, { id: 'tab.close', title: '关闭当前标签页', when: hasNote, run: () => void closeTab(ws.active!) },
{ id: 'app.trash', title: '打开回收站', run: () => (ws.trashOpen = true) }, { 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) registerCommand(cmd)
} }
+1 -1
View File
@@ -52,7 +52,7 @@ export class HorizontalRuleWidget extends WidgetType {
} }
// 写入剪贴板;Clipboard API 只在 HTTPS/localhost 可用,局域网 HTTP 访问时退回 execCommand // 写入剪贴板;Clipboard API 只在 HTTPS/localhost 可用,局域网 HTTP 访问时退回 execCommand
async function copyText(text: string): Promise<boolean> { export async function copyText(text: string): Promise<boolean> {
if (navigator.clipboard && window.isSecureContext) { if (navigator.clipboard && window.isSecureContext) {
try { try {
await navigator.clipboard.writeText(text) await navigator.clipboard.writeText(text)
+2 -2
View File
@@ -58,7 +58,7 @@ describe('安全模式', () => {
describe('Typora 扩展', () => { describe('Typora 扩展', () => {
it('高亮、上下标、脚注引用', () => { it('高亮、上下标、脚注引用', () => {
expect(renderMarkdown('==a== x^2^ H~2~O[^1]')).toBe('<p><mark>a</mark> x<sup>2</sup> H<sub>2</sub>O<sup class="footnote-ref">1</sup></p>\n') expect(renderMarkdown('==a== x^2^ H~2~O[^1]')).toBe('<p><mark>a</mark> x<sup>2</sup> H<sub>2</sub>O<sup class="footnote-ref"><a href="#fn-1">1</a></sup></p>\n')
}) })
it('公式与 emoji 输出待填充的占位元素,内容被转义', () => { it('公式与 emoji 输出待填充的占位元素,内容被转义', () => {
expect(renderMarkdown('$a<b$ :smile:')).toBe('<p><span data-math="a&lt;b">a&lt;b</span> <span data-emoji="smile">:smile:</span></p>\n') expect(renderMarkdown('$a<b$ :smile:')).toBe('<p><span data-math="a&lt;b">a&lt;b</span> <span data-emoji="smile">:smile:</span></p>\n')
@@ -67,7 +67,7 @@ describe('Typora 扩展', () => {
) )
}) })
it('脚注定义、front matter 与 [TOC]', () => { it('脚注定义、front matter 与 [TOC]', () => {
expect(renderMarkdown('---\ntitle: x\n---\n[TOC]\n\n[^1]: 说明 **重点**')).toBe('<div class="footnote"><sup>1</sup> 说明 <strong>重点</strong></div>\n') expect(renderMarkdown('---\ntitle: x\n---\n[TOC]\n\n[^1]: 说明 **重点**')).toBe('<nav class="toc"></nav>\n<div class="footnote" id="fn-1"><sup>1</sup> 说明 <strong>重点</strong></div>\n')
}) })
}) })
+12 -4
View File
@@ -19,6 +19,8 @@ interface LinkDefinition {
const BLOCK_CONTAINERS = new Set(['Blockquote', 'ListItem']) const BLOCK_CONTAINERS = new Set(['Blockquote', 'ListItem'])
const footnoteId = (label: string) => 'fn-' + label.toLowerCase().replace(/\s+/g, '-')
export function escapeHtml(s: string): string { export function escapeHtml(s: string): string {
return s.replace(/[&<>"]/g, (c) => (c === '&' ? '&amp;' : c === '<' ? '&lt;' : c === '>' ? '&gt;' : '&quot;')) return s.replace(/[&<>"]/g, (c) => (c === '&' ? '&amp;' : c === '<' ? '&lt;' : c === '>' ? '&gt;' : '&quot;'))
} }
@@ -275,8 +277,10 @@ export function renderMarkdown(src: string, { parser = markdownParser, unsafe =
const raw = text(node.from, node.to) const raw = text(node.from, node.to)
return `<span data-emoji="${escapeHtml(raw.slice(1, -1))}">${escapeHtml(raw)}</span>` return `<span data-emoji="${escapeHtml(raw.slice(1, -1))}">${escapeHtml(raw)}</span>`
} }
case 'FootnoteReference': case 'FootnoteReference': {
return `<sup class="footnote-ref">${escapeHtml(text(node.from + 2, node.to - 1))}</sup>` const label = text(node.from + 2, node.to - 1)
return `<sup class="footnote-ref"><a href="#${escapeAttr(footnoteId(label))}">${escapeHtml(label)}</a></sup>`
}
case 'InlineCode': { case 'InlineCode': {
const marks = node.getChildren('CodeMark') const marks = node.getChildren('CodeMark')
let code = text(marks[0].to, marks[marks.length - 1].from).replace(/\n/g, ' ') 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': { case 'FootnoteDefinition': {
const label = node.getChild('FootnoteLabel')! const label = node.getChild('FootnoteLabel')!
return `<div class="footnote"><sup>${escapeHtml(text(label.from + 2, label.to - 2))}</sup> ${inline(node, label.to, node.to).trim()}</div>\n` const name = text(label.from + 2, label.to - 2)
return `<div class="footnote" id="${escapeAttr(footnoteId(name))}"><sup>${escapeHtml(name)}</sup> ${inline(node, label.to, node.to).trim()}</div>\n`
} }
// front matter 是元数据,不输出;[TOC] 在只读分享页面(M4)中生成 // 目录占位,由分享页根据标题填充
case 'TableOfContents':
return '<nav class="toc"></nav>\n'
// front matter 是元数据,不输出
default: default:
return '' return ''
} }
+160
View File
@@ -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 = `
<p>这篇分享需要访问密码</p>
<input type="password" autocomplete="current-password" required aria-label="访问密码" />
<p class="rm-share-error"></p>
<button type="submit">查看</button>`
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<HTMLElement>('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<HTMLElement>('h1, h2, h3, h4, h5, h6')]
const used = new Set<string>()
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()
+112
View File
@@ -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; }
}
+15 -3
View File
@@ -2,11 +2,23 @@ import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vitest/config' import { defineConfig } from 'vitest/config'
export default defineConfig({ 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: { build: {
rolldownOptions: { rolldownOptions: {
// demo.html 为免登录的编辑器演示页,供自动化性能/交互测试使用 // demo.html 为免登录的编辑器演示页,供自动化性能/交互测试使用;share.html 为只读分享页(/s/{token})
input: { index: 'index.html', demo: 'demo.html' }, input: { index: 'index.html', demo: 'demo.html', share: 'share.html' },
}, },
}, },
worker: { format: 'es' }, worker: { format: 'es' },