Add synced themes, color modes and custom CSS snippets
This commit is contained in:
1 parent
1562519f08
commit
7f65162115
17 files changed
+766
-36
No files matched your search
@@ -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},
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user