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:
@ -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"})
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user