mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-09-19 17:50:12 +00:00
Introduce Personal Access Tokens (nzp_*) as a stateless auth path alongside
JWT, gated per-endpoint by a scope middleware (nezha:{resource}:{verb}) with
fail-closed empty-scope defaults and a server-id whitelist. Self-management
endpoints (profile, api-tokens, oauth2 bind, refresh-token) explicitly reject
PATs to block privilege-escalation chains. A revoke registry tears down active
long-lived connections (terminal, fm, ws, transfer, mcp) the moment a PAT is
deleted, with a tombstone closing the revoke->register race.
Add an MCP endpoint that proxies tool calls (exec, fs read/write/delete,
transfer) to agents over gRPC, guarded by origin/DNS-rebinding checks, a
per-token rate limiter, audit logging, and a kill switch. Serialize all
sends through the IOStream wrapper to honour grpc-go's concurrency contract.
Add CSRF double-submit protection on unsafe cookie-authenticated methods,
exempting authenticated PAT requests by context identity (not a forgeable
Authorization header). Apply visibility/whitelist filtering consistently
across list, get-by-id, and mutate paths to enforce tenant isolation.
Migrate legacy mcp:* scopes: rewrite read/exec to nezha:* equivalents and
drop dangerous write/delete/wildcard grants.
Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
471 lines
19 KiB
Go
471 lines
19 KiB
Go
package controller
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"io/fs"
|
||
"log"
|
||
"net/http"
|
||
"os"
|
||
"regexp"
|
||
"slices"
|
||
"strings"
|
||
|
||
jwt "github.com/appleboy/gin-jwt/v2"
|
||
"github.com/gin-contrib/pprof"
|
||
"github.com/gin-gonic/gin"
|
||
swaggerfiles "github.com/swaggo/files"
|
||
ginSwagger "github.com/swaggo/gin-swagger"
|
||
|
||
"github.com/nezhahq/nezha/cmd/dashboard/controller/waf"
|
||
docs "github.com/nezhahq/nezha/cmd/dashboard/docs"
|
||
"github.com/nezhahq/nezha/model"
|
||
"github.com/nezhahq/nezha/pkg/utils"
|
||
"github.com/nezhahq/nezha/service/singleton"
|
||
)
|
||
|
||
func ServeWeb(frontendDist fs.FS) http.Handler {
|
||
gin.SetMode(gin.ReleaseMode)
|
||
r := gin.Default()
|
||
|
||
if singleton.Conf.Debug {
|
||
gin.SetMode(gin.DebugMode)
|
||
pprof.Register(r)
|
||
}
|
||
if singleton.Conf.Debug {
|
||
log.Printf("NEZHA>> Swagger(%s) UI available at http://localhost:%d/swagger/index.html", docs.SwaggerInfo.Version, singleton.Conf.ListenPort)
|
||
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerfiles.Handler))
|
||
}
|
||
|
||
r.Use(waf.RealIp)
|
||
r.Use(waf.Waf)
|
||
r.Use(recordPath)
|
||
|
||
routers(r, frontendDist)
|
||
|
||
kickoffTransferGC()
|
||
|
||
return r
|
||
}
|
||
|
||
func routers(r *gin.Engine, frontendDist fs.FS) {
|
||
authMiddleware, err := jwt.New(initParams())
|
||
if err != nil {
|
||
log.Fatal("JWT Error:" + err.Error())
|
||
}
|
||
if err := authMiddleware.MiddlewareInit(); err != nil {
|
||
log.Fatal("authMiddleware.MiddlewareInit Error:" + err.Error())
|
||
}
|
||
// /mcp — Model Context Protocol endpoint, authenticated by PAT only (闸 1 + 闸 2)。
|
||
// 不放在 /api/v1 下:MCP client 配置 URL 更短,且 MCP transport 协议演进与 REST API
|
||
// 解耦。鉴权一律走 apiTokenAuthMiddleware;不接受 JWT 以避免浏览器误触。
|
||
// mcpOriginGuard 防止 DNS rebinding / 浏览器跨站调用。
|
||
r.POST("/mcp", mcpOriginGuard(), apiTokenAuthMiddleware(), mcpEndpoint)
|
||
// Streamable HTTP 规范要求:不实现 standalone SSE / session 时,GET / DELETE
|
||
// 必须显式返回 405,让客户端走 POST-only 路径并跳过 session 终止流程。
|
||
// 不显式注册时,Gin 会走 NoRoute → fallbackToFrontend,对 MCP 客户端是 HTML/404。
|
||
r.GET("/mcp", mcpOriginGuard(), mcpMethodNotAllowed)
|
||
r.DELETE("/mcp", mcpOriginGuard(), mcpMethodNotAllowed)
|
||
r.GET("/mcp/download/:token", mcpOriginGuard(), transferDownloadHandler)
|
||
r.POST("/mcp/upload/:token", mcpOriginGuard(), transferUploadHandler)
|
||
|
||
api := r.Group("api/v1")
|
||
api.POST("/login", authMiddleware.LoginHandler)
|
||
api.GET("/oauth2/:provider", commonHandler(oauth2redirect))
|
||
|
||
fallbackAuthMw := fallbackAuthMiddleware(authMiddleware)
|
||
fallbackAuth := api.Group("", fallbackAuthMw)
|
||
fallbackAuth.GET("/setting", commonHandler(listConfig))
|
||
fallbackAuth.GET("/oauth2/callback", commonHandler(oauth2callback(authMiddleware)))
|
||
|
||
jwtMw := authMiddleware.MiddlewareFunc()
|
||
patMw := apiTokenAuthMiddleware()
|
||
authMw := jwtOrPATAuthMiddleware(patMw, jwtMw)
|
||
// optional 路由:ForceAuth=true 走严格 PAT-or-JWT;ForceAuth=false 走
|
||
// PAT-or-FallbackJWT,保证两种模式下 PAT 都会被解析,restScopeMiddleware
|
||
// 才能按 scope 真实收口(否则匿名 PAT 请求会被当 guest,scope 失效)。
|
||
optionalAuthMw := utils.IfOr(singleton.Conf.ForceAuth, authMw, patOrFallbackAuthMiddleware(patMw, fallbackAuthMw))
|
||
|
||
optionalAuth := api.Group("", optionalAuthMw)
|
||
optionalAuth.GET("/ws/server", restScopeMiddleware(model.ScopeServerRead), commonHandler(serverStream))
|
||
optionalAuth.GET("/server-group", restScopeMiddleware(model.ScopeServerRead), commonHandler(listServerGroup))
|
||
|
||
optionalAuth.GET("/service", restScopeMiddleware(model.ScopeServiceRead), commonHandler(showService))
|
||
optionalAuth.GET("/service/server", restScopeMiddleware(model.ScopeServiceRead), commonHandler(listServerWithServices))
|
||
optionalAuth.GET("/service/:id/history", restScopeMiddleware(model.ScopeServiceRead), commonHandler(getServiceHistory))
|
||
optionalAuth.GET("/server/:id/service", restScopeMiddleware(model.ScopeServiceRead), commonHandler(listServerServices))
|
||
optionalAuth.GET("/server/:id/metrics", restScopeMiddleware(model.ScopeServerRead), commonHandler(getServerMetrics))
|
||
|
||
// CSRF middleware applies group-wide. Safe methods short-circuit and
|
||
// PAT bearer requests bypass — so the only callers gated are
|
||
// cookie-JWT POST/PATCH/PUT/DELETE, which is exactly the H6 surface.
|
||
auth := api.Group("", authMw, csrfMiddleware())
|
||
|
||
// 「自我管理」类端点 — 显式禁止 PAT 访问(避免 PAT 自我提权链)。
|
||
patForbidden := restPATForbiddenMiddleware()
|
||
auth.POST("/refresh-token", patForbidden, authMiddleware.RefreshHandler)
|
||
auth.GET("/profile", patForbidden, commonHandler(getProfile))
|
||
auth.POST("/profile", patForbidden, commonHandler(updateProfile))
|
||
auth.POST("/oauth2/:provider/unbind", patForbidden, commonHandler(unbindOauth2))
|
||
auth.GET("/api-tokens", patForbidden, commonHandler(listAPITokens))
|
||
auth.POST("/api-tokens", patForbidden, commonHandler(createAPIToken))
|
||
auth.DELETE("/api-tokens/:id", patForbidden, commonHandler(deleteAPIToken))
|
||
|
||
// server / terminal / fm / transfer 共享 nezha:server:* 资源族
|
||
auth.POST("/terminal", restScopeMiddleware(model.ScopeServerExec), commonHandler(createTerminal))
|
||
auth.GET("/ws/terminal/:id", restScopeMiddleware(model.ScopeServerExec), commonHandler(terminalStream))
|
||
auth.POST("/file", restScopeAllOf(model.ScopeServerRead, model.ScopeServerWrite, model.ScopeServerDelete), commonHandler(createFM))
|
||
auth.GET("/ws/file/:id", restScopeAllOf(model.ScopeServerRead, model.ScopeServerWrite, model.ScopeServerDelete), commonHandler(fmStream))
|
||
auth.GET("/server", restScopeMiddleware(model.ScopeServerRead), listHandler(listServer))
|
||
auth.PATCH("/server/:id", restScopeMiddleware(model.ScopeServerWrite), commonHandler(updateServer))
|
||
auth.GET("/server/config/:id", restScopeMiddleware(serverConfigSensitiveScope()), commonHandler(getServerConfig))
|
||
auth.POST("/server/config", restScopeMiddleware(model.ScopeServerWrite), commonHandler(setServerConfig))
|
||
auth.POST("/batch-delete/server", restScopeMiddleware(model.ScopeServerDelete), commonHandler(batchDeleteServer))
|
||
auth.POST("/batch-move/server", restScopeMiddleware(model.ScopeServerWrite), commonHandler(batchMoveServer))
|
||
auth.POST("/force-update/server", restScopeMiddleware(model.ScopeServerWrite), commonHandler(forceUpdateServer))
|
||
auth.POST("/server-group", restScopeMiddleware(model.ScopeServerWrite), commonHandler(createServerGroup))
|
||
auth.PATCH("/server-group/:id", restScopeMiddleware(model.ScopeServerWrite), commonHandler(updateServerGroup))
|
||
auth.POST("/batch-delete/server-group", restScopeMiddleware(model.ScopeServerDelete), commonHandler(batchDeleteServerGroup))
|
||
|
||
// transfer — 严格使用 nezha:transfer 资源族 scope(read/write/delete)。
|
||
// 注意:曾经计划让 nezha:server:read 兼听只读 transfer,但 restScopeMiddleware
|
||
// / APIToken.HasScope 不做 server↔transfer 别名展开,前端 SCOPE_OPTIONS 也已经
|
||
// 单独暴露 nezha:transfer:read,所以这里维持精确匹配语义。
|
||
auth.GET("/transfer", restScopeMiddleware(model.ScopeTransferRead), listHandler(listServerTransfer))
|
||
auth.POST("/transfer/:id/cancel", restScopeMiddleware(model.ScopeTransferWrite), commonHandler(cancelServerTransfer))
|
||
auth.POST("/transfer/:id/retry", restScopeMiddleware(model.ScopeTransferWrite), commonHandler(retryServerTransfer))
|
||
auth.GET("/ws/transfer", restScopeMiddleware(model.ScopeTransferRead), commonHandler(transferStream))
|
||
|
||
// service monitor
|
||
auth.GET("/service/list", restScopeMiddleware(model.ScopeServiceRead), listHandler(listService))
|
||
auth.POST("/service", restScopeMiddleware(model.ScopeServiceWrite), commonHandler(createService))
|
||
auth.PATCH("/service/:id", restScopeMiddleware(model.ScopeServiceWrite), commonHandler(updateService))
|
||
auth.POST("/batch-delete/service", restScopeMiddleware(model.ScopeServiceDelete), commonHandler(batchDeleteService))
|
||
|
||
auth.GET("/notification-group", restScopeMiddleware(model.ScopeNotificationGroupRead), commonHandler(listNotificationGroup))
|
||
auth.POST("/notification-group", restScopeMiddleware(model.ScopeNotificationGroupWrite), commonHandler(createNotificationGroup))
|
||
auth.PATCH("/notification-group/:id", restScopeMiddleware(model.ScopeNotificationGroupWrite), commonHandler(updateNotificationGroup))
|
||
auth.POST("/batch-delete/notification-group", restScopeMiddleware(model.ScopeNotificationGroupDelete), commonHandler(batchDeleteNotificationGroup))
|
||
|
||
auth.GET("/notification", restScopeMiddleware(model.ScopeNotificationRead), listHandler(listNotification))
|
||
auth.POST("/notification", restScopeMiddleware(model.ScopeNotificationWrite), commonHandler(createNotification))
|
||
auth.PATCH("/notification/:id", restScopeMiddleware(model.ScopeNotificationWrite), commonHandler(updateNotification))
|
||
auth.POST("/batch-delete/notification", restScopeMiddleware(model.ScopeNotificationDelete), commonHandler(batchDeleteNotification))
|
||
|
||
auth.GET("/alert-rule", restScopeMiddleware(model.ScopeAlertRuleRead), listHandler(listAlertRule))
|
||
auth.POST("/alert-rule", restScopeMiddleware(model.ScopeAlertRuleWrite), commonHandler(createAlertRule))
|
||
auth.PATCH("/alert-rule/:id", restScopeMiddleware(model.ScopeAlertRuleWrite), commonHandler(updateAlertRule))
|
||
auth.POST("/batch-delete/alert-rule", restScopeMiddleware(model.ScopeAlertRuleDelete), commonHandler(batchDeleteAlertRule))
|
||
|
||
auth.GET("/cron", restScopeMiddleware(model.ScopeCronRead), listHandler(listCron))
|
||
auth.POST("/cron", restScopeMiddleware(model.ScopeCronWrite), commonHandler(createCron))
|
||
auth.PATCH("/cron/:id", restScopeMiddleware(model.ScopeCronWrite), commonHandler(updateCron))
|
||
auth.POST("/cron/:id/manual", restScopeMiddleware(model.ScopeCronExec), commonHandler(manualTriggerCron))
|
||
auth.POST("/batch-delete/cron", restScopeMiddleware(model.ScopeCronDelete), commonHandler(batchDeleteCron))
|
||
|
||
auth.GET("/ddns", restScopeMiddleware(model.ScopeDDNSRead), listHandler(listDDNS))
|
||
auth.GET("/ddns/providers", restScopeMiddleware(model.ScopeDDNSRead), commonHandler(listProviders))
|
||
auth.POST("/ddns", restScopeMiddleware(model.ScopeDDNSWrite), commonHandler(createDDNS))
|
||
auth.PATCH("/ddns/:id", restScopeMiddleware(model.ScopeDDNSWrite), commonHandler(updateDDNS))
|
||
auth.POST("/batch-delete/ddns", restScopeMiddleware(model.ScopeDDNSDelete), commonHandler(batchDeleteDDNS))
|
||
|
||
auth.GET("/nat", restScopeMiddleware(model.ScopeNATRead), listHandler(listNAT))
|
||
auth.POST("/nat", restScopeMiddleware(model.ScopeNATWrite), commonHandler(createNAT))
|
||
auth.PATCH("/nat/:id", restScopeMiddleware(model.ScopeNATWrite), commonHandler(updateNAT))
|
||
auth.POST("/batch-delete/nat", restScopeMiddleware(model.ScopeNATDelete), commonHandler(batchDeleteNAT))
|
||
|
||
// 管理员资源 — 仅 nezha:* / nezha:admin:* 持有者可调(adminHandler 进一步校验 user.Role)。
|
||
auth.GET("/user", restScopeMiddleware(model.ScopeAdminAll), adminHandler(listUser))
|
||
auth.POST("/user", restScopeMiddleware(model.ScopeAdminAll), adminHandler(createUser))
|
||
auth.POST("/batch-delete/user", restScopeMiddleware(model.ScopeAdminAll), adminHandler(batchDeleteUser))
|
||
auth.GET("/waf", restScopeMiddleware(model.ScopeAdminAll), pAdminHandler(listBlockedAddress))
|
||
auth.POST("/batch-delete/waf", restScopeMiddleware(model.ScopeAdminAll), adminHandler(batchDeleteBlockedAddress))
|
||
auth.GET("/online-user", restScopeMiddleware(model.ScopeAdminAll), pAdminHandler(listOnlineUser))
|
||
auth.POST("/online-user/batch-block", restScopeMiddleware(model.ScopeAdminAll), adminHandler(batchBlockOnlineUser))
|
||
auth.PATCH("/setting", restScopeMiddleware(model.ScopeAdminAll), adminHandler(updateConfig))
|
||
auth.POST("/maintenance", restScopeMiddleware(model.ScopeAdminAll), adminHandler(runMaintenance))
|
||
|
||
r.NoRoute(fallbackToFrontend(frontendDist))
|
||
}
|
||
|
||
func recordPath(c *gin.Context) {
|
||
url := c.Request.URL.String()
|
||
for _, p := range c.Params {
|
||
url = strings.Replace(url, p.Value, ":"+p.Key, 1)
|
||
}
|
||
c.Set("MatchedPath", url)
|
||
}
|
||
|
||
func newErrorResponse(err error) model.CommonResponse[any] {
|
||
return model.CommonResponse[any]{
|
||
Success: false,
|
||
Error: err.Error(),
|
||
}
|
||
}
|
||
|
||
type handlerFunc[T any] func(c *gin.Context) (T, error)
|
||
type pHandlerFunc[S ~[]E, E any] func(c *gin.Context) (*model.Value[S], error)
|
||
|
||
// There are many error types in gorm, so create a custom type to represent all
|
||
// gorm errors here instead
|
||
type gormError struct {
|
||
msg string
|
||
a []any
|
||
}
|
||
|
||
func newGormError(format string, args ...any) error {
|
||
return &gormError{
|
||
msg: format,
|
||
a: args,
|
||
}
|
||
}
|
||
|
||
func (ge *gormError) Error() string {
|
||
return fmt.Sprintf(ge.msg, ge.a...)
|
||
}
|
||
|
||
type wsError struct {
|
||
msg string
|
||
a []any
|
||
}
|
||
|
||
func newWsError(format string, args ...any) error {
|
||
return &wsError{
|
||
msg: format,
|
||
a: args,
|
||
}
|
||
}
|
||
|
||
func (we *wsError) Error() string {
|
||
return fmt.Sprintf(we.msg, we.a...)
|
||
}
|
||
|
||
var errNoop = errors.New("wrote")
|
||
|
||
func commonHandler[T any](handler handlerFunc[T]) func(*gin.Context) {
|
||
return func(c *gin.Context) {
|
||
handle(c, handler)
|
||
}
|
||
}
|
||
|
||
func adminHandler[T any](handler handlerFunc[T]) func(*gin.Context) {
|
||
return func(c *gin.Context) {
|
||
auth, ok := c.Get(model.CtxKeyAuthorizedUser)
|
||
if !ok {
|
||
c.JSON(http.StatusOK, newErrorResponse(singleton.Localizer.ErrorT("unauthorized")))
|
||
return
|
||
}
|
||
|
||
user := *auth.(*model.User)
|
||
if !user.Role.IsAdmin() {
|
||
c.JSON(http.StatusOK, newErrorResponse(singleton.Localizer.ErrorT("permission denied")))
|
||
return
|
||
}
|
||
|
||
handle(c, handler)
|
||
}
|
||
}
|
||
|
||
func handle[T any](c *gin.Context, handler handlerFunc[T]) {
|
||
data, err := handler(c)
|
||
if err == nil {
|
||
c.JSON(http.StatusOK, model.CommonResponse[T]{Success: true, Data: data})
|
||
return
|
||
}
|
||
switch err.(type) {
|
||
case *gormError:
|
||
log.Printf("NEZHA>> gorm error: %v", err)
|
||
c.JSON(http.StatusOK, newErrorResponse(singleton.Localizer.ErrorT("database error")))
|
||
return
|
||
case *wsError:
|
||
// Connection is upgraded to WebSocket, so c.Writer is no longer usable
|
||
if msg := err.Error(); msg != "" {
|
||
log.Printf("NEZHA>> websocket error: %v", err)
|
||
}
|
||
return
|
||
default:
|
||
if !errors.Is(err, errNoop) {
|
||
c.JSON(http.StatusOK, newErrorResponse(err))
|
||
}
|
||
return
|
||
}
|
||
}
|
||
|
||
func listHandler[S ~[]E, E model.CommonInterface](handler handlerFunc[S]) func(*gin.Context) {
|
||
return func(c *gin.Context) {
|
||
data, err := handler(c)
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, newErrorResponse(err))
|
||
return
|
||
}
|
||
|
||
filtered := filter(c, data)
|
||
c.JSON(http.StatusOK, model.CommonResponse[S]{Success: true, Data: model.SearchByIDCtx(c, filtered)})
|
||
}
|
||
}
|
||
|
||
func pCommonHandler[S ~[]E, E any](handler pHandlerFunc[S, E]) func(*gin.Context) {
|
||
return func(c *gin.Context) {
|
||
data, err := handler(c)
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, newErrorResponse(err))
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, model.PaginatedResponse[S, E]{Success: true, Data: data})
|
||
}
|
||
}
|
||
|
||
func pAdminHandler[S ~[]E, E any](handler pHandlerFunc[S, E]) func(*gin.Context) {
|
||
return func(c *gin.Context) {
|
||
auth, ok := c.Get(model.CtxKeyAuthorizedUser)
|
||
if !ok {
|
||
c.JSON(http.StatusOK, newErrorResponse(singleton.Localizer.ErrorT("unauthorized")))
|
||
return
|
||
}
|
||
user := *auth.(*model.User)
|
||
if !user.Role.IsAdmin() {
|
||
c.JSON(http.StatusOK, newErrorResponse(singleton.Localizer.ErrorT("permission denied")))
|
||
return
|
||
}
|
||
|
||
data, err := handler(c)
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, newErrorResponse(err))
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, model.PaginatedResponse[S, E]{Success: true, Data: data})
|
||
}
|
||
}
|
||
|
||
func filter[S ~[]E, E model.CommonInterface](ctx *gin.Context, s S) S {
|
||
return slices.DeleteFunc(s, func(e E) bool {
|
||
return !e.HasPermission(ctx)
|
||
})
|
||
}
|
||
|
||
func getUid(c *gin.Context) uint64 {
|
||
user, _ := c.MustGet(model.CtxKeyAuthorizedUser).(*model.User)
|
||
return user.ID
|
||
}
|
||
|
||
func fallbackToFrontend(frontendDist fs.FS) func(*gin.Context) {
|
||
serveFile := func(c *gin.Context, name string, file fs.File, customStatusCode int) bool {
|
||
defer file.Close()
|
||
fileStat, err := file.Stat()
|
||
if err != nil {
|
||
return false
|
||
}
|
||
if fileStat.IsDir() {
|
||
return false
|
||
}
|
||
readSeeker, ok := file.(io.ReadSeeker)
|
||
if !ok {
|
||
return false
|
||
}
|
||
http.ServeContent(utils.NewGinCustomWriter(c, customStatusCode), c.Request, name, fileStat.ModTime(), readSeeker)
|
||
return true
|
||
}
|
||
|
||
checkLocalFileOrFs := func(c *gin.Context, frontendFS fs.FS, templateRoot, filePath string, customStatusCode int) bool {
|
||
if filePath != "" {
|
||
localRoot, err := os.OpenRoot(templateRoot)
|
||
if err == nil {
|
||
defer localRoot.Close()
|
||
// URL paths must stay inside the selected template root; never join them against the process cwd.
|
||
if file, err := localRoot.Open(filePath); err == nil && serveFile(c, filePath, file, customStatusCode) {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
|
||
if !fs.ValidPath(filePath) {
|
||
return false
|
||
}
|
||
templateFS, err := fs.Sub(frontendFS, templateRoot)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
file, err := templateFS.Open(filePath)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
if serveFile(c, filePath, file, customStatusCode) {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
frontendPageUrlRegistry := []*regexp.Regexp{
|
||
// official user frontend
|
||
regexp.MustCompile(`^/$`),
|
||
regexp.MustCompile(`^/server/\d*$`),
|
||
// backend frontend
|
||
regexp.MustCompile(`^/dashboard/$`),
|
||
regexp.MustCompile(`^/dashboard/login$`),
|
||
regexp.MustCompile(`^/dashboard/service$`),
|
||
regexp.MustCompile(`^/dashboard/cron$`),
|
||
regexp.MustCompile(`^/dashboard/notification$`),
|
||
regexp.MustCompile(`^/dashboard/alert-rule$`),
|
||
regexp.MustCompile(`^/dashboard/ddns$`),
|
||
regexp.MustCompile(`^/dashboard/nat$`),
|
||
regexp.MustCompile(`^/dashboard/server-group$`),
|
||
regexp.MustCompile(`^/dashboard/notification-group$`),
|
||
regexp.MustCompile(`^/dashboard/profile$`),
|
||
regexp.MustCompile(`^/dashboard/settings$`),
|
||
regexp.MustCompile(`^/dashboard/settings/user$`),
|
||
regexp.MustCompile(`^/dashboard/settings/online-user$`),
|
||
regexp.MustCompile(`^/dashboard/settings/waf$`),
|
||
regexp.MustCompile(`^/dashboard/settings/api-tokens$`),
|
||
// 注意:这里的白名单决定哪些 URL 走 index.html fallback;漏一条就会把
|
||
// 直接刷新该页面变成 404(HTTP 状态码层面,body 仍是 index.html,所以
|
||
// 浏览器内 SPA 看起来正常,但 monitoring / 链接预览会以为站点挂了)。
|
||
// 新增前端路由时必须在 admin-frontend/src/main.tsx 与这里同步加。
|
||
regexp.MustCompile(`^/dashboard/transfer$`),
|
||
}
|
||
|
||
getFallbackStatusCode := func(path string) int {
|
||
for _, reg := range frontendPageUrlRegistry {
|
||
if reg.MatchString(path) {
|
||
return http.StatusOK
|
||
}
|
||
}
|
||
return http.StatusNotFound
|
||
}
|
||
|
||
return func(c *gin.Context) {
|
||
if strings.HasPrefix(c.Request.URL.Path, "/api") {
|
||
c.JSON(http.StatusNotFound, newErrorResponse(errors.New("404 Not Found")))
|
||
return
|
||
}
|
||
|
||
// redirect for /dashboard to /dashboard/
|
||
if c.Request.URL.Path == "/dashboard" {
|
||
c.Redirect(http.StatusMovedPermanently, "/dashboard/")
|
||
return
|
||
}
|
||
|
||
fallbackStatusCode := getFallbackStatusCode(c.Request.URL.Path)
|
||
// Only /dashboard/ belongs to the admin frontend; /dashboard.. must not be trimmed into ../.
|
||
if strings.HasPrefix(c.Request.URL.Path, "/dashboard/") {
|
||
stripPath := strings.TrimPrefix(c.Request.URL.Path, "/dashboard/")
|
||
if checkLocalFileOrFs(c, frontendDist, singleton.Conf.AdminTemplate, stripPath, http.StatusOK) {
|
||
return
|
||
}
|
||
if !checkLocalFileOrFs(c, frontendDist, singleton.Conf.AdminTemplate, "index.html", fallbackStatusCode) {
|
||
c.JSON(http.StatusNotFound, newErrorResponse(errors.New("404 Not Found")))
|
||
}
|
||
return
|
||
}
|
||
stripPath := strings.TrimPrefix(c.Request.URL.Path, "/")
|
||
if checkLocalFileOrFs(c, frontendDist, singleton.Conf.UserTemplate, stripPath, http.StatusOK) {
|
||
return
|
||
}
|
||
if !checkLocalFileOrFs(c, frontendDist, singleton.Conf.UserTemplate, "index.html", fallbackStatusCode) {
|
||
c.JSON(http.StatusNotFound, newErrorResponse(errors.New("404 Not Found")))
|
||
}
|
||
}
|
||
}
|