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