优化结构

This commit is contained in:
Yvan 2026-08-23 01:42:04 +08:00
parent b4d843290d
commit 7d89e2ea4d
3 changed files with 133 additions and 105 deletions

View File

@ -6,7 +6,9 @@ grouped by role:
- `handler/`: resource handlers and HTTP boundary validation - `handler/`: resource handlers and HTTP boundary validation
- `middleware/`: request metadata, auth, access control, audit, recovery, CORS - `middleware/`: request metadata, auth, access control, audit, recovery, CORS
- `router/`: resource route registration and the system route registrar - `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 Cross-cutting route policy lives in `internal/routecatalog`: public/private
Swagger security, operation-audit flags, sensitive request-body handling and 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 Keep new files in the matching role directory instead of adding transport files
to the root package. 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.

View File

@ -2,10 +2,7 @@ package server
import ( import (
"log/slog" "log/slog"
"mime"
"net/http" "net/http"
"os"
"path"
"strings" "strings"
"time" "time"
@ -14,6 +11,7 @@ import (
"kra/internal/server/handler" "kra/internal/server/handler"
"kra/internal/server/middleware" "kra/internal/server/middleware"
"kra/internal/server/router" "kra/internal/server/router"
"kra/internal/server/staticfiles"
"kra/internal/service" "kra/internal/service"
"kra/pkg/httpx" "kra/pkg/httpx"
platformmodule "kra/pkg/module" platformmodule "kra/pkg/module"
@ -70,7 +68,7 @@ func NewGinEngineWithRuntime(runtime *conf.Runtime, access *service.AccessContro
engine.GET(ws.Path(), handleWebSocket) engine.GET(ws.Path(), handleWebSocket)
} }
registerSwagger(engine, prefix, version, logger) registerSwagger(engine, prefix, version, logger)
registerLocalStorage(engine, runtime) staticfiles.Register(engine, runtime)
if logger != nil { if logger != nil {
for _, route := range engine.Routes() { for _, route := range engine.Routes() {
logger.Info("router registered", "method", route.Method, "path", route.Path) logger.Info("router registered", "method", route.Method, "path", route.Path)
@ -83,7 +81,7 @@ func NewGinEngineWithRuntime(runtime *conf.Runtime, access *service.AccessContro
handleWebSocket(c) handleWebSocket(c)
return return
} }
if serveLocalStorage(c, runtime) { if staticfiles.Serve(c, runtime) {
return return
} }
httpx.Fail(c, "请求的接口不存在") httpx.Fail(c, "请求的接口不存在")
@ -114,102 +112,3 @@ func NewGinServer(c *conf.Server, engine *gin.Engine) *kratoshttp.Server {
server.Server.MaxHeaderBytes = 1 << 20 server.Server.MaxHeaderBytes = 1 << 20
return server 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
}

View File

@ -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
}