chore: 初始化 idsync 项目

飞书通讯录 ↔ 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/)不入库。
This commit is contained in:
2026-09-29 00:10:59 +08:00
commit 947b1a1475
43 changed files with 10079 additions and 0 deletions
+130
View File
@@ -0,0 +1,130 @@
// 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
}