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:
1 parent
7b076346a4
commit
5ac01894e6
26 files changed
+3146
-51
No files matched your search
@@ -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`。
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
|
||||
+2
-1
@@ -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()),
|
||||
|
||||
+98
-26
@@ -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 _, 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)
|
||||
}
|
||||
for pattern, h := range protected {
|
||||
mux.Handle(pattern, s.requireAuth(h))
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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[:])
|
||||
|
||||
+133
-1
@@ -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
|
||||
}
|
||||
+27
-11
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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 }>()
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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) {
|
||||
<button @click="runMenu('folder')"><Icon name="folderAdd" />新建文件夹</button>
|
||||
</template>
|
||||
<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>
|
||||
</div>
|
||||
<div v-if="menuOpen" class="rm-menu-backdrop" @click.stop="menuOpen = false" @contextmenu.prevent.stop="menuOpen = false" />
|
||||
|
||||
@@ -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(() => {
|
||||
</main>
|
||||
<QuickSwitcher v-if="ws.switcher !== null" />
|
||||
<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>
|
||||
</template>
|
||||
@@ -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<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,
|
||||
restore: (id: string) => json<TrashItem>(request('POST', `/api/v1/trash/${id}/restore`)),
|
||||
purge: (id: string) => request('DELETE', `/api/v1/trash/${id}`),
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ export class HorizontalRuleWidget extends WidgetType {
|
||||
}
|
||||
|
||||
// 写入剪贴板;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) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
|
||||
@@ -58,7 +58,7 @@ describe('安全模式', () => {
|
||||
|
||||
describe('Typora 扩展', () => {
|
||||
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 输出待填充的占位元素,内容被转义', () => {
|
||||
expect(renderMarkdown('$a<b$ :smile:')).toBe('<p><span data-math="a<b">a<b</span> <span data-emoji="smile">:smile:</span></p>\n')
|
||||
@@ -67,7 +67,7 @@ describe('Typora 扩展', () => {
|
||||
)
|
||||
})
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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 `<span data-emoji="${escapeHtml(raw.slice(1, -1))}">${escapeHtml(raw)}</span>`
|
||||
}
|
||||
case 'FootnoteReference':
|
||||
return `<sup class="footnote-ref">${escapeHtml(text(node.from + 2, node.to - 1))}</sup>`
|
||||
case 'FootnoteReference': {
|
||||
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': {
|
||||
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 `<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:
|
||||
return ''
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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' },
|
||||
|
||||
Reference in new issue
Block a user