From e409f967a950d4d3413c95b8924c2f68a866327a Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 11 Sep 2026 11:46:53 +0800 Subject: [PATCH] feat(admin): custom wallpaper for the nav background MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - admin-server 新增 GET/POST /api/wallpaper: multipart 上传(png/jpg/webp/gif/svg,上限 8MB)或 JSON 直接给 URL, 服务端把一段带标记的 CSS 写入 appConfig.customCss(标记块可反复替换, 不碰主题其余部分),并记录 appConfig.wallpaper / wallpaperDim。 - 上传的图片落在 data 卷 wallpapers/,nginx 以 /wallpapers/ 提供 (长缓存,文件名带时间戳)。 - 遮罩:壁纸上面铺一层可调强度的白色 linear-gradient,默认 0.45, 否则玻璃卡片上的文字在照片上会读不清。 - 安全:URL 校验只允许 / 或 http(s):// 开头且不含引号/括号/换行, 避免注入到 CSS;类型判定走正文特征而非文件名。 - admin UI 增加「外观」区块:上传/填 URL/遮罩滑块/应用/移除 + 预览。 实测:上传 → /wallpapers/wall-*.png,web 200 image/png, body background-image 为遮罩+图片,移除后恢复默认渐变。 --- admin-server/main.go | 216 ++++++++++++++++++++++++++++++++++++++++++- admin/index.html | 96 +++++++++++++++++++ nginx.conf | 6 ++ 3 files changed, 317 insertions(+), 1 deletion(-) diff --git a/admin-server/main.go b/admin-server/main.go index d8f5dc7..ae8a9e1 100644 --- a/admin-server/main.go +++ b/admin-server/main.go @@ -9,6 +9,7 @@ import ( "net/http" "os" "path/filepath" + "strconv" "strings" "sync" "time" @@ -28,9 +29,221 @@ var ( requireTokenForWrite = isTruthy(os.Getenv("NAVI_ADMIN_REQUIRE_TOKEN")) // 本地上传的图标目录(Dashy 通过 /item-icons/ 读取) iconDir = envOr("NAVI_ICON_DIR", filepath.Join(filepath.Dir(confFile), "item-icons")) - mu sync.Mutex + // 壁纸目录(nginx 通过 /wallpapers/ 提供) + wallpaperDir = envOr("NAVI_WALLPAPER_DIR", filepath.Join(filepath.Dir(confFile), "wallpapers")) + mu sync.Mutex ) +// ── 壁纸 ── +// +// Dashy 没有原生壁纸配置,所以由服务端把一段带标记的 CSS 写进 +// appConfig.customCss。标记块可反复替换,不碰主题的其余部分。 +const ( + wallpaperBegin = "/* ==NAVI-WALLPAPER-BEGIN== */" + wallpaperEnd = "/* ==NAVI-WALLPAPER-END== */" + wallpaperMaxBytes = 8 << 20 +) + +// applyWallpaperBlock 把壁纸规则替换/插入 customCss;url 为空则只移除。 +// dim 是铺在图片上的白色遮罩强度,保证玻璃卡片上的文字仍然可读。 +func applyWallpaperBlock(css, url string, dim float64) string { + for { + i := strings.Index(css, wallpaperBegin) + if i < 0 { + break + } + rest := css[i:] + j := strings.Index(rest, wallpaperEnd) + if j < 0 { + css = css[:i] + break + } + css = css[:i] + rest[j+len(wallpaperEnd):] + } + css = strings.TrimRight(css, " \t\r\n") + if url == "" { + return css + "\n" + } + if dim < 0 { + dim = 0 + } + if dim > 0.9 { + dim = 0.9 + } + scrim := strconv.FormatFloat(dim, 'f', 2, 64) + return css + "\n\n" + wallpaperBegin + "\n" + + "html[data-theme] body{\n" + + " background-image:\n" + + " linear-gradient(rgba(255,255,255," + scrim + "), rgba(255,255,255," + scrim + ")),\n" + + " url(\"" + url + "\") !important;\n" + + " background-size: cover !important;\n" + + " background-position: center center !important;\n" + + " background-attachment: fixed !important;\n" + + " background-repeat: no-repeat !important;\n" + + "}\n" + wallpaperEnd + "\n" +} + +// safeCSSURL 避免把引号/括号/换行注入到 CSS 里。 +func safeCSSURL(u string) bool { + if u == "" { + return true + } + if len(u) > 512 { + return false + } + if strings.ContainsAny(u, "\"'()\\\n\r\t") { + return false + } + return strings.HasPrefix(u, "/") || strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") +} + +func readWallpaper(cfg *Config) map[string]any { + url, _ := cfg.AppConfig["wallpaper"].(string) + dim := 0.45 + switch v := cfg.AppConfig["wallpaperDim"].(type) { + case float64: + dim = v + case int: + dim = float64(v) + } + return map[string]any{"url": url, "dim": dim} +} + +// handleWallpaper 支持: +// +// GET 查看当前壁纸 +// POST multipart 上传图片(字段 file) +// POST json 直接设置 URL 或关闭({"url":"","dim":0.45}) +func handleWallpaper(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + cfg, err := loadConfig() + if err != nil { + jsonErr(w, 500, err.Error()) + return + } + if cfg.AppConfig == nil { + cfg.AppConfig = map[string]any{} + } + + switch r.Method { + case http.MethodGet: + jsonResp(w, readWallpaper(cfg)) + return + case http.MethodPost: + default: + jsonErr(w, 405, "method not allowed") + return + } + + if !requireAdminToken(w, r) { + return + } + + url := "" + dim := 0.45 + if v, ok := cfg.AppConfig["wallpaperDim"].(float64); ok { + dim = v + } + + ct := r.Header.Get("Content-Type") + if strings.HasPrefix(ct, "multipart/form-data") { + r.Body = http.MaxBytesReader(w, r.Body, wallpaperMaxBytes+1<<20) + if err := r.ParseMultipartForm(4 << 20); err != nil { + jsonErr(w, 400, "解析上传失败: "+err.Error()) + return + } + file, _, err := r.FormFile("file") + if err != nil { + jsonErr(w, 400, "缺少 file 字段") + return + } + defer file.Close() + data, err := io.ReadAll(io.LimitReader(file, wallpaperMaxBytes+1)) + if err != nil { + jsonErr(w, 400, err.Error()) + return + } + if len(data) > wallpaperMaxBytes { + jsonErr(w, 400, "壁纸过大(上限 8MB)") + return + } + ext, ok := detectWallpaperExt(data) + if !ok { + jsonErr(w, 400, "壁纸只支持 png / jpg / webp / gif / svg") + return + } + name := fmt.Sprintf("wall-%d%s", time.Now().Unix(), ext) + if err := os.MkdirAll(wallpaperDir, 0755); err != nil { + jsonErr(w, 500, err.Error()) + return + } + if err := os.WriteFile(filepath.Join(wallpaperDir, name), data, 0644); err != nil { + jsonErr(w, 500, err.Error()) + return + } + url = "/wallpapers/" + name + } else { + var body struct { + URL *string `json:"url"` + Dim *float64 `json:"dim"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + jsonErr(w, 400, err.Error()) + return + } + if body.URL != nil { + url = strings.TrimSpace(*body.URL) + } else { + url, _ = cfg.AppConfig["wallpaper"].(string) + } + if body.Dim != nil { + dim = *body.Dim + } + } + + if !safeCSSURL(url) { + jsonErr(w, 400, "壁纸地址不合法(需以 / 或 http(s):// 开头,且不含引号括号)") + return + } + + css, _ := cfg.AppConfig["customCss"].(string) + cfg.AppConfig["customCss"] = applyWallpaperBlock(css, url, dim) + cfg.AppConfig["wallpaper"] = url + cfg.AppConfig["wallpaperDim"] = dim + + if err := saveConfig(cfg); err != nil { + jsonErr(w, 500, err.Error()) + return + } + jsonResp(w, map[string]any{"url": url, "dim": dim}) +} + +// detectWallpaperExt 以正文特征判定图片类型。 +func detectWallpaperExt(data []byte) (string, bool) { + switch { + case bytes.HasPrefix(data, []byte("\x89PNG\r\n\x1a\n")): + return ".png", true + case bytes.HasPrefix(data, []byte("\xff\xd8\xff")): + return ".jpg", true + case bytes.HasPrefix(data, []byte("GIF87a")), bytes.HasPrefix(data, []byte("GIF89a")): + return ".gif", true + case len(data) >= 12 && bytes.HasPrefix(data, []byte("RIFF")) && bytes.Equal(data[8:12], []byte("WEBP")): + return ".webp", true + } + trim := bytes.TrimSpace(bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})) + low := bytes.ToLower(trim) + if len(low) > 512 { + low = low[:512] + } + if bytes.HasPrefix(low, []byte("

加载中…

+ +
+

外观

+

上传一张壁纸作为导航页背景。上面会铺一层浅色遮罩, + 保证玻璃卡片上的文字仍然清晰(下面可调强度)。

+
+
+
+
+ + + +
+
+
+ + +
+
+ + +
+
+
+
@@ -383,9 +408,80 @@ document.getElementById('ic').addEventListener('input', (e) => { } }); +/* ── 外观:壁纸 ── */ +async function loadWallpaper() { + try { + const r = await fetch(A + '/wallpaper', { headers: authHeaders() }); + const d = await r.json(); + document.getElementById('wp-url').value = d.url || ''; + const dim = (typeof d.dim === 'number') ? d.dim : 0.45; + document.getElementById('wp-dim').value = dim; + document.getElementById('dim-val').textContent = Number(dim).toFixed(2); + renderWallPreview(d.url); + } catch (e) { /* 忽略:不影响其它功能 */ } +} + +function renderWallPreview(u) { + const box = document.getElementById('wall-preview'); + if (!u) { box.innerHTML = '

当前使用默认渐变背景

'; return; } + box.innerHTML = ''; +} + +async function saveWallpaper(url) { + const r = await fetch(A + '/wallpaper', { + method: 'POST', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ url: url, dim: parseFloat(document.getElementById('wp-dim').value) }), + }); + if (r.ok) { + const d = await r.json(); + document.getElementById('wp-url').value = d.url || ''; + renderWallPreview(d.url); + toast(d.url ? '壁纸已应用' : '已移除壁纸'); + } else { + toast('保存失败: ' + (await r.text()).slice(0, 120), '#ef4444'); + } +} + +document.getElementById('wp-dim').addEventListener('input', (e) => { + document.getElementById('dim-val').textContent = Number(e.target.value).toFixed(2); +}); + +document.getElementById('apply-wall').addEventListener('click', () => { + saveWallpaper(document.getElementById('wp-url').value.trim()); +}); + +document.getElementById('clear-wall').addEventListener('click', () => { + document.getElementById('wp-url').value = ''; + saveWallpaper(''); +}); + +document.getElementById('pick-wall').addEventListener('click', () => { + document.getElementById('file-wall').click(); +}); + +document.getElementById('file-wall').addEventListener('change', async (ev) => { + const f = ev.target.files && ev.target.files[0]; + ev.target.value = ''; + if (!f) return; + if (f.size > 8 * 1024 * 1024) { toast('壁纸过大(上限 8MB)', '#ef4444'); return; } + const fd = new FormData(); + fd.append('file', f); + let r; + try { + r = await fetch(A + '/wallpaper', { method: 'POST', headers: authHeaders(), body: fd }); + } catch (e) { toast('上传失败: ' + e.message, '#ef4444'); return; } + if (!r.ok) { toast('上传失败: ' + (await r.text()).slice(0, 120), '#ef4444'); return; } + const d = await r.json(); + document.getElementById('wp-url').value = d.url || ''; + renderWallPreview(d.url); + toast('壁纸已上传并应用'); +}); + (async function init() { await loadPageInfo(); await loadSections(); + await loadWallpaper(); })(); diff --git a/nginx.conf b/nginx.conf index 2291603..97321a5 100644 --- a/nginx.conf +++ b/nginx.conf @@ -86,6 +86,12 @@ http { alias /app/data/item-icons/; } + # 自定义壁纸(后台「外观」上传,目录也在 data 卷) + location /wallpapers/ { + alias /app/data/wallpapers/; + add_header Cache-Control "public, max-age=31536000, immutable"; + } + # Dashy SPA 路由兜底 location / { try_files $uri $uri/ /index.html;