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
+3147
-52
No files matched your search
+99
-27
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user