mirror of
https://github.com/tnb-labs/panel.git
synced 2026-09-21 13:20:10 +08:00
依赖注入:
- 移除 google/wire(含 wire.go/wire_gen.go 与全部 ProviderSet),
改用 samber/do v2 单注入器 + 双入口惰性构建
- 贡献模型替代命令式注册:路由 routes:、命令 commands:、
任务 jobs: 前缀经 internal/registry 收集与校验
- 构造函数统一 func NewXxx(i do.Injector) (T, error)
三层架构:
- 补全用例层:每个 biz.XxxRepo 配 XxxUsecase,
service/command/job 表现层只依赖用例,不再直接引用仓储
路由与文档:
- route/http.go 按域拆为声明式 Endpoint 贡献,
Endpoint 承载登录白名单与端点限流语义
- 调试模式下提供 OpenAPI 3.1 文档:/openapi.json 与 /docs(Scalar),
从 validate 标签生成
目录与依赖:
- internal/http/{middleware,request,rule} 拍平至 internal/*
- CLI 命令拆至 internal/command
- 日志轮转 timberjack 换为 libtnb/logrotate
- 升级 validator/cron/sessions/gormstore/sqlite/securecookie
- 数据库迁移保持 gormigrate 不变
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
185 lines
4.8 KiB
Go
185 lines
4.8 KiB
Go
package bootstrap
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/tls"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"mime"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/andybalholm/brotli"
|
|
"github.com/bddjr/hlfhr"
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/leonelquinteros/gotext"
|
|
"github.com/libtnb/validator"
|
|
"github.com/libtnb/validator/contrib/openapi"
|
|
"github.com/samber/do/v2"
|
|
|
|
"github.com/acepanel/panel/v3/internal/app"
|
|
"github.com/acepanel/panel/v3/internal/middleware"
|
|
"github.com/acepanel/panel/v3/internal/route"
|
|
"github.com/acepanel/panel/v3/pkg/apploader"
|
|
"github.com/acepanel/panel/v3/pkg/config"
|
|
"github.com/acepanel/panel/v3/pkg/embed"
|
|
"github.com/acepanel/panel/v3/pkg/tlscert"
|
|
)
|
|
|
|
func NewRouter(i do.Injector) (*chi.Mux, error) {
|
|
t := do.MustInvoke[*gotext.Locale](i)
|
|
mws := do.MustInvoke[*middleware.Middlewares](i)
|
|
conf := do.MustInvoke[*config.Config](i)
|
|
loader := do.MustInvoke[*apploader.Loader](i)
|
|
// 供 service.Bind / route.SpecJSON 使用
|
|
validator.SetDefault(do.MustInvoke[*validator.Validator](i))
|
|
|
|
// 数据驱动的登录白名单
|
|
public, err := route.PublicPaths(i)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
r := chi.NewRouter()
|
|
r.Use(mws.Globals(t, r, public)...)
|
|
|
|
// 注册各域路由
|
|
if err := route.HTTP(i, r); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 动态应用子路由
|
|
r.Route("/api/apps", func(r chi.Router) {
|
|
loader.Register(r)
|
|
})
|
|
|
|
// 仅调试模式挂载 OpenAPI 文档
|
|
if conf.App.Debug {
|
|
spec, err := route.SpecJSON(i, "AcePanel")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
docs := openapi.DocsHTML("AcePanel", "/openapi.json")
|
|
r.Get("/openapi.json", func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write(spec)
|
|
})
|
|
r.Get("/docs", func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_, _ = w.Write(docs)
|
|
})
|
|
}
|
|
|
|
r.NotFound(func(w http.ResponseWriter, req *http.Request) {
|
|
// /api 开头的返回 404
|
|
if strings.HasPrefix(req.URL.Path, "/api") {
|
|
http.NotFound(w, req)
|
|
return
|
|
}
|
|
// 其他返回前端页面
|
|
frontend, _ := fs.Sub(embed.PublicFS, "frontend")
|
|
newPrecompressedSPAHandler(http.FS(frontend)).ServeHTTP(w, req)
|
|
})
|
|
|
|
return r, nil
|
|
}
|
|
|
|
func NewTLSReloader(i do.Injector) (*tlscert.Reloader, error) {
|
|
conf := do.MustInvoke[*config.Config](i)
|
|
if !conf.HTTP.IsHTTPS() {
|
|
return nil, nil
|
|
}
|
|
|
|
certFile := filepath.Join(app.Root, "panel/storage/cert.pem")
|
|
keyFile := filepath.Join(app.Root, "panel/storage/cert.key")
|
|
reloader, err := tlscert.NewReloader(certFile, keyFile)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load certificate: %w", err)
|
|
}
|
|
return reloader, nil
|
|
}
|
|
|
|
func NewHttp(i do.Injector) (*hlfhr.Server, error) {
|
|
conf := do.MustInvoke[*config.Config](i)
|
|
mux := do.MustInvoke[*chi.Mux](i)
|
|
reloader := do.MustInvoke[*tlscert.Reloader](i)
|
|
|
|
srv := hlfhr.New(&http.Server{
|
|
Addr: fmt.Sprintf(":%d", conf.HTTP.Port),
|
|
Handler: mux,
|
|
MaxHeaderBytes: 4 << 20,
|
|
})
|
|
srv.Listen80RedirectTo443 = true
|
|
|
|
if conf.HTTP.IsHTTPS() && reloader != nil {
|
|
srv.TLSConfig = &tls.Config{
|
|
MinVersion: tls.VersionTLS12,
|
|
GetCertificate: reloader.GetCertificate,
|
|
}
|
|
}
|
|
|
|
return srv, nil
|
|
}
|
|
|
|
func newPrecompressedSPAHandler(fsys http.FileSystem) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
path := r.URL.Path
|
|
acceptBr := strings.Contains(r.Header.Get("Accept-Encoding"), "br")
|
|
|
|
if served := serveFileWithBr(w, r, fsys, path, acceptBr); served {
|
|
return
|
|
}
|
|
|
|
// 文件不存在,SPA fallback 到 index.html
|
|
serveFileWithBr(w, r, fsys, "/index.html", acceptBr)
|
|
}
|
|
}
|
|
|
|
func serveFileWithBr(w http.ResponseWriter, r *http.Request, fsys http.FileSystem, path string, acceptBr bool) bool {
|
|
name := filepath.Base(path)
|
|
// 尝试打开 .br 版本
|
|
if f, err := fsys.Open(path + ".br"); err == nil {
|
|
defer func(f http.File) { _ = f.Close() }(f)
|
|
fi, err := f.Stat()
|
|
if err != nil || fi.IsDir() {
|
|
return false
|
|
}
|
|
|
|
ct := mime.TypeByExtension(filepath.Ext(path))
|
|
if ct == "" {
|
|
ct = "application/octet-stream"
|
|
}
|
|
w.Header().Set("Content-Type", ct)
|
|
w.Header().Set("Vary", "Accept-Encoding")
|
|
|
|
if acceptBr {
|
|
// 客户端支持 br,直接透传
|
|
w.Header().Set("Content-Encoding", "br")
|
|
http.ServeContent(w, r, name, fi.ModTime(), f)
|
|
} else {
|
|
// 客户端不支持 br,解压后返回(由中间件处理 gzip)
|
|
decoded, err := io.ReadAll(brotli.NewReader(f))
|
|
if err != nil {
|
|
return false
|
|
}
|
|
http.ServeContent(w, r, name, fi.ModTime(), bytes.NewReader(decoded))
|
|
}
|
|
return true
|
|
}
|
|
|
|
// 回退到原始文件(字体、图片等未压缩的资源)
|
|
f, err := fsys.Open(path)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
defer func(f http.File) { _ = f.Close() }(f)
|
|
fi, err := f.Stat()
|
|
if err != nil || fi.IsDir() {
|
|
return false
|
|
}
|
|
http.ServeContent(w, r, name, fi.ModTime(), f)
|
|
return true
|
|
}
|