diff --git a/admin-server/main.go b/admin-server/main.go index 465286d..d8f5dc7 100644 --- a/admin-server/main.go +++ b/admin-server/main.go @@ -1,13 +1,17 @@ package main import ( + "bytes" "encoding/json" + "fmt" + "io" "log" "net/http" "os" "path/filepath" "strings" "sync" + "time" "gopkg.in/yaml.v3" ) @@ -15,9 +19,37 @@ import ( var ( confFile = envOr("DASHY_CONF", "/app/data/conf.yml") adminToken = os.Getenv("NAVI_ADMIN_TOKEN") - mu sync.Mutex + // 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 则不校验 @@ -31,25 +63,30 @@ func requireToken(w http.ResponseWriter, r *http.Request, expected string) bool } type Config struct { - AppConfig map[string]any `yaml:"appConfig"` - PageInfo map[string]any `yaml:"pageInfo"` - Sections []Section `yaml:"sections"` - Pages []any `yaml:"pages,omitempty"` + 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"` - Icon string `yaml:"icon,omitempty"` - Collapsed *bool `yaml:"collapsed,omitempty"` - Items []NavItem `yaml:"items"` + 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"` - Description string `yaml:"description,omitempty"` - Icon string `yaml:"icon,omitempty"` - URL string `yaml:"url"` - StatusCheck *bool `yaml:"statusCheck,omitempty"` + 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 { @@ -87,7 +124,7 @@ 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") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") if r.Method == http.MethodOptions { w.WriteHeader(204) return @@ -123,7 +160,7 @@ func handleAddItem(w http.ResponseWriter, r *http.Request) { jsonErr(w, 405, "method not allowed") return } - if !requireToken(w, r, adminToken) { + if !requireAdminToken(w, r) { return } mu.Lock() @@ -169,7 +206,7 @@ func handleDeleteItem(w http.ResponseWriter, r *http.Request) { jsonErr(w, 405, "method not allowed") return } - if !requireToken(w, r, adminToken) { + if !requireAdminToken(w, r) { return } mu.Lock() @@ -206,6 +243,159 @@ func handleDeleteItem(w http.ResponseWriter, r *http.Request) { 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("= '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) { @@ -222,6 +412,8 @@ func main() { 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"}) })) diff --git a/admin/index.html b/admin/index.html index 6195c06..779fd3d 100644 --- a/admin/index.html +++ b/admin/index.html @@ -6,57 +6,387 @@ 导航管理
-

导航管理

-

加载中…

-
-

添加导航项

-
+

导航管理

+

站点信息、顶部导航与各分组服务。图标支持 png / svg / jpg,选择 .ico 会自动转成 png。

+ + +
+

站点信息

+
+
+
+
+
+ + +
+ + + +
+
+ + +
+

添加服务

+
+
+
+
-
+
-
-
+
+
+ + + +
- +
+
+
+ + +

加载中…

+ + +
+
+ 接口令牌(一般不用填) +

+ /admin/ 已在门户登录保护之下,服务端默认不再额外校验令牌。
+ 仅当部署方设置了 NAVI_ADMIN_REQUIRE_TOKEN=1 时才需要填这里的值(对应 NAVI_ADMIN_TOKEN)。 +

+
+
+
+ +
+ diff --git a/conf.example.yml b/conf.example.yml index 1f89c0d..5b31569 100644 --- a/conf.example.yml +++ b/conf.example.yml @@ -1,5 +1,5 @@ appConfig: - theme: default + theme: jianf-glass dayTheme: default nightTheme: midnight customColors: {} @@ -8,50 +8,46 @@ appConfig: searchEngine: custom customSearchEngine: /search?q= openingMethod: newtab - customCss: "" -layout: masonry -iconSize: large -language: zh-CN -statusCheck: true -statusCheckInterval: 60 - + customCss: "/* ═══ JianF 玻璃拟态主题 v2 ═══\n 注意:必须按 Dashy 真实渲染出的类名写。此前版本用了 .tile / .item-group /\n .item-group-outer —— 这三个类在页面里并不存在(querySelectorAll 计数为 0),\n 所以大部分规则是死代码,界面才一直显得平。\n\n 真实结构(自内向外):\n a.item ← 卡片表面(灰底就来自这里)\n div.item-wrapper ← 网格单元\n div.there-are-items\n div.content-inner\n div.collapsible-content\n div.collapsable.section_<名字> ← 分组容器\n div.item-group-container\n */\n\n/* ── 背景 ──\n .home 有不透明背景会盖住 body 渐变,必须一起透明掉,否则彩色背景完全看不见。 */\nhtml[data-theme] body {\n background:\n radial-gradient(1200px 720px at 6% -12%, #a5b4fc 0%, transparent 56%),\n radial-gradient(1000px 640px at 96% 2%, #7dd3fc 0%, transparent 52%),\n radial-gradient(900px 700px at 46% 112%, #f0abfc 0%, transparent 56%),\n linear-gradient(160deg, #eef2ff 0%, #f8fafc 45%, #ecfeff 100%) !important;\n background-attachment: fixed !important;\n min-height: 100vh;\n}\nhtml[data-theme]\ + \ .home {\n background: transparent !important;\n}\n\n/* ── 顶栏 ── */\nhtml[data-theme] header {\n background: rgba(255,255,255,.44) !important;\n backdrop-filter: blur(18px) saturate(180%) !important;\n -webkit-backdrop-filter: blur(18px) saturate(180%) !important;\n border-bottom: 1px solid rgba(255,255,255,.6) !important;\n box-shadow: 0 6px 24px rgba(15,23,42,.06) !important;\n}\n\n/* ── 卡片:真正的表面是 a.item ── */\nhtml[data-theme] a.item {\n background: rgba(255,255,255,.52) !important;\n backdrop-filter: blur(18px) saturate(180%) !important;\n -webkit-backdrop-filter: blur(18px) saturate(180%) !important;\n border: 1px solid rgba(255,255,255,.66) !important;\n border-radius: 18px !important;\n box-shadow:\n 0 6px 18px rgba(15,23,42,.07),\n inset 0 1px 0 rgba(255,255,255,.85) !important;\n transition: transform .28s cubic-bezier(.34,1.2,.64,1),\n box-shadow .28s ease, background .28s ease, border-color .28s ease !important;\n}\nhtml[data-theme] a.item:hover\ + \ {\n background: rgba(255,255,255,.88) !important;\n border-color: rgba(147,197,253,.85) !important;\n transform: translateY(-4px) !important;\n box-shadow:\n 0 18px 36px rgba(30,64,175,.18),\n 0 0 0 1px rgba(147,197,253,.35),\n inset 0 1px 0 rgba(255,255,255,.9) !important;\n}\n/* 外层网格单元保持透明,避免叠两层底 */\nhtml[data-theme] div.item-wrapper {\n background: transparent !important;\n backdrop-filter: none !important;\n -webkit-backdrop-filter: none !important;\n border: none !important;\n box-shadow: none !important;\n}\nhtml[data-theme] a.item .tile-icon,\nhtml[data-theme] a.item .item-icon {\n border-radius: 13px !important;\n filter: drop-shadow(0 3px 6px rgba(15,23,42,.10)) !important;\n transition: transform .28s cubic-bezier(.34,1.2,.64,1) !important;\n}\nhtml[data-theme] a.item:hover .tile-icon { transform: scale(1.08) !important; }\nhtml[data-theme] a.item .tile-title { font-weight: 650 !important; letter-spacing: .1px !important; }\nhtml[data-theme] a.item .description,\n\ + html[data-theme] a.item .item-url { color: #64748b !important; }\n\n/* ── 分组容器 ── */\nhtml[data-theme] div.collapsable {\n background: rgba(255,255,255,.40) !important;\n backdrop-filter: blur(20px) saturate(180%) !important;\n -webkit-backdrop-filter: blur(20px) saturate(180%) !important;\n border: 1px solid rgba(255,255,255,.6) !important;\n border-radius: 24px !important;\n box-shadow:\n 0 12px 32px rgba(15,23,42,.09),\n inset 0 1px 0 rgba(255,255,255,.85) !important;\n overflow: hidden !important;\n}\nhtml[data-theme] div.collapsible-content {\n background: transparent !important;\n border-radius: 0 0 22px 22px !important;\n}\nhtml[data-theme] div.collapsable .content-inner { padding: 10px 12px !important; }\nhtml[data-theme] .item-group-container { padding: 0 4px !important; }\n\n/* 分组标题 */\nhtml[data-theme] .collapsable .title,\nhtml[data-theme] .collapsable h1,\nhtml[data-theme] .collapsable h2 {\n font-weight: 700 !important;\n letter-spacing: .2px !important;\n\ + }\n\n/* ── 搜索 ── */\nhtml[data-theme] .search-container,\nhtml[data-theme] header input[type=\"text\"] {\n background: rgba(255,255,255,.6) !important;\n border: 1px solid rgba(255,255,255,.7) !important;\n border-radius: 16px !important;\n backdrop-filter: blur(14px) saturate(170%) !important;\n -webkit-backdrop-filter: blur(14px) saturate(170%) !important;\n box-shadow: inset 0 1px 0 rgba(255,255,255,.8), 0 4px 14px rgba(15,23,42,.05) !important;\n}\nhtml[data-theme] .navi-search-bar:focus-within {\n box-shadow: 0 14px 34px rgba(37,99,235,.22), 0 0 0 3px rgba(37,99,235,.16) !important;\n}\n\n/* ── 滚动条 ── */\nhtml[data-theme] ::-webkit-scrollbar { width: 10px; height: 10px; }\nhtml[data-theme] ::-webkit-scrollbar-track { background: transparent; }\nhtml[data-theme] ::-webkit-scrollbar-thumb {\n background: rgba(100,116,139,.32);\n border-radius: 999px;\n border: 2px solid transparent;\n background-clip: content-box;\n}\nhtml[data-theme] ::-webkit-scrollbar-thumb:hover {\n\ + \ background: rgba(100,116,139,.5);\n background-clip: content-box;\n}\n\n/* ── 工作空间侧边栏 ──\n 底部「切换视图」按钮组内容 70px 塞进 56px 的栏里,把 side-bar 撑出横向\n 滚动条,必须左右滑动才能看全。这里让它换行并收住溢出。 */\nhtml[data-theme] nav.side-bar { overflow-x: hidden !important; }\nhtml[data-theme] nav.side-bar .switch-view-buttons {\n width: 100% !important;\n display: flex !important;\n flex-wrap: wrap !important;\n justify-content: center !important;\n align-items: center !important;\n gap: 2px !important;\n margin: 8px 0 0 !important;\n}\nhtml[data-theme] nav.side-bar .switch-view-buttons > * {\n flex: 0 0 auto !important;\n min-width: 0 !important;\n max-width: 100% !important;\n}\n\n/* ── 设置抽屉 / 菜单 / 提示 ── */\nhtml[data-theme] .settings-outer,\nhtml[data-theme] .config-container,\nhtml[data-theme] .context-menu,\nhtml[data-theme] .toast,\nhtml[data-theme] .welcome-popup {\n background: rgba(255,255,255,.72) !important;\n backdrop-filter: blur(20px) saturate(180%) !important;\n -webkit-backdrop-filter:\ + \ blur(20px) saturate(180%) !important;\n border: 1px solid rgba(255,255,255,.66) !important;\n border-radius: 18px !important;\n box-shadow: 0 14px 34px rgba(15,23,42,.14) !important;\n}\n\n@media (prefers-reduced-motion: reduce) {\n html[data-theme] a.item,\n html[data-theme] a.item .tile-icon { transition: none !important; }\n html[data-theme] a.item:hover { transform: none !important; }\n}\n" + layout: masonry + iconSize: large + language: zh-CN + statusCheck: true + statusCheckInterval: 60 pageInfo: title: Navigation Portal description: Self-hosted unified navigation navLinks: [] - footer: "" - + footer: '' sections: - # 名称必须是 Favorites / 常用服务 —— 服务端会把访问量最高的 4 个导航写到这里 - - name: Favorites - icon: fas fa-star - collapsed: false - items: - - title: Gitea - description: Code repository - icon: fas fa-code-branch - url: http://192.168.1.100:3000 - statusCheck: true - - # 名称必须是 History / 历史访问 —— - # 服务端会把插件安装入口固定放在第一条,其余按访问频次填充。 - # 插件已安装的浏览器由 /navi.js 自动隐藏该入口。 - - name: History - icon: fas fa-history - collapsed: false - items: - - title: 安装浏览历史插件 - description: 一键安装,自动按访问频次排序 - icon: fas fa-download - url: /extension/install.html - - - name: Tools - icon: fas fa-tools - collapsed: true - items: - - title: Example Service - description: Add your own services - icon: fas fa-cog - url: http://192.168.1.100:8080 - -pages: [] +- name: Favorites + icon: fas fa-star + collapsed: false + items: + - title: Gitea + description: Code repository + icon: fas fa-code-branch + url: http://192.168.1.100:3000 + statusCheck: true +- name: History + icon: fas fa-history + collapsed: false + items: + - title: 安装浏览历史插件 + description: 一键安装,自动按访问频次排序 + icon: fas fa-download + url: /extension/install.html +- name: Tools + icon: fas fa-tools + collapsed: true + items: + - title: Example Service + description: Add your own services + icon: fas fa-cog + url: http://192.168.1.100:8080 diff --git a/web/extension/install.html b/web/extension/install.html index 3c673db..667c586 100644 --- a/web/extension/install.html +++ b/web/extension/install.html @@ -7,32 +7,42 @@ 安装浏览历史插件 @@ -47,23 +57,23 @@
- + + +