后台之前有两处让它完全不可用的问题(都属于「看着有、实际不工作」):
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)。
427 lines
12 KiB
Go
427 lines
12 KiB
Go
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))
|
||
}
|