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

+99 -27
View File
@@ -17,6 +17,7 @@ import (
"rawmark/internal/auth"
"rawmark/internal/events"
"rawmark/internal/search"
"rawmark/internal/store"
"rawmark/internal/vault"
)
@@ -30,41 +31,82 @@ const (
type Server struct {
vault *vault.Vault
auth *auth.Manager
store *store.Store
hub *events.Hub
index *search.Index
// OnSetup 在首次初始化完成后调用(用于删除启动码文件)
OnSetup func()
}
func New(v *vault.Vault, a *auth.Manager, hub *events.Hub, index *search.Index) *Server {
return &Server{vault: v, auth: a, hub: hub, index: index}
func New(v *vault.Vault, a *auth.Manager, st *store.Store, hub *events.Hub, index *search.Index) *Server {
return &Server{vault: v, auth: a, store: st, hub: hub, index: index}
}
// access 为接口的访问要求
type access int
const (
public access = iota
// 登录会话或 API 令牌(只读令牌只能用 GET)
authed
// 只允许登录会话:管理分享与 API 令牌,避免令牌给自己扩权
sessionOnly
)
type route struct {
pattern string
access access
handler http.HandlerFunc
}
// routes 为全部接口;openapi.json 须与之一致(见 TestOpenAPICoversRoutes)
func (s *Server) routes() []route {
return []route{
{"GET /api/v1/health", public, func(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]bool{"ok": true}) }},
{"GET /api/v1/openapi.json", public, serveOpenAPI},
{"GET /api/v1/auth/state", public, s.authState},
{"POST /api/v1/auth/setup", public, s.setup},
{"POST /api/v1/auth/login", public, s.login},
{"POST /api/v1/auth/logout", public, s.logout},
{"GET /api/v1/tree", authed, s.tree},
{"GET /api/v1/files/{path...}", authed, s.readFile},
{"PUT /api/v1/files/{path...}", authed, s.writeFile},
{"DELETE /api/v1/files/{path...}", authed, s.deleteFile},
{"POST /api/v1/move", authed, s.move},
{"POST /api/v1/folders", authed, s.mkdir},
{"POST /api/v1/assets", authed, s.uploadAsset},
{"GET /api/v1/raw/{path...}", authed, s.raw},
{"GET /api/v1/trash", authed, s.trashList},
{"POST /api/v1/trash/{id}/restore", authed, s.trashRestore},
{"DELETE /api/v1/trash/{id}", authed, s.trashPurge},
{"GET /api/v1/events", authed, s.events},
{"GET /api/v1/search", authed, s.search},
{"GET /api/v1/shares", sessionOnly, s.listShares},
{"POST /api/v1/shares", sessionOnly, s.createShare},
{"DELETE /api/v1/shares/{token}", sessionOnly, s.deleteShare},
{"GET /api/v1/tokens", sessionOnly, s.listTokens},
{"POST /api/v1/tokens", sessionOnly, s.createToken},
{"DELETE /api/v1/tokens/{id}", sessionOnly, s.deleteToken},
{"GET /api/v1/public/shares/{token}", public, s.publicShare},
{"POST /api/v1/public/shares/{token}/unlock", public, s.unlockShare},
{"GET /api/v1/public/shares/{token}/assets/{path...}", public, s.publicShareAsset},
}
}
func (s *Server) Handler(static fs.FS) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/health", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]bool{"ok": true}) })
mux.HandleFunc("GET /api/v1/auth/state", s.authState)
mux.HandleFunc("POST /api/v1/auth/setup", s.setup)
mux.HandleFunc("POST /api/v1/auth/login", s.login)
mux.HandleFunc("POST /api/v1/auth/logout", s.logout)
protected := map[string]http.HandlerFunc{
"GET /api/v1/tree": s.tree,
"GET /api/v1/files/{path...}": s.readFile,
"PUT /api/v1/files/{path...}": s.writeFile,
"DELETE /api/v1/files/{path...}": s.deleteFile,
"POST /api/v1/move": s.move,
"POST /api/v1/folders": s.mkdir,
"POST /api/v1/assets": s.uploadAsset,
"GET /api/v1/raw/{path...}": s.raw,
"GET /api/v1/trash": s.trashList,
"POST /api/v1/trash/{id}/restore": s.trashRestore,
"DELETE /api/v1/trash/{id}": s.trashPurge,
"GET /api/v1/events": s.events,
"GET /api/v1/search": s.search,
}
for pattern, h := range protected {
mux.Handle(pattern, s.requireAuth(h))
for _, rt := range s.routes() {
var h http.Handler = rt.handler
switch rt.access {
case authed:
h = s.requireAuth(h, true)
case sessionOnly:
h = s.requireAuth(h, false)
}
mux.Handle(rt.pattern, h)
}
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { writeError(w, 404, "接口不存在") })
mux.Handle("/", newStaticHandler(static))
@@ -99,8 +141,30 @@ func sameOrigin(next http.Handler) http.Handler {
})
}
func (s *Server) requireAuth(next http.Handler) http.Handler {
// requireAuth 校验登录会话;allowToken 时也接受 Authorization: Bearer 的 API 令牌
func (s *Server) requireAuth(next http.Handler, allowToken bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if bearer, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer "); ok {
if !allowToken {
writeError(w, 403, "此接口只能在登录后使用,不接受 API 令牌")
return
}
scope, valid, err := s.auth.ValidateAPIToken(strings.TrimSpace(bearer))
if err != nil {
internalError(w, r, err)
return
}
if !valid {
writeError(w, 401, "API 令牌无效")
return
}
if scope != "write" && r.Method != http.MethodGet && r.Method != http.MethodHead {
writeError(w, 403, "只读令牌不能修改数据")
return
}
next.ServeHTTP(w, r)
return
}
c, err := r.Cookie(cookieName)
if err != nil {
writeError(w, 401, "未登录")
@@ -275,6 +339,10 @@ func (s *Server) move(w http.ResponseWriter, r *http.Request) {
vaultError(w, r, err)
return
}
if err := s.store.MoveShares(body.From, body.To); err != nil {
internalError(w, r, err)
return
}
writeJSON(w, 200, map[string]bool{"ok": true})
}
@@ -309,7 +377,11 @@ func (s *Server) uploadAsset(w http.ResponseWriter, r *http.Request) {
// raw 输出仓库内任意非隐藏文件(主要是图片)。以沙箱 CSP 返回,防止 SVG/HTML 中的脚本在本站执行
func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
f, info, err := s.vault.OpenRaw(r.PathValue("path"))
s.serveRaw(w, r, r.PathValue("path"))
}
func (s *Server) serveRaw(w http.ResponseWriter, r *http.Request, rel string) {
f, info, err := s.vault.OpenRaw(rel)
if err != nil {
vaultError(w, r, err)
return
+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
}
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)
}
+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
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
View File
@@ -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
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) }
// 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 {