kra-oa/internal/server/gin.go

226 lines
7.6 KiB
Go

package server
import (
"log/slog"
"mime"
"net/http"
"os"
"path"
"sort"
"strings"
"time"
"kra/internal/conf"
websocket "kra/internal/integration/websocket"
"kra/internal/server/handler"
"kra/internal/server/httpx"
"kra/internal/server/middleware"
"kra/internal/server/router"
"kra/internal/service"
platformmodule "kra/pkg/module"
"github.com/gin-gonic/gin"
kratoshttp "github.com/go-kratos/kratos/v3/transport/http"
)
func NewGinEngine(runtime *conf.Runtime, access *service.AccessControlService, handlers *handler.Set, auth *service.AuthService, security *service.SecurityService, audit *service.AuditRecorder, logger *slog.Logger, version string) *gin.Engine {
return NewGinEngineWithRuntime(runtime, access, auth, security, audit, logger, version, platformmodule.NewRuntime(router.NewRoutes(handlers)), nil)
}
func NewGinEngineWithRuntime(runtime *conf.Runtime, access *service.AccessControlService, auth *service.AuthService, security *service.SecurityService, audit *service.AuditRecorder, logger *slog.Logger, version string, routes *platformmodule.Runtime, ws *websocket.Server) *gin.Engine {
gin.SetMode(gin.ReleaseMode)
engine := gin.New()
if err := engine.SetTrustedProxies(nil); err != nil && logger != nil {
logger.Error("disable trusted proxies failed", "error", err)
}
engine.Use(middleware.RequestMeta(), middleware.Recovery(logger), middleware.AccessLog(runtime, logger, version), middleware.CORS(runtime), middleware.ErrorAudit(logger), middleware.SecurityRateLimit(security))
prefix := ""
config := runtime.Admin()
if config != nil {
prefix = strings.TrimSuffix(config.RouterPrefix, "/")
}
public := engine.Group(prefix)
public.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, "ok") })
private := engine.Group(prefix)
// The reference administration behavior installs operation recording after JWT,
// password and Casbin/data-scope middleware. Keeping the recorder after the
// equivalent Kra guards avoids persisting rejected unauthenticated or
// unauthorized requests as successful business operations.
private.Use(middleware.Auth(auth), middleware.MustChangePassword(), middleware.AccessControl(runtime, access), middleware.OperationAudit(runtime, audit))
if routes != nil {
routes.RegisterRoutes(public, private, engine)
}
if ws != nil && ws.Enabled() {
path := ws.Path()
engine.GET(path, func(c *gin.Context) {
if !ws.Enabled() || c.Request.URL.Path != ws.Path() {
c.Status(http.StatusNotFound)
return
}
if err := ws.HandleRequest(c.Writer, c.Request); err != nil && logger != nil {
logger.Warn("websocket request failed", "mod", "websocket", "error", err)
}
})
}
registerSwagger(engine, prefix, version, logger)
registerLocalStorage(engine, runtime)
engine.NoRoute(func(c *gin.Context) {
if ws != nil && ws.Enabled() && c.Request.Method == http.MethodGet && c.Request.URL.Path == ws.Path() {
if err := ws.HandleRequest(c.Writer, c.Request); err != nil && logger != nil {
logger.Warn("websocket request failed", "mod", "websocket", "error", err)
}
return
}
if serveLocalStorage(c, runtime) {
return
}
httpx.Fail(c, "请求的接口不存在")
})
logRegisteredRoutes(engine, logger)
return engine
}
func logRegisteredRoutes(engine *gin.Engine, logger *slog.Logger) {
if logger == nil {
return
}
routes := append([]gin.RouteInfo(nil), engine.Routes()...)
sort.Slice(routes, func(i, j int) bool {
if routes[i].Path == routes[j].Path {
return routes[i].Method < routes[j].Method
}
return routes[i].Path < routes[j].Path
})
systemLogger := logger.With("mod", "system")
for _, route := range routes {
systemLogger.Info("router registered", "method", route.Method, "path", route.Path)
}
systemLogger.Info("router register success", "route_count", len(routes))
}
func NewGinServer(c *conf.Server, engine *gin.Engine) *kratoshttp.Server {
network, address := "tcp", ":8000"
if c != nil && c.Http != nil {
if c.Http.Network != "" {
network = c.Http.Network
}
if c.Http.Addr != "" {
address = c.Http.Addr
}
}
timeout := 10 * time.Minute
if c != nil && c.Http != nil && c.Http.Timeout != nil && c.Http.Timeout.AsDuration() > 0 {
timeout = c.Http.Timeout.AsDuration()
}
server := kratoshttp.NewServer(kratoshttp.Network(network), kratoshttp.Address(address), kratoshttp.Timeout(timeout))
server.HandlePrefix("/", engine)
server.Server.ReadHeaderTimeout = 10 * time.Second
server.Server.ReadTimeout = timeout
server.Server.WriteTimeout = timeout
server.Server.IdleTimeout = 2 * time.Minute
server.Server.MaxHeaderBytes = 1 << 20
return server
}
// serveLocalStorage resolves the local path for every request so a config
// reload takes effect without rebuilding the Gin engine.
func serveLocalStorage(c *gin.Context, runtime *conf.Runtime) bool {
config := runtime.Admin()
if config == nil || config.Local == nil || config.Local.StorePath == "" {
return false
}
prefix := "/" + strings.Trim(config.Local.PathPrefix, "/")
return serveLocalStorageAt(c, runtime, prefix)
}
func registerLocalStorage(engine *gin.Engine, runtime *conf.Runtime) {
config := runtime.Admin()
if config == nil || config.Local == nil || config.Local.StorePath == "" || strings.Trim(config.Local.PathPrefix, "/") == "" {
return
}
if config.Storage != nil && config.Storage.Type != "" && config.Storage.Type != "local" {
return
}
prefix := "/" + strings.Trim(config.Local.PathPrefix, "/")
if localStorageRouteConflicts(engine.Routes(), prefix) {
return
}
handler := func(c *gin.Context) {
if !serveLocalStorageAt(c, runtime, prefix) {
c.Status(http.StatusNotFound)
}
}
engine.GET(prefix+"/*filepath", handler)
engine.HEAD(prefix+"/*filepath", handler)
}
func localStorageRouteConflicts(routes []gin.RouteInfo, prefix string) bool {
staticRoot := strings.Split(strings.TrimPrefix(prefix, "/"), "/")[0]
for _, route := range routes {
if route.Method != http.MethodGet && route.Method != http.MethodHead {
continue
}
routeRoot := strings.Split(strings.TrimPrefix(route.Path, "/"), "/")[0]
if routeRoot == staticRoot {
return true
}
}
return false
}
func serveLocalStorageAt(c *gin.Context, runtime *conf.Runtime, prefix string) bool {
config := runtime.Admin()
if config == nil || config.Local == nil || config.Local.StorePath == "" {
return false
}
if config.Storage != nil && config.Storage.Type != "" && config.Storage.Type != "local" {
return false
}
if currentPrefix := "/" + strings.Trim(config.Local.PathPrefix, "/"); currentPrefix != prefix {
return false
}
if prefix == "/" || (c.Request.URL.Path != prefix && !strings.HasPrefix(c.Request.URL.Path, prefix+"/")) {
return false
}
c.Header("X-Content-Type-Options", "nosniff")
filename := path.Base(c.Request.URL.Path)
if !canServeUploadInline(filename) {
c.Header("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": filename}))
}
http.StripPrefix(prefix, http.FileServer(filesOnly{FileSystem: http.Dir(config.Local.StorePath)})).ServeHTTP(c.Writer, c.Request)
return true
}
func canServeUploadInline(filename string) bool {
switch strings.ToLower(path.Ext(filename)) {
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".ico", ".avif",
".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac",
".mp4", ".webm", ".mov", ".avi", ".mkv":
return true
default:
return false
}
}
type filesOnly struct{ http.FileSystem }
func (f filesOnly) Open(name string) (http.File, error) {
file, err := f.FileSystem.Open(name)
if err != nil {
return nil, err
}
info, statErr := file.Stat()
if statErr != nil {
_ = file.Close()
return nil, statErr
}
if info.IsDir() {
_ = file.Close()
return nil, os.ErrPermission
}
return file, nil
}