diff --git a/cmd/rawmark/main.go b/cmd/rawmark/main.go
index 6b22534..f92de21 100644
--- a/cmd/rawmark/main.go
+++ b/cmd/rawmark/main.go
@@ -95,6 +95,7 @@ func run(addr string) error {
go cleanupSessions(ctx, st)
srv := api.New(v, am, st, hub, idx)
+ srv.ThemesDir = filepath.Join(dataDir, "themes")
srv.OnSetup = func() { os.Remove(codeFile) }
go srv.FollowMoves(ctx)
httpServer := &http.Server{
diff --git a/internal/api/api.go b/internal/api/api.go
index fe2de22..3f697b9 100644
--- a/internal/api/api.go
+++ b/internal/api/api.go
@@ -39,6 +39,8 @@ type Server struct {
settingsMu sync.Mutex
// OnSetup 在首次初始化完成后调用(用于删除启动码文件)
OnSetup func()
+ // ThemesDir 指向数据目录中的主题目录;为空时只有内置主题。
+ ThemesDir string
}
func New(v *vault.Vault, a *auth.Manager, st *store.Store, hub *events.Hub, index *search.Index) *Server {
@@ -89,6 +91,8 @@ func (s *Server) routes() []route {
{"GET /api/v1/settings", authed, s.getSettings},
{"PATCH /api/v1/settings", sessionOnly, s.patchSettings},
+ {"GET /api/v1/themes", authed, s.listThemes},
+ {"GET /api/v1/themes/{id}", authed, s.getTheme},
{"GET /api/v1/shares", sessionOnly, s.listShares},
{"POST /api/v1/shares", sessionOnly, s.createShare},
{"DELETE /api/v1/shares/{token}", sessionOnly, s.deleteShare},
@@ -97,6 +101,7 @@ func (s *Server) routes() []route {
{"DELETE /api/v1/tokens/{id}", sessionOnly, s.deleteToken},
{"GET /api/v1/public/shares/{token}", public, s.publicShare},
+ {"GET /api/v1/public/shares/{token}/appearance", public, s.publicAppearance},
{"POST /api/v1/public/shares/{token}/unlock", public, s.unlockShare},
{"GET /api/v1/public/shares/{token}/assets/{path...}", public, s.publicShareAsset},
}
diff --git a/internal/api/api_test.go b/internal/api/api_test.go
index f09170d..bf9f6ea 100644
--- a/internal/api/api_test.go
+++ b/internal/api/api_test.go
@@ -55,7 +55,9 @@ func newFixture(t *testing.T) *fixture {
t.Fatal(err)
}
t.Cleanup(func() { idx.Close() })
- srv := httptest.NewServer(New(v, am, st, events.NewHub(), idx).Handler(fstest.MapFS{}))
+ server := New(v, am, st, events.NewHub(), idx)
+ server.ThemesDir = filepath.Join(dir, "themes")
+ srv := httptest.NewServer(server.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 {
diff --git a/internal/api/openapi.json b/internal/api/openapi.json
index fa1a72b..6d7f3d6 100644
--- a/internal/api/openapi.json
+++ b/internal/api/openapi.json
@@ -1510,6 +1510,164 @@
}
}
}
+ },
+ "/themes": {
+ "get": {
+ "summary": "列出主题(含内置默认主题)",
+ "tags": [
+ "外观"
+ ],
+ "responses": {
+ "200": {
+ "description": "主题列表",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "themes": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/Theme"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/themes/{id}": {
+ "get": {
+ "summary": "读取主题 CSS",
+ "tags": [
+ "外观"
+ ],
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "主题信息与 CSS",
+ "content": {
+ "application/json": {
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/Theme"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "css": {
+ "type": "string"
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "主题不存在或文件不完整"
+ }
+ }
+ }
+ },
+ "/public/shares/{token}/appearance": {
+ "get": {
+ "summary": "读取有效分享使用的外观配置",
+ "description": "仅返回明暗模式、字号、正文宽度、主题 CSS 和启用的 CSS 片段;密码分享需先解锁,不返回其他设置,不加载插件。",
+ "tags": [
+ "分享"
+ ],
+ "security": [
+ {}
+ ],
+ "parameters": [
+ {
+ "name": "token",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "公开的外观配置",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "colorMode": {
+ "type": "string",
+ "enum": [
+ "system",
+ "light",
+ "dark"
+ ]
+ },
+ "fontSize": {
+ "type": "integer"
+ },
+ "contentWidth": {
+ "type": "integer"
+ },
+ "themeCSS": {
+ "type": "string"
+ },
+ "cssSnippets": {
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "css": {
+ "type": "string"
+ },
+ "enabled": {
+ "type": "boolean"
+ }
+ }
+ }
+ },
+ "theme": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "需要访问密码"
+ },
+ "404": {
+ "description": "分享不存在、已过期或已撤销"
+ }
+ }
+ }
}
},
"components": {
@@ -1672,6 +1830,25 @@
"description": "Unix 毫秒,0 为从未使用"
}
}
+ },
+ "Theme": {
+ "type": "object",
+ "required": [
+ "id",
+ "name",
+ "version"
+ ],
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "version": {
+ "type": "string"
+ }
+ }
}
}
}
diff --git a/internal/api/themes.go b/internal/api/themes.go
new file mode 100644
index 0000000..cd36b47
--- /dev/null
+++ b/internal/api/themes.go
@@ -0,0 +1,165 @@
+package api
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "net/http"
+ "os"
+ "regexp"
+)
+
+var themeIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,63}$`)
+
+type themeManifest struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Version string `json:"version"`
+}
+
+func readThemeFile(root *os.Root, name string, limit int64) ([]byte, error) {
+ f, err := root.Open(name)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+ info, err := f.Stat()
+ if err != nil {
+ return nil, err
+ }
+ if !info.Mode().IsRegular() {
+ return nil, fmt.Errorf("主题文件必须为普通文件")
+ }
+ data, err := io.ReadAll(io.LimitReader(f, limit+1))
+ if err != nil {
+ return nil, err
+ }
+ if int64(len(data)) > limit {
+ return nil, fmt.Errorf("主题文件过大")
+ }
+ return data, nil
+}
+
+func (s *Server) readTheme(id string) (themeManifest, string, error) {
+ var manifest themeManifest
+ if !themeIDPattern.MatchString(id) || id == "default" || s.ThemesDir == "" {
+ return manifest, "", fs.ErrNotExist
+ }
+ root, err := os.OpenRoot(s.ThemesDir)
+ if err != nil {
+ return manifest, "", err
+ }
+ defer root.Close()
+ dir, err := root.OpenRoot(id)
+ if err != nil {
+ return manifest, "", err
+ }
+ defer dir.Close()
+ data, err := readThemeFile(dir, "manifest.json", 16<<10)
+ if err != nil {
+ return manifest, "", err
+ }
+ if err := json.Unmarshal(data, &manifest); err != nil {
+ return manifest, "", fmt.Errorf("主题 %s 的 manifest.json 无法解析:%w", id, err)
+ }
+ if manifest.ID != id || manifest.Name == "" || manifest.Version == "" {
+ return manifest, "", fmt.Errorf("主题 %s 的 id、name 或 version 无效", id)
+ }
+ css, err := readThemeFile(dir, "theme.css", 256<<10)
+ return manifest, string(css), err
+}
+
+func (s *Server) listThemes(w http.ResponseWriter, r *http.Request) {
+ themes := []themeManifest{{ID: "default", Name: "默认", Version: "1"}}
+ if s.ThemesDir != "" {
+ entries, err := os.ReadDir(s.ThemesDir)
+ if err != nil && !errors.Is(err, fs.ErrNotExist) {
+ internalError(w, r, err)
+ return
+ }
+ for _, entry := range entries {
+ if !entry.IsDir() || !themeIDPattern.MatchString(entry.Name()) || entry.Name() == "default" {
+ continue
+ }
+ manifest, _, err := s.readTheme(entry.Name())
+ if err != nil {
+ internalError(w, r, err)
+ return
+ }
+ themes = append(themes, manifest)
+ }
+ }
+ writeJSON(w, 200, map[string]any{"themes": themes})
+}
+
+func (s *Server) getTheme(w http.ResponseWriter, r *http.Request) {
+ manifest, css, err := s.readTheme(r.PathValue("id"))
+ if errors.Is(err, fs.ErrNotExist) {
+ writeError(w, 404, "主题不存在或文件不完整")
+ return
+ }
+ if err != nil {
+ internalError(w, r, err)
+ return
+ }
+ writeJSON(w, 200, struct {
+ themeManifest
+ CSS string `json:"css"`
+ }{manifest, css})
+}
+
+// 只有有效分享的访客可读取外观;不返回账户、插件和其他应用设置。
+func (s *Server) publicAppearance(w http.ResponseWriter, r *http.Request) {
+ if _, ok := s.openShare(w, r); !ok {
+ return
+ }
+ settings, err := s.readSettings()
+ if err != nil {
+ internalError(w, r, err)
+ return
+ }
+ out := map[string]any{}
+ for _, key := range []string{"colorMode", "fontSize", "contentWidth"} {
+ if value, ok := settings[key]; ok {
+ out[key] = value
+ }
+ }
+ var snippets []struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ CSS string `json:"css"`
+ Enabled bool `json:"enabled"`
+ }
+ if value, ok := settings["cssSnippets"]; ok {
+ if err := json.Unmarshal(value, &snippets); err != nil {
+ internalError(w, r, err)
+ return
+ }
+ }
+ active := snippets[:0]
+ for _, snippet := range snippets {
+ if snippet.Enabled {
+ active = append(active, snippet)
+ }
+ }
+ out["cssSnippets"] = active
+ var theme string
+ if value, ok := settings["theme"]; ok {
+ if err := json.Unmarshal(value, &theme); err != nil {
+ internalError(w, r, err)
+ return
+ }
+ }
+ if theme != "" && theme != "default" {
+ _, css, err := s.readTheme(theme)
+ if err != nil {
+ internalError(w, r, err)
+ return
+ }
+ out["themeCSS"] = css
+ }
+ out["theme"] = theme
+ writeJSON(w, 200, out)
+}
diff --git a/internal/api/themes_test.go b/internal/api/themes_test.go
new file mode 100644
index 0000000..4851d0c
--- /dev/null
+++ b/internal/api/themes_test.go
@@ -0,0 +1,76 @@
+package api
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestThemeAndShareAppearanceBoundaries(t *testing.T) {
+ f := newFixture(t)
+ dir := filepath.Join(filepath.Dir(f.root), "themes", "paper")
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ for name, data := range map[string]string{
+ "manifest.json": `{"id":"paper","name":"纸张","version":"1"}`,
+ "theme.css": `:root { --rm-bg: #faf6ed; }`,
+ } {
+ if err := os.WriteFile(filepath.Join(dir, name), []byte(data), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ guest := newClient()
+ if code := f.status(guest, "GET", "/api/v1/themes/paper", "", nil); code != 401 {
+ t.Fatalf("匿名用户不应读取主题,得到 %d", code)
+ }
+ themes := decode[struct{ Themes []themeManifest }](t, f.do(f.admin, "GET", "/api/v1/themes", "", nil))
+ if len(themes.Themes) != 2 || themes.Themes[1].ID != "paper" {
+ t.Fatalf("主题列表错误:%+v", themes.Themes)
+ }
+ theme := decode[map[string]string](t, f.do(f.admin, "GET", "/api/v1/themes/paper", "", nil))
+ if !strings.Contains(theme["css"], "#faf6ed") {
+ t.Fatal("未读取到主题 CSS")
+ }
+ f.write("笔记.md", "正文")
+ f.do(f.admin, "PATCH", "/api/v1/settings", `{"theme":"paper","colorMode":"dark","fontSize":18,"privateSetting":"不公开","cssSnippets":[{"id":"a","name":"启用","css":"body { color: red }","enabled":true},{"id":"b","name":"停用","css":"不公开片段","enabled":false}]}`, nil)
+ sh := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", `{"path":"笔记.md","password":"访问密码"}`, nil))
+ base := "/api/v1/public/shares/" + sh.Token
+ if code := f.status(guest, "GET", base+"/appearance", "", nil); code != 401 {
+ t.Fatalf("未解锁不能读取外观,得到 %d", code)
+ }
+ f.do(guest, "POST", base+"/unlock", `{"password":"访问密码"}`, nil)
+ res := f.do(guest, "GET", base+"/appearance", "", nil)
+ raw, err := io.ReadAll(res.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var appearance map[string]any
+ if err := json.Unmarshal(raw, &appearance); err != nil {
+ t.Fatal(err)
+ }
+ if res.StatusCode != 200 || appearance["colorMode"] != "dark" || !strings.Contains(appearance["themeCSS"].(string), "#faf6ed") {
+ t.Fatalf("分享外观错误:%s", raw)
+ }
+ if strings.Contains(string(raw), "不公开") || len(appearance["cssSnippets"].([]any)) != 1 {
+ t.Fatalf("分享泄露了其他设置或停用片段:%s", raw)
+ }
+ f.do(f.admin, "DELETE", "/api/v1/shares/"+sh.Token, "", nil)
+ if code := f.status(guest, "GET", base+"/appearance", "", nil); code != 404 {
+ t.Fatalf("撤销分享后不能读取外观,得到 %d", code)
+ }
+ // CSS 符号链接不能读取主题目录之外的应用数据。
+ if err := os.Remove(filepath.Join(dir, "theme.css")); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(filepath.Join(filepath.Dir(f.root), "rawmark.db"), filepath.Join(dir, "theme.css")); err != nil {
+ t.Fatal(err)
+ }
+ if code := f.status(f.admin, "GET", "/api/v1/themes/paper", "", nil); code != http.StatusInternalServerError {
+ t.Fatalf("应拒绝越界链接,得到 %d", code)
+ }
+}
diff --git a/web/src/app/AppearanceSettings.vue b/web/src/app/AppearanceSettings.vue
new file mode 100644
index 0000000..2babe22
--- /dev/null
+++ b/web/src/app/AppearanceSettings.vue
@@ -0,0 +1,104 @@
+
+
+
+ 已临时停用主题和 CSS。可恢复默认外观后重新打开页面。
+
+
+
+
+
+
+
+
+
+ {{ error }}
+ 主题从服务器数据目录的 themes 文件夹读取;主题和已启用的 CSS 也会应用到分享页。
+ CSS 片段
+
+
+
+
+
+
+ 默认外观
+ 若自定义样式导致界面无法操作,可在地址末尾加 ?reset-appearance,临时停用后再恢复默认。
+
diff --git a/web/src/app/SettingsDialog.vue b/web/src/app/SettingsDialog.vue
index d34dc85..6d6102a 100644
--- a/web/src/app/SettingsDialog.vue
+++ b/web/src/app/SettingsDialog.vue
@@ -3,6 +3,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { api, ApiError } from './api'
import { allCommands, formatKeys, runCommand } from './commands'
import Icon, { type IconName } from './Icon.vue'
+import AppearanceSettings from './AppearanceSettings.vue'
import { CONTENT_WIDTHS, FONT_SIZES, loadSettings, settings, updateSettings, type Settings } from './settings'
import SharePanel from './SharePanel.vue'
import TokensPanel from './TokensPanel.vue'
@@ -106,6 +107,7 @@ async function changePassword() {
+
字号