Files
HomeDash/admin-server/main.go
JianFeeeee f8c4771828 fix(admin,theme): make admin actually work; glass theme targets real DOM
后台之前有两处让它完全不可用的问题(都属于「看着有、实际不工作」):
1. 写操作 401 —— admin UI 从未带 Authorization 头,而 admin-server 强制校验
   NAVI_ADMIN_TOKEN。于是添加/删除一直静默失败。
2. JSON 键名不匹配 —— Section/NavItem 只有 yaml tag,没有 json tag,
   接口返回 {"Name":..,"Items":[{"Title":..}]},而前端读 s.name / it.title,
   渲染直接抛异常(菜单区永远停在「加载中…」)。

修法:
- admin-server:token 改为**默认不校验**(/admin/ 已在外层门户登录墙内,
  用户确认无需叠加),仅当显式设 NAVI_ADMIN_REQUIRE_TOKEN=1 时才校验;
  历史 DELETE 不受影响(那条路径被门户放行,仍必须自带 token)。
- 补 json tag;Config/Section/NavItem 加 yaml ",inline" 保留未知键,
  避免保存 conf.yml 时把不认识的配置(layout/pages/item 的 target 等)丢掉。
- 新增 /api/pageinfo(标题/副标题/页脚/顶部导航可在后台编辑)与
  /api/icon(图标上传,限 png/svg/jpg,512KB)。
- 重写 admin UI:站点信息 + 顶部导航编辑、图标上传(.ico 用 canvas 转 PNG,
  因为 Dashy 的 isImage() 只认 svg/png/jpg)、可选令牌。

主题:旧 customCss 大部分是死代码 —— .tile / .item-group / .item-group-outer
在页面里根本不存在(querySelectorAll 计数为 0),卡片灰底其实来自 a.item,
而 .home 的不透明背景盖住了 body 渐变,所以玻璃效果一直看不出来。
现按真实类名(a.item / .item-wrapper / .collapsable / .collapsible-content /
.home / header)重写,并加入工作空间侧边栏的溢出修复。

安装页:.btn 自带 display 会盖掉浏览器默认的 [hidden]{display:none},
未配置商店时「一键安装」按钮仍然可见且 href 为空(点了没反应)。
现显式兜住 [hidden],并把浏览器识别补全(iOS/Android 单独提示为不可安装、
Edge/Brave/Vivaldi/Opera 给出各自的 extensions 地址、优先用 userAgentData)。
2026-09-11 11:43:44 +08:00

427 lines
12 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"
"io"
"log"
"net/http"
"os"
"path/filepath"
"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"))
mu sync.Mutex
)
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/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))
}