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 @@ + + +