feat(admin): custom wallpaper for the nav background

- 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 为遮罩+图片,移除后恢复默认渐变。
This commit is contained in:
JianFeeeee
2026-09-11 11:46:53 +08:00
parent f8c4771828
commit e409f967a9
3 changed files with 317 additions and 1 deletions

View File

@ -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("<svg")) ||
(bytes.HasPrefix(low, []byte("<?xml")) && bytes.Contains(low, []byte("<svg"))) {
return ".svg", true
}
return "", false
}
func isTruthy(v string) bool {
switch strings.ToLower(strings.TrimSpace(v)) {
case "1", "true", "yes", "on":
@ -414,6 +627,7 @@ func main() {
http.HandleFunc("/api/item/delete", cors(handleDeleteItem))
http.HandleFunc("/api/pageinfo", cors(handlePageInfo))
http.HandleFunc("/api/icon", cors(handleIconUpload))
http.HandleFunc("/api/wallpaper", cors(handleWallpaper))
http.HandleFunc("/api/reload", cors(func(w http.ResponseWriter, r *http.Request) {
jsonResp(w, map[string]string{"status": "saved"})
}))

View File

@ -124,6 +124,31 @@ details summary{cursor:pointer;font-size:13px;font-weight:600;color:var(--tx2)}
<!-- 分组 -->
<div id="app"><div class="card"><p class="hint">加载中…</p></div></div>
<!-- 外观 -->
<div class="card">
<h2>外观</h2>
<p class="hint">上传一张壁纸作为导航页背景。上面会铺一层浅色遮罩,
保证玻璃卡片上的文字仍然清晰(下面可调强度)。</p>
<div class="row" style="margin-top:10px">
<div class="fg"><label>壁纸地址(也可直接填 URL)</label>
<input id="wp-url" placeholder="/wallpapers/wall-xxx.jpg 或 https://..."></div>
<div class="fg" style="max-width:170px;justify-content:flex-end">
<label>&nbsp;</label>
<button id="pick-wall">上传壁纸</button>
<input type="file" id="file-wall" accept="image/*" hidden>
</div>
</div>
<div class="fg" style="max-width:340px">
<label>遮罩强度 <span id="dim-val">0.45</span></label>
<input type="range" id="wp-dim" min="0" max="0.9" step="0.05" value="0.45" style="padding:0">
</div>
<div class="row" style="margin-top:12px">
<button class="primary" id="apply-wall">应用</button>
<button id="clear-wall">移除壁纸</button>
</div>
<div id="wall-preview" style="margin-top:12px"></div>
</div>
<!-- 令牌 -->
<div class="card">
<details>
@ -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 = '<p class="hint">当前使用默认渐变背景</p>'; return; }
box.innerHTML = '<img src="' + esc(u) + '" alt="" style="max-width:100%;max-height:220px;border-radius:12px;border:1px solid var(--bd)">';
}
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();
})();
</script>
</body>

View File

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