diff --git a/Wavelet/frontend/lib/services/openflare/legacy-base.service.ts b/Wavelet/frontend/lib/services/openflare/legacy-base.service.ts index 72b82608..8b48cacc 100644 --- a/Wavelet/frontend/lib/services/openflare/legacy-base.service.ts +++ b/Wavelet/frontend/lib/services/openflare/legacy-base.service.ts @@ -16,7 +16,12 @@ export class LegacyOpenFlareBaseService { protected static readonly basePath: string = ''; protected static getFullPath(path: string): string { - return `${this.basePath}${path}`; + const full = `${this.basePath}${path}`; + // Avoid Next.js 308 (strip slash) <-> Gin 301 (add slash) redirect loops in dev proxy. + if (full.length > 1 && full.endsWith('/')) { + return full.slice(0, -1); + } + return full; } protected static parseLegacyResponse(body: LegacyApiResponse): T { diff --git a/Wavelet/frontend/next.config.ts b/Wavelet/frontend/next.config.ts index 7f918247..14ff5b49 100644 --- a/Wavelet/frontend/next.config.ts +++ b/Wavelet/frontend/next.config.ts @@ -4,6 +4,8 @@ const isExport = process.env.NEXT_STANDALONE_EXPORT === 'true'; const nextConfig: NextConfig = { reactCompiler: true, + // Prevent 308 redirects on /api/* trailing slashes; dev rewrites proxy legacy APIs as-is. + skipTrailingSlashRedirect: true, experimental: { }, ...(isExport ? { output: 'export' } : { diff --git a/Wavelet/internal/apps/openflare/compat/routes.go b/Wavelet/internal/apps/openflare/compat/routes.go new file mode 100644 index 00000000..9a5aa3d5 --- /dev/null +++ b/Wavelet/internal/apps/openflare/compat/routes.go @@ -0,0 +1,23 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package compat + +import ( + "strings" + + "github.com/gin-gonic/gin" +) + +// RegisterCollection registers a collection endpoint on both "" and "/" so requests +// work with or without a trailing slash. This avoids 301/308 redirect loops between +// Next.js dev proxy and Gin when paths disagree on trailing slashes. +// +// When the group base already ends with "/" (e.g. userGroup.Group("/")), only "/" is +// registered to avoid duplicate route panic. +func RegisterCollection(route *gin.RouterGroup, method string, handlers ...gin.HandlerFunc) { + route.Handle(method, "/", handlers...) + if !strings.HasSuffix(route.BasePath(), "/") { + route.Handle(method, "", handlers...) + } +} diff --git a/Wavelet/internal/apps/openflare/legacy/register_apply_log.go b/Wavelet/internal/apps/openflare/legacy/register_apply_log.go index 19e96a6f..8c1cc80b 100644 --- a/Wavelet/internal/apps/openflare/legacy/register_apply_log.go +++ b/Wavelet/internal/apps/openflare/legacy/register_apply_log.go @@ -13,7 +13,7 @@ func registerApplyLogRoutes(apiGroup *gin.RouterGroup) { applyLogRoute := apiGroup.Group("/apply-logs") applyLogRoute.Use(compat.AdminAuth()) { - applyLogRoute.GET("/", apply_log.GetApplyLogs) + compat.RegisterCollection(applyLogRoute, "GET", apply_log.GetApplyLogs) applyLogRoute.POST("/cleanup", apply_log.CleanupApplyLogs) } } diff --git a/Wavelet/internal/apps/openflare/legacy/register_auth.go b/Wavelet/internal/apps/openflare/legacy/register_auth.go index 7fe17444..95b626f6 100644 --- a/Wavelet/internal/apps/openflare/legacy/register_auth.go +++ b/Wavelet/internal/apps/openflare/legacy/register_auth.go @@ -27,7 +27,7 @@ func registerAuthRoutes(apiGroup *gin.RouterGroup) { externalAccounts := oauthGroup.Group("/external-accounts") externalAccounts.Use(compat.UserAuth()) { - externalAccounts.GET("/", ListExternalAccounts) + compat.RegisterCollection(externalAccounts, "GET", ListExternalAccounts) externalAccounts.POST("/:id/delete", DeleteExternalAccount) } } @@ -56,10 +56,10 @@ func registerAuthRoutes(apiGroup *gin.RouterGroup) { adminGroup := userGroup.Group("/") adminGroup.Use(compat.AdminAuth()) { - adminGroup.GET("/", GetAllUsers) + compat.RegisterCollection(adminGroup, "GET", GetAllUsers) adminGroup.GET("/search", SearchUsers) adminGroup.GET("/:id", GetUser) - adminGroup.POST("/", CreateUser) + compat.RegisterCollection(adminGroup, "POST", CreateUser) adminGroup.POST("/manage", ManageUser) adminGroup.POST("/update", UpdateUser) adminGroup.POST("/:id/delete", DeleteUser) @@ -69,8 +69,8 @@ func registerAuthRoutes(apiGroup *gin.RouterGroup) { authSourceGroup := apiGroup.Group("/auth-sources") authSourceGroup.Use(compat.RootAuth()) { - authSourceGroup.GET("/", ListAuthSources) - authSourceGroup.POST("/", CreateAuthSource) + compat.RegisterCollection(authSourceGroup, "GET", ListAuthSources) + compat.RegisterCollection(authSourceGroup, "POST", CreateAuthSource) authSourceGroup.POST("/:id/update", UpdateAuthSource) authSourceGroup.POST("/:id/delete", DeleteAuthSource) authSourceGroup.POST("/:id/toggle", ToggleAuthSource) diff --git a/Wavelet/internal/apps/openflare/legacy/register_config_version.go b/Wavelet/internal/apps/openflare/legacy/register_config_version.go index 2ed698c8..e74474eb 100644 --- a/Wavelet/internal/apps/openflare/legacy/register_config_version.go +++ b/Wavelet/internal/apps/openflare/legacy/register_config_version.go @@ -13,7 +13,7 @@ func registerConfigVersionRoutes(apiGroup *gin.RouterGroup) { configVersionGroup := apiGroup.Group("/config-versions") configVersionGroup.Use(compat.AdminAuth()) { - configVersionGroup.GET("/", config_version.ListConfigVersionsHandler) + compat.RegisterCollection(configVersionGroup, "GET", config_version.ListConfigVersionsHandler) configVersionGroup.GET("/active", config_version.GetActiveConfigVersionHandler) configVersionGroup.GET("/preview", config_version.PreviewConfigVersionHandler) configVersionGroup.GET("/diff", config_version.DiffConfigVersionHandler) diff --git a/Wavelet/internal/apps/openflare/legacy/register_node.go b/Wavelet/internal/apps/openflare/legacy/register_node.go index 0bb8b5d4..d3b147ac 100644 --- a/Wavelet/internal/apps/openflare/legacy/register_node.go +++ b/Wavelet/internal/apps/openflare/legacy/register_node.go @@ -15,8 +15,8 @@ func registerNodeRoutes(apiGroup *gin.RouterGroup) { { nodeRoute.GET("/bootstrap-token", node.GetBootstrapTokenHandler) nodeRoute.POST("/bootstrap-token/rotate", node.RotateBootstrapTokenHandler) - nodeRoute.GET("/", node.ListNodesHandler) - nodeRoute.POST("/", node.CreateNodeHandler) + compat.RegisterCollection(nodeRoute, "GET", node.ListNodesHandler) + compat.RegisterCollection(nodeRoute, "POST", node.CreateNodeHandler) nodeRoute.GET("/:id/agent-release", node.GetAgentReleaseHandler) nodeRoute.POST("/:id/update", node.UpdateNodeHandler) nodeRoute.POST("/:id/delete", node.DeleteNodeHandler) diff --git a/Wavelet/internal/apps/openflare/legacy/register_origin.go b/Wavelet/internal/apps/openflare/legacy/register_origin.go index 322ac018..aab13e02 100644 --- a/Wavelet/internal/apps/openflare/legacy/register_origin.go +++ b/Wavelet/internal/apps/openflare/legacy/register_origin.go @@ -13,9 +13,9 @@ func registerOriginRoutes(apiGroup *gin.RouterGroup) { originRoute := apiGroup.Group("/origins") originRoute.Use(compat.AdminAuth()) { - originRoute.GET("/", origin.GetOrigins) + compat.RegisterCollection(originRoute, "GET", origin.GetOrigins) originRoute.GET("/:id", origin.GetOrigin) - originRoute.POST("/", origin.CreateOriginHandler) + compat.RegisterCollection(originRoute, "POST", origin.CreateOriginHandler) originRoute.POST("/:id/update", origin.UpdateOriginHandler) originRoute.POST("/:id/delete", origin.DeleteOriginHandler) } diff --git a/Wavelet/internal/apps/openflare/legacy/register_pages.go b/Wavelet/internal/apps/openflare/legacy/register_pages.go index 1e59e504..5b2156ab 100644 --- a/Wavelet/internal/apps/openflare/legacy/register_pages.go +++ b/Wavelet/internal/apps/openflare/legacy/register_pages.go @@ -13,9 +13,9 @@ func registerPagesRoutes(apiGroup *gin.RouterGroup) { pagesRoute := apiGroup.Group("/pages") pagesRoute.Use(compat.AdminAuth()) { - pagesRoute.GET("/", pages.ListProjectsHandler) + compat.RegisterCollection(pagesRoute, "GET", pages.ListProjectsHandler) pagesRoute.GET("/:id", pages.GetProjectHandler) - pagesRoute.POST("/", pages.CreateProjectHandler) + compat.RegisterCollection(pagesRoute, "POST", pages.CreateProjectHandler) pagesRoute.POST("/:id/update", pages.UpdateProjectHandler) pagesRoute.POST("/:id/delete", pages.DeleteProjectHandler) pagesRoute.GET("/:id/deployments", pages.ListDeploymentsHandler) diff --git a/Wavelet/internal/apps/openflare/legacy/register_proxy_route.go b/Wavelet/internal/apps/openflare/legacy/register_proxy_route.go index 9683b0e9..de56fe35 100644 --- a/Wavelet/internal/apps/openflare/legacy/register_proxy_route.go +++ b/Wavelet/internal/apps/openflare/legacy/register_proxy_route.go @@ -13,9 +13,9 @@ func registerProxyRouteRoutes(apiGroup *gin.RouterGroup) { proxyRouteGroup := apiGroup.Group("/proxy-routes") proxyRouteGroup.Use(compat.AdminAuth()) { - proxyRouteGroup.GET("/", proxy_route.GetProxyRoutes) + compat.RegisterCollection(proxyRouteGroup, "GET", proxy_route.GetProxyRoutes) proxyRouteGroup.GET("/:id", proxy_route.GetProxyRouteHandler) - proxyRouteGroup.POST("/", proxy_route.CreateProxyRouteHandler) + compat.RegisterCollection(proxyRouteGroup, "POST", proxy_route.CreateProxyRouteHandler) proxyRouteGroup.POST("/:id/update", proxy_route.UpdateProxyRouteHandler) proxyRouteGroup.POST("/:id/delete", proxy_route.DeleteProxyRouteHandler) } diff --git a/Wavelet/internal/apps/openflare/legacy/register_tls.go b/Wavelet/internal/apps/openflare/legacy/register_tls.go index 35de79eb..ac701a77 100644 --- a/Wavelet/internal/apps/openflare/legacy/register_tls.go +++ b/Wavelet/internal/apps/openflare/legacy/register_tls.go @@ -13,9 +13,9 @@ func registerTLSRoutes(apiGroup *gin.RouterGroup) { managedDomainRoute := apiGroup.Group("/managed-domains") managedDomainRoute.Use(compat.AdminAuth()) { - managedDomainRoute.GET("/", tls.GetManagedDomains) + compat.RegisterCollection(managedDomainRoute, "GET", tls.GetManagedDomains) managedDomainRoute.GET("/match", tls.MatchManagedDomainCertificateHandler) - managedDomainRoute.POST("/", tls.CreateManagedDomainHandler) + compat.RegisterCollection(managedDomainRoute, "POST", tls.CreateManagedDomainHandler) managedDomainRoute.POST("/:id/update", tls.UpdateManagedDomainHandler) managedDomainRoute.POST("/:id/delete", tls.DeleteManagedDomainHandler) } @@ -23,10 +23,10 @@ func registerTLSRoutes(apiGroup *gin.RouterGroup) { tlsCertificateRoute := apiGroup.Group("/tls-certificates") tlsCertificateRoute.Use(compat.AdminAuth()) { - tlsCertificateRoute.GET("/", tls.GetCertificates) + compat.RegisterCollection(tlsCertificateRoute, "GET", tls.GetCertificates) tlsCertificateRoute.GET("/:id", tls.GetCertificateDetail) tlsCertificateRoute.GET("/:id/content", tls.GetCertificateContentHandler) - tlsCertificateRoute.POST("/", tls.CreateCertificateHandler) + compat.RegisterCollection(tlsCertificateRoute, "POST", tls.CreateCertificateHandler) tlsCertificateRoute.POST("/:id/update", tls.UpdateCertificateHandler) tlsCertificateRoute.POST("/:id/update-acme", tls.UpdateACMECertificateHandler) tlsCertificateRoute.POST("/:id/convert-acme", tls.ConvertCertificateToACMEHandler) @@ -45,8 +45,8 @@ func registerTLSRoutes(apiGroup *gin.RouterGroup) { dnsAccountRoute := apiGroup.Group("/dns-accounts") dnsAccountRoute.Use(compat.AdminAuth()) { - dnsAccountRoute.GET("/", tls.GetDNSAccounts) - dnsAccountRoute.POST("/", tls.CreateDNSAccountHandler) + compat.RegisterCollection(dnsAccountRoute, "GET", tls.GetDNSAccounts) + compat.RegisterCollection(dnsAccountRoute, "POST", tls.CreateDNSAccountHandler) dnsAccountRoute.POST("/:id/update", tls.UpdateDNSAccountHandler) dnsAccountRoute.POST("/:id/delete", tls.DeleteDNSAccountHandler) } diff --git a/Wavelet/internal/apps/openflare/observability/routers.go b/Wavelet/internal/apps/openflare/observability/routers.go index b8a93ab9..9abb6612 100644 --- a/Wavelet/internal/apps/openflare/observability/routers.go +++ b/Wavelet/internal/apps/openflare/observability/routers.go @@ -15,7 +15,7 @@ func RegisterRoutes(apiGroup *gin.RouterGroup) { accessLogRoute := apiGroup.Group("/access-logs") accessLogRoute.Use(compat.AdminAuth()) { - accessLogRoute.GET("/", getAccessLogsHandler) + compat.RegisterCollection(accessLogRoute, "GET", getAccessLogsHandler) accessLogRoute.GET("/folds", getFoldedAccessLogsHandler) accessLogRoute.GET("/folds/ip-summary", getFoldedAccessLogIPsHandler) accessLogRoute.GET("/ip-summary", getAccessLogIPSummariesHandler) diff --git a/Wavelet/internal/apps/openflare/option/routers.go b/Wavelet/internal/apps/openflare/option/routers.go index b2b2a24e..e5fefc7f 100644 --- a/Wavelet/internal/apps/openflare/option/routers.go +++ b/Wavelet/internal/apps/openflare/option/routers.go @@ -22,7 +22,7 @@ func RegisterRoutes(apiGroup *gin.RouterGroup) { optionRoute := apiGroup.Group("/option") optionRoute.Use(compat.RootAuth()) { - optionRoute.GET("/", listOptionsHandler) + compat.RegisterCollection(optionRoute, "GET", listOptionsHandler) optionRoute.POST("/update", updateOptionHandler) optionRoute.POST("/update-batch", updateOptionsBatchHandler) optionRoute.POST("/geoip/lookup", lookupGeoIPHandler) diff --git a/Wavelet/internal/router/router.go b/Wavelet/internal/router/router.go index 0784ae3d..dfe4535c 100644 --- a/Wavelet/internal/router/router.go +++ b/Wavelet/internal/router/router.go @@ -38,6 +38,8 @@ func Serve() { // 初始化路由 r := gin.New() + // Legacy OpenFlare list endpoints register both /resource and /resource/; disable auto slash redirects. + r.RedirectTrailingSlash = false r.Use(gin.Recovery()) r.Use(corsMiddleware()) diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 50f1259b..e2f12440 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -43,6 +43,7 @@ sidebar: false - 修复静态导出构建中 `useSearchParams` 未包裹 Suspense 的问题;`pnpm build:embed` 全量 46 页通过。 - 补全前端迁移收尾:新增 `/openflare/about` 关于页与 `AboutService`;补全 `UpdateService` 升级流程(在线升级、手动上传、WebSocket 日志流)及顶栏版本升级入口。 - 前端 UI 打磨:节点/WAF/DNS/配置清理等 Dialog 补 RHF+Zod;代理规则详情与访问日志 Tab 补 error 态;`pnpm build:embed` 增至 47 静态页。 +- 修复开发模式下 `/api/*` 尾斜杠引发 Next.js 308 与 Gin 301 循环重定向:前端 Service 规范化路径、Next `skipTrailingSlashRedirect`、后端双路径注册。 ## [v2.3.4] - 2026-06-17