mirror of
https://github.com/tnb-labs/panel.git
synced 2026-08-29 02:10:58 +08:00
1df45f438b
* chore(deps): Update non-major dependencies * fix: 适配 libtnb validator v0.4.3 与 cron v0.5.4 新 API - 自定义规则改经 WithRules/WithFallibleRules 注入,exists/not_exists 转为 FallibleRule 让数据库错误上抛而非静默失败 - openapi contrib 移除运行时 Op,端点文档改为 Document 闭包在定义处捕获类型 - SetDefault 已移除,Bind 校验器改由 service.SetValidator 注入 - cron.New 现在返回 error Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: 耗子 <haozi@loli.email> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
177 lines
4.6 KiB
Go
177 lines
4.6 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/acepanel/panel/v3/internal/app"
|
|
"github.com/acepanel/panel/v3/internal/middleware"
|
|
"github.com/acepanel/panel/v3/internal/route"
|
|
"github.com/acepanel/panel/v3/internal/service"
|
|
"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(loader *apploader.Loader, conf *config.Config, t *gotext.Locale, middlewares *middleware.Middlewares, v *validator.Validator, endpoints []route.Endpoints) (*chi.Mux, error) {
|
|
mws := middlewares
|
|
|
|
// 供 service.Bind 使用
|
|
service.SetValidator(v)
|
|
|
|
// 数据驱动的登录白名单
|
|
public := route.PublicPaths(endpoints)
|
|
|
|
r := chi.NewRouter()
|
|
r.Use(mws.Globals(t, r, public)...)
|
|
|
|
// 注册各域路由
|
|
route.HTTP(conf, endpoints, r)
|
|
|
|
// 动态应用子路由
|
|
r.Route("/api/apps", func(r chi.Router) {
|
|
loader.Register(r)
|
|
})
|
|
|
|
// 仅调试模式挂载 OpenAPI 文档
|
|
if conf.App.Debug {
|
|
spec, err := route.SpecJSON(endpoints, "AcePanel", v)
|
|
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(conf *config.Config) (*tlscert.Reloader, error) {
|
|
|
|
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(router *chi.Mux, conf *config.Config, reloader *tlscert.Reloader) *hlfhr.Server {
|
|
|
|
mux := router
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|