This commit is contained in:
Yvan
2026-08-17 15:23:57 +08:00
parent 05b49cd86a
commit 083cc3a9ea
24 changed files with 639 additions and 287 deletions
+48 -1
View File
@@ -61,6 +61,7 @@ func NewGinEngine(runtime *conf.Runtime, access *service.AccessControlService, h
serverrouter.RegisterAnnouncement(private, public, handlers.Announcement)
serverrouter.RegisterEmail(private, handlers.Email)
registerSwagger(engine, prefix, version, logger)
registerLocalStorage(engine, runtime)
engine.NoRoute(func(c *gin.Context) {
if serveLocalStorage(c, runtime) {
@@ -117,6 +118,50 @@ func NewGinServer(c *conf.Server, engine *gin.Engine) *kratoshttp.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
@@ -124,7 +169,9 @@ func serveLocalStorage(c *gin.Context, runtime *conf.Runtime) bool {
if config.Storage != nil && config.Storage.Type != "" && config.Storage.Type != "local" {
return false
}
prefix := "/" + strings.Trim(config.Local.PathPrefix, "/")
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
}
+69
View File
@@ -90,6 +90,15 @@ func TestSwaggerSupportsRouterPrefix(t *testing.T) {
}
}
func TestSwaggerUsesRootBasePathWithoutRouterPrefix(t *testing.T) {
engine := NewGinEngine(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, emptyHandlers(), nil, nil, nil, nil, "v1.0.0")
response := httptest.NewRecorder()
engine.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/swagger/doc.json", nil))
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"basePath":"/"`) {
t.Fatalf("swagger basePath mismatch: status=%d body=%s", response.Code, response.Body.String())
}
}
func TestLocalStorageResponseHeaders(t *testing.T) {
root := t.TempDir()
for name, body := range map[string]string{"script.html": "<script>alert(1)</script>", "image.png": "png"} {
@@ -120,6 +129,66 @@ func TestLocalStorageResponseHeaders(t *testing.T) {
}
}
func TestLocalStorageRoutesAreRegistered(t *testing.T) {
runtime := conf.NewRuntime(nil, &conf.AdminBackend{Local: &conf.AdminBackend_Local{StorePath: t.TempDir(), PathPrefix: "uploads/file"}})
engine := NewGinEngine(runtime, nil, emptyHandlers(), nil, nil, nil, nil, "test")
seen := map[string]bool{}
for _, route := range engine.Routes() {
seen[route.Method+" "+route.Path] = true
}
for _, expected := range []string{"GET /uploads/file/*filepath", "HEAD /uploads/file/*filepath"} {
if !seen[expected] {
t.Fatalf("missing registered static route %s", expected)
}
}
response := httptest.NewRecorder()
engine.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/swagger/doc.json", nil))
if strings.Contains(response.Body.String(), "/uploads/file/") {
t.Fatal("swagger document should not expose static file wildcard")
}
}
func TestLocalStorageOldPrefixStopsServingAfterReload(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "image.png"), []byte("png"), 0o600); err != nil {
t.Fatal(err)
}
runtime := conf.NewRuntime(nil, &conf.AdminBackend{Local: &conf.AdminBackend_Local{StorePath: root, PathPrefix: "uploads/file"}, Storage: &conf.AdminBackend_Storage{Type: "local"}})
engine := NewGinEngine(runtime, nil, emptyHandlers(), nil, nil, nil, nil, "test")
runtime.Replace(nil, &conf.AdminBackend{Local: &conf.AdminBackend_Local{StorePath: root, PathPrefix: "files"}, Storage: &conf.AdminBackend_Storage{Type: "local"}})
oldResponse := httptest.NewRecorder()
engine.ServeHTTP(oldResponse, httptest.NewRequest(http.MethodGet, "/uploads/file/image.png", nil))
if oldResponse.Code != http.StatusNotFound {
t.Fatalf("old static prefix status = %d, want %d", oldResponse.Code, http.StatusNotFound)
}
newResponse := httptest.NewRecorder()
engine.ServeHTTP(newResponse, httptest.NewRequest(http.MethodGet, "/files/image.png", nil))
if newResponse.Code != http.StatusOK {
t.Fatalf("new static prefix status = %d, want %d", newResponse.Code, http.StatusOK)
}
}
func TestLocalStorageConflictingPrefixFallsBackWithoutStartupPanic(t *testing.T) {
runtime := conf.NewRuntime(nil, &conf.AdminBackend{Local: &conf.AdminBackend_Local{StorePath: t.TempDir(), PathPrefix: "api"}, Storage: &conf.AdminBackend_Storage{Type: "local"}})
engine := NewGinEngine(runtime, nil, emptyHandlers(), nil, nil, nil, nil, "test")
for _, route := range engine.Routes() {
if route.Method == http.MethodGet && route.Path == "/api/*filepath" {
t.Fatal("conflicting static wildcard must not be registered")
}
}
}
func TestLocalStorageRouteIsNotRegisteredForRemoteStorage(t *testing.T) {
runtime := conf.NewRuntime(nil, &conf.AdminBackend{Local: &conf.AdminBackend_Local{StorePath: t.TempDir(), PathPrefix: "uploads/file"}, Storage: &conf.AdminBackend_Storage{Type: "s3"}})
engine := NewGinEngine(runtime, nil, emptyHandlers(), nil, nil, nil, nil, "test")
for _, route := range engine.Routes() {
if strings.HasSuffix(route.Path, "/*filepath") && strings.HasPrefix(route.Path, "/uploads/file/") {
t.Fatalf("remote storage registered local route %s %s", route.Method, route.Path)
}
}
}
const expectedGinRouteContract = `DELETE /api/deleteApisByIds
DELETE /dataAccessLog/deleteDataAccessLogByIds
DELETE /department/deleteDepartment
+4 -3
View File
@@ -199,9 +199,10 @@ func (h *API) ApplySync(c *gin.Context) {
httpx.OK(c)
}
func (h *API) FreshCasbin(c *gin.Context) {
// Policies are read from casbin_rule on every authorization decision, so
// there is no in-memory enforcer cache to reload. Keep the compatible
// endpoint and success response.
if err := h.service.FreshCasbin(c.Request.Context()); err != nil {
httpx.Fail(c, "刷新失败")
return
}
httpx.Write(c, httpx.CodeSuccess, gin.H{}, "刷新成功")
}
+22 -5
View File
@@ -22,15 +22,26 @@ func NewExport(service *service.ExportService) *Export {
return &Export{service: service}
}
func exportParams(values url.Values) map[string]string {
func exportParams(values url.Values) (map[string]string, error) {
out := map[string]string{}
nested, _ := url.ParseQuery(values.Get("params"))
nested, err := url.ParseQuery(values.Get("params"))
if err != nil {
return nil, err
}
for key, items := range nested {
if len(items) > 0 {
out[key] = items[0]
}
}
return out
return out, nil
}
func exportIssueParams(values url.Values, blank bool) (map[string]string, error) {
params, err := exportParams(values)
if err != nil && blank {
return map[string]string{}, nil
}
return params, err
}
func (h *Export) Create(c *gin.Context) {
@@ -133,7 +144,8 @@ func (h *Export) Preview(c *gin.Context) {
httpx.Fail(c, "模板ID不能为空")
return
}
sql, err := h.service.Preview(c.Request.Context(), templateID, exportParams(c.Request.URL.Query()))
params, _ := exportParams(c.Request.URL.Query())
sql, err := h.service.Preview(c.Request.Context(), templateID, params)
if err != nil {
httpx.Fail(c, "获取失败")
return
@@ -148,7 +160,12 @@ func (h *Export) Issue(blank bool) gin.HandlerFunc {
httpx.Fail(c, "模板ID不能为空")
return
}
token, err := h.service.IssueToken(c.Request.Context(), templateID, exportParams(c.Request.URL.Query()), blank)
params, err := exportIssueParams(c.Request.URL.Query(), blank)
if err != nil {
httpx.Fail(c, "解析 params 参数失败")
return
}
token, err := h.service.IssueToken(c.Request.Context(), templateID, params, blank)
if err != nil {
httpx.Fail(c, "导出令牌创建失败")
return
+40
View File
@@ -0,0 +1,40 @@
package handler
import (
"net/url"
"testing"
)
func TestExportParamsRejectsMalformedNestedQuery(t *testing.T) {
_, err := exportParams(url.Values{"params": {"name=%zz"}})
if err == nil {
t.Fatal("exportParams accepted malformed nested query")
}
}
func TestExportParamsUsesFirstValue(t *testing.T) {
got, err := exportParams(url.Values{"params": {"name=first&name=second"}})
if err != nil {
t.Fatalf("exportParams returned error: %v", err)
}
if got["name"] != "first" {
t.Fatalf("exportParams name = %q, want first", got["name"])
}
}
func TestExportIssueParamsKeepsBlankTemplateCompatible(t *testing.T) {
got, err := exportIssueParams(url.Values{"params": {"name=%zz"}}, true)
if err != nil {
t.Fatalf("blank template params returned error: %v", err)
}
if len(got) != 0 {
t.Fatalf("blank template params = %#v, want empty", got)
}
}
func TestExportIssueParamsRejectsMalformedExcelParams(t *testing.T) {
_, err := exportIssueParams(url.Values{"params": {"name=%zz"}}, false)
if err == nil {
t.Fatal("Excel export accepted malformed nested query")
}
}
+2 -7
View File
@@ -1,8 +1,6 @@
package middleware
import (
"strings"
"kra/internal/biz"
"kra/internal/conf"
"kra/internal/server/httpx"
@@ -20,11 +18,8 @@ func AccessControl(runtime *conf.Runtime, access *service.AccessControlService)
}
path := c.Request.URL.Path
policyPath := path
if config := runtime.Admin(); config != nil && config.RouterPrefix != "" {
policyPath = strings.TrimPrefix(policyPath, strings.TrimSuffix(config.RouterPrefix, "/"))
if policyPath == "" {
policyPath = "/"
}
if config := runtime.Admin(); config != nil {
policyPath = service.NormalizeRoutePath(policyPath, config.RouterPrefix)
}
allowed, err := access.Authorize(c.Request.Context(), claims.AuthorityID, policyPath, c.Request.Method)
if err != nil || !allowed {
+9 -1
View File
@@ -3,6 +3,7 @@ package middleware
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
@@ -17,6 +18,8 @@ import (
"github.com/gin-gonic/gin"
)
const ctxOperationAuditPersistFailedKey = "operation_audit_persist_failed"
func OperationAudit(runtime *conf.Runtime, service *service.AuditRecorder) gin.HandlerFunc {
return func(c *gin.Context) {
path := c.Request.URL.Path
@@ -66,7 +69,12 @@ func OperationAudit(runtime *conf.Runtime, service *service.AuditRecorder) gin.H
responseBody = "[超出记录长度]"
}
errorMessage := c.Errors.ByType(gin.ErrorTypePrivate).String()
_ = service.RecordOperationRequest(c.Request.Context(), &dto.OperationRecordRequest{IP: c.ClientIP(), Method: c.Request.Method, Path: path, Status: status, LatencyMS: time.Since(started).Milliseconds(), Agent: c.Request.UserAgent(), ErrorMessage: errorMessage, Body: operationRequestBody(requestBody, c.GetHeader("Content-Type"), maxBytes), Response: responseBody, UserID: userID, RequestID: stringValue(requestID), TraceID: stringValueFromContext(c, "trace_id"), DeviceID: c.GetHeader("X-Device-Id")})
if err := service.RecordOperationRequest(c.Request.Context(), &dto.OperationRecordRequest{IP: c.ClientIP(), Method: c.Request.Method, Path: path, Status: status, LatencyMS: time.Since(started).Milliseconds(), Agent: c.Request.UserAgent(), ErrorMessage: errorMessage, Body: operationRequestBody(requestBody, c.GetHeader("Content-Type"), maxBytes), Response: responseBody, UserID: userID, RequestID: stringValue(requestID), TraceID: stringValueFromContext(c, "trace_id"), DeviceID: c.GetHeader("X-Device-Id")}); err != nil {
// Preserve the business response, but expose audit persistence failures
// to the global access/error logging pipeline.
c.Set(ctxOperationAuditPersistFailedKey, true)
_ = c.Error(fmt.Errorf("operation audit persist: %w", err))
}
}
}
+4 -1
View File
@@ -26,15 +26,18 @@ func CORS(runtime *conf.Runtime) gin.HandlerFunc {
}
mode := strings.TrimSpace(config.Cors.Mode)
origin := c.GetHeader("Origin")
corsHandled := false
if mode == "allow-all" {
setCORSHeaders(c, origin, defaultCORSHeaders, defaultCORSMethods, defaultCORSExpose, true)
corsHandled = true
} else if rule := matchingCORSRule(config.Cors.Whitelist, origin); rule != nil {
setCORSHeaders(c, rule.AllowOrigin, rule.AllowHeaders, rule.AllowMethods, rule.ExposeHeaders, rule.AllowCredentials)
corsHandled = true
} else if mode == "strict-whitelist" && !(c.Request.Method == http.MethodGet && c.Request.URL.Path == "/health") {
c.AbortWithStatus(http.StatusForbidden)
return
}
if c.Request.Method == http.MethodOptions {
if corsHandled && c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
+39
View File
@@ -0,0 +1,39 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"kra/internal/conf"
"github.com/gin-gonic/gin"
)
func runCORSTest(t *testing.T, admin *conf.AdminBackend, method, origin string) *httptest.ResponseRecorder {
t.Helper()
engine := gin.New()
engine.Use(CORS(conf.NewRuntime(nil, admin)))
engine.Any("/test", func(c *gin.Context) { c.Status(http.StatusOK) })
request := httptest.NewRequest(method, "/test", nil)
if origin != "" {
request.Header.Set("Origin", origin)
}
response := httptest.NewRecorder()
engine.ServeHTTP(response, request)
return response
}
func TestCORSDoesNotConsumeUnmatchedWhitelistPreflight(t *testing.T) {
response := runCORSTest(t, &conf.AdminBackend{Cors: &conf.AdminBackend_CORS{Mode: "whitelist"}}, http.MethodOptions, "https://unknown.example")
if response.Code != http.StatusOK {
t.Fatalf("unmatched whitelist preflight status = %d, want %d", response.Code, http.StatusOK)
}
}
func TestCORSConsumesMatchedWhitelistPreflight(t *testing.T) {
response := runCORSTest(t, &conf.AdminBackend{Cors: &conf.AdminBackend_CORS{Mode: "whitelist", Whitelist: []*conf.AdminBackend_CORSRule{{AllowOrigin: "https://admin.example"}}}}, http.MethodOptions, "https://admin.example")
if response.Code != http.StatusNoContent {
t.Fatalf("matched whitelist preflight status = %d, want %d", response.Code, http.StatusNoContent)
}
}
+15 -3
View File
@@ -17,11 +17,13 @@ import (
func ErrorAudit(logger *slog.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()
privateErrors := strings.TrimSpace(c.Errors.ByType(gin.ErrorTypePrivate).String())
auditPersistFailed, _ := c.Get(ctxOperationAuditPersistFailedKey)
// sysError writes must never audit themselves. Log-viewer failures are
// already recorded by the handler with the underlying filesystem error;
// emitting again from the response envelope would duplicate both the
// classified error file and the sys_error row.
if strings.Contains(c.Request.URL.Path, "/sysError/") || strings.Contains(c.Request.URL.Path, "/logViewer/") || c.Writer.Status() >= 300 && c.Writer.Status() < 500 {
if auditPersistFailed != true && (strings.Contains(c.Request.URL.Path, "/sysError/") || strings.Contains(c.Request.URL.Path, "/logViewer/") || c.Writer.Status() >= 300 && c.Writer.Status() < 500) {
return
}
var response httpx.Response
@@ -31,12 +33,22 @@ func ErrorAudit(logger *slog.Logger) gin.HandlerFunc {
body = buffer.Bytes()
}
}
if json.Unmarshal(body, &response) != nil || response.Code == httpx.CodeSuccess || expectedClientFailure(response.Msg) {
if json.Unmarshal(body, &response) != nil && privateErrors == "" {
return
}
if response.Code == httpx.CodeSuccess && privateErrors == "" {
return
}
if privateErrors == "" && expectedClientFailure(response.Msg) {
return
}
errorMessage := response.Msg
if privateErrors != "" {
errorMessage = privateErrors
}
requestID, _ := c.Get("request_id")
if logger != nil {
logger.ErrorContext(c.Request.Context(), "请求处理失败", "mod", failureLogModule(c.Request.URL.Path), "path", c.Request.URL.Path, "method", c.Request.Method, "status", c.Writer.Status(), "error", response.Msg, "request_id", stringValue(requestID), "trace_id", stringValueFromContext(c, "trace_id"))
logger.ErrorContext(c.Request.Context(), "请求处理失败", "mod", failureLogModule(c.Request.URL.Path), "path", c.Request.URL.Path, "method", c.Request.Method, "status", c.Writer.Status(), "error", errorMessage, "request_id", stringValue(requestID), "trace_id", stringValueFromContext(c, "trace_id"))
}
}
}
@@ -2,6 +2,7 @@ package middleware
import (
"bytes"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
@@ -73,3 +74,38 @@ func TestErrorAuditSkipsLogViewerFailureAlreadyLoggedByHandler(t *testing.T) {
t.Fatalf("log viewer failure must not be emitted twice, got %s", output.String())
}
}
func TestErrorAuditEmitsPrivateMiddlewareErrorOnSuccessResponse(t *testing.T) {
var output bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&output, nil))
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.Use(ErrorAudit(logger))
engine.GET("/test", func(c *gin.Context) {
c.Error(errors.New("operation audit persist: database unavailable"))
c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{}, "msg": "成功"})
})
response := httptest.NewRecorder()
engine.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/test", nil))
if !strings.Contains(output.String(), "operation audit persist: database unavailable") {
t.Fatalf("private middleware error was not logged: %s", output.String())
}
}
func TestErrorAuditEmitsOperationAuditFailureForSysErrorRoute(t *testing.T) {
var output bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&output, nil))
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.Use(ErrorAudit(logger))
engine.PUT("/sysError/updateSysError", func(c *gin.Context) {
c.Set(ctxOperationAuditPersistFailedKey, true)
c.Error(errors.New("operation audit persist: database unavailable"))
c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{}, "msg": "成功"})
})
response := httptest.NewRecorder()
engine.ServeHTTP(response, httptest.NewRequest(http.MethodPut, "/sysError/updateSysError", nil))
if !strings.Contains(output.String(), "operation audit persist: database unavailable") {
t.Fatalf("sysError operation audit failure was not logged: %s", output.String())
}
}
+3
View File
@@ -69,6 +69,9 @@ func buildSwaggerDocument(routes []gin.RouteInfo, prefix, version string) string
})
paths := make(map[string]map[string]any, len(routes))
for _, route := range routes {
if strings.HasSuffix(route.Path, "/*filepath") {
continue
}
method := strings.ToLower(route.Method)
switch method {
case "get", "post", "put", "delete", "patch":