Files
HomeDash/admin-server/main.go
JianFeeeee e4967b99ee feat(admin): 备案号可编辑,页脚模板由服务端生成
备案号原来只能写死在 pageInfo.footer 的 HTML 里。现在 /admin/ 站点信息
拆出「ICP 备案号」「公安备案号」两个输入框,页脚 HTML 由服务端
beianFooter() 统一按模板生成(含 html.EscapeString 转义),
自定义页脚 HTML 降级为「高级」折叠项;旧 footer 会被解析回填到字段。

conf.example.yml 里重复的 .navi-search-bar 规则已由 web/navi.css 接管,删除。
2026-09-11 16:25:26 +08:00

702 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"bytes"
"encoding/json"
"fmt"
"html"
"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,
})
}
// beianFooter 按固定模板生成页脚备案号 HTML。
//
// 备案号在 /admin/ 里就是两个普通输入框,模板统一由服务端生成,
// 用户不必写 HTML;类名与 web/navi.css 里的 .navi-footer 对应。
// footer 字段本身仍然保留:传了自定义 HTML 就以它为准(高级用法)。
func beianFooter(icp, gongan string) string {
icp = strings.TrimSpace(icp)
gongan = strings.TrimSpace(gongan)
parts := make([]string, 0, 2)
if icp != "" {
parts = append(parts, `<a href="https://beian.miit.gov.cn" target="_blank" rel="noopener noreferrer">`+html.EscapeString(icp)+`</a>`)
}
if gongan != "" {
parts = append(parts, `<a href="https://beian.mps.gov.cn" target="_blank" rel="noopener noreferrer">`+html.EscapeString(gongan)+`</a>`)
}
if len(parts) == 0 {
return ""
}
return `<div class="navi-footer">` + strings.Join(parts, `<span class="navi-footer-sep">·</span>`) + `</div>`
}
// mapString 读取 map 里的字符串字段,缺失或类型不符时返回空串。
func mapString(m map[string]any, key string) string {
v, _ := m[key].(string)
return v
}
// ── 站点信息:标题 / 副标题 / 顶部导航 / 页脚 ──
// 这些东西原先只能在 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"`
ICP *string `json:"icp"`
Gongan *string `json:"gongan"`
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
}
// 备案号单独存字段,便于在 /admin/ 里直接编辑,而不是让用户写 HTML。
if body.ICP != nil {
cfg.PageInfo["icp"] = *body.ICP
}
if body.Gongan != nil {
cfg.PageInfo["gongan"] = *body.Gongan
}
// 页脚为空(留空 = 交给模板)时,按备案号重新生成。
if strings.TrimSpace(mapString(cfg.PageInfo, "footer")) == "" {
cfg.PageInfo["footer"] = beianFooter(mapString(cfg.PageInfo, "icp"), mapString(cfg.PageInfo, "gongan"))
}
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))
}