Files
panel/internal/bootstrap/http.go
T
renovate[bot] 1df45f438b chore(deps): Update non-major dependencies (#1750)
* 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>
2026-08-26 23:11:15 +08:00

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
}