Files
HomeDash/admin-server/main.go
HomeDash Bot 472d8d1cd0 feat(pages): 认证页与门户页文案可配置,不再写死
需求:登录页与门户页的内容应可自定义。

实现(配置存数据卷、后台可改、留空回落内置默认,不配置行为不变):
- pages.go:pageStr 读取 pageInfo;renderPortalPage 优先数据卷
  <data>/portal/index.html 整页覆盖,否则用内置模板替换占位符;
  authPageInfo 提供登录页字段
- auth.go:登录页标题/副标题/徽标/页脚改为模板数据(含备案号)
- gateway.go:kind=portal 的首页改由 admin-server /portal-page 渲染
  (静态资源仍由 nginx 提供),使文案可配置
- portal/index.html:加入 <!--PAGE_TITLE--> / <!--HERO_TITLE--> /
  <!--HERO_SUBTITLE--> / <!--FOOTER--> 占位符
- 后台「站点信息」新增六项文案输入(含回填与保存),并提示整页覆盖方式
- README 新增「认证页与门户页文案(可配置,不写死)」含字段表与自检命令

测试:默认回落、配置取值去空白、认证页字段读取、数据卷覆盖优先、空白覆盖忽略。
2026-09-12 09:12:13 +08:00

1262 lines
37 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"
"net/url"
"os"
"os/exec"
"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")
// history-server 地址(管理后台转发历史记录管理请求用)
historyBaseURL = envOr("NAVI_HISTORY_URL", "http://127.0.0.1:3004")
// 本地上传的图标目录(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)
// 深色下必须换一层深色遮罩:否则亮色的 70% 白遮罩会把壁纸洗白
// (实测:深色模式里 background-image 仍是 rgba(255,255,255,.7) 那层)。
// 用同一个 dim 作为深色遮罩强度,深浅两种模式观感一致。
dark := strconv.FormatFloat(dim+0.15, 'f', 2, 64)
if dim+0.15 > 0.9 {
dark = "0.90"
}
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" +
// 深色:同一张壁纸 + 深色遮罩(选择器特异度更高,与注入顺序无关)
"html.navi-dark body.navi-dark,\n" +
"html[data-theme].navi-dark body{\n" +
" background-image:\n" +
" linear-gradient(rgba(6,12,24," + dark + "), rgba(6,12,24," + dark + ")),\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)
}
// ── 历史记录管理 ──
//
// 门户对 /api/history/ 是放行的(不走登录),所以那条路径的 DELETE 必须自带 token。
// 管理后台改走 /admin/api/history*:门户登录已是关卡,admin token 只在容器内部使用,
// 不必让用户在浏览器里保存它。
func historyProxy(method, path, token string) (*http.Response, error) {
req, err := http.NewRequest(method, historyBaseURL+path, nil)
if err != nil {
return nil, err
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
client := &http.Client{Timeout: 10 * time.Second}
return client.Do(req)
}
func relayHistory(w http.ResponseWriter, res *http.Response) {
defer res.Body.Close()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(res.StatusCode)
_, _ = io.Copy(w, res.Body)
}
// handleHistoryList 列出历史记录(按访问次数降序)。
func handleHistoryList(w http.ResponseWriter, r *http.Request) {
res, err := historyProxy(http.MethodGet, "/", syncToken)
if err != nil {
jsonErr(w, 502, "history-server unreachable: "+err.Error())
return
}
relayHistory(w, res)
}
// handleHistoryClear 清空全部历史。
func handleHistoryClear(w http.ResponseWriter, r *http.Request) {
if !requireAdminToken(w, r) {
return
}
res, err := historyProxy(http.MethodDelete, "/", adminToken)
if err != nil {
jsonErr(w, 502, "history-server unreachable: "+err.Error())
return
}
relayHistory(w, res)
}
// handleHistoryDeleteItem 删除单条历史。
func handleHistoryDeleteItem(w http.ResponseWriter, r *http.Request) {
if !requireAdminToken(w, r) {
return
}
raw := r.URL.Query().Get("url")
if raw == "" {
jsonErr(w, 400, "url query parameter is required")
return
}
res, err := historyProxy(http.MethodDelete, "/item?url="+url.QueryEscape(raw), adminToken)
if err != nil {
jsonErr(w, 502, "history-server unreachable: "+err.Error())
return
}
relayHistory(w, res)
}
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})
}
// ── 可内嵌性探测 ──
//
// Dashy 的「工作空间」用 iframe 打开应用。部分应用会带 X-Frame-Options 或
// CSP frame-ancestors 拒绝被嵌,此时浏览器只给一片空白,而且 JS 无法区分
// 「被拒绝」和「加载中」(onload 一样会触发)。所以由服务端先读响应头,
// 前端据此决定:能嵌就嵌,不能嵌就给出明确提示 + 新标签页按钮。
// frameBlockReason 判断响应头是否禁止被本站内嵌;允许时返回空串。
func frameBlockReason(h http.Header) string {
if xfo := strings.TrimSpace(h.Get("X-Frame-Options")); xfo != "" {
return "X-Frame-Options: " + xfo
}
for _, part := range strings.Split(h.Get("Content-Security-Policy"), ";") {
p := strings.TrimSpace(part)
if !strings.HasPrefix(strings.ToLower(p), "frame-ancestors") {
continue
}
if strings.Contains(p, "*") {
return ""
}
return p
}
return ""
}
// frameVerdict 是三态而不是布尔:
// 探测会被 WAF 拦成 403(响应里没有 X-Frame-Options),此时不能当成
// “可内嵌”——否则浏览器照样拒绝,用户只能看到一片空白。
// Conclusive=false 表示探测没拿到可信证据,前端会照常尝试内嵌,
// 但同时在工具条里说明原因并给出新标签页退路。
type frameVerdict struct {
Frameable bool `json:"frameable"`
Conclusive bool `json:"conclusive"`
Reason string `json:"reason"`
}
// browserUA 像普通浏览器:用自造 UA 会被 WAF 当机器人拦掉。
const browserUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
func frameableVerdict(target, cookie string) frameVerdict {
u, err := url.Parse(target)
if err != nil || u.Host == "" {
return frameVerdict{Frameable: false, Conclusive: true, Reason: "地址无效"}
}
if u.Scheme != "http" && u.Scheme != "https" {
// /extension/install.html 这类站内相对地址天然同源,可直接内嵌
return frameVerdict{Frameable: true, Conclusive: true}
}
req, err := http.NewRequest(http.MethodGet, target, nil)
if err != nil {
return frameVerdict{Frameable: false, Conclusive: true, Reason: "地址无效"}
}
req.Header.Set("User-Agent", browserUA)
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
// 关键:带上浏览器自己的 Cookie。门户后面的应用对未登录请求会 302 到
// 登录页(无 X-Frame-Options),不转发会话就会把「重定向到登录页」
// 误判成「可内嵌」。
if cookie != "" {
req.Header.Set("Cookie", cookie)
}
client := &http.Client{Timeout: 8 * time.Second}
res, err := client.Do(req)
if err != nil {
return frameVerdict{Frameable: true, Conclusive: false, Reason: "探测未能连通"}
}
defer res.Body.Close()
io.Copy(io.Discard, io.LimitReader(res.Body, 8<<10))
// 跳到了别的主机(例如登录页)→ 拿到的不是目标应用本身的响应头,不能当证据
if res.Request != nil && res.Request.URL != nil && !strings.EqualFold(res.Request.URL.Host, u.Host) {
return frameVerdict{Frameable: true, Conclusive: false,
Reason: "被重定向到 " + res.Request.URL.Host}
}
// 响应头里有明确的拒绝证据 → 确定不可内嵌
if reason := frameBlockReason(res.Header); reason != "" {
return frameVerdict{Frameable: false, Conclusive: true, Reason: reason}
}
if res.StatusCode < 200 || res.StatusCode > 299 {
return frameVerdict{Frameable: true, Conclusive: false, Reason: fmt.Sprintf("探测返回 HTTP %d", res.StatusCode)}
}
return frameVerdict{Frameable: true, Conclusive: true}
}
func handleFrameable(w http.ResponseWriter, r *http.Request) {
target := r.URL.Query().Get("url")
if target == "" {
jsonErr(w, 400, "缺少 url 参数")
return
}
jsonResp(w, frameableVerdict(target, r.Header.Get("Cookie")))
}
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)
}
// ── 分组(section)增 / 改 / 删 ──
//
// 「历史访问」分组由 history-server 依据浏览记录重建,改名或删除都会被它
// 覆写回来;这里明确拒绝,避免用户以为删掉了、下次同步又“长”回来。
const historySectionName = "历史访问"
func isHistorySection(s Section) bool {
return strings.TrimSpace(s.Name) == historySectionName
}
func handleAddSection(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 {
Name string `json:"name"`
Icon string `json:"icon"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "请求体解析失败")
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
jsonErr(w, 400, "分组名不能为空")
return
}
if name == historySectionName {
jsonErr(w, 409, "「"+historySectionName+"」由浏览历史自动维护,不能手工创建")
return
}
cfg, err := loadConfig()
if err != nil {
jsonErr(w, 500, err.Error())
return
}
for _, s := range cfg.Sections {
if strings.TrimSpace(s.Name) == name {
jsonErr(w, 409, "已存在同名分组")
return
}
}
icon := strings.TrimSpace(req.Icon)
if icon == "" {
icon = "fas fa-star"
}
cfg.Sections = append(cfg.Sections, Section{Name: name, Icon: icon})
if err := saveConfig(cfg); err != nil {
jsonErr(w, 500, err.Error())
return
}
jsonResp(w, cfg.Sections)
}
func handleUpdateSection(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 {
Index int `json:"index"`
Name string `json:"name"`
Icon string `json:"icon"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "请求体解析失败")
return
}
cfg, err := loadConfig()
if err != nil {
jsonErr(w, 500, err.Error())
return
}
if req.Index < 0 || req.Index >= len(cfg.Sections) {
jsonErr(w, 400, "分组不存在")
return
}
if isHistorySection(cfg.Sections[req.Index]) {
jsonErr(w, 409, "「"+historySectionName+"」由浏览历史自动维护,不能修改")
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
jsonErr(w, 400, "分组名不能为空")
return
}
if name == historySectionName {
jsonErr(w, 409, "不能使用分组名「"+historySectionName+"」(它由浏览历史自动维护)")
return
}
for i, s := range cfg.Sections {
if i != req.Index && strings.TrimSpace(s.Name) == name {
jsonErr(w, 409, "已存在同名分组")
return
}
}
cfg.Sections[req.Index].Name = name
if icon := strings.TrimSpace(req.Icon); icon != "" {
cfg.Sections[req.Index].Icon = icon
}
if err := saveConfig(cfg); err != nil {
jsonErr(w, 500, err.Error())
return
}
jsonResp(w, cfg.Sections)
}
// handleDeleteSection 删除分组。默认拒绝删除非空分组(需 force=1),
// 避免误点一下就把一组服务连带删掉。
func handleDeleteSection(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 {
Index int `json:"index"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "请求体解析失败")
return
}
cfg, err := loadConfig()
if err != nil {
jsonErr(w, 500, err.Error())
return
}
if req.Index < 0 || req.Index >= len(cfg.Sections) {
jsonErr(w, 400, "分组不存在")
return
}
if isHistorySection(cfg.Sections[req.Index]) {
jsonErr(w, 409, "「"+historySectionName+"」由浏览历史自动维护,不能删除")
return
}
if n := len(cfg.Sections[req.Index].Items); n > 0 && r.URL.Query().Get("force") != "1" {
jsonErr(w, 409, fmt.Sprintf("该分组下还有 %d 个服务,删除会一并移除;确认请加 force=1", n))
return
}
cfg.Sections = append(cfg.Sections[:req.Index], cfg.Sections[req.Index+1:]...)
if err := saveConfig(cfg); 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"`
// 认证页与门户页的可配置文案(原先写死在代码/镜像里)
AuthTitle *string `json:"authTitle"`
AuthSubtitle *string `json:"authSubtitle"`
AuthLogo *string `json:"authLogo"`
PortalTitle *string `json:"portalTitle"`
HeroTitle *string `json:"heroTitle"`
HeroSubtitle *string `json:"heroSubtitle"`
}
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"))
} // 认证页 / 门户页文案:空串表示回落到内置默认值
setPageStr := func(k string, v *string) {
if v != nil {
if strings.TrimSpace(*v) == "" {
delete(cfg.PageInfo, k)
} else {
cfg.PageInfo[k] = strings.TrimSpace(*v)
}
}
}
setPageStr("authTitle", body.AuthTitle)
setPageStr("authSubtitle", body.AuthSubtitle)
setPageStr("authLogo", body.AuthLogo)
setPageStr("portalTitle", body.PortalTitle)
setPageStr("heroTitle", body.HeroTitle)
setPageStr("heroSubtitle", body.HeroSubtitle)
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)
}
// firstLines 取前 n 行,避免把 nginx 的长输出整段塞进界面。
func firstLines(s string, n int) string {
lines := strings.Split(strings.TrimSpace(s), "\n")
if len(lines) > n {
lines = lines[:n]
}
return strings.Join(lines, "\n")
}
// ── 统一入口(gateway)路由表 API ──
//
// GET /api/gateway/routes → 当前路由表(含"哪些域名走统一认证")
// POST /api/gateway/routes → 整体替换并保存(不立即生效)
// POST /api/gateway/apply → 生成配置 → nginx -t 校验 → 通过才 reload
//
// 校验失败时保留上一份可用配置,并把 nginx 的原始报错回给界面,
// 避免"点了应用,整个入口挂了"。
func handleGatewayRoutes(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
cfg, err := loadGateway()
if err != nil {
jsonErr(w, 500, err.Error())
return
}
jsonResp(w, cfg)
case http.MethodPost:
if !requireAdminToken(w, r) {
return
}
mu.Lock()
defer mu.Unlock()
var req GatewayConfig
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "请求体解析失败")
return
}
if req.Routes == nil {
req.Routes = []Route{}
}
for _, rt := range req.Routes {
if err := ValidateRoute(rt); err != nil {
jsonErr(w, 400, err.Error())
return
}
}
if _, err := RenderGatewayConf(&req); err != nil {
jsonErr(w, 400, err.Error())
return
}
if req.ListenPort == 0 {
req.ListenPort = 8081
}
if req.ListenTLSPort == 0 {
req.ListenTLSPort = 8443
}
// 路由与 Dashy 导航是一份操作:先确认导航分组都存在,再同时更新。
// 任一步失败都恢复 gateway.yml 与 conf.yml,避免后台显示和导航页各说各话。
dashy, err := loadConfig()
if err != nil {
jsonErr(w, 500, err.Error())
return
}
if err := validateGatewayNavigation(&req, dashy); err != nil {
jsonErr(w, 400, err.Error())
return
}
oldGateway, gatewayExisted := os.ReadFile(gatewayFile)
oldConf, confErr := os.ReadFile(confFile)
if confErr != nil {
jsonErr(w, 500, confErr.Error())
return
}
if err := saveGateway(&req); err != nil {
jsonErr(w, 500, err.Error())
return
}
if err := syncGatewayNavigation(&req); err != nil {
if gatewayExisted == nil {
_ = os.WriteFile(gatewayFile, oldGateway, 0o600)
} else {
_ = os.Remove(gatewayFile)
}
_ = os.WriteFile(confFile, oldConf, 0o644)
jsonErr(w, 500, "同步 Dashy 导航失败,已回滚:"+err.Error())
return
}
jsonResp(w, map[string]any{"status": "saved", "routes": len(req.Routes)})
default:
jsonErr(w, 405, "method not allowed")
}
}
func nginxCommand(args ...string) (string, error) {
cmd := exec.Command("nginx", args...)
out, err := cmd.CombinedOutput()
return string(out), err
}
func handleGatewayApply(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()
cfg, err := loadGateway()
if err != nil {
jsonErr(w, 500, err.Error())
return
}
conf, err := RenderGatewayConf(cfg)
if err != nil {
jsonErr(w, 400, err.Error())
return
}
if err := os.MkdirAll(gatewayOutDir, 0o755); err != nil {
jsonErr(w, 500, err.Error())
return
}
target := gatewayFileFor()
prev, hadPrev := []byte{}, false
if b, err := os.ReadFile(target); err == nil {
prev, hadPrev = b, true
}
if err := os.WriteFile(target, []byte(conf), 0o644); err != nil {
jsonErr(w, 500, err.Error())
return
}
// 校验:不通过就把上一份写回去,保证入口始终有一份可用配置
if out, err := nginxCommand("-t"); err != nil {
if hadPrev {
_ = os.WriteFile(target, prev, 0o644)
} else {
_ = os.Remove(target)
}
jsonErr(w, 400, "配置未通过 nginx 校验,已保留上一份可用配置:\n"+firstLines(out, 12))
return
}
if out, err := nginxCommand("-s", "reload"); err != nil {
jsonErr(w, 500, "reload 失败:"+firstLines(out, 6))
return
}
jsonResp(w, map[string]any{"status": "applied", "routes": len(cfg.Routes), "file": target})
}
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)
}
}
// 内置统一登录服务:与 gateway 共处一个容器,不再需要 portal-auth 容器。
registerPortalAuth()
// 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/section", cors(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
handleAddSection(w, r)
case http.MethodDelete:
handleDeleteSection(w, r)
default:
jsonErr(w, 405, "method not allowed")
}
}))
http.HandleFunc("/api/section/update", cors(handleUpdateSection))
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/frameable", cors(handleFrameable))
http.HandleFunc("/api/gateway/routes", cors(handleGatewayRoutes))
http.HandleFunc("/api/gateway/apply", cors(handleGatewayApply))
// 从现有 nginx 配置导入路由(只解析,不写盘)
http.HandleFunc("/api/gateway/import", cors(handleGatewayImport))
// 统一登录的账号设置(存数据卷,改完立即生效,不用重新部署)
http.HandleFunc("/api/portal-auth", cors(handlePortalAuthSettings))
// 门户首页渲染(可配置文案 + 数据卷整页覆盖),供 gateway 的 kind=portal 反代
http.HandleFunc("/portal-page", handlePortalPage)
// 历史记录管理:列表 / 清空全部 / 删除单条
http.HandleFunc("/api/history", cors(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
handleHistoryList(w, r)
case http.MethodDelete:
handleHistoryClear(w, r)
default:
jsonErr(w, 405, "method not allowed")
}
}))
http.HandleFunc("/api/history/item", cors(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
jsonErr(w, 405, "method not allowed")
return
}
handleHistoryDeleteItem(w, r)
}))
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))
}