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"})
}))