This commit is contained in:
parent
05b49cd86a
commit
083cc3a9ea
|
|
@ -6,13 +6,13 @@ server:
|
||||||
data:
|
data:
|
||||||
database:
|
database:
|
||||||
driver: mysql
|
driver: mysql
|
||||||
source: root:root@tcp(127.0.0.1:3306)/kra?timeout=5s&parseTime=True&loc=Local&charset=utf8mb4
|
source: root:Xu950329.@tcp(127.0.0.1:3306)/kra?charset=utf8mb4&parseTime=True&loc=Local
|
||||||
host: 127.0.0.1
|
host: 127.0.0.1
|
||||||
port: "3306"
|
port: "3306"
|
||||||
user: root
|
user: root
|
||||||
password: "12345678"
|
password: Xu950329.
|
||||||
name: kra
|
name: kra
|
||||||
config: timeout=5s&parseTime=True&loc=Local&charset=utf8mb4
|
config: charset=utf8mb4&parseTime=True&loc=Local
|
||||||
path: ""
|
path: ""
|
||||||
alias_name: ""
|
alias_name: ""
|
||||||
disable: false
|
disable: false
|
||||||
|
|
@ -88,7 +88,7 @@ admin:
|
||||||
router_prefix: ""
|
router_prefix: ""
|
||||||
jwt:
|
jwt:
|
||||||
# Production deployments must override this value with a private secret.
|
# Production deployments must override this value with a private secret.
|
||||||
signing_key: change-me-before-production
|
signing_key: 86a6eb31-46b3-4da9-ae79-85aeff3e699d
|
||||||
expires_time: 604800s
|
expires_time: 604800s
|
||||||
buffer_time: 86400s
|
buffer_time: 86400s
|
||||||
issuer: kra
|
issuer: kra
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ type APIRepo interface {
|
||||||
ListAPIs(context.Context, int, int, *API) ([]*API, int64, error)
|
ListAPIs(context.Context, int, int, *API) ([]*API, int64, error)
|
||||||
APIRoleIDs(context.Context, string, string) ([]uint, error)
|
APIRoleIDs(context.Context, string, string) ([]uint, error)
|
||||||
SetAPIRoles(context.Context, string, string, []uint) error
|
SetAPIRoles(context.Context, string, string, []uint) error
|
||||||
|
CheckPolicyStore(context.Context) error
|
||||||
Authorize(context.Context, uint, string, string) (bool, error)
|
Authorize(context.Context, uint, string, string) (bool, error)
|
||||||
PolicyPaths(context.Context, uint) ([]*API, error)
|
PolicyPaths(context.Context, uint) ([]*API, error)
|
||||||
SetPolicyPaths(context.Context, uint, []*API) error
|
SetPolicyPaths(context.Context, uint, []*API) error
|
||||||
|
|
@ -38,6 +39,10 @@ type APIUsecase struct{ APIRepo }
|
||||||
|
|
||||||
func NewAPIUsecase(repo APIRepo) *APIUsecase { return &APIUsecase{APIRepo: repo} }
|
func NewAPIUsecase(repo APIRepo) *APIUsecase { return &APIUsecase{APIRepo: repo} }
|
||||||
|
|
||||||
|
func (uc *APIUsecase) FreshCasbin(ctx context.Context) error {
|
||||||
|
return uc.CheckPolicyStore(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteAPI preserves the single-delete contract used by the legacy admin:
|
// DeleteAPI preserves the single-delete contract used by the legacy admin:
|
||||||
// the target is looked up first, so deleting a missing API returns the
|
// the target is looked up first, so deleting a missing API returns the
|
||||||
// repository's not-found error instead of silently succeeding on an empty
|
// repository's not-found error instead of silently succeeding on an empty
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,12 @@ func (r *apiRepo) SetAPIRoles(ctx context.Context, path, method string, ids []ui
|
||||||
return tx.Create(&rules).Error
|
return tx.Create(&rules).Error
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *apiRepo) CheckPolicyStore(ctx context.Context) error {
|
||||||
|
var count int64
|
||||||
|
return r.data.gormDB.WithContext(ctx).Model(&casbinRulePO{}).Count(&count).Error
|
||||||
|
}
|
||||||
|
|
||||||
func (r *apiRepo) Authorize(ctx context.Context, aid uint, path, method string) (bool, error) {
|
func (r *apiRepo) Authorize(ctx context.Context, aid uint, path, method string) (bool, error) {
|
||||||
rows, err := policyRowsForAuthority(r.data.gormDB.WithContext(ctx), aid)
|
rows, err := policyRowsForAuthority(r.data.gormDB.WithContext(ctx), aid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -112,3 +112,17 @@ func TestSetPolicyPathsUsesCompatibleDedupeKey(t *testing.T) {
|
||||||
t.Fatalf("deduplicated policies = %#v, want only the first concatenated-key match", rows)
|
t.Fatalf("deduplicated policies = %#v, want only the first concatenated-key match", rows)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCheckPolicyStore(t *testing.T) {
|
||||||
|
data := newPolicyTestData(t)
|
||||||
|
repo := &apiRepo{data: data}
|
||||||
|
if err := repo.CheckPolicyStore(context.Background()); err != nil {
|
||||||
|
t.Fatalf("CheckPolicyStore returned error for migrated table: %v", err)
|
||||||
|
}
|
||||||
|
if err := data.gormDB.WithContext(context.Background()).Migrator().DropTable(&casbinRulePO{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := repo.CheckPolicyStore(context.Background()); err == nil {
|
||||||
|
t.Fatal("CheckPolicyStore succeeded after casbin_rule was dropped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ func migrateAll(db *gorm.DB) error {
|
||||||
&apiPO{}, &ignoredAPIPO{}, &authorityAPIPO{}, &casbinRulePO{}, &menuButtonPO{}, &authorityButtonPO{},
|
&apiPO{}, &ignoredAPIPO{}, &authorityAPIPO{}, &casbinRulePO{}, &menuButtonPO{}, &authorityButtonPO{},
|
||||||
&departmentPO{}, &positionPO{}, &userDepartmentPO{}, &userPositionPO{}, &authorityDepartmentPO{},
|
&departmentPO{}, &positionPO{}, &userDepartmentPO{}, &userPositionPO{}, &authorityDepartmentPO{},
|
||||||
&dictionaryPO{}, &dictionaryDetailPO{}, ¶meterPO{}, &apiTokenPO{}, &jwtBlacklistPO{}, &securityConfigPO{},
|
&dictionaryPO{}, &dictionaryDetailPO{}, ¶meterPO{}, &apiTokenPO{}, &jwtBlacklistPO{}, &securityConfigPO{},
|
||||||
|
&integrationConfigPO{},
|
||||||
&versionPO{}, &exportTemplatePO{}, &exportConditionPO{}, &exportJoinPO{},
|
&versionPO{}, &exportTemplatePO{}, &exportConditionPO{}, &exportJoinPO{},
|
||||||
&operationPO{}, &loginLogPO{}, &dataAccessLogPO{}, &errorRecordPO{},
|
&operationPO{}, &loginLogPO{}, &dataAccessLogPO{}, &errorRecordPO{},
|
||||||
&taskPO{}, &taskLogPO{}, &mediaPO{}, &categoryPO{}, &uploadSessionPO{}, &uploadChunkPO{},
|
&taskPO{}, &taskLogPO{}, &mediaPO{}, &categoryPO{}, &uploadSessionPO{}, &uploadChunkPO{},
|
||||||
|
|
|
||||||
|
|
@ -198,7 +198,11 @@ func (r *initializationRepo) Initialize(ctx context.Context, input *biz.Database
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ignoredAPIs := defaultIgnoredAPIs()
|
staticPath := "uploads/file"
|
||||||
|
if admin := r.data.runtime.Admin(); admin != nil && admin.Local != nil && strings.Trim(admin.Local.PathPrefix, "/") != "" {
|
||||||
|
staticPath = strings.Trim(admin.Local.PathPrefix, "/")
|
||||||
|
}
|
||||||
|
ignoredAPIs := defaultIgnoredAPIs(staticPath)
|
||||||
for _, ignored := range ignoredAPIs {
|
for _, ignored := range ignoredAPIs {
|
||||||
if err := tx.FirstOrCreate(&ignored, ignored).Error; err != nil {
|
if err := tx.FirstOrCreate(&ignored, ignored).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -250,10 +254,12 @@ func (r *initializationRepo) Initialize(ctx context.Context, input *biz.Database
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultIgnoredAPIs() []ignoredAPIPO {
|
func defaultIgnoredAPIs(staticPath string) []ignoredAPIPO {
|
||||||
|
staticRoute := "/" + strings.Trim(staticPath, "/") + "/*filepath"
|
||||||
return []ignoredAPIPO{
|
return []ignoredAPIPO{
|
||||||
{Method: "GET", Path: "/api/freshCasbin"}, {Method: "GET", Path: "/health"},
|
{Method: "GET", Path: "/api/freshCasbin"}, {Method: "GET", Path: "/health"},
|
||||||
{Method: "GET", Path: "/swagger/*any"},
|
{Method: "GET", Path: "/swagger/*any"},
|
||||||
|
{Method: "GET", Path: staticRoute}, {Method: "HEAD", Path: staticRoute},
|
||||||
{Method: "POST", Path: "/system/reloadSystem"}, {Method: "POST", Path: "/base/login"},
|
{Method: "POST", Path: "/system/reloadSystem"}, {Method: "POST", Path: "/base/login"},
|
||||||
{Method: "POST", Path: "/base/captcha"}, {Method: "POST", Path: "/init/initdb"},
|
{Method: "POST", Path: "/base/captcha"}, {Method: "POST", Path: "/init/initdb"},
|
||||||
{Method: "POST", Path: "/init/checkdb"}, {Method: "GET", Path: "/info/getInfoDataSource"},
|
{Method: "POST", Path: "/init/checkdb"}, {Method: "GET", Path: "/info/getInfoDataSource"},
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,20 @@ package data
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
func TestDefaultIgnoredAPIsIncludeSwagger(t *testing.T) {
|
func TestDefaultIgnoredAPIsIncludeSwagger(t *testing.T) {
|
||||||
for _, api := range defaultIgnoredAPIs() {
|
wants := map[string]bool{
|
||||||
if api.Method == "GET" && api.Path == "/swagger/*any" {
|
"GET /swagger/*any": false,
|
||||||
return
|
"GET /uploads/file/*filepath": false,
|
||||||
|
"HEAD /uploads/file/*filepath": false,
|
||||||
|
}
|
||||||
|
for _, api := range defaultIgnoredAPIs("uploads/file") {
|
||||||
|
key := api.Method + " " + api.Path
|
||||||
|
if _, ok := wants[key]; ok {
|
||||||
|
wants[key] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key, found := range wants {
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("default ignored APIs do not include %s", key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
t.Fatal("default ignored APIs do not include the Swagger handler")
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ func NewGinEngine(runtime *conf.Runtime, access *service.AccessControlService, h
|
||||||
serverrouter.RegisterAnnouncement(private, public, handlers.Announcement)
|
serverrouter.RegisterAnnouncement(private, public, handlers.Announcement)
|
||||||
serverrouter.RegisterEmail(private, handlers.Email)
|
serverrouter.RegisterEmail(private, handlers.Email)
|
||||||
registerSwagger(engine, prefix, version, logger)
|
registerSwagger(engine, prefix, version, logger)
|
||||||
|
registerLocalStorage(engine, runtime)
|
||||||
|
|
||||||
engine.NoRoute(func(c *gin.Context) {
|
engine.NoRoute(func(c *gin.Context) {
|
||||||
if serveLocalStorage(c, runtime) {
|
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
|
// serveLocalStorage resolves the local path for every request so a config
|
||||||
// reload takes effect without rebuilding the Gin engine.
|
// reload takes effect without rebuilding the Gin engine.
|
||||||
func serveLocalStorage(c *gin.Context, runtime *conf.Runtime) bool {
|
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()
|
config := runtime.Admin()
|
||||||
if config == nil || config.Local == nil || config.Local.StorePath == "" {
|
if config == nil || config.Local == nil || config.Local.StorePath == "" {
|
||||||
return false
|
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" {
|
if config.Storage != nil && config.Storage.Type != "" && config.Storage.Type != "local" {
|
||||||
return false
|
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+"/")) {
|
if prefix == "/" || (c.Request.URL.Path != prefix && !strings.HasPrefix(c.Request.URL.Path, prefix+"/")) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestLocalStorageResponseHeaders(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
for name, body := range map[string]string{"script.html": "<script>alert(1)</script>", "image.png": "png"} {
|
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
|
const expectedGinRouteContract = `DELETE /api/deleteApisByIds
|
||||||
DELETE /dataAccessLog/deleteDataAccessLogByIds
|
DELETE /dataAccessLog/deleteDataAccessLogByIds
|
||||||
DELETE /department/deleteDepartment
|
DELETE /department/deleteDepartment
|
||||||
|
|
|
||||||
|
|
@ -199,9 +199,10 @@ func (h *API) ApplySync(c *gin.Context) {
|
||||||
httpx.OK(c)
|
httpx.OK(c)
|
||||||
}
|
}
|
||||||
func (h *API) FreshCasbin(c *gin.Context) {
|
func (h *API) FreshCasbin(c *gin.Context) {
|
||||||
// Policies are read from casbin_rule on every authorization decision, so
|
if err := h.service.FreshCasbin(c.Request.Context()); err != nil {
|
||||||
// there is no in-memory enforcer cache to reload. Keep the compatible
|
httpx.Fail(c, "刷新失败")
|
||||||
// endpoint and success response.
|
return
|
||||||
|
}
|
||||||
httpx.Write(c, httpx.CodeSuccess, gin.H{}, "刷新成功")
|
httpx.Write(c, httpx.CodeSuccess, gin.H{}, "刷新成功")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,15 +22,26 @@ func NewExport(service *service.ExportService) *Export {
|
||||||
return &Export{service: service}
|
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{}
|
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 {
|
for key, items := range nested {
|
||||||
if len(items) > 0 {
|
if len(items) > 0 {
|
||||||
out[key] = 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) {
|
func (h *Export) Create(c *gin.Context) {
|
||||||
|
|
@ -133,7 +144,8 @@ func (h *Export) Preview(c *gin.Context) {
|
||||||
httpx.Fail(c, "模板ID不能为空")
|
httpx.Fail(c, "模板ID不能为空")
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
httpx.Fail(c, "获取失败")
|
httpx.Fail(c, "获取失败")
|
||||||
return
|
return
|
||||||
|
|
@ -148,7 +160,12 @@ func (h *Export) Issue(blank bool) gin.HandlerFunc {
|
||||||
httpx.Fail(c, "模板ID不能为空")
|
httpx.Fail(c, "模板ID不能为空")
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
httpx.Fail(c, "导出令牌创建失败")
|
httpx.Fail(c, "导出令牌创建失败")
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
|
||||||
|
|
||||||
"kra/internal/biz"
|
"kra/internal/biz"
|
||||||
"kra/internal/conf"
|
"kra/internal/conf"
|
||||||
"kra/internal/server/httpx"
|
"kra/internal/server/httpx"
|
||||||
|
|
@ -20,11 +18,8 @@ func AccessControl(runtime *conf.Runtime, access *service.AccessControlService)
|
||||||
}
|
}
|
||||||
path := c.Request.URL.Path
|
path := c.Request.URL.Path
|
||||||
policyPath := path
|
policyPath := path
|
||||||
if config := runtime.Admin(); config != nil && config.RouterPrefix != "" {
|
if config := runtime.Admin(); config != nil {
|
||||||
policyPath = strings.TrimPrefix(policyPath, strings.TrimSuffix(config.RouterPrefix, "/"))
|
policyPath = service.NormalizeRoutePath(policyPath, config.RouterPrefix)
|
||||||
if policyPath == "" {
|
|
||||||
policyPath = "/"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
allowed, err := access.Authorize(c.Request.Context(), claims.AuthorityID, policyPath, c.Request.Method)
|
allowed, err := access.Authorize(c.Request.Context(), claims.AuthorityID, policyPath, c.Request.Method)
|
||||||
if err != nil || !allowed {
|
if err != nil || !allowed {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package middleware
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
@ -17,6 +18,8 @@ import (
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const ctxOperationAuditPersistFailedKey = "operation_audit_persist_failed"
|
||||||
|
|
||||||
func OperationAudit(runtime *conf.Runtime, service *service.AuditRecorder) gin.HandlerFunc {
|
func OperationAudit(runtime *conf.Runtime, service *service.AuditRecorder) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
path := c.Request.URL.Path
|
path := c.Request.URL.Path
|
||||||
|
|
@ -66,7 +69,12 @@ func OperationAudit(runtime *conf.Runtime, service *service.AuditRecorder) gin.H
|
||||||
responseBody = "[超出记录长度]"
|
responseBody = "[超出记录长度]"
|
||||||
}
|
}
|
||||||
errorMessage := c.Errors.ByType(gin.ErrorTypePrivate).String()
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,15 +26,18 @@ func CORS(runtime *conf.Runtime) gin.HandlerFunc {
|
||||||
}
|
}
|
||||||
mode := strings.TrimSpace(config.Cors.Mode)
|
mode := strings.TrimSpace(config.Cors.Mode)
|
||||||
origin := c.GetHeader("Origin")
|
origin := c.GetHeader("Origin")
|
||||||
|
corsHandled := false
|
||||||
if mode == "allow-all" {
|
if mode == "allow-all" {
|
||||||
setCORSHeaders(c, origin, defaultCORSHeaders, defaultCORSMethods, defaultCORSExpose, true)
|
setCORSHeaders(c, origin, defaultCORSHeaders, defaultCORSMethods, defaultCORSExpose, true)
|
||||||
|
corsHandled = true
|
||||||
} else if rule := matchingCORSRule(config.Cors.Whitelist, origin); rule != nil {
|
} else if rule := matchingCORSRule(config.Cors.Whitelist, origin); rule != nil {
|
||||||
setCORSHeaders(c, rule.AllowOrigin, rule.AllowHeaders, rule.AllowMethods, rule.ExposeHeaders, rule.AllowCredentials)
|
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") {
|
} else if mode == "strict-whitelist" && !(c.Request.Method == http.MethodGet && c.Request.URL.Path == "/health") {
|
||||||
c.AbortWithStatus(http.StatusForbidden)
|
c.AbortWithStatus(http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if c.Request.Method == http.MethodOptions {
|
if corsHandled && c.Request.Method == http.MethodOptions {
|
||||||
c.AbortWithStatus(http.StatusNoContent)
|
c.AbortWithStatus(http.StatusNoContent)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,11 +17,13 @@ import (
|
||||||
func ErrorAudit(logger *slog.Logger) gin.HandlerFunc {
|
func ErrorAudit(logger *slog.Logger) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
c.Next()
|
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
|
// sysError writes must never audit themselves. Log-viewer failures are
|
||||||
// already recorded by the handler with the underlying filesystem error;
|
// already recorded by the handler with the underlying filesystem error;
|
||||||
// emitting again from the response envelope would duplicate both the
|
// emitting again from the response envelope would duplicate both the
|
||||||
// classified error file and the sys_error row.
|
// 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
|
return
|
||||||
}
|
}
|
||||||
var response httpx.Response
|
var response httpx.Response
|
||||||
|
|
@ -31,12 +33,22 @@ func ErrorAudit(logger *slog.Logger) gin.HandlerFunc {
|
||||||
body = buffer.Bytes()
|
body = buffer.Bytes()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if json.Unmarshal(body, &response) != nil || response.Code == httpx.CodeSuccess || expectedClientFailure(response.Msg) {
|
if json.Unmarshal(body, &response) != nil && privateErrors == "" {
|
||||||
return
|
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")
|
requestID, _ := c.Get("request_id")
|
||||||
if logger != nil {
|
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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"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())
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,9 @@ func buildSwaggerDocument(routes []gin.RouteInfo, prefix, version string) string
|
||||||
})
|
})
|
||||||
paths := make(map[string]map[string]any, len(routes))
|
paths := make(map[string]map[string]any, len(routes))
|
||||||
for _, route := range routes {
|
for _, route := range routes {
|
||||||
|
if strings.HasSuffix(route.Path, "/*filepath") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
method := strings.ToLower(route.Method)
|
method := strings.ToLower(route.Method)
|
||||||
switch method {
|
switch method {
|
||||||
case "get", "post", "put", "delete", "patch":
|
case "get", "post", "put", "delete", "patch":
|
||||||
|
|
|
||||||
|
|
@ -18,20 +18,14 @@ func NewAPIService(uc *biz.APIUsecase, settings biz.RuntimeSettings) *APIService
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIService) NormalizeRoutePath(path string) string {
|
func (s *APIService) NormalizeRoutePath(path string) string {
|
||||||
routerPrefix := s.settings.RouterPrefix()
|
if s.settings == nil {
|
||||||
if routerPrefix == "" {
|
|
||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
prefix := strings.TrimSuffix(routerPrefix, "/")
|
return NormalizeRoutePath(path, s.settings.RouterPrefix())
|
||||||
normalized := strings.TrimPrefix(path, prefix)
|
|
||||||
if normalized == "" {
|
|
||||||
return "/"
|
|
||||||
}
|
|
||||||
return normalized
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiDomain(value *dto.APIRequest) *biz.API {
|
func (s *APIService) apiDomain(value *dto.APIRequest) *biz.API {
|
||||||
return &biz.API{ID: value.ID, Path: value.Path, Description: value.Description, APIGroup: value.APIGroup, Method: value.Method}
|
return &biz.API{ID: value.ID, Path: s.NormalizeRoutePath(value.Path), Description: value.Description, APIGroup: value.APIGroup, Method: value.Method}
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiResponse(value *biz.API) *dto.APIResponse {
|
func apiResponse(value *biz.API) *dto.APIResponse {
|
||||||
|
|
@ -87,14 +81,14 @@ func (s *APIService) Groups(ctx context.Context) ([]string, map[string]string, e
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIService) CreateAPIRequest(ctx context.Context, req *dto.APIRequest) (*dto.APIResponse, error) {
|
func (s *APIService) CreateAPIRequest(ctx context.Context, req *dto.APIRequest) (*dto.APIResponse, error) {
|
||||||
value := apiDomain(req)
|
value := s.apiDomain(req)
|
||||||
if err := s.uc.CreateAPI(ctx, value); err != nil {
|
if err := s.uc.CreateAPI(ctx, value); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return apiResponse(value), nil
|
return apiResponse(value), nil
|
||||||
}
|
}
|
||||||
func (s *APIService) UpdateAPIRequest(ctx context.Context, req *dto.APIRequest) error {
|
func (s *APIService) UpdateAPIRequest(ctx context.Context, req *dto.APIRequest) error {
|
||||||
return s.uc.UpdateAPI(ctx, apiDomain(req))
|
return s.uc.UpdateAPI(ctx, s.apiDomain(req))
|
||||||
}
|
}
|
||||||
func (s *APIService) FindAPIResponse(ctx context.Context, id uint) (*dto.APIResponse, error) {
|
func (s *APIService) FindAPIResponse(ctx context.Context, id uint) (*dto.APIResponse, error) {
|
||||||
value, err := s.uc.FindAPI(ctx, id)
|
value, err := s.uc.FindAPI(ctx, id)
|
||||||
|
|
@ -107,10 +101,10 @@ func (s *APIService) ApplyAPISyncRequest(ctx context.Context, req *dto.ApplyAPIS
|
||||||
added := make([]*biz.API, 0, len(req.NewAPIs))
|
added := make([]*biz.API, 0, len(req.NewAPIs))
|
||||||
deleted := make([]*biz.API, 0, len(req.DeleteAPIs))
|
deleted := make([]*biz.API, 0, len(req.DeleteAPIs))
|
||||||
for i := range req.NewAPIs {
|
for i := range req.NewAPIs {
|
||||||
added = append(added, apiDomain(&req.NewAPIs[i]))
|
added = append(added, s.apiDomain(&req.NewAPIs[i]))
|
||||||
}
|
}
|
||||||
for i := range req.DeleteAPIs {
|
for i := range req.DeleteAPIs {
|
||||||
deleted = append(deleted, apiDomain(&req.DeleteAPIs[i]))
|
deleted = append(deleted, s.apiDomain(&req.DeleteAPIs[i]))
|
||||||
}
|
}
|
||||||
return s.uc.ApplyAPISync(ctx, added, deleted)
|
return s.uc.ApplyAPISync(ctx, added, deleted)
|
||||||
}
|
}
|
||||||
|
|
@ -142,7 +136,7 @@ func (s *APIService) SyncAPIResponses(ctx context.Context, routes []dto.APIReque
|
||||||
// The compatible sync endpoint compares Gin's route table directly, so newly
|
// The compatible sync endpoint compares Gin's route table directly, so newly
|
||||||
// discovered routes carry only path and method. Group/description are
|
// discovered routes carry only path and method. Group/description are
|
||||||
// intentionally left empty for the operator to fill in the sync dialog.
|
// intentionally left empty for the operator to fill in the sync dialog.
|
||||||
items = append(items, apiDomain(&routes[i]))
|
items = append(items, s.apiDomain(&routes[i]))
|
||||||
}
|
}
|
||||||
return s.SyncAPIs(ctx, items)
|
return s.SyncAPIs(ctx, items)
|
||||||
}
|
}
|
||||||
|
|
@ -154,10 +148,13 @@ func (s *APIService) DeleteAPI(ctx context.Context, id uint) error {
|
||||||
return s.uc.DeleteAPI(ctx, id)
|
return s.uc.DeleteAPI(ctx, id)
|
||||||
}
|
}
|
||||||
func (s *APIService) APIRoleIDs(ctx context.Context, path, method string) ([]uint, error) {
|
func (s *APIService) APIRoleIDs(ctx context.Context, path, method string) ([]uint, error) {
|
||||||
return s.uc.APIRoleIDs(ctx, path, method)
|
return s.uc.APIRoleIDs(ctx, s.NormalizeRoutePath(path), method)
|
||||||
}
|
}
|
||||||
func (s *APIService) SetAPIRoles(ctx context.Context, path, method string, ids []uint) error {
|
func (s *APIService) SetAPIRoles(ctx context.Context, path, method string, ids []uint) error {
|
||||||
return s.uc.SetAPIRoles(ctx, path, method, ids)
|
return s.uc.SetAPIRoles(ctx, s.NormalizeRoutePath(path), method, ids)
|
||||||
|
}
|
||||||
|
func (s *APIService) FreshCasbin(ctx context.Context) error {
|
||||||
|
return s.uc.FreshCasbin(ctx)
|
||||||
}
|
}
|
||||||
func (s *APIService) SyncAPIs(ctx context.Context, routes []*biz.API) (*dto.APISyncResponse, error) {
|
func (s *APIService) SyncAPIs(ctx context.Context, routes []*biz.API) (*dto.APISyncResponse, error) {
|
||||||
diff, err := s.uc.SyncAPIs(ctx, routes)
|
diff, err := s.uc.SyncAPIs(ctx, routes)
|
||||||
|
|
@ -167,5 +164,5 @@ func (s *APIService) SyncAPIs(ctx context.Context, routes []*biz.API) (*dto.APIS
|
||||||
return &dto.APISyncResponse{NewAPIs: apiResponses(diff.Added), DeleteAPIs: apiResponses(diff.Deleted), IgnoreAPIs: apiResponses(diff.Ignored)}, nil
|
return &dto.APISyncResponse{NewAPIs: apiResponses(diff.Added), DeleteAPIs: apiResponses(diff.Deleted), IgnoreAPIs: apiResponses(diff.Ignored)}, nil
|
||||||
}
|
}
|
||||||
func (s *APIService) SetAPIIgnored(ctx context.Context, path, method string, ignored bool) error {
|
func (s *APIService) SetAPIIgnored(ctx context.Context, path, method string, ignored bool) error {
|
||||||
return s.uc.SetAPIIgnored(ctx, path, method, ignored)
|
return s.uc.SetAPIIgnored(ctx, s.NormalizeRoutePath(path), method, ignored)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
package service
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// NormalizeRoutePath removes only a complete configured router prefix. Paths
|
||||||
|
// such as /administrator must not be shortened when the prefix is /admin.
|
||||||
|
func NormalizeRoutePath(path, routerPrefix string) string {
|
||||||
|
prefix := strings.TrimSuffix(strings.TrimSpace(routerPrefix), "/")
|
||||||
|
if prefix == "" || prefix == "/" {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
if path == prefix {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(path, prefix+"/") {
|
||||||
|
return strings.TrimPrefix(path, prefix)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
package service
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestNormalizeRoutePath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
prefix string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "empty prefix", path: "/api/login", prefix: "", want: "/api/login"},
|
||||||
|
{name: "configured prefix", path: "/admin/api/login", prefix: "/admin", want: "/api/login"},
|
||||||
|
{name: "trailing slash prefix", path: "/admin/api/login", prefix: "/admin/", want: "/api/login"},
|
||||||
|
{name: "prefix root", path: "/admin", prefix: "/admin", want: "/"},
|
||||||
|
{name: "prefix boundary", path: "/administrator/api/login", prefix: "/admin", want: "/administrator/api/login"},
|
||||||
|
{name: "already normalized", path: "/api/login", prefix: "/admin", want: "/api/login"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := NormalizeRoutePath(tt.path, tt.prefix); got != tt.want {
|
||||||
|
t.Fatalf("NormalizeRoutePath(%q, %q) = %q, want %q", tt.path, tt.prefix, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,6 @@ package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"kra/internal/biz"
|
"kra/internal/biz"
|
||||||
"kra/internal/routeinfo"
|
"kra/internal/routeinfo"
|
||||||
|
|
@ -29,13 +28,7 @@ func (s *SystemConfigService) Initialize(ctx context.Context, input *dto.Databas
|
||||||
func (s *SystemConfigService) InitializeRoutes(ctx context.Context, input *dto.DatabaseInitRequest, routes []dto.Route) error {
|
func (s *SystemConfigService) InitializeRoutes(ctx context.Context, input *dto.DatabaseInitRequest, routes []dto.Route) error {
|
||||||
apis := make([]*biz.API, 0, len(routes))
|
apis := make([]*biz.API, 0, len(routes))
|
||||||
for _, route := range routes {
|
for _, route := range routes {
|
||||||
path := route.Path
|
path := NormalizeRoutePath(route.Path, s.settings.RouterPrefix())
|
||||||
if routerPrefix := s.settings.RouterPrefix(); routerPrefix != "" {
|
|
||||||
path = strings.TrimPrefix(path, strings.TrimSuffix(routerPrefix, "/"))
|
|
||||||
if path == "" {
|
|
||||||
path = "/"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
group, description := routeinfo.Metadata(route.Method, path)
|
group, description := routeinfo.Metadata(route.Method, path)
|
||||||
apis = append(apis, &biz.API{Path: path, Method: route.Method, APIGroup: group, Description: description})
|
apis = append(apis, &biz.API{Path: path, Method: route.Method, APIGroup: group, Description: description})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,14 +15,19 @@ const pathMapPlugin = () => ({
|
||||||
const result = {}
|
const result = {}
|
||||||
const walk = (directory) => {
|
const walk = (directory) => {
|
||||||
if (!fs.existsSync(directory)) return
|
if (!fs.existsSync(directory)) return
|
||||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
const entries = fs.readdirSync(directory, { withFileTypes: true })
|
||||||
|
.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
|
||||||
|
for (const entry of entries) {
|
||||||
const filename = path.join(directory, entry.name)
|
const filename = path.join(directory, entry.name)
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
walk(filename)
|
walk(filename)
|
||||||
} else if (filename.endsWith('.vue')) {
|
} else if (filename.endsWith('.vue')) {
|
||||||
const source = fs.readFileSync(filename, 'utf8')
|
const source = fs.readFileSync(filename, 'utf8')
|
||||||
const match = source.match(/defineOptions\s*\(\s*{[\s\S]*?name:\s*['"]([^'"]+)['"]/)
|
const match = source.match(/defineOptions\s*\(\s*{[\s\S]*?name:\s*['"]([^'"]+)['"]/)
|
||||||
if (match) result[`/src/${filename.replace(/^src\//, '')}`] = match[1]
|
if (match) {
|
||||||
|
const relativePath = path.relative('src', filename).split(path.sep).join('/')
|
||||||
|
result[`/src/${relativePath}`] = match[1]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue