diff --git a/internal/server/README.md b/internal/server/README.md index 0a03045..5cd0ec2 100644 --- a/internal/server/README.md +++ b/internal/server/README.md @@ -6,7 +6,9 @@ grouped by role: - `handler/`: resource handlers and HTTP boundary validation - `middleware/`: request metadata, auth, access control, audit, recovery, CORS - `router/`: resource route registration and the system route registrar -- `httpx/`: system adapter for shared response and cookie helpers +- `pkg/httpx/`: transport-level response and cookie helpers shared by handlers + and middleware +- `staticfiles/`: local upload storage route registration and file serving Cross-cutting route policy lives in `internal/routecatalog`: public/private Swagger security, operation-audit flags, sensitive request-body handling and @@ -15,3 +17,9 @@ keep the catalog aligned with the Gin registrations. Keep new files in the matching role directory instead of adding transport files to the root package. + +`middleware/` intentionally remains under `internal/server`: authentication, +access control, audit, rate limiting and CORS depend on application config and +services. Only dependency-free transport helpers belong in `pkg`; they should +not be moved to `internal/utils`, which is reserved for stateless application +helpers. diff --git a/internal/server/gin.go b/internal/server/gin.go index f34795b..9d8fa4f 100644 --- a/internal/server/gin.go +++ b/internal/server/gin.go @@ -2,10 +2,7 @@ package server import ( "log/slog" - "mime" "net/http" - "os" - "path" "strings" "time" @@ -14,6 +11,7 @@ import ( "kra/internal/server/handler" "kra/internal/server/middleware" "kra/internal/server/router" + "kra/internal/server/staticfiles" "kra/internal/service" "kra/pkg/httpx" platformmodule "kra/pkg/module" @@ -70,7 +68,7 @@ func NewGinEngineWithRuntime(runtime *conf.Runtime, access *service.AccessContro engine.GET(ws.Path(), handleWebSocket) } registerSwagger(engine, prefix, version, logger) - registerLocalStorage(engine, runtime) + staticfiles.Register(engine, runtime) if logger != nil { for _, route := range engine.Routes() { logger.Info("router registered", "method", route.Method, "path", route.Path) @@ -83,7 +81,7 @@ func NewGinEngineWithRuntime(runtime *conf.Runtime, access *service.AccessContro handleWebSocket(c) return } - if serveLocalStorage(c, runtime) { + if staticfiles.Serve(c, runtime) { return } httpx.Fail(c, "请求的接口不存在") @@ -114,102 +112,3 @@ func NewGinServer(c *conf.Server, engine *gin.Engine) *kratoshttp.Server { 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 -} diff --git a/internal/server/staticfiles/staticfiles.go b/internal/server/staticfiles/staticfiles.go new file mode 100644 index 0000000..efe7575 --- /dev/null +++ b/internal/server/staticfiles/staticfiles.go @@ -0,0 +1,121 @@ +// Package staticfiles serves files from the configured local upload store. +// It owns the Gin/storage adapter so the server root only has to compose it. +package staticfiles + +import ( + "mime" + "net/http" + "os" + "path" + "strings" + + "kra/internal/conf" + + "github.com/gin-gonic/gin" +) + +// Register adds local upload routes when local storage is enabled. A route is +// skipped when its root conflicts with an existing GET/HEAD route. +func Register(engine *gin.Engine, runtime *conf.Runtime) { + config := localConfig(runtime) + if config == nil || strings.Trim(config.PathPrefix, "/") == "" { + return + } + prefix := "/" + strings.Trim(config.PathPrefix, "/") + if localStorageRouteConflicts(engine.Routes(), prefix) { + return + } + handler := func(c *gin.Context) { + if !serveAt(c, runtime, prefix) { + c.Status(http.StatusNotFound) + } + } + engine.GET(prefix+"/*filepath", handler) + engine.HEAD(prefix+"/*filepath", handler) +} + +// Serve resolves the current runtime configuration on every request so a +// configuration reload takes effect without rebuilding the Gin engine. +func Serve(c *gin.Context, runtime *conf.Runtime) bool { + config := localConfig(runtime) + if config == nil { + return false + } + prefix := "/" + strings.Trim(config.PathPrefix, "/") + return serveAt(c, runtime, prefix) +} + +func localConfig(runtime *conf.Runtime) *conf.AdminBackend_Local { + if runtime == nil { + return nil + } + config := runtime.Admin() + if config == nil || config.Local == nil || config.Local.StorePath == "" { + return nil + } + if config.Storage != nil && config.Storage.Type != "" && config.Storage.Type != "local" { + return nil + } + return config.Local +} + +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 serveAt(c *gin.Context, runtime *conf.Runtime, prefix string) bool { + config := localConfig(runtime) + if config == nil || "/"+strings.Trim(config.PathPrefix, "/") != 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 !canServeInline(filename) { + c.Header("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": filename})) + } + http.StripPrefix(prefix, http.FileServer(filesOnly{FileSystem: http.Dir(config.StorePath)})).ServeHTTP(c.Writer, c.Request) + return true +} + +func canServeInline(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 +}