插件必须填 NAVI_SYNC_TOKEN,但页面上没有任何地方能看到它 —— 用户只能去翻 .env,实际就是配不起来。 - admin-server 新增 GET /admin/api/sync-info,返回 syncToken / hasToken。 放在 /admin/ 下而不是 /api/ 下是必须的:插件上报用的 /api/history/sync 被门户 auth_request 放行,若令牌挂在同一前缀下就等于公开。 实测 /api/history/sync-info 返回 401,只有 /admin/api/sync-info 能取到。 - 安装引导页把「第一步」改为填写两项(服务地址 + 同步令牌),各自可复制; 服务端未设令牌时提示留空即可;未登录时提示需先登录门户。 - README 补充对应说明。
661 lines
18 KiB
Go
661 lines
18 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"))
|
||
// 浏览器插件同步用的令牌(供安装引导页展示,方便用户拷贝)
|
||
syncToken = os.Getenv("NAVI_SYNC_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"})
|
||
}
|
||
|
||
// handleSyncInfo 把浏览器插件需要的接入信息给已登录用户。
|
||
//
|
||
// 为何可以在这里返回令牌:/admin/ 整体在门户登录墙之内;
|
||
// 而 /api/history/sync 是被门户放行的(否则插件无法上报),
|
||
// 所以这个 token 仍然是有意义的凭据,并且不能挂在 /api/ 下暴露。
|
||
func handleSyncInfo(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodGet {
|
||
jsonErr(w, 405, "method not allowed")
|
||
return
|
||
}
|
||
jsonResp(w, map[string]any{
|
||
"syncToken": syncToken,
|
||
"hasToken": syncToken != "",
|
||
"requireAdminToken": requireTokenForWrite,
|
||
})
|
||
}
|
||
|
||
// ── 站点信息:标题 / 副标题 / 顶部导航 / 页脚 ──
|
||
// 这些东西原先只能在 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/sync-info", cors(handleSyncInfo))
|
||
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))
|
||
}
|