- 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 为遮罩+图片,移除后恢复默认渐变。
641 lines
17 KiB
Go
641 lines
17 KiB
Go
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"gopkg.in/yaml.v3"
|
||
)
|
||
|
||
var (
|
||
confFile = envOr("DASHY_CONF", "/app/data/conf.yml")
|
||
adminToken = os.Getenv("NAVI_ADMIN_TOKEN")
|
||
// requireTokenForWrite:是否需要额外的 Bearer token。
|
||
//
|
||
// /admin/ 在生产里整体位于外层统一认证反向代理(portal-nginx 的 auth_request)
|
||
// 之后,再叠一层 token 是重复的,而且会让 admin UI 的写操作静默 401。
|
||
// 所以默认**不校验**;只有当你把容器端口直接暴露给不受信的网段、
|
||
// 而没有外层认证时才设 NAVI_ADMIN_REQUIRE_TOKEN=1。
|
||
requireTokenForWrite = isTruthy(os.Getenv("NAVI_ADMIN_REQUIRE_TOKEN"))
|
||
// 本地上传的图标目录(Dashy 通过 /item-icons/ 读取)
|
||
iconDir = envOr("NAVI_ICON_DIR", filepath.Join(filepath.Dir(confFile), "item-icons"))
|
||
// 壁纸目录(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":
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// requireAdminToken 仅在显式开启时才校验。
|
||
func requireAdminToken(w http.ResponseWriter, r *http.Request) bool {
|
||
if !requireTokenForWrite {
|
||
return true
|
||
}
|
||
if adminToken == "" {
|
||
return true // 开了开关却没配 token,等同于不校验
|
||
}
|
||
return requireToken(w, r, adminToken)
|
||
}
|
||
|
||
func requireToken(w http.ResponseWriter, r *http.Request, expected string) bool {
|
||
if expected == "" {
|
||
return true // 未配置 token 则不校验
|
||
}
|
||
auth := r.Header.Get("Authorization")
|
||
if strings.HasPrefix(auth, "Bearer ") && strings.TrimPrefix(auth, "Bearer ") == expected {
|
||
return true
|
||
}
|
||
jsonErr(w, http.StatusUnauthorized, "invalid or missing token")
|
||
return false
|
||
}
|
||
|
||
type Config struct {
|
||
AppConfig map[string]any `yaml:"appConfig" json:"appConfig"`
|
||
PageInfo map[string]any `yaml:"pageInfo" json:"pageInfo"`
|
||
Sections []Section `yaml:"sections" json:"sections"`
|
||
// Extra 用 yaml 的 inline 机制原样保留本程序不认识的顶层键(如 layout、
|
||
// iconSize、pages 等)。admin 保存 conf.yml 时不能把没碰过的配置丢掉。
|
||
Extra map[string]any `yaml:",inline" json:"-"`
|
||
}
|
||
|
||
type Section struct {
|
||
Name string `yaml:"name" json:"name"`
|
||
Icon string `yaml:"icon,omitempty" json:"icon,omitempty"`
|
||
Collapsed *bool `yaml:"collapsed,omitempty" json:"collapsed,omitempty"`
|
||
Items []NavItem `yaml:"items" json:"items"`
|
||
Extra map[string]any `yaml:",inline" json:"-"`
|
||
}
|
||
|
||
type NavItem struct {
|
||
Title string `yaml:"title" json:"title"`
|
||
Description string `yaml:"description,omitempty" json:"description,omitempty"`
|
||
Icon string `yaml:"icon,omitempty" json:"icon,omitempty"`
|
||
URL string `yaml:"url" json:"url"`
|
||
StatusCheck *bool `yaml:"statusCheck,omitempty" json:"statusCheck,omitempty"`
|
||
// Dashy 的 item 还可能有 target / hotkey / color 等字段,一并保留
|
||
Extra map[string]any `yaml:",inline" json:"-"`
|
||
}
|
||
|
||
func envOr(k, def string) string {
|
||
if v := os.Getenv(k); v != "" {
|
||
return v
|
||
}
|
||
return def
|
||
}
|
||
|
||
func loadConfig() (*Config, error) {
|
||
data, err := os.ReadFile(confFile)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var cfg Config
|
||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||
return nil, err
|
||
}
|
||
return &cfg, nil
|
||
}
|
||
|
||
func saveConfig(cfg *Config) error {
|
||
data, err := yaml.Marshal(cfg)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
tmp := confFile + ".tmp"
|
||
if err := os.WriteFile(tmp, data, 0644); err != nil {
|
||
return err
|
||
}
|
||
return os.Rename(tmp, confFile)
|
||
}
|
||
|
||
func cors(next http.HandlerFunc) http.HandlerFunc {
|
||
return func(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
|
||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||
if r.Method == http.MethodOptions {
|
||
w.WriteHeader(204)
|
||
return
|
||
}
|
||
next(w, r)
|
||
}
|
||
}
|
||
|
||
func jsonResp(w http.ResponseWriter, v any) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(v)
|
||
}
|
||
|
||
func jsonErr(w http.ResponseWriter, status int, msg string) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(status)
|
||
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||
}
|
||
|
||
func handleSections(w http.ResponseWriter, r *http.Request) {
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
cfg, err := loadConfig()
|
||
if err != nil {
|
||
jsonErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
jsonResp(w, cfg.Sections)
|
||
}
|
||
|
||
func handleAddItem(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
jsonErr(w, 405, "method not allowed")
|
||
return
|
||
}
|
||
if !requireAdminToken(w, r) {
|
||
return
|
||
}
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
|
||
var req struct {
|
||
SectionIndex int `json:"sectionIndex"`
|
||
Item NavItem `json:"item"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
jsonErr(w, 400, err.Error())
|
||
return
|
||
}
|
||
if req.Item.Title == "" || req.Item.URL == "" {
|
||
jsonErr(w, 400, "title and url required")
|
||
return
|
||
}
|
||
// Block javascript: URLs
|
||
if strings.HasPrefix(strings.ToLower(req.Item.URL), "javascript:") {
|
||
jsonErr(w, 400, "javascript: URLs not allowed")
|
||
return
|
||
}
|
||
|
||
cfg, err := loadConfig()
|
||
if err != nil {
|
||
jsonErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
if req.SectionIndex < 0 || req.SectionIndex >= len(cfg.Sections) {
|
||
jsonErr(w, 400, "invalid section index")
|
||
return
|
||
}
|
||
cfg.Sections[req.SectionIndex].Items = append(cfg.Sections[req.SectionIndex].Items, req.Item)
|
||
if err := saveConfig(cfg); err != nil {
|
||
jsonErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
jsonResp(w, map[string]string{"status": "ok"})
|
||
}
|
||
|
||
func handleDeleteItem(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodDelete {
|
||
jsonErr(w, 405, "method not allowed")
|
||
return
|
||
}
|
||
if !requireAdminToken(w, r) {
|
||
return
|
||
}
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
|
||
var req struct {
|
||
SectionIndex int `json:"sectionIndex"`
|
||
ItemIndex int `json:"itemIndex"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
jsonErr(w, 400, err.Error())
|
||
return
|
||
}
|
||
|
||
cfg, err := loadConfig()
|
||
if err != nil {
|
||
jsonErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
if req.SectionIndex < 0 || req.SectionIndex >= len(cfg.Sections) {
|
||
jsonErr(w, 400, "invalid section index")
|
||
return
|
||
}
|
||
sec := &cfg.Sections[req.SectionIndex]
|
||
if req.ItemIndex < 0 || req.ItemIndex >= len(sec.Items) {
|
||
jsonErr(w, 400, "invalid item index")
|
||
return
|
||
}
|
||
sec.Items = append(sec.Items[:req.ItemIndex], sec.Items[req.ItemIndex+1:]...)
|
||
if err := saveConfig(cfg); err != nil {
|
||
jsonErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
jsonResp(w, map[string]string{"status": "ok"})
|
||
}
|
||
|
||
// ── 站点信息:标题 / 副标题 / 顶部导航 / 页脚 ──
|
||
// 这些东西原先只能在 conf.yml 里手改,现在可在 /admin/ 编辑。
|
||
func handlePageInfo(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.PageInfo == nil {
|
||
cfg.PageInfo = map[string]any{}
|
||
}
|
||
|
||
switch r.Method {
|
||
case http.MethodGet:
|
||
jsonResp(w, cfg.PageInfo)
|
||
case http.MethodPost:
|
||
if !requireAdminToken(w, r) {
|
||
return
|
||
}
|
||
var body struct {
|
||
Title *string `json:"title"`
|
||
Description *string `json:"description"`
|
||
Footer *string `json:"footer"`
|
||
NavLinks *[]map[string]any `json:"navLinks"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||
jsonErr(w, 400, err.Error())
|
||
return
|
||
}
|
||
if body.Title != nil {
|
||
cfg.PageInfo["title"] = *body.Title
|
||
}
|
||
if body.Description != nil {
|
||
cfg.PageInfo["description"] = *body.Description
|
||
}
|
||
if body.Footer != nil {
|
||
cfg.PageInfo["footer"] = *body.Footer
|
||
}
|
||
if body.NavLinks != nil {
|
||
cfg.PageInfo["navLinks"] = *body.NavLinks
|
||
}
|
||
if err := saveConfig(cfg); err != nil {
|
||
jsonErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
jsonResp(w, cfg.PageInfo)
|
||
default:
|
||
jsonErr(w, 405, "method not allowed")
|
||
}
|
||
}
|
||
|
||
// ── 图标上传 ──
|
||
//
|
||
// 只接受 Dashy 能渲染的格式:其 isImage() 白名单是 [svg,png,jpg]。
|
||
// .ico 渲染不出来,由前端先用 canvas 转成 PNG 再上传。
|
||
const iconMaxBytes = 512 << 10
|
||
|
||
func handleIconUpload(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
jsonErr(w, 405, "method not allowed")
|
||
return
|
||
}
|
||
if !requireAdminToken(w, r) {
|
||
return
|
||
}
|
||
r.Body = http.MaxBytesReader(w, r.Body, 4<<20)
|
||
if err := r.ParseMultipartForm(4 << 20); err != nil {
|
||
jsonErr(w, 400, "解析上传失败: "+err.Error())
|
||
return
|
||
}
|
||
file, hdr, err := r.FormFile("file")
|
||
if err != nil {
|
||
jsonErr(w, 400, "缺少 file 字段")
|
||
return
|
||
}
|
||
defer file.Close()
|
||
|
||
data, err := io.ReadAll(io.LimitReader(file, iconMaxBytes+1))
|
||
if err != nil {
|
||
jsonErr(w, 400, err.Error())
|
||
return
|
||
}
|
||
if len(data) > iconMaxBytes {
|
||
jsonErr(w, 400, "图标过大(上限 512KB)")
|
||
return
|
||
}
|
||
ext, ok := detectIconExt(data)
|
||
if !ok {
|
||
jsonErr(w, 400, "只支持 png / svg / jpg(.ico 请在页面上选择,会自动转成 png)")
|
||
return
|
||
}
|
||
name := safeIconName(r.FormValue("name"), hdr.Filename, ext)
|
||
if err := os.MkdirAll(iconDir, 0755); err != nil {
|
||
jsonErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
if err := os.WriteFile(filepath.Join(iconDir, name), data, 0644); err != nil {
|
||
jsonErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
jsonResp(w, map[string]string{"icon": name})
|
||
}
|
||
|
||
// detectIconExt 以**正文特征**判定类型,不信任上传的文件名/Cotent-Type。
|
||
func detectIconExt(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
|
||
}
|
||
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
|
||
}
|
||
|
||
// safeIconName 用「提示名 + 时间戳」生成安全文件名:时间戳兼做缓存失效,
|
||
// 避免换图后 Dashy 或浏览器还沿用旧图。
|
||
func safeIconName(hint, filename, ext string) string {
|
||
base := strings.TrimSpace(hint)
|
||
if base == "" {
|
||
base = filename
|
||
}
|
||
base = strings.ToLower(strings.TrimSuffix(base, filepath.Ext(base)))
|
||
var b strings.Builder
|
||
for _, r := range base {
|
||
switch {
|
||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_':
|
||
b.WriteRune(r)
|
||
case r == '.' || r == ' ':
|
||
b.WriteByte('-')
|
||
}
|
||
}
|
||
s := b.String()
|
||
if s == "" {
|
||
s = "icon"
|
||
}
|
||
if len(s) > 48 {
|
||
s = s[:48]
|
||
}
|
||
return fmt.Sprintf("%s-%d%s", s, time.Now().Unix(), ext)
|
||
}
|
||
|
||
func main() {
|
||
// Ensure conf file exists
|
||
if _, err := os.Stat(confFile); os.IsNotExist(err) {
|
||
defaultConf := envOr("NAVI_DEFAULT_CONFIG", "/app/defaults/conf.yml")
|
||
if data, err := os.ReadFile(defaultConf); err == nil {
|
||
os.MkdirAll(filepath.Dir(confFile), 0755)
|
||
os.WriteFile(confFile, data, 0644)
|
||
}
|
||
}
|
||
|
||
// Serve admin UI static files (nginx strips /admin/ prefix)
|
||
http.Handle("/", http.FileServer(http.Dir("/app/admin")))
|
||
|
||
http.HandleFunc("/api/sections", cors(handleSections))
|
||
http.HandleFunc("/api/item", cors(handleAddItem))
|
||
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"})
|
||
}))
|
||
http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||
jsonResp(w, map[string]string{"status": "ok"})
|
||
})
|
||
|
||
log.Println("admin-server listening on :3005")
|
||
log.Fatal(http.ListenAndServe(":3005", nil))
|
||
}
|