Add settings page with synced appearance and password change
- Store app settings as one JSON document on the server, merged per key and pushed to other devices; cache locally to avoid a flash on load - Settings page groups appearance, account, shortcuts, shares, API tokens and trash; full screen with a section list on phones - Font size and content width settings - Change the login password, signing out other sessions - Settings writes and password changes only accept a login session
This commit is contained in:
1 parent
5b326a70bf
commit
eb63b14c8e
25 files changed
+915
-290
No files matched your search
+16
-5
@@ -12,6 +12,7 @@ import (
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"rawmark/internal/auth"
|
||||
@@ -34,6 +35,8 @@ type Server struct {
|
||||
store *store.Store
|
||||
hub *events.Hub
|
||||
index *search.Index
|
||||
// 串行化设置的"读取-合并-写入"
|
||||
settingsMu sync.Mutex
|
||||
// OnSetup 在首次初始化完成后调用(用于删除启动码文件)
|
||||
OnSetup func()
|
||||
}
|
||||
@@ -68,6 +71,7 @@ func (s *Server) routes() []route {
|
||||
{"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/password", sessionOnly, s.changePassword},
|
||||
|
||||
{"GET /api/v1/tree", authed, s.tree},
|
||||
{"GET /api/v1/files/{path...}", authed, s.readFile},
|
||||
@@ -83,6 +87,8 @@ func (s *Server) routes() []route {
|
||||
{"GET /api/v1/events", authed, s.events},
|
||||
{"GET /api/v1/search", authed, s.search},
|
||||
|
||||
{"GET /api/v1/settings", authed, s.getSettings},
|
||||
{"PATCH /api/v1/settings", sessionOnly, s.patchSettings},
|
||||
{"GET /api/v1/shares", sessionOnly, s.listShares},
|
||||
{"POST /api/v1/shares", sessionOnly, s.createShare},
|
||||
{"DELETE /api/v1/shares/{token}", sessionOnly, s.deleteShare},
|
||||
@@ -230,11 +236,7 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, password string) {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
token, err := s.auth.Login(host, password)
|
||||
token, err := s.auth.Login(clientIP(r), password)
|
||||
switch {
|
||||
case errors.Is(err, auth.ErrRateLimited):
|
||||
writeError(w, 429, err.Error())
|
||||
@@ -426,6 +428,15 @@ func (s *Server) trashPurge(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// ---- 响应工具 ----
|
||||
|
||||
// clientIP 返回限速用的来源地址(未经反向代理头部,避免伪造)
|
||||
func clientIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
func readJSON(w http.ResponseWriter, r *http.Request, v any) bool {
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxJSONBytes)).Decode(v); err != nil {
|
||||
writeError(w, 400, "请求格式错误")
|
||||
|
||||
@@ -309,3 +309,58 @@ func TestAPITokenScopes(t *testing.T) {
|
||||
t.Errorf("吊销后应为 401,得到 %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsMerge(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
if got := decode[map[string]any](t, f.do(f.admin, "GET", "/api/v1/settings", "", nil)); len(got) != 0 {
|
||||
t.Fatalf("初始为空对象: %v", got)
|
||||
}
|
||||
f.do(f.admin, "PATCH", "/api/v1/settings", `{"fontSize":18,"theme":"dark"}`, nil)
|
||||
got := decode[map[string]any](t, f.do(f.admin, "PATCH", "/api/v1/settings", `{"fontSize":20,"theme":null,"cssSnippets":[{"id":"a"}]}`, nil))
|
||||
if got["fontSize"] != 20.0 || got["theme"] != nil || len(got["cssSnippets"].([]any)) != 1 {
|
||||
t.Fatalf("合并结果: %v", got)
|
||||
}
|
||||
if again := decode[map[string]any](t, f.do(f.admin, "GET", "/api/v1/settings", "", nil)); again["fontSize"] != 20.0 || len(again) != 2 {
|
||||
t.Fatalf("读取: %v", again)
|
||||
}
|
||||
for _, body := range []string{`[1]`, `null`, `"x"`, `{`} {
|
||||
if code := f.status(f.admin, "PATCH", "/api/v1/settings", body, nil); code != 400 {
|
||||
t.Errorf("%s 应为 400,得到 %d", body, code)
|
||||
}
|
||||
}
|
||||
tok := decode[struct{ Token string }](t, f.do(f.admin, "POST", "/api/v1/tokens", `{"name":"x","scope":"write"}`, nil)).Token
|
||||
bearer := map[string]string{"Authorization": "Bearer " + tok}
|
||||
if code := f.status(&http.Client{}, "GET", "/api/v1/settings", "", bearer); code != 200 {
|
||||
t.Errorf("令牌可读设置,得到 %d", code)
|
||||
}
|
||||
if code := f.status(&http.Client{}, "PATCH", "/api/v1/settings", `{"a":1}`, bearer); code != 403 {
|
||||
t.Errorf("令牌不能改设置(自定义 CSS 等会影响页面),得到 %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if code := f.status(f.admin, "POST", "/api/v1/auth/password", `{"current":"password123","next":"short"}`, nil); code != 400 {
|
||||
t.Errorf("新密码过短应为 400,得到 %d", code)
|
||||
}
|
||||
if code := f.status(f.admin, "POST", "/api/v1/auth/password", `{"current":"password123","next":"new-password"}`, nil); code != 200 {
|
||||
t.Fatalf("修改密码: %d", code)
|
||||
}
|
||||
if code := f.status(f.admin, "GET", "/api/v1/tree", "", nil); code != 200 {
|
||||
t.Errorf("当前会话应保留,得到 %d", code)
|
||||
}
|
||||
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 {
|
||||
t.Errorf("旧密码应失效,得到 %d", code)
|
||||
}
|
||||
if code := f.status(newClient(), "POST", "/api/v1/auth/login", `{"password":"new-password"}`, nil); code != 200 {
|
||||
t.Errorf("新密码登录,得到 %d", code)
|
||||
}
|
||||
}
|
||||
+157
-2
@@ -245,6 +245,160 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/password": {
|
||||
"post": {
|
||||
"summary": "修改登录密码;当前会话保留,其他设备需重新登录",
|
||||
"tags": [
|
||||
"登录"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"cookie": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"current": {
|
||||
"type": "string"
|
||||
},
|
||||
"next": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"current",
|
||||
"next"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "成功",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Ok"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "新密码过短",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "当前密码错误",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "失败次数过多",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/settings": {
|
||||
"get": {
|
||||
"summary": "应用设置(全部设备共用的一份 JSON 对象)",
|
||||
"tags": [
|
||||
"设置"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "设置",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"summary": "合并修改设置",
|
||||
"tags": [
|
||||
"设置"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"cookie": []
|
||||
}
|
||||
],
|
||||
"description": "按顶层键合并:请求中的键覆盖原值,值为 null 的键被删除。成功后经 WebSocket 推送 settings 事件。",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "合并后的设置",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "不是 JSON 对象",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"413": {
|
||||
"description": "超过 1 MB",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tree": {
|
||||
"get": {
|
||||
"summary": "全部笔记与文件夹(不含 .assets 图片目录与隐藏文件)",
|
||||
@@ -1446,7 +1600,7 @@
|
||||
},
|
||||
"Event": {
|
||||
"type": "object",
|
||||
"description": "tree:目录结构变化;changed:内容变化(rev 为新版本);deleted:删除;moved:移动(from → path)",
|
||||
"description": "tree:目录结构变化;changed:内容变化(rev 为新版本);deleted:删除;moved:移动(from → path);settings:设置变化",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
@@ -1454,7 +1608,8 @@
|
||||
"tree",
|
||||
"changed",
|
||||
"deleted",
|
||||
"moved"
|
||||
"moved",
|
||||
"settings"
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"rawmark/internal/auth"
|
||||
"rawmark/internal/events"
|
||||
)
|
||||
|
||||
// 应用设置:一份 JSON 对象,全部设备共用。内容由前端定义,服务端只做合并与保存
|
||||
const settingsKey = "app.settings"
|
||||
|
||||
func (s *Server) readSettings() (map[string]json.RawMessage, error) {
|
||||
v, ok, err := s.store.Setting(settingsKey)
|
||||
if err != nil || !ok {
|
||||
return map[string]json.RawMessage{}, err
|
||||
}
|
||||
out := map[string]json.RawMessage{}
|
||||
return out, json.Unmarshal([]byte(v), &out)
|
||||
}
|
||||
|
||||
func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) {
|
||||
settings, err := s.readSettings()
|
||||
if err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, 200, settings)
|
||||
}
|
||||
|
||||
// patchSettings 按顶层键合并:请求中的键覆盖原值,值为 null 的键被删除。
|
||||
// 不同设备同时修改不同的设置不会互相覆盖;合并后通知其他设备重新读取
|
||||
func (s *Server) patchSettings(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxJSONBytes))
|
||||
if err != nil {
|
||||
writeError(w, 413, "设置过大")
|
||||
return
|
||||
}
|
||||
var patch map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &patch); err != nil || patch == nil {
|
||||
writeError(w, 400, "设置必须是 JSON 对象")
|
||||
return
|
||||
}
|
||||
s.settingsMu.Lock()
|
||||
defer s.settingsMu.Unlock()
|
||||
settings, err := s.readSettings()
|
||||
if err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
for k, v := range patch {
|
||||
if bytes.Equal(v, []byte("null")) {
|
||||
delete(settings, k)
|
||||
} else {
|
||||
settings[k] = v
|
||||
}
|
||||
}
|
||||
merged, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
if len(merged) > maxJSONBytes {
|
||||
writeError(w, 413, "设置过大")
|
||||
return
|
||||
}
|
||||
if err := s.store.SetSetting(settingsKey, string(merged)); err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
s.hub.Broadcast(events.Event{Type: "settings"})
|
||||
writeJSON(w, 200, settings)
|
||||
}
|
||||
|
||||
// changePassword 修改登录密码;当前会话保留,其他设备需重新登录
|
||||
func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct{ Current, Next string }
|
||||
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); {
|
||||
case errors.Is(err, auth.ErrRateLimited):
|
||||
writeError(w, 429, err.Error())
|
||||
case errors.Is(err, auth.ErrBadCredentials):
|
||||
writeError(w, 401, "当前密码错误")
|
||||
case errors.Is(err, auth.ErrWeakPassword):
|
||||
writeError(w, 400, err.Error())
|
||||
case err != nil:
|
||||
internalError(w, r, err)
|
||||
default:
|
||||
writeJSON(w, 200, map[string]bool{"ok": true})
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
@@ -191,11 +190,7 @@ func (s *Server) unlockShare(w http.ResponseWriter, r *http.Request) {
|
||||
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); {
|
||||
switch err := s.auth.CheckSharePassword(clientIP(r), sh.PasswordHash, body.Password); {
|
||||
case errors.Is(err, auth.ErrRateLimited):
|
||||
writeError(w, 429, err.Error())
|
||||
return
|
||||
|
||||
Reference in new issue
Block a user