飞书通讯录 ↔ LDAP 同步工具的首个提交。 - internal/feishu: 飞书通讯录客户端、快照与重试 - internal/ldaplib: LDAP 读写与批量操作 - internal/mapping: 飞书部门/用户到 LDAP 条目的映射 - internal/syncer: 同步计划生成、执行与调度 - internal/store: SQLite 台账 - internal/server + internal/webui: 管理后台与内嵌前端产物 - internal/password: SSHA 口令处理 - config.example.yml / Dockerfile / Makefile / web 前端源码 真实凭据(config.yml)、运行时数据(data/、backup/)与构建产物(bin/)不入库。
131 lines
3.4 KiB
Go
131 lines
3.4 KiB
Go
// Package webui 通过 go:embed 把前端资源打进二进制。
|
|
//
|
|
// 前端用 Tailwind v4 + FlyonUI 构建:FlyonUI 官方不支持 CDN 方式
|
|
// (样式在构建期生成),所以这里使用构建产物,用户无需 Node 环境。
|
|
// 构建命令见 scripts/build-webui.sh,产物在 internal/webui/dist 下。
|
|
package webui
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"embed"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io/fs"
|
|
"net/http"
|
|
"strings"
|
|
)
|
|
|
|
// dist 是前端构建产物。
|
|
//
|
|
//go:embed all:dist
|
|
var dist embed.FS
|
|
|
|
// assets 保存资源名 → 内容;assetETags 保存内容指纹用于 ETag。
|
|
var (
|
|
assets map[string][]byte
|
|
assetETags map[string]string
|
|
)
|
|
|
|
func init() {
|
|
fsys, err := fs.Sub(dist, "dist")
|
|
if err != nil {
|
|
return
|
|
}
|
|
assets = make(map[string][]byte, 8)
|
|
assetETags = make(map[string]string, 8)
|
|
|
|
_ = fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil || d.IsDir() {
|
|
return nil
|
|
}
|
|
data, err := fs.ReadFile(fsys, path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
assets[path] = data
|
|
sum := sha256.Sum256(data)
|
|
assetETags[path] = `"` + hex.EncodeToString(sum[:8]) + `"`
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// contentTypes 是各静态资源的 MIME 类型。
|
|
var contentTypes = map[string]string{
|
|
".css": "text/css; charset=utf-8",
|
|
".js": "application/javascript; charset=utf-8",
|
|
".html": "text/html; charset=utf-8",
|
|
".svg": "image/svg+xml",
|
|
".ico": "image/x-icon",
|
|
".png": "image/png",
|
|
".woff2": "font/woff2",
|
|
}
|
|
|
|
// IndexHandler 输出内嵌的 index.html。
|
|
//
|
|
// 刻意不缓存 HTML:它引用 app.js,一旦被缓存,升级二进制后浏览器仍会
|
|
// 加载旧脚本,表现为界面数据莫名其妙地缺失或显示 undefined。
|
|
func IndexHandler() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
data, ok := assets["index.html"]
|
|
if !ok {
|
|
http.Error(w, "前端资源缺失,请先执行 make css", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
_, _ = w.Write(data)
|
|
}
|
|
}
|
|
|
|
// AssetHandler 提供 /static/ 下的静态资源。
|
|
//
|
|
// 用内容指纹做 ETag 并允许缓存,但每次回源校验(no-cache)。
|
|
// 这样既有 304 的高效,又不会在升级二进制后继续使用旧资源——
|
|
// 仅设 max-age 而不带指纹的策略在内嵌场景下会长期给出过期界面。
|
|
func AssetHandler() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
name := strings.TrimPrefix(r.URL.Path, "/static/")
|
|
if name == "" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
data, ok := assets[name]
|
|
if !ok {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
etag := assetETags[name]
|
|
w.Header().Set("ETag", etag)
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
|
|
if match := r.Header.Get("If-None-Match"); match != "" && strings.Contains(match, etag) {
|
|
w.WriteHeader(http.StatusNotModified)
|
|
return
|
|
}
|
|
|
|
if ct, ok := contentTypes[extOf(name)]; ok {
|
|
w.Header().Set("Content-Type", ct)
|
|
}
|
|
w.Header().Set("Content-Length", fmt.Sprint(len(data)))
|
|
_, _ = w.Write(data)
|
|
}
|
|
}
|
|
|
|
func extOf(name string) string {
|
|
if i := strings.LastIndex(name, "."); i >= 0 {
|
|
return name[i:]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// AssetNames 返回已嵌入的资源名,供测试与健康检查使用。
|
|
func AssetNames() []string {
|
|
out := make([]string, 0, len(assets))
|
|
for name := range assets {
|
|
out = append(out, name)
|
|
}
|
|
return out
|
|
}
|