package api import ( _ "embed" "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"` } //go:embed themes/white.css var whiteThemeCSS string //go:embed themes/black.css var blackThemeCSS string var builtinThemes = []struct { themeManifest css string }{ {themeManifest{"white", "白色", "1"}, whiteThemeCSS}, {themeManifest{"black", "黑色", "1"}, blackThemeCSS}, } func builtinTheme(id string) (themeManifest, string, bool) { for _, theme := range builtinThemes { if theme.ID == id { return theme.themeManifest, theme.css, true } } return themeManifest{}, "", false } 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) { if manifest, css, ok := builtinTheme(id); ok { return manifest, css, nil } 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"}} for _, theme := range builtinThemes { themes = append(themes, theme.themeManifest) } 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 _, _, ok := builtinTheme(entry.Name()); ok { continue } 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", "textFont", "codeFont", "lineHeight", "paragraphSpacing", "glassOpacity"} { 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) }