From 1b2b2b12ff8b84413f286d095318c2276d5382d9 Mon Sep 17 00:00:00 2001 From: eezd Date: Sun, 25 Oct 2026 20:42:20 +0800 Subject: [PATCH] feat(api): require bearer authentication and unify response contracts --- deploy/nginx/raw.134333.xyz.conf | 17 +- go.mod | 1 + go.sum | 2 + internal/api/api.go | 141 +--- internal/api/api_test.go | 102 ++- internal/api/authentication.go | 77 ++ internal/api/authentication_test.go | 215 +++++ internal/api/captcha.go | 125 +++ internal/api/events.go | 15 +- internal/api/export_notes_test.go | 4 +- internal/api/import_notes_test.go | 6 +- internal/api/maintenance.go | 5 +- internal/api/maintenance_test.go | 35 +- internal/api/maintenance_uploads.go | 5 +- internal/api/openapi.json | 1192 +++++++++++++++++++++++---- internal/api/protocol.go | 44 + internal/api/resources.go | 114 +++ internal/api/settings.go | 9 +- internal/api/storage_test.go | 4 +- 19 files changed, 1799 insertions(+), 314 deletions(-) create mode 100644 internal/api/authentication.go create mode 100644 internal/api/authentication_test.go create mode 100644 internal/api/captcha.go create mode 100644 internal/api/protocol.go create mode 100644 internal/api/resources.go diff --git a/deploy/nginx/raw.134333.xyz.conf b/deploy/nginx/raw.134333.xyz.conf index 8bbebfd..07efea3 100644 --- a/deploy/nginx/raw.134333.xyz.conf +++ b/deploy/nginx/raw.134333.xyz.conf @@ -1,3 +1,12 @@ +# 票据属于资源凭据,日志保留资源路径但不记录明文票据。 +map $uri $rawmark_log_path { + ~^/api/v1/resources/[^/]+(?/.*)$ /api/v1/resources/[redacted]$rawmark_resource_suffix; + default $uri; +} +log_format rawmark '$remote_addr - $remote_user [$time_local] ' + '"$request_method $rawmark_log_path $server_protocol" $status $body_bytes_sent ' + '"$http_user_agent"'; + # raw.134333.xyz -> RawMark 容器(127.0.0.1:21000) server { @@ -12,7 +21,7 @@ server { include /etc/nginx/snippets/ssl-134333.xyz.conf; include /etc/nginx/snippets/no-spider.conf; - access_log /var/log/nginx/raw.134333.xyz.log; + access_log /var/log/nginx/raw.134333.xyz.log rawmark; error_log /var/log/nginx/raw.134333.xyz.error.log warn; location / { @@ -26,5 +35,11 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_read_timeout 1h; + + # Nginx 错误日志不能脱敏请求行;票据路径只保留脱敏访问日志和应用错误日志。 + location ^~ /api/v1/resources/ { + proxy_pass http://127.0.0.1:21000; + error_log /dev/null; + } } } diff --git a/go.mod b/go.mod index 5d27efc..b16ff15 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.27.2 require ( github.com/coder/websocket v1.8.15 + github.com/dchest/captcha v1.1.0 github.com/fsnotify/fsnotify v1.10.1 github.com/yuin/goldmark/v2 v2.1.8 go.yaml.in/yaml/v3 v3.0.5 diff --git a/go.sum b/go.sum index 3adc103..00e3a4f 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/dchest/captcha v1.1.0 h1:2kt47EoYUUkaISobUdTbqwx55xvKOJxyScVfw25xzhQ= +github.com/dchest/captcha v1.1.0/go.mod h1:7zoElIawLp7GUMLcj54K9kbw+jEyvz2K0FDdRRYhvWo= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= diff --git a/internal/api/api.go b/internal/api/api.go index c80fef3..5481b57 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -15,7 +15,6 @@ import ( "strings" "sync" "sync/atomic" - "time" "rawmark/internal/auth" "rawmark/internal/events" @@ -25,18 +24,18 @@ import ( ) const ( - cookieName = "rawmark_session" maxNoteBytes = 20 << 20 maxAssetBytes = 50 << 20 maxJSONBytes = 1 << 20 ) type Server struct { - vault *vault.Vault - auth *auth.Manager - store *store.Store - hub *events.Hub - index *search.Index + vault *vault.Vault + auth *auth.Manager + captchas *captchaManager + store *store.Store + hub *events.Hub + index *search.Index // 串行化设置的"读取-合并-写入" settingsMu sync.Mutex // ThemesDir 指向数据目录中的主题目录;为空时只有内置主题。 @@ -59,7 +58,7 @@ type Server struct { } 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} + return &Server{vault: v, auth: a, store: st, hub: hub, index: index, captchas: newCaptchas()} } // access 为接口的访问要求 @@ -87,7 +86,11 @@ func (s *Server) routes() []route { {"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}, + {"POST /api/v1/auth/logout", sessionOnly, s.logout}, + {"GET /api/v1/auth/me", sessionOnly, s.me}, + {"POST /api/v1/auth/captcha", public, s.createCaptcha}, + {"POST /api/v1/auth/tickets", sessionOnly, s.createTicket}, + {"GET /api/v1/resources/{ticket}/{kind}/{path...}", public, s.resource}, {"POST /api/v1/auth/password", sessionOnly, s.changePassword}, {"GET /api/v1/tree", authed, s.tree}, @@ -173,13 +176,13 @@ func securityHeaders(next http.Handler) http.Handler { h.Set("Cache-Control", "no-store") } h.Set("X-Content-Type-Options", "nosniff") - h.Set("Referrer-Policy", "same-origin") + h.Set("Referrer-Policy", "no-referrer") h.Set("X-Frame-Options", "DENY") next.ServeHTTP(w, r) }) } -// sameOrigin 拒绝跨站的写请求(CSRF 防护的第二道防线,第一道是 SameSite=Strict Cookie)。 +// sameOrigin 保持写请求的同源边界;Bearer 鉴权不依赖浏览器 Cookie。 // 没有 Origin 头的请求来自非浏览器客户端,放行 func sameOrigin(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -195,48 +198,6 @@ func sameOrigin(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 && !(r.Method == http.MethodPost && r.URL.Path == "/api/v1/export") { - writeError(w, 403, "只读令牌不能修改数据") - return - } - next.ServeHTTP(w, r) - return - } - c, err := r.Cookie(cookieName) - if err != nil { - writeError(w, 401, "未登录") - return - } - ok, err := s.auth.Validate(c.Value) - if err != nil { - internalError(w, r, err) - return - } - if !ok { - writeError(w, 401, "登录已过期") - return - } - next.ServeHTTP(w, r) - }) -} - // ---- 鉴权 ---- func (s *Server) authState(w http.ResponseWriter, r *http.Request) { @@ -245,14 +206,7 @@ func (s *Server) authState(w http.ResponseWriter, r *http.Request) { internalError(w, r, err) return } - authed := false - if c, err := r.Cookie(cookieName); err == nil { - if authed, err = s.auth.Validate(c.Value); err != nil { - internalError(w, r, err) - return - } - } - writeJSON(w, 200, map[string]bool{"setUp": setUp, "authenticated": authed}) + writeJSON(w, 200, map[string]bool{"setUp": setUp}) } func (s *Server) setup(w http.ResponseWriter, r *http.Request) { @@ -268,50 +222,55 @@ func (s *Server) setup(w http.ResponseWriter, r *http.Request) { case err != nil: internalError(w, r, err) default: - s.startSession(w, r, body.Password) + s.startSession(w, r, body.Password, nil) } } func (s *Server) login(w http.ResponseWriter, r *http.Request) { - var body struct{ Password string } + var body struct { + Password string + CaptchaID string `json:"captchaId"` + CaptchaCode string `json:"captchaCode"` + } if !readJSON(w, r, &body) { return } - s.startSession(w, r, body.Password) + s.startSession(w, r, body.Password, func() error { return s.captchas.consume(clientIP(r), body.CaptchaID, body.CaptchaCode) }) } -func (s *Server) startSession(w http.ResponseWriter, r *http.Request, password string) { - token, err := s.auth.Login(clientIP(r), password) +func (s *Server) startSession(w http.ResponseWriter, r *http.Request, password string, challenge func() error) { + token, err := s.auth.AttemptLogin(clientIP(r), password, challenge) switch { case errors.Is(err, auth.ErrRateLimited): writeError(w, 429, err.Error()) + case errors.Is(err, errCaptchaInvalid): + writeFailure(w, 400, CodeCaptchaInvalid, err.Error(), nil) + case errors.Is(err, errCaptchaExpired): + writeFailure(w, 400, CodeCaptchaExpired, err.Error(), nil) case errors.Is(err, auth.ErrBadCredentials): - writeError(w, 401, err.Error()) + writeFailure(w, 401, CodeBadCredentials, err.Error(), nil) case err != nil: internalError(w, r, err) default: - http.SetCookie(w, &http.Cookie{ - Name: cookieName, - Value: token, - Path: "/", - MaxAge: int(auth.SessionTTL / 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}) + expires, _, err := s.auth.SessionExpiry(token) + if err != nil { + internalError(w, r, err) + return + } + writeJSON(w, 200, map[string]any{"token": token, "expiresAt": expires}) } } func (s *Server) logout(w http.ResponseWriter, r *http.Request) { - if c, err := r.Cookie(cookieName); err == nil { - if err := s.auth.Logout(c.Value); err != nil { - internalError(w, r, err) - return - } + if err := s.auth.Logout(identity(r).Token); err != nil { + internalError(w, r, err) + return } - http.SetCookie(w, &http.Cookie{Name: cookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteStrictMode}) - writeJSON(w, 200, map[string]bool{"ok": true}) + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) me(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 200, map[string]any{"permissions": []string{"vault:read", "vault:write", "instance:manage"}, "expiresAt": identity(r).ExpiresAt}) } // ---- 文件 ---- @@ -551,18 +510,8 @@ func readJSON(w http.ResponseWriter, r *http.Request, v any) bool { return true } -func writeJSON(w http.ResponseWriter, status int, v any) { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(status) - json.NewEncoder(w).Encode(v) -} - -func writeError(w http.ResponseWriter, status int, msg string) { - writeJSON(w, status, map[string]string{"error": msg}) -} - func internalError(w http.ResponseWriter, r *http.Request, err error) { - slog.Error("请求处理失败", "method", r.Method, "path", r.URL.Path, "err", err) + slog.Error("请求处理失败", "method", r.Method, "path", logPath(r.URL.Path), "err", err) writeError(w, 500, "服务器内部错误") } @@ -570,7 +519,7 @@ func vaultError(w http.ResponseWriter, r *http.Request, err error) { var conflict *vault.ConflictError switch { case errors.As(err, &conflict): - writeJSON(w, 412, map[string]string{"error": err.Error(), "rev": conflict.Rev}) + writeFailure(w, 412, CodeConflict, err.Error(), map[string]string{"rev": conflict.Rev}) case errors.Is(err, vault.ErrInvalidPath), errors.Is(err, vault.ErrInvalidImport), errors.Is(err, vault.ErrInvalidIcon): writeError(w, 400, err.Error()) case errors.Is(err, vault.ErrNotFound): diff --git a/internal/api/api_test.go b/internal/api/api_test.go index f88347e..360f6bb 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -7,12 +7,12 @@ import ( "errors" "io" "net/http" - "net/http/cookiejar" "net/http/httptest" "os" "path/filepath" "regexp" "strings" + "sync" "testing" "testing/fstest" "time" @@ -27,11 +27,12 @@ import ( ) type fixture struct { - t *testing.T - url string - root string - store *store.Store - hub *events.Hub + server *Server + t *testing.T + url string + root string + store *store.Store + hub *events.Hub // 已登录的客户端 admin *http.Client } @@ -71,7 +72,7 @@ func TestEventsRejectRevokedCredentials(t *testing.T) { headers := http.Header{} var tokenID string if kind == "session" { - f.do(client, "POST", "/api/v1/auth/login", `{"password":"password123"}`, nil).Body.Close() + f.login(client, "password123").Body.Close() } else { created := decode[struct { Token string @@ -134,16 +135,59 @@ func newFixture(t *testing.T) *fixture { server.FontsDir = filepath.Join(dir, "fonts") srv := httptest.NewServer(server.Handler(fstest.MapFS{})) t.Cleanup(srv.Close) - f := &fixture{t: t, url: srv.URL, root: v.Root(), store: st, hub: hub, admin: newClient()} - if res := f.do(f.admin, "POST", "/api/v1/auth/login", `{"password":"password123"}`, nil); res.StatusCode != 200 { + f := &fixture{t: t, url: srv.URL, root: v.Root(), store: st, hub: hub, admin: newClient(), server: server} + if res := f.login(f.admin, "password123"); res.StatusCode != 200 { t.Fatalf("登录失败: %d", res.StatusCode) } return f } -func newClient() *http.Client { - jar, _ := cookiejar.New(nil) - return &http.Client{Jar: jar} +type bearerTransport struct { + mu sync.Mutex + token string +} + +func (b *bearerTransport) RoundTrip(r *http.Request) (*http.Response, error) { + r = r.Clone(r.Context()) + b.mu.Lock() + token := b.token + b.mu.Unlock() + if token != "" && r.Header.Get("Authorization") == "" { + r.Header.Set("Authorization", "Bearer "+token) + } + return http.DefaultTransport.RoundTrip(r) +} +func newClient() *http.Client { return &http.Client{Transport: &bearerTransport{}} } +func setClientToken(c *http.Client, token string) { + b := c.Transport.(*bearerTransport) + b.mu.Lock() + b.token = token + b.mu.Unlock() +} +func (f *fixture) login(c *http.Client, password string) *http.Response { + challenge := decode[struct { + CaptchaID string `json:"captchaId"` + }](f.t, f.do(c, "POST", "/api/v1/auth/captcha", "{}", nil)) + f.server.captchas.mu.Lock() + digits := f.server.captchas.challenges[challenge.CaptchaID].digits + f.server.captchas.mu.Unlock() + code := make([]byte, len(digits)) + for i, digit := range digits { + code[i] = digit + '0' + } + body, _ := json.Marshal(map[string]string{"password": password, "captchaId": challenge.CaptchaID, "captchaCode": string(code)}) + res := f.do(c, "POST", "/api/v1/auth/login", string(body), nil) + if res.StatusCode == 200 { + content, _ := io.ReadAll(res.Body) + res.Body.Close() + res.Body = io.NopCloser(strings.NewReader(string(content))) + var result struct{ Data struct{ Token string } } + if err := json.Unmarshal(content, &result); err != nil { + f.t.Fatal(err) + } + setClientToken(c, result.Data.Token) + } + return res } func (f *fixture) write(p, content string) { @@ -180,11 +224,18 @@ func (f *fixture) status(c *http.Client, method, path, body string, headers map[ 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 { + var envelope struct { + Code int + Data T + Message string + } + if err := json.NewDecoder(res.Body).Decode(&envelope); err != nil { t.Fatal(err) } - return v + if envelope.Code != 0 { + t.Fatalf("业务请求失败:HTTP %d,code=%d,message=%s", res.StatusCode, envelope.Code, envelope.Message) + } + return envelope.Data } // 文档与实际接口一一对应:新增接口忘记写文档、文档中残留已删除的接口都会失败 @@ -335,9 +386,9 @@ func TestSettingsMerge(t *testing.T) { func TestChangePassword(t *testing.T) { f := newFixture(t) other := newClient() - f.do(other, "POST", "/api/v1/auth/login", `{"password":"password123"}`, nil) - if code := f.status(f.admin, "POST", "/api/v1/auth/password", `{"current":"wrong","next":"new-password"}`, nil); code != 401 { - t.Errorf("当前密码错误应为 401,得到 %d", code) + f.login(other, "password123") + if code := f.status(f.admin, "POST", "/api/v1/auth/password", `{"current":"wrong","next":"new-password"}`, nil); code != 400 { + t.Errorf("当前密码错误应为 400,得到 %d", code) } if code := f.status(f.admin, "POST", "/api/v1/auth/password", `{"current":"password123","next":"short"}`, nil); code != 400 { t.Errorf("新密码过短应为 400,得到 %d", code) @@ -351,10 +402,10 @@ func TestChangePassword(t *testing.T) { if code := f.status(other, "GET", "/api/v1/tree", "", nil); code != 401 { t.Errorf("其他会话应失效,得到 %d", code) } - if code := f.status(newClient(), "POST", "/api/v1/auth/login", `{"password":"password123"}`, nil); code != 401 { + if code := f.login(newClient(), "password123").StatusCode; code != 401 { t.Errorf("旧密码应失效,得到 %d", code) } - if code := f.status(newClient(), "POST", "/api/v1/auth/login", `{"password":"new-password"}`, nil); code != 200 { + if code := f.login(newClient(), "new-password").StatusCode; code != 200 { t.Errorf("新密码登录,得到 %d", code) } } @@ -368,7 +419,7 @@ func TestSetupWithoutCode(t *testing.T) { server := New(nil, auth.New(st), st, nil, nil) srv := httptest.NewServer(server.Handler(fstest.MapFS{})) t.Cleanup(srv.Close) - f := &fixture{t: t, url: srv.URL} + f := &fixture{t: t, url: srv.URL, server: server} owner := newClient() before := decode[struct{ SetUp, Authenticated bool }](t, f.do(owner, "GET", "/api/v1/auth/state", "", nil)) if before.SetUp || before.Authenticated { @@ -377,11 +428,16 @@ func TestSetupWithoutCode(t *testing.T) { if status := f.status(owner, "POST", "/api/v1/auth/setup", `{"password":"short"}`, nil); status != 400 { t.Fatalf("弱密码应被拒绝: %d", status) } - if status := f.status(owner, "POST", "/api/v1/auth/setup", `{"password":"password123"}`, nil); status != 200 { + setup := f.do(owner, "POST", "/api/v1/auth/setup", `{"password":"password123"}`, nil) + if status := setup.StatusCode; status != 200 { t.Fatalf("无需启动码即可初始化: %d", status) } + setClientToken(owner, decode[struct{ Token string }](t, setup).Token) + if res := f.do(owner, "GET", "/api/v1/auth/me", "", nil); res.StatusCode != 200 { + t.Fatal("初始化未签发有效会话") + } after := decode[struct{ SetUp, Authenticated bool }](t, f.do(owner, "GET", "/api/v1/auth/state", "", nil)) - if !after.SetUp || !after.Authenticated { + if !after.SetUp { t.Fatal("初始化后应自动登录") } if status := f.status(newClient(), "POST", "/api/v1/auth/setup", `{"password":"replacement"}`, nil); status != 409 { diff --git a/internal/api/authentication.go b/internal/api/authentication.go new file mode 100644 index 0000000..dc064c3 --- /dev/null +++ b/internal/api/authentication.go @@ -0,0 +1,77 @@ +package api + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "net/http" + "strings" + "time" + + "rawmark/internal/auth" +) + +type principal struct { + Token string + SessionHash string + Scope string + ExpiresAt time.Time +} +type principalKey struct{} + +func identity(r *http.Request) principal { return r.Context().Value(principalKey{}).(principal) } +func withIdentity(r *http.Request, p principal) *http.Request { + return r.WithContext(context.WithValue(r.Context(), principalKey{}, p)) +} +func tokenHash(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} + +func (s *Server) requireAuth(next http.Handler, allowAPI bool) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + header := r.Header.Get("Authorization") + token, ok := strings.CutPrefix(header, "Bearer ") + if !ok || token == "" || strings.ContainsAny(token, " \t\r\n") { + writeError(w, 401, "请先登录") + return + } + p := principal{Token: token} + var valid bool + var err error + if strings.HasPrefix(token, auth.SessionPrefix) { + p.ExpiresAt, valid, err = s.auth.SessionExpiry(token) + p.SessionHash = tokenHash(token) + } else { + p.Scope, valid, err = s.auth.ValidateAPIToken(token) + } + if err != nil { + internalError(w, r, err) + return + } + if !valid { + writeError(w, 401, "登录凭据无效或已过期") + return + } + if p.SessionHash == "" { + if !allowAPI { + writeError(w, 403, "此接口需要登录会话") + return + } + if p.Scope != "write" && r.Method != http.MethodGet && r.Method != http.MethodHead && !(r.Method == http.MethodPost && r.URL.Path == "/api/v1/export") { + writeError(w, 403, "只读令牌不能修改数据") + return + } + } + next.ServeHTTP(w, withIdentity(r, p)) + }) +} + +func (s *Server) validPrincipal(p principal) (bool, error) { + if p.SessionHash != "" { + _, ok, err := s.store.SessionExpiry(p.SessionHash, time.Now()) + return ok, err + } + _, ok, err := s.auth.ValidateAPIToken(p.Token) + return ok, err +} diff --git a/internal/api/authentication_test.go b/internal/api/authentication_test.go new file mode 100644 index 0000000..4e45276 --- /dev/null +++ b/internal/api/authentication_test.go @@ -0,0 +1,215 @@ +package api + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" +) + +func challengeFor(t *testing.T, f *fixture) (string, string) { + t.Helper() + item := decode[struct{ CaptchaID, ImageDataURL string }](t, f.do(newClient(), "POST", "/api/v1/auth/captcha", "{}", nil)) + png, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(item.ImageDataURL, "data:image/png;base64,")) + if err != nil || !bytes.HasPrefix(png, []byte{137, 80, 78, 71, 13, 10, 26, 10}) { + t.Fatal("未返回真实PNG验证码") + } + f.server.captchas.mu.Lock() + digits := append([]byte(nil), f.server.captchas.challenges[item.CaptchaID].digits...) + f.server.captchas.mu.Unlock() + for i := range digits { + digits[i] += '0' + } + return item.CaptchaID, string(digits) +} +func loginBody(id, code, password string) string { + body, _ := json.Marshal(map[string]string{"captchaId": id, "captchaCode": code, "password": password}) + return string(body) +} +func TestCaptchaLoginLifecycle(t *testing.T) { + f := newFixture(t) + c := newClient() + if res := f.do(c, "POST", "/api/v1/auth/login", `{"password":"password123"}`, nil); res.StatusCode != 400 { + t.Fatal("缺失验证码未拒绝") + } + id, code := challengeFor(t, f) + bad := loginBody(id, "invalid", "password123") + if res := f.do(c, "POST", "/api/v1/auth/login", bad, nil); res.StatusCode != 400 { + t.Fatal("错误验证码未拒绝") + } + if res := f.do(c, "POST", "/api/v1/auth/login", loginBody(id, code, "password123"), nil); res.StatusCode != 400 { + t.Fatal("错误提交后挑战仍可复用") + } + id, code = challengeFor(t, f) + if res := f.do(c, "POST", "/api/v1/auth/login", loginBody(id, code, "wrong"), nil); res.StatusCode != 401 { + t.Fatal("密码错误状态不符") + } + if res := f.do(c, "POST", "/api/v1/auth/login", loginBody(id, code, "password123"), nil); res.StatusCode != 400 { + t.Fatal("密码失败后挑战仍可复用") + } + id, code = challengeFor(t, f) + f.server.captchas.mu.Lock() + item := f.server.captchas.challenges[id] + item.expires = time.Now().Add(-time.Second) + f.server.captchas.challenges[id] = item + f.server.captchas.mu.Unlock() + expired := f.do(c, "POST", "/api/v1/auth/login", loginBody(id, code, "password123"), nil) + var failure response + if err := json.NewDecoder(expired.Body).Decode(&failure); err != nil || expired.StatusCode != 400 || failure.Code != CodeCaptchaExpired { + t.Fatal("未区分过期挑战") + } + id, code = challengeFor(t, f) + refreshed := f.do(c, "POST", "/api/v1/auth/captcha", `{"replaceId":"`+id+`"}`, nil) + if refreshed.StatusCode != 200 { + t.Fatal("刷新失败") + } + if res := f.do(c, "POST", "/api/v1/auth/login", loginBody(id, code, "password123"), nil); res.StatusCode != 400 { + t.Fatal("刷新后旧挑战仍有效") + } + id, code = challengeFor(t, f) + good := f.do(c, "POST", "/api/v1/auth/login", loginBody(id, code, "password123"), nil) + data := decode[struct { + Token string + ExpiresAt time.Time + }](t, good) + if good.StatusCode != 200 || !strings.HasPrefix(data.Token, "rms_") || time.Until(data.ExpiresAt) < 29*24*time.Hour || len(good.Cookies()) != 0 { + t.Fatal("未返回固定有效期Bearer会话") + } + setClientToken(c, data.Token) + if f.status(c, "GET", "/api/v1/auth/me", "", nil) != 200 { + t.Fatal("身份恢复失败") + } + + req, _ := http.NewRequest("GET", f.url+"/api/v1/tree", nil) + req.AddCookie(&http.Cookie{Name: "rawmark_session", Value: data.Token}) + cookieRes, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer cookieRes.Body.Close() + if cookieRes.StatusCode != 401 { + t.Fatal("旧Cookie仍被接受") + } + if f.status(c, "POST", "/api/v1/auth/logout", "", nil) != 204 || f.status(c, "GET", "/api/v1/tree", "", nil) != 401 { + t.Fatal("服务端未撤销会话") + } +} + +func TestCaptchaSingleConsumptionAndLimits(t *testing.T) { + c := newCaptchas() + clock := time.Now() + c.now = func() time.Time { return clock } + id, item, err := c.create("source", "") + if err != nil { + t.Fatal(err) + } + digits := append([]byte(nil), item.digits...) + for i := range digits { + digits[i] += '0' + } + results := make(chan error, 2) + var wg sync.WaitGroup + for range 2 { + wg.Add(1) + go func() { defer wg.Done(); results <- c.consume("source", id, string(digits)) }() + } + wg.Wait() + close(results) + success := 0 + for err := range results { + if err == nil { + success++ + } + } + if success != 1 { + t.Fatal("挑战被重复消费") + } + for range 59 { + if _, _, err = c.create("source", ""); err != nil { + t.Fatal(err) + } + } + if _, _, err = c.create("source", ""); err == nil { + t.Fatal("签发未限流") + } + clock = clock.Add(time.Minute) + if _, _, err = c.create("source", ""); err != nil { + t.Fatal("限流窗口结束仍拒绝") + } +} + +func TestResourceTicketsScopePersistenceAndRevocation(t *testing.T) { + f := newFixture(t) + f.write("assets/image.png", "image") + f.write("private.md", "secret") + ticket := decode[struct{ URL string }](t, f.do(f.admin, "POST", "/api/v1/auth/tickets", `{"kind":"images"}`, nil)) + res := f.do(newClient(), "GET", ticket.URL+"assets/image.png", "", nil) + body, _ := io.ReadAll(res.Body) + if res.StatusCode != 200 || string(body) != "image" || res.Header.Get("Cache-Control") != "no-store" || res.Header.Get("Referrer-Policy") != "no-referrer" { + t.Fatal("图片票据无法安全加载") + } + if f.status(newClient(), "GET", ticket.URL+"private.md", "", nil) != 403 { + t.Fatal("图片票据越权读取笔记") + } + if f.status(newClient(), "GET", strings.Replace(ticket.URL, "/images/", "/fonts/", 1)+"x/file", "", nil) != 403 { + t.Fatal("票据用途越界") + } + token := strings.Split(ticket.URL, "/")[4] + stored, ok, err := f.store.Ticket(tokenHash(token), false, time.Now()) + if err != nil || !ok || stored.SessionHash == "" { + t.Fatal("票据未持久保存") + } + eventTicket := decode[struct{ URL string }](t, f.do(f.admin, "POST", "/api/v1/auth/tickets", `{"kind":"events"}`, nil)) + eventID := strings.Split(eventTicket.URL, "/")[4] + first, ok, err := f.store.Ticket(tokenHash(eventID), true, time.Now()) + if err != nil || !ok || time.Until(first.ExpiresAt) > time.Minute { + t.Fatal("事件票据不符") + } + if _, ok, err = f.store.Ticket(tokenHash(eventID), true, time.Now()); err != nil || ok { + t.Fatal("事件票据可复用") + } + f.do(f.admin, "POST", "/api/v1/auth/logout", "", nil) + if f.status(newClient(), "GET", ticket.URL+"assets/image.png", "", nil) != 401 { + t.Fatal("注销后资源票据仍有效") + } + if strings.Contains(logPath(ticket.URL), token) { + t.Fatal("日志泄露票据") + } +} + +func TestCaptchaSourceBinding(t *testing.T) { + c := newCaptchas() + id, _, err := c.create("one", "") + if err != nil { + t.Fatal(err) + } + if c.consume("two", id, "123456") == nil { + t.Fatal("挑战未绑定来源") + } +} + +func TestJSONEnvelopeAndConflict(t *testing.T) { + f := newFixture(t) + f.write("a.md", "original") + res := f.do(f.admin, "PUT", "/api/v1/files/a.md", "changed", map[string]string{"If-Match": "\"wrong\""}) + var result struct { + Code int + Data struct{ Rev string } + Message string + } + if err := json.NewDecoder(res.Body).Decode(&result); err != nil || res.StatusCode != 412 || result.Code != CodeConflict || result.Data.Rev == "" || result.Message == "" { + t.Fatal("冲突协议丢失版本或错误语义") + } + recorder := httptest.NewRecorder() + writeError(recorder, 403, "拒绝") + var failure response + if err := json.Unmarshal(recorder.Body.Bytes(), &failure); err != nil || failure.Code != 40300 || failure.Data != nil { + t.Fatal("错误外壳不符") + } +} diff --git a/internal/api/captcha.go b/internal/api/captcha.go new file mode 100644 index 0000000..92c7200 --- /dev/null +++ b/internal/api/captcha.go @@ -0,0 +1,125 @@ +package api + +import ( + "bytes" + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "errors" + "net/http" + "sync" + "time" + + "github.com/dchest/captcha" + "rawmark/internal/auth" +) + +var ( + errCaptchaInvalid = errors.New("验证码错误或已使用,请刷新后重试") + errCaptchaExpired = errors.New("验证码已过期,请刷新后重试") +) + +type loginChallenge struct { + digits []byte + source string + expires time.Time +} +type captchaManager struct { + mu sync.Mutex + challenges map[string]loginChallenge + issued map[string][]time.Time + now func() time.Time +} + +func newCaptchas() *captchaManager { + return &captchaManager{challenges: map[string]loginChallenge{}, issued: map[string][]time.Time{}, now: time.Now} +} + +func opaqueToken() string { + b := make([]byte, 32) + rand.Read(b) + return base64.RawURLEncoding.EncodeToString(b) +} + +func (c *captchaManager) create(source, replace string) (string, loginChallenge, error) { + c.mu.Lock() + defer c.mu.Unlock() + now := c.now() + for id, item := range c.challenges { + if !now.Before(item.expires) { + delete(c.challenges, id) + } + } + for ip, times := range c.issued { + recent := times[:0] + for _, t := range times { + if now.Sub(t) < time.Minute { + recent = append(recent, t) + } + } + if len(recent) == 0 { + delete(c.issued, ip) + } else { + c.issued[ip] = recent + } + } + if len(c.issued[source]) >= 60 || len(c.challenges) >= 10000 { + return "", loginChallenge{}, auth.ErrRateLimited + } + if previous, ok := c.challenges[replace]; ok && previous.source == source { + delete(c.challenges, replace) + } + id := opaqueToken() + item := loginChallenge{digits: captcha.RandomDigits(6), source: source, expires: now.Add(5 * time.Minute)} + c.challenges[id] = item + c.issued[source] = append(c.issued[source], now) + return id, item, nil +} + +func (c *captchaManager) consume(source, id, code string) error { + c.mu.Lock() + item, ok := c.challenges[id] + delete(c.challenges, id) + now := c.now() + c.mu.Unlock() + if !ok || item.source != source { + return errCaptchaInvalid + } + if !now.Before(item.expires) { + return errCaptchaExpired + } + if len(code) != 6 { + return errCaptchaInvalid + } + digits := []byte(code) + for i, v := range digits { + if v < '0' || v > '9' { + return errCaptchaInvalid + } + digits[i] = v - '0' + } + if subtle.ConstantTimeCompare(digits, item.digits) != 1 { + return errCaptchaInvalid + } + return nil +} + +func (s *Server) createCaptcha(w http.ResponseWriter, r *http.Request) { + var body struct { + ReplaceID string `json:"replaceId"` + } + if !readJSON(w, r, &body) { + return + } + id, item, err := s.captchas.create(clientIP(r), body.ReplaceID) + if err != nil { + writeError(w, 429, err.Error()) + return + } + var png bytes.Buffer + if _, err = captcha.NewImage(id, item.digits, 240, 80).WriteTo(&png); err != nil { + internalError(w, r, err) + return + } + writeJSON(w, 200, map[string]any{"captchaId": id, "imageDataUrl": "data:image/png;base64," + base64.StdEncoding.EncodeToString(png.Bytes()), "expiresAt": item.expires}) +} diff --git a/internal/api/events.go b/internal/api/events.go index 16c94e6..2018c0c 100644 --- a/internal/api/events.go +++ b/internal/api/events.go @@ -4,7 +4,6 @@ import ( "context" "log/slog" "net/http" - "strings" "time" "github.com/coder/websocket" @@ -23,17 +22,9 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { } defer conn.CloseNow() reauthorize := func() bool { - var valid bool - var err error - if bearer, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer "); ok { - _, valid, err = s.auth.ValidateAPIToken(strings.TrimSpace(bearer)) - } else { - var cookie *http.Cookie - cookie, err = r.Cookie(cookieName) - if err == nil { - valid, err = s.auth.Validate(cookie.Value) - } - } + principal := identity(r) + valid, err := s.validPrincipal(principal) + if err != nil { slog.Error("事件连接鉴权失败", "err", err) conn.Close(websocket.StatusInternalError, "鉴权失败") diff --git a/internal/api/export_notes_test.go b/internal/api/export_notes_test.go index 2f6b78d..ad136d8 100644 --- a/internal/api/export_notes_test.go +++ b/internal/api/export_notes_test.go @@ -60,8 +60,8 @@ func TestPortableNotesExcludeApplicationData(t *testing.T) { server.DataDir = data srv := httptest.NewServer(server.Handler(fstest.MapFS{})) defer srv.Close() - f := &fixture{t: t, url: srv.URL, root: root, store: st, admin: newClient()} - res := f.do(f.admin, "POST", "/api/v1/auth/login", `{"password":"disposable-export-password"}`, nil) + f := &fixture{t: t, url: srv.URL, root: root, store: st, admin: newClient(), server: server} + res := f.login(f.admin, "disposable-export-password") assertOK(t, res) res.Body.Close() f.write("note.md", "# portable note\n![](assets/a.png)") diff --git a/internal/api/import_notes_test.go b/internal/api/import_notes_test.go index bff4301..83f5fd6 100644 --- a/internal/api/import_notes_test.go +++ b/internal/api/import_notes_test.go @@ -3,7 +3,6 @@ package api import ( "archive/zip" "bytes" - "encoding/json" "io" "mime/multipart" "net/http" @@ -67,10 +66,7 @@ func TestImportNotesAPI(t *testing.T) { if res.StatusCode != 201 { t.Fatalf("导入失败: %d", res.StatusCode) } - var result vault.ImportResult - if err := json.NewDecoder(res.Body).Decode(&result); err != nil { - t.Fatal(err) - } + result := decode[vault.ImportResult](t, res) res.Body.Close() if result.Notes != 1 || len(result.Paths) != 1 || result.Paths[0] != "n.md" || result.Warnings == nil { t.Fatalf("响应不符: %+v", result) diff --git a/internal/api/maintenance.go b/internal/api/maintenance.go index a008336..54ba3d1 100644 --- a/internal/api/maintenance.go +++ b/internal/api/maintenance.go @@ -38,7 +38,10 @@ func (s *Server) guardAPI(next http.Handler) http.Handler { writeError(w, 503, "正在维护,请稍后重试") return } - if strings.HasPrefix(r.URL.Path, "/api/v1/maintenance/") || r.URL.Path == "/api/v1/events" { + resourcePath, resource := strings.CutPrefix(r.URL.Path, "/api/v1/resources/") + _, resourceTail, hasTicket := strings.Cut(resourcePath, "/") + eventResource := resource && hasTicket && resourceTail == "events/" + if strings.HasPrefix(r.URL.Path, "/api/v1/maintenance/") || r.URL.Path == "/api/v1/events" || eventResource { next.ServeHTTP(w, r) return } diff --git a/internal/api/maintenance_test.go b/internal/api/maintenance_test.go index 3b8e6f3..7e97e4f 100644 --- a/internal/api/maintenance_test.go +++ b/internal/api/maintenance_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "io" "net/http" + "net/http/httptest" "os" "path/filepath" "strconv" @@ -16,6 +17,38 @@ import ( "rawmark/internal/store" ) +func TestGuardAPIOnlyEventsBypassActivityLock(t *testing.T) { + for _, tc := range []struct { + path string + locked bool + }{ + {"/api/v1/events", false}, + {"/api/v1/resources/ticket/events/", false}, + {"/api/v1/resources/ticket/plugins/demo/assets/revision/events/lib.js", true}, + {"/api/v1/resources/ticket/images/events/picture.png", true}, + {"/api/v1/files/note.md", true}, + } { + t.Run(tc.path, func(t *testing.T) { + s := &Server{} + called := false + handler := s.guardAPI(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + lockFree := s.activity.TryLock() + if lockFree { + s.activity.Unlock() + } + if lockFree == tc.locked { + t.Errorf("activity lock: free=%v, want locked=%v", lockFree, tc.locked) + } + })) + handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", tc.path, nil)) + if !called { + t.Fatal("request was not served") + } + }) + } +} + func exportedPackage(t *testing.T, f *fixture, kind string) []byte { t.Helper() res := f.do(f.admin, "POST", "/api/v1/maintenance/export/"+kind, "", nil) @@ -186,7 +219,7 @@ func TestMaintenanceUploadsEnforceOwnershipOffsetsAndValidation(t *testing.T) { t.Fatal("允许校验未完成上传") } other := newClient() - assertOK(t, f.do(other, "POST", "/api/v1/auth/login", `{"password":"password123"}`, nil)) + assertOK(t, f.login(other, "password123")) if code := f.status(other, "DELETE", "/api/v1/maintenance/uploads/"+id, "", nil); code != 400 { t.Fatal("允许其他会话删除上传") } diff --git a/internal/api/maintenance_uploads.go b/internal/api/maintenance_uploads.go index db9b290..e851667 100644 --- a/internal/api/maintenance_uploads.go +++ b/internal/api/maintenance_uploads.go @@ -2,7 +2,6 @@ package api import ( "crypto/rand" - "crypto/sha256" "encoding/hex" "encoding/json" "errors" @@ -33,9 +32,7 @@ type maintenanceUpload struct { } func uploadOwner(r *http.Request) string { - cookie, _ := r.Cookie(cookieName) - sum := sha256.Sum256([]byte(cookie.Value)) - return hex.EncodeToString(sum[:]) + return identity(r).SessionHash } func (s *Server) readUpload(r *http.Request, id string) (maintenanceUpload, string, error) { var meta maintenanceUpload diff --git a/internal/api/openapi.json b/internal/api/openapi.json index a7ad2dd..6db68da 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "RawMark API", "version": "1", - "description": "RawMark 的 REST API。自动化调用请在「API 令牌」中创建令牌,以 `Authorization: Bearer ` 访问;只读令牌可使用 GET 和 POST /export(只读导出)。令牌的管理接口只接受登录会话。所有错误响应均为 `{\"error\": \"说明\"}`。" + "description": "RawMark单用户API。普通JSON统一{code,data,message},code=0成功;保留HTTP语义。登录会话固定30天;API令牌保留read/write权限。受保护API只接受Bearer,不接受Cookie。文件、下载、事件、204与OpenAPI文档不包装。" }, "servers": [ { @@ -13,9 +13,6 @@ "security": [ { "bearer": [] - }, - { - "cookie": [] } ], "paths": { @@ -34,7 +31,23 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Ok" + "type": "object", + "required": [ + "code", + "data", + "message" + ], + "properties": { + "code": { + "const": 0 + }, + "data": { + "$ref": "#/components/schemas/Ok" + }, + "message": { + "type": "string" + } + } } } } @@ -67,7 +80,7 @@ }, "/auth/state": { "get": { - "summary": "是否已初始化、当前是否已登录", + "summary": "是否已经初始化", "tags": [ "登录" ], @@ -76,17 +89,33 @@ ], "responses": { "200": { - "description": "状态", + "description": "成功", "content": { "application/json": { "schema": { "type": "object", + "required": [ + "code", + "data", + "message" + ], "properties": { - "setUp": { - "type": "boolean" + "code": { + "const": 0 }, - "authenticated": { - "type": "boolean" + "data": { + "type": "object", + "required": [ + "setUp" + ], + "properties": { + "setUp": { + "type": "boolean" + } + } + }, + "message": { + "type": "string" } } } @@ -98,7 +127,7 @@ }, "/auth/setup": { "post": { - "summary": "首次设置登录密码", + "summary": "首次设置密码并返回Bearer会话", "tags": [ "登录" ], @@ -111,14 +140,14 @@ "application/json": { "schema": { "type": "object", + "required": [ + "password" + ], "properties": { "password": { "type": "string" } - }, - "required": [ - "password" - ] + } } } } @@ -129,7 +158,36 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Ok" + "type": "object", + "required": [ + "code", + "data", + "message" + ], + "properties": { + "code": { + "const": 0 + }, + "data": { + "type": "object", + "required": [ + "token", + "expiresAt" + ], + "properties": { + "token": { + "type": "string" + }, + "expiresAt": { + "type": "string", + "format": "date-time" + } + } + }, + "message": { + "type": "string" + } + } } } } @@ -160,7 +218,7 @@ }, "/auth/login": { "post": { - "summary": "登录,成功后设置会话 Cookie", + "summary": "验证码及密码登录,返回Bearer会话", "tags": [ "登录" ], @@ -173,14 +231,22 @@ "application/json": { "schema": { "type": "object", + "required": [ + "password", + "captchaId", + "captchaCode" + ], "properties": { "password": { "type": "string" + }, + "captchaId": { + "type": "string" + }, + "captchaCode": { + "type": "string" } - }, - "required": [ - "password" - ] + } } } } @@ -191,7 +257,36 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Ok" + "type": "object", + "required": [ + "code", + "data", + "message" + ], + "properties": { + "code": { + "const": 0 + }, + "data": { + "type": "object", + "required": [ + "token", + "expiresAt" + ], + "properties": { + "token": { + "type": "string" + }, + "expiresAt": { + "type": "string", + "format": "date-time" + } + } + }, + "message": { + "type": "string" + } + } } } } @@ -215,6 +310,16 @@ } } } + }, + "400": { + "description": "验证码错误、已使用或过期(40001/40002)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } } } } @@ -226,18 +331,13 @@ "登录" ], "security": [ - {} + { + "bearer": [] + } ], "responses": { - "200": { - "description": "成功", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Ok" - } - } - } + "204": { + "description": "服务端会话已撤销" } } } @@ -250,7 +350,7 @@ ], "security": [ { - "cookie": [] + "bearer": [] } ], "requestBody": { @@ -281,7 +381,23 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Ok" + "type": "object", + "required": [ + "code", + "data", + "message" + ], + "properties": { + "code": { + "const": 0 + }, + "data": { + "$ref": "#/components/schemas/Ok" + }, + "message": { + "type": "string" + } + } } } } @@ -332,7 +448,23 @@ "application/json": { "schema": { "type": "object", - "additionalProperties": true + "required": [ + "code", + "data", + "message" + ], + "properties": { + "code": { + "const": 0 + }, + "data": { + "type": "object", + "additionalProperties": true + }, + "message": { + "type": "string" + } + } } } } @@ -346,7 +478,7 @@ ], "security": [ { - "cookie": [] + "bearer": [] } ], "description": "按顶层键合并:请求中的键覆盖原值,值为 null 的键被删除。成功后经 WebSocket 推送 settings 事件。 trashRetentionDays 为非负安全整数(默认30,0为永不自动删除),null恢复默认。期限适用于已有和新回收站条目,服务启动及每小时按原删除时间永久清理。", @@ -379,7 +511,23 @@ "application/json": { "schema": { "type": "object", - "additionalProperties": true + "required": [ + "code", + "data", + "message" + ], + "properties": { + "code": { + "const": 0 + }, + "data": { + "type": "object", + "additionalProperties": true + }, + "message": { + "type": "string" + } + } } } } @@ -420,12 +568,28 @@ "application/json": { "schema": { "type": "object", + "required": [ + "code", + "data", + "message" + ], "properties": { - "entries": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Entry" + "code": { + "const": 0 + }, + "data": { + "type": "object", + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Entry" + } + } } + }, + "message": { + "type": "string" } } } @@ -525,8 +689,24 @@ "application/json": { "schema": { "type": "object", + "required": [ + "code", + "data", + "message" + ], "properties": { - "rev": { + "code": { + "const": 0 + }, + "data": { + "type": "object", + "properties": { + "rev": { + "type": "string" + } + } + }, + "message": { "type": "string" } } @@ -568,13 +748,30 @@ "application/json": { "schema": { "type": "object", + "required": [ + "code", + "data", + "message" + ], "properties": { - "error": { - "type": "string" + "code": { + "type": "integer", + "minimum": 1 }, - "rev": { - "type": "string", - "description": "服务器上的当前版本;为空表示已不存在" + "data": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "rev": { + "type": "string", + "description": "服务器上的当前版本;为空表示已不存在" + } + } + }, + "message": { + "type": "string" } } } @@ -604,7 +801,23 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TrashItem" + "type": "object", + "required": [ + "code", + "data", + "message" + ], + "properties": { + "code": { + "const": 0 + }, + "data": { + "$ref": "#/components/schemas/TrashItem" + }, + "message": { + "type": "string" + } + } } } } @@ -657,7 +870,23 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Ok" + "type": "object", + "required": [ + "code", + "data", + "message" + ], + "properties": { + "code": { + "const": 0 + }, + "data": { + "$ref": "#/components/schemas/Ok" + }, + "message": { + "type": "string" + } + } } } } @@ -715,7 +944,23 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Ok" + "type": "object", + "required": [ + "code", + "data", + "message" + ], + "properties": { + "code": { + "const": 0 + }, + "data": { + "$ref": "#/components/schemas/Ok" + }, + "message": { + "type": "string" + } + } } } } @@ -784,8 +1029,24 @@ "application/json": { "schema": { "type": "object", + "required": [ + "code", + "data", + "message" + ], "properties": { - "link": { + "code": { + "const": 0 + }, + "data": { + "type": "object", + "properties": { + "link": { + "type": "string" + } + } + }, + "message": { "type": "string" } } @@ -850,8 +1111,24 @@ "application/json": { "schema": { "type": "object", + "required": [ + "code", + "data", + "message" + ], "properties": { - "link": { + "code": { + "const": 0 + }, + "data": { + "type": "object", + "properties": { + "link": { + "type": "string" + } + } + }, + "message": { "type": "string" } } @@ -978,12 +1255,28 @@ "application/json": { "schema": { "type": "object", + "required": [ + "code", + "data", + "message" + ], "properties": { - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SearchResult" + "code": { + "const": 0 + }, + "data": { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SearchResult" + } + } } + }, + "message": { + "type": "string" } } } @@ -1006,12 +1299,28 @@ "application/json": { "schema": { "type": "object", + "required": [ + "code", + "data", + "message" + ], "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TrashItem" + "code": { + "const": 0 + }, + "data": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TrashItem" + } + } } + }, + "message": { + "type": "string" } } } @@ -1032,7 +1341,23 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TrashPurgeResult" + "type": "object", + "required": [ + "code", + "data", + "message" + ], + "properties": { + "code": { + "const": 0 + }, + "data": { + "$ref": "#/components/schemas/TrashPurgeResult" + }, + "message": { + "type": "string" + } + } } } } @@ -1062,7 +1387,23 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TrashItem" + "type": "object", + "required": [ + "code", + "data", + "message" + ], + "properties": { + "code": { + "const": 0 + }, + "data": { + "$ref": "#/components/schemas/TrashItem" + }, + "message": { + "type": "string" + } + } } } } @@ -1112,7 +1453,23 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Ok" + "type": "object", + "required": [ + "code", + "data", + "message" + ], + "properties": { + "code": { + "const": 0 + }, + "data": { + "$ref": "#/components/schemas/Ok" + }, + "message": { + "type": "string" + } + } } } } @@ -1152,7 +1509,7 @@ ], "security": [ { - "cookie": [] + "bearer": [] } ], "responses": { @@ -1162,12 +1519,28 @@ "application/json": { "schema": { "type": "object", + "required": [ + "code", + "data", + "message" + ], "properties": { - "tokens": { - "type": "array", - "items": { - "$ref": "#/components/schemas/APIToken" + "code": { + "const": 0 + }, + "data": { + "type": "object", + "properties": { + "tokens": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIToken" + } + } } + }, + "message": { + "type": "string" } } } @@ -1183,7 +1556,7 @@ ], "security": [ { - "cookie": [] + "bearer": [] } ], "requestBody": { @@ -1219,12 +1592,28 @@ "application/json": { "schema": { "type": "object", + "required": [ + "code", + "data", + "message" + ], "properties": { - "token": { - "type": "string" + "code": { + "const": 0 }, - "info": { - "$ref": "#/components/schemas/APIToken" + "data": { + "type": "object", + "properties": { + "token": { + "type": "string" + }, + "info": { + "$ref": "#/components/schemas/APIToken" + } + } + }, + "message": { + "type": "string" } } } @@ -1262,7 +1651,7 @@ ], "security": [ { - "cookie": [] + "bearer": [] } ], "responses": { @@ -1271,7 +1660,23 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Ok" + "type": "object", + "required": [ + "code", + "data", + "message" + ], + "properties": { + "code": { + "const": 0 + }, + "data": { + "$ref": "#/components/schemas/Ok" + }, + "message": { + "type": "string" + } + } } } } @@ -1302,12 +1707,28 @@ "application/json": { "schema": { "type": "object", + "required": [ + "code", + "data", + "message" + ], "properties": { - "themes": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Theme" + "code": { + "const": 0 + }, + "data": { + "type": "object", + "properties": { + "themes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Theme" + } + } } + }, + "message": { + "type": "string" } } } @@ -1339,19 +1760,35 @@ "content": { "application/json": { "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/Theme" + "type": "object", + "required": [ + "code", + "data", + "message" + ], + "properties": { + "code": { + "const": 0 }, - { - "type": "object", - "properties": { - "css": { - "type": "string" + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/Theme" + }, + { + "type": "object", + "properties": { + "css": { + "type": "string" + } + } } - } + ] + }, + "message": { + "type": "string" } - ] + } } } } @@ -1370,7 +1807,7 @@ ], "security": [ { - "cookie": [] + "bearer": [] } ], "responses": { @@ -1380,12 +1817,28 @@ "application/json": { "schema": { "type": "object", + "required": [ + "code", + "data", + "message" + ], "properties": { - "plugins": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Plugin" + "code": { + "const": 0 + }, + "data": { + "type": "object", + "properties": { + "plugins": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Plugin" + } + } } + }, + "message": { + "type": "string" } } } @@ -1410,7 +1863,7 @@ ], "security": [ { - "cookie": [] + "bearer": [] } ], "responses": { @@ -1419,7 +1872,23 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Plugin" + "type": "object", + "required": [ + "code", + "data", + "message" + ], + "properties": { + "code": { + "const": 0 + }, + "data": { + "$ref": "#/components/schemas/Plugin" + }, + "message": { + "type": "string" + } + } } } } @@ -1469,7 +1938,7 @@ ], "security": [ { - "cookie": [] + "bearer": [] } ], "responses": { @@ -1478,7 +1947,23 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Plugin" + "type": "object", + "required": [ + "code", + "data", + "message" + ], + "properties": { + "code": { + "const": 0 + }, + "data": { + "$ref": "#/components/schemas/Plugin" + }, + "message": { + "type": "string" + } + } } } } @@ -1532,7 +2017,7 @@ ], "security": [ { - "cookie": [] + "bearer": [] } ], "responses": { @@ -1572,7 +2057,7 @@ ], "security": [ { - "cookie": [] + "bearer": [] } ], "responses": { @@ -1583,10 +2068,26 @@ "schema": { "type": "object", "required": [ - "data" + "code", + "data", + "message" ], "properties": { - "data": {} + "code": { + "const": 0 + }, + "data": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": {} + } + }, + "message": { + "type": "string" + } } } } @@ -1623,7 +2124,7 @@ ], "security": [ { - "cookie": [] + "bearer": [] } ], "responses": { @@ -1634,10 +2135,26 @@ "schema": { "type": "object", "required": [ - "data" + "code", + "data", + "message" ], "properties": { - "data": {} + "code": { + "const": 0 + }, + "data": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": {} + } + }, + "message": { + "type": "string" + } } } } @@ -1692,7 +2209,7 @@ ], "security": [ { - "cookie": [] + "bearer": [] } ], "responses": { @@ -1785,30 +2302,46 @@ "schema": { "type": "object", "required": [ - "paths", - "notes", - "assets", - "warnings" + "code", + "data", + "message" ], "properties": { - "paths": { - "type": "array", - "items": { - "type": "string" + "code": { + "const": 0 + }, + "data": { + "type": "object", + "required": [ + "paths", + "notes", + "assets", + "warnings" + ], + "properties": { + "paths": { + "type": "array", + "items": { + "type": "string" + } + }, + "notes": { + "type": "integer" + }, + "assets": { + "type": "integer", + "description": "附件文件总数,包含原始附件和独立副本" + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + } } }, - "notes": { - "type": "integer" - }, - "assets": { - "type": "integer", - "description": "附件文件总数,包含原始附件和独立副本" - }, - "warnings": { - "type": "array", - "items": { - "type": "string" - } + "message": { + "type": "string" } } } @@ -1948,9 +2481,6 @@ "get": { "summary": "读取笔记关系、标签与资源引用索引", "security": [ - { - "cookie": [] - }, { "bearer": [] } @@ -2039,7 +2569,23 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Ok" + "type": "object", + "required": [ + "code", + "data", + "message" + ], + "properties": { + "code": { + "const": 0 + }, + "data": { + "$ref": "#/components/schemas/Ok" + }, + "message": { + "type": "string" + } + } } } } @@ -2061,7 +2607,7 @@ ], "security": [ { - "cookie": [] + "bearer": [] } ], "responses": { @@ -2083,7 +2629,7 @@ ], "security": [ { - "cookie": [] + "bearer": [] } ], "responses": { @@ -2136,7 +2682,7 @@ ], "security": [ { - "cookie": [] + "bearer": [] } ], "responses": { @@ -2170,7 +2716,7 @@ ], "security": [ { - "cookie": [] + "bearer": [] } ], "responses": { @@ -2195,7 +2741,7 @@ ], "security": [ { - "cookie": [] + "bearer": [] } ], "responses": { @@ -2204,7 +2750,23 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StorageUsage" + "type": "object", + "required": [ + "code", + "data", + "message" + ], + "properties": { + "code": { + "const": 0 + }, + "data": { + "$ref": "#/components/schemas/StorageUsage" + }, + "message": { + "type": "string" + } + } } } } @@ -2226,7 +2788,7 @@ ], "security": [ { - "cookie": [] + "bearer": [] } ], "responses": { @@ -2256,7 +2818,7 @@ ], "security": [ { - "cookie": [] + "bearer": [] } ], "responses": { @@ -2304,7 +2866,7 @@ ], "security": [ { - "cookie": [] + "bearer": [] } ], "responses": { @@ -2342,7 +2904,7 @@ ], "security": [ { - "cookie": [] + "bearer": [] } ], "responses": { @@ -2403,7 +2965,7 @@ ], "security": [ { - "cookie": [] + "bearer": [] } ], "responses": { @@ -2461,7 +3023,7 @@ ], "security": [ { - "cookie": [] + "bearer": [] } ], "responses": { @@ -2501,7 +3063,7 @@ ], "security": [ { - "cookie": [] + "bearer": [] } ], "responses": { @@ -2541,7 +3103,7 @@ ], "security": [ { - "cookie": [] + "bearer": [] } ], "responses": { @@ -2594,7 +3156,7 @@ ], "security": [ { - "cookie": [] + "bearer": [] } ], "responses": { @@ -2643,7 +3205,7 @@ ], "security": [ { - "cookie": [] + "bearer": [] } ], "responses": { @@ -2699,7 +3261,7 @@ ], "security": [ { - "cookie": [] + "bearer": [] } ], "responses": { @@ -2739,7 +3301,7 @@ ], "security": [ { - "cookie": [] + "bearer": [] } ], "responses": { @@ -2769,7 +3331,7 @@ ], "security": [ { - "cookie": [] + "bearer": [] } ], "responses": { @@ -2798,6 +3360,291 @@ } } } + }, + "/auth/me": { + "get": { + "summary": "恢复登录会话身份与权限", + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "code", + "data", + "message" + ], + "properties": { + "code": { + "const": 0 + }, + "data": { + "type": "object", + "required": [ + "permissions", + "expiresAt" + ], + "properties": { + "permissions": { + "type": "array", + "items": { + "type": "string" + } + }, + "expiresAt": { + "type": "string", + "format": "date-time" + } + } + }, + "message": { + "type": "string" + } + } + } + } + } + }, + "401": { + "description": "登录凭据无效或过期", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "需要登录会话,API令牌无权调用", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/auth/captcha": { + "post": { + "summary": "签发5分钟、单次登录验证码", + "security": [ + {} + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [], + "properties": { + "replaceId": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "code", + "data", + "message" + ], + "properties": { + "code": { + "const": 0 + }, + "data": { + "type": "object", + "required": [ + "captchaId", + "imageDataUrl", + "expiresAt" + ], + "properties": { + "captchaId": { + "type": "string" + }, + "imageDataUrl": { + "type": "string" + }, + "expiresAt": { + "type": "string", + "format": "date-time" + } + } + }, + "message": { + "type": "string" + } + } + } + } + } + }, + "429": { + "description": "验证码签发频率或容量限制", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/auth/tickets": { + "post": { + "summary": "签发绑定当前会话的资源读取或一次性事件票据", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "kind" + ], + "properties": { + "kind": { + "enum": [ + "images", + "fonts", + "plugin", + "events" + ] + }, + "pluginId": { + "type": "string" + }, + "revision": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "code", + "data", + "message" + ], + "properties": { + "code": { + "const": 0 + }, + "data": { + "type": "object", + "required": [ + "url", + "expiresAt" + ], + "properties": { + "url": { + "type": "string" + }, + "expiresAt": { + "type": "string", + "format": "date-time" + } + } + }, + "message": { + "type": "string" + } + } + } + } + } + }, + "401": { + "description": "登录凭据无效或过期", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "需要登录会话,API令牌无权调用", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/resources/{ticket}/{kind}/{path}": { + "get": { + "summary": "受限原生资源;事件票据60秒一次性,其余跟随父会话到期,每次检查父会话", + "security": [ + {} + ], + "parameters": [ + { + "name": "ticket", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "kind", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "path", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "对应资源原始内容" + }, + "101": { + "description": "WebSocket事件升级" + } + } + } } }, "components": { @@ -2806,25 +3653,31 @@ "type": "http", "scheme": "bearer", "description": "API 令牌(rm_ 开头)" - }, - "cookie": { - "type": "apiKey", - "in": "cookie", - "name": "rawmark_session", - "description": "浏览器登录会话" } }, "schemas": { "Error": { "type": "object", + "required": [ + "code", + "data", + "message" + ], "properties": { - "error": { + "code": { + "type": "integer", + "minimum": 1 + }, + "data": { + "type": [ + "object", + "null" + ] + }, + "message": { "type": "string" } - }, - "required": [ - "error" - ] + } }, "Ok": { "type": "object", @@ -3109,5 +3962,22 @@ } } } + }, + "x-business-codes": { + "0": "成功", + "40000": "参数无效", + "40001": "验证码错误或已使用", + "40002": "验证码过期", + "40003": "当前密码错误", + "40100": "认证无效", + "40101": "登录密码错误", + "40300": "权限不足", + "40400": "资源不存在", + "40900": "资源冲突", + "41200": "文档版本冲突,data.rev为最新版本", + "41300": "超过大小上限", + "42900": "请求过于频繁", + "50000": "服务端错误", + "50300": "维护中" } } diff --git a/internal/api/protocol.go b/internal/api/protocol.go new file mode 100644 index 0000000..2090aae --- /dev/null +++ b/internal/api/protocol.go @@ -0,0 +1,44 @@ +package api + +import ( + "encoding/json" + "net/http" + "strings" +) + +const ( + CodeCaptchaInvalid = 40001 + CodeCaptchaExpired = 40002 + CodeCurrentPassword = 40003 + CodeBadCredentials = 40101 + CodeConflict = 41200 +) + +type response struct { + Code int `json:"code"` + Data any `json:"data"` + Message string `json:"message"` +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + writeResponse(w, status, response{Code: 0, Data: v, Message: "成功"}) +} +func writeFailure(w http.ResponseWriter, status, code int, message string, data any) { + writeResponse(w, status, response{Code: code, Data: data, Message: message}) +} +func writeError(w http.ResponseWriter, status int, message string) { + writeFailure(w, status, status*100, message, nil) +} +func writeResponse(w http.ResponseWriter, status int, value response) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + json.NewEncoder(w).Encode(value) +} +func logPath(value string) string { + const prefix = "/api/v1/resources/" + if strings.HasPrefix(value, prefix) { + _, rest, _ := strings.Cut(strings.TrimPrefix(value, prefix), "/") + return prefix + "[redacted]/" + rest + } + return value +} diff --git a/internal/api/resources.go b/internal/api/resources.go new file mode 100644 index 0000000..73725b4 --- /dev/null +++ b/internal/api/resources.go @@ -0,0 +1,114 @@ +package api + +import ( + "net/http" + "strings" + "time" + + "rawmark/internal/store" + "rawmark/internal/vault" +) + +func (s *Server) createTicket(w http.ResponseWriter, r *http.Request) { + var body struct { + Kind string `json:"kind"` + PluginID string `json:"pluginId"` + Revision string `json:"revision"` + } + if !readJSON(w, r, &body) { + return + } + p := identity(r) + t := store.ResourceTicket{SessionHash: p.SessionHash, Kind: body.Kind, ExpiresAt: p.ExpiresAt} + suffix := body.Kind + "/" + switch body.Kind { + case "images", "fonts": + if body.PluginID != "" || body.Revision != "" { + writeError(w, 400, "资源票据范围无效") + return + } + case "events": + t.ExpiresAt = time.Now().Add(time.Minute) + if t.ExpiresAt.After(p.ExpiresAt) { + t.ExpiresAt = p.ExpiresAt + } + case "plugin": + dir, info, err := s.openPlugin(body.PluginID) + if err != nil { + s.pluginError(w, r, err) + return + } + dir.Close() + if info.Revision != body.Revision { + writeError(w, 409, "插件版本已改变,请刷新") + return + } + t.Resource, t.Revision = body.PluginID, body.Revision + suffix = "plugins/" + body.PluginID + "/assets/" + body.Revision + "/" + default: + writeError(w, 400, "资源票据用途无效") + return + } + plain := opaqueToken() + t.Hash = tokenHash(plain) + if err := s.store.CreateTicket(t); err != nil { + internalError(w, r, err) + return + } + writeJSON(w, 200, map[string]any{"url": "/api/v1/resources/" + plain + "/" + suffix, "expiresAt": t.ExpiresAt}) +} + +func (s *Server) resource(w http.ResponseWriter, r *http.Request) { + kind := r.PathValue("kind") + t, ok, err := s.store.Ticket(tokenHash(r.PathValue("ticket")), kind == "events", time.Now()) + if err != nil { + internalError(w, r, err) + return + } + if !ok { + writeError(w, 401, "资源票据已失效") + return + } + parent := principal{SessionHash: t.SessionHash, ExpiresAt: t.ExpiresAt} + valid, err := s.validPrincipal(parent) + if err != nil { + internalError(w, r, err) + return + } + if !valid { + writeError(w, 401, "登录已失效") + return + } + r = withIdentity(r, parent) + name := r.PathValue("path") + switch { + case kind == "images" && t.Kind == "images": + if !vault.IsImage(name) { + writeError(w, 403, "票据仅允许读取图片") + return + } + s.raw(w, r) + case kind == "fonts" && t.Kind == "fonts": + id, ok := strings.CutSuffix(name, "/file") + if !ok || strings.Contains(id, "/") { + writeError(w, 403, "字体票据范围不匹配") + return + } + r.SetPathValue("id", id) + s.fontFile(w, r) + case kind == "plugins" && t.Kind == "plugin": + parts := strings.SplitN(name, "/", 4) + if len(parts) != 4 || parts[0] != t.Resource || parts[1] != "assets" || parts[2] != t.Revision { + writeError(w, 403, "插件票据范围不匹配") + return + } + r.SetPathValue("id", t.Resource) + r.SetPathValue("revision", t.Revision) + r.SetPathValue("path", parts[3]) + s.pluginAsset(w, r) + case kind == "events" && t.Kind == "events" && name == "": + s.events(w, r) + default: + writeError(w, 403, "资源票据用途不匹配") + } +} diff --git a/internal/api/settings.go b/internal/api/settings.go index 38dc51a..8b1cf55 100644 --- a/internal/api/settings.go +++ b/internal/api/settings.go @@ -87,16 +87,11 @@ func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) { if !readJSON(w, r, &body) { return } - c, err := r.Cookie(cookieName) - if err != nil { - writeError(w, 401, "未登录") - return - } - switch err := s.auth.ChangePassword(clientIP(r), c.Value, body.Current, body.Next); { + switch err := s.auth.ChangePassword(clientIP(r), identity(r).Token, body.Current, body.Next); { case errors.Is(err, auth.ErrRateLimited): writeError(w, 429, err.Error()) case errors.Is(err, auth.ErrBadCredentials): - writeError(w, 401, "当前密码错误") + writeFailure(w, 400, CodeCurrentPassword, "当前密码错误", nil) case errors.Is(err, auth.ErrWeakPassword): writeError(w, 400, err.Error()) case err != nil: diff --git a/internal/api/storage_test.go b/internal/api/storage_test.go index 44ea0d9..fe807d5 100644 --- a/internal/api/storage_test.go +++ b/internal/api/storage_test.go @@ -95,10 +95,12 @@ func TestStorageIncludesOptionalAndImportedFontBytes(t *testing.T) { s := &Server{DataDir: root, vault: v, BuiltinFontsSize: 456} recorder := httptest.NewRecorder() s.getStorage(recorder, httptest.NewRequest("GET", "/", nil)) + var envelope struct{ Data storageUsage } var usage storageUsage - if err = json.Unmarshal(recorder.Body.Bytes(), &usage); err != nil { + if err = json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { t.Fatal(err) } + usage = envelope.Data if usage.BuiltinFontsSize != 456 || usage.CustomFontsSize != 123 || usage.Categories[2].Size != 579 { t.Fatalf("字体统计错误: %+v", usage) }