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:
eezd committed 2026-10-17 16:23:39 +08:00
1 parent 5b326a70bf
commit eb63b14c8e
25 files changed
+915 -290

No files matched your search

+16 -5
View File
@@ -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, "请求格式错误")
+55
View File
@@ -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
View File
@@ -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": {
+103
View File
@@ -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})
}
}
+1 -6
View File
@@ -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
+26
View File
@@ -143,6 +143,32 @@ func (m *Manager) Validate(token string) (bool, error) {
func (m *Manager) Logout(token string) error { return m.store.DeleteSession(hashToken(token)) }
// ChangePassword 校验当前密码后设置新密码,并注销 keepToken 以外的所有会话
func (m *Manager) ChangePassword(ip, keepToken, current, next string) error {
if !m.allow(ip) {
return ErrRateLimited
}
encoded, _, err := m.store.Setting(passwordKey)
if err != nil {
return err
}
ok, err := verifyPassword(encoded, current)
if err != nil {
return err
}
if !ok {
m.recordFailure(ip)
return ErrBadCredentials
}
if len([]rune(next)) < MinPasswordLen {
return ErrWeakPassword
}
if err := m.store.SetSetting(passwordKey, hashPassword(next)); err != nil {
return err
}
return m.store.DeleteOtherSessions(hashToken(keepToken))
}
// ---- 分享 ----
// HashSharePassword 为分享密码生成哈希;空密码表示不设密码,返回空字符串
+1
View File
@@ -13,6 +13,7 @@ import (
// changed 笔记内容变化,Rev 为新版本
// deleted 笔记被删除
// moved 笔记被移动(From -> Path),内容版本不变
// settings 应用设置变化,客户端重新读取
type Event struct {
Type string `json:"type"`
Path string `json:"path,omitempty"`
+3 -2
View File
@@ -101,8 +101,9 @@ func (s *Store) DeleteSession(tokenHash string) error {
return err
}
func (s *Store) DeleteAllSessions() error {
_, err := s.db.Exec(`DELETE FROM sessions`)
// DeleteOtherSessions 删除除 keepHash 之外的所有会话(修改密码后让其他设备重新登录)
func (s *Store) DeleteOtherSessions(keepHash string) error {
_, err := s.db.Exec(`DELETE FROM sessions WHERE token_hash <> ?`, keepHash)
return err
}