diff --git a/admin/server/api/v1/gaia/app_version.go b/admin/server/api/v1/gaia/app_version.go new file mode 100644 index 000000000..6c86998bc --- /dev/null +++ b/admin/server/api/v1/gaia/app_version.go @@ -0,0 +1,299 @@ +package gaia + +import ( + "strconv" + + "github.com/flipped-aurora/gin-vue-admin/server/global" + "github.com/flipped-aurora/gin-vue-admin/server/model/common/response" + gaiaReq "github.com/flipped-aurora/gin-vue-admin/server/model/gaia/request" + "github.com/flipped-aurora/gin-vue-admin/server/service" + "github.com/flipped-aurora/gin-vue-admin/server/utils" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +type AppVersionApi struct{} + +var appVersionService = service.ServiceGroupApp.GaiaServiceGroup.AppVersionService + +// GetLatest 客户端获取最新版本(公开接口) +// @Tags AppVersion +// @Summary 客户端获取最新版本 +// @accept application/json +// @Produce application/json +// @Param platform query string true "平台(如 win32/darwin/linux)" +// @Param arch query string false "架构(可选,未传或未匹配时取该 platform 第一个包)" +// @Param token query string false "Token(若后台配置了则必填)" +// @Success 200 {object} response.Response "获取成功" +// @Router /latest [get] +func (appVersionApi *AppVersionApi) GetLatest(c *gin.Context) { + platform := c.Query("platform") + arch := c.Query("arch") + token := c.Query("token") + if platform == "" { + response.FailWithMessage("platform is required", c) + return + } + resp, code := appVersionService.GetLatest(platform, arch, token) + if code == 401 { + response.NoAuth("token required or invalid", c) + return + } + if code == 404 { + response.FailWithMessage("no package for this platform/arch", c) + return + } + if code != 200 { + global.GVA_LOG.Error("GetLatest failed", zap.Int("code", code)) + response.FailWithMessage("internal error", c) + return + } + response.OkWithData(resp, c) +} + +func buildDownloadUrl(c *gin.Context) func(string) string { + prefix := global.GVA_CONFIG.System.RouterPrefix + return func(path string) string { + scheme := "https" + if c.GetHeader("X-Forwarded-Proto") != "" { + scheme = c.GetHeader("X-Forwarded-Proto") + } else if c.Request.TLS == nil { + scheme = "http" + } + host := c.Request.Host + if path == "" { + return "" + } + if path[0] != '/' { + path = "/" + path + } + if prefix != "" { + return scheme + "://" + host + prefix + path + } + return scheme + "://" + host + path + } +} + +// GetTokenConfig 管理端获取全局 Token 配置 +// @Tags AppVersion +// @Summary 管理端获取全局 Token 配置 +// @Security ApiKeyAuth +// @accept application/json +// @Produce application/json +// @Success 200 {object} response.Response "获取成功" +// @Router /gaia/app-version/token [get] +func (appVersionApi *AppVersionApi) GetTokenConfig(c *gin.Context) { + cfg, err := appVersionService.GetTokenConfig() + if err != nil { + global.GVA_LOG.Error("获取Token配置失败!", zap.Error(err)) + response.FailWithMessage("获取失败:"+err.Error(), c) + return + } + response.OkWithData(cfg, c) +} + +// SetTokenConfig 管理端设置全局 Token +// @Tags AppVersion +// @Summary 管理端设置全局 Token +// @Security ApiKeyAuth +// @accept application/json +// @Produce application/json +// @Param data body gaiaReq.AppVersionTokenConfig true "Token 配置" +// @Success 200 {object} response.Response "设置成功" +// @Router /gaia/app-version/token [put] +func (appVersionApi *AppVersionApi) SetTokenConfig(c *gin.Context) { + var req gaiaReq.AppVersionTokenConfig + if err := c.ShouldBindJSON(&req); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + if err := appVersionService.SetTokenConfig(req); err != nil { + global.GVA_LOG.Error("设置Token配置失败!", zap.Error(err)) + response.FailWithMessage(err.Error(), c) + return + } + response.Ok(c) +} + +// RevealToken 输入登录密码验证后返回明文 Token +// @Tags AppVersion +// @Summary 输入登录密码验证后返回明文 Token +// @Security ApiKeyAuth +// @accept application/json +// @Produce application/json +// @Param data body gaiaReq.AppVersionTokenReveal true "密码" +// @Success 200 {object} response.Response "获取成功" +// @Router /gaia/app-version/token/reveal [post] +func (appVersionApi *AppVersionApi) RevealToken(c *gin.Context) { + var req gaiaReq.AppVersionTokenReveal + if err := c.ShouldBindJSON(&req); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + userID := utils.GetUserID(c) + token, err := appVersionService.RevealToken(userID, req.Password) + if err != nil { + global.GVA_LOG.Error("RevealToken失败!", zap.Error(err)) + response.FailWithMessage(err.Error(), c) + return + } + response.OkWithData(gin.H{"token": token}, c) +} + +// ListReleases 版本列表 +// @Tags AppVersion +// @Summary 版本列表 +// @Security ApiKeyAuth +// @accept application/json +// @Produce application/json +// @Success 200 {object} response.Response "获取成功" +// @Router /gaia/app-version/releases [get] +func (appVersionApi *AppVersionApi) ListReleases(c *gin.Context) { + list, err := appVersionService.ListReleases() + if err != nil { + global.GVA_LOG.Error("获取版本列表失败!", zap.Error(err)) + response.FailWithMessage("获取失败:"+err.Error(), c) + return + } + response.OkWithData(list, c) +} + +// CreateRelease 新增版本 +// @Tags AppVersion +// @Summary 新增版本 +// @Security ApiKeyAuth +// @accept application/json +// @Produce application/json +// @Param data body gaiaReq.AppVersionReleaseCreate true "版本信息" +// @Success 200 {object} response.Response "创建成功" +// @Router /gaia/app-version/releases [post] +func (appVersionApi *AppVersionApi) CreateRelease(c *gin.Context) { + var req gaiaReq.AppVersionReleaseCreate + if err := c.ShouldBindJSON(&req); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + r, err := appVersionService.CreateRelease(req) + if err != nil { + global.GVA_LOG.Error("创建版本失败!", zap.Error(err)) + response.FailWithMessage(err.Error(), c) + return + } + response.OkWithData(r, c) +} + +// GetRelease 获取单个版本详情 +// @Tags AppVersion +// @Summary 获取单个版本详情 +// @Security ApiKeyAuth +// @accept application/json +// @Produce application/json +// @Param id path int true "版本 ID" +// @Success 200 {object} response.Response "获取成功" +// @Router /gaia/app-version/releases/:id [get] +func (appVersionApi *AppVersionApi) GetRelease(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + response.FailWithMessage("无效的版本 id", c) + return + } + detail, err := appVersionService.GetReleaseByID(uint(id)) + if err != nil { + global.GVA_LOG.Error("获取版本详情失败!", zap.Error(err)) + response.FailWithMessage(err.Error(), c) + return + } + response.OkWithData(detail, c) +} + +// UpdateRelease 更新版本信息 +// @Tags AppVersion +// @Summary 更新版本信息 +// @Security ApiKeyAuth +// @accept application/json +// @Produce application/json +// @Param id path int true "版本 ID" +// @Param data body gaiaReq.AppVersionReleaseUpdate true "版本信息" +// @Success 200 {object} response.Response "更新成功" +// @Router /gaia/app-version/releases/:id [put] +func (appVersionApi *AppVersionApi) UpdateRelease(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + response.FailWithMessage("无效的版本 id", c) + return + } + var req gaiaReq.AppVersionReleaseUpdate + if err = c.ShouldBindJSON(&req); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + if err = appVersionService.UpdateRelease(uint(id), req); err != nil { + global.GVA_LOG.Error("更新版本失败!", zap.Error(err)) + response.FailWithMessage(err.Error(), c) + return + } + response.Ok(c) +} + +// UploadToRelease 上传安装包到指定版本(根据文件名自动识别平台/架构) +// @Tags AppVersion +// @Summary 上传安装包到指定版本 +// @Security ApiKeyAuth +// @accept multipart/form-data +// @Produce application/json +// @Param id path int true "版本 ID" +// @Param file formData file true "安装包文件" +// @Success 200 {object} response.Response "上传成功" +// @Router /gaia/app-version/releases/:id/upload [post] +func (appVersionApi *AppVersionApi) UploadToRelease(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + response.FailWithMessage("无效的版本 id", c) + return + } + file, err := c.FormFile("file") + if err != nil { + response.FailWithMessage("请选择文件: "+err.Error(), c) + return + } + if err = appVersionService.UploadPackageToRelease(uint(id), file, buildDownloadUrl(c)); err != nil { + global.GVA_LOG.Error("上传安装包失败!", zap.Error(err)) + response.FailWithMessage(err.Error(), c) + return + } + response.Ok(c) +} + +// DeleteDownload 删除指定版本下某 platform/arch 的包 +// @Tags AppVersion +// @Summary 删除指定版本下某 platform/arch 的包 +// @Security ApiKeyAuth +// @accept application/json +// @Produce application/json +// @Param id path int true "版本 ID" +// @Param platform query string true "平台" +// @Param arch query string true "架构" +// @Success 200 {object} response.Response "删除成功" +// @Router /gaia/app-version/releases/:id/download [delete] +func (appVersionApi *AppVersionApi) DeleteDownload(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + response.FailWithMessage("无效的版本 id", c) + return + } + var q gaiaReq.AppVersionDeleteQuery + if err = c.ShouldBindQuery(&q); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + if err = appVersionService.DeleteDownload(uint(id), q.Platform, q.Arch); err != nil { + global.GVA_LOG.Error("删除安装包失败!", zap.Error(err)) + response.FailWithMessage(err.Error(), c) + return + } + response.Ok(c) +} diff --git a/admin/server/api/v1/gaia/enter.go b/admin/server/api/v1/gaia/enter.go index 0b8f269eb..a159e9e51 100644 --- a/admin/server/api/v1/gaia/enter.go +++ b/admin/server/api/v1/gaia/enter.go @@ -10,6 +10,7 @@ type ApiGroup struct { TestApi SystemOAuth2Api BatchWorkflowApi + AppVersionApi } var ( diff --git a/admin/server/initialize/gorm.go b/admin/server/initialize/gorm.go index c60cba662..0239d0749 100644 --- a/admin/server/initialize/gorm.go +++ b/admin/server/initialize/gorm.go @@ -73,6 +73,9 @@ func RegisterTables() { gaia.ForwardingExtend{}, // Extend Forwarding Extend gaia.BatchWorkflow{}, // Extend Batch Workflow gaia.BatchWorkflowTask{}, // Extend Batch Workflow Task + gaia.AppVersionConfig{}, // 应用版本全局配置(Token) + gaia.AppVersionRelease{}, // 应用版本发布 + gaia.AppVersionDownload{}, // 应用版本各平台安装包 system.SysUserGlobalCode{}, // Extend Global Code // Extend gaia model ) @@ -127,6 +130,9 @@ func createPostgreSQLSequences(db *gorm.DB) { "forwarding_extends", "batch_workflows", "batch_workflow_tasks", + "app_version_config", + "app_version_releases", + "app_version_downloads", "sys_user_global_codes", } diff --git a/admin/server/initialize/router_biz.go b/admin/server/initialize/router_biz.go index 521da06f1..c7e749908 100644 --- a/admin/server/initialize/router_biz.go +++ b/admin/server/initialize/router_biz.go @@ -21,5 +21,6 @@ func initBizRouter(routers ...*gin.RouterGroup) { gaiaRouter.InitTestRouter(privateGroup, publicGroup) gaiaRouter.InitSystemRouter(privateGroup) gaiaRouter.InitWorkflowRouter(privateGroup) + gaiaRouter.InitAppVersionRouter(publicGroup, privateGroup) } } diff --git a/admin/server/model/gaia/app_version_config.go b/admin/server/model/gaia/app_version_config.go new file mode 100644 index 000000000..afaa589f9 --- /dev/null +++ b/admin/server/model/gaia/app_version_config.go @@ -0,0 +1,14 @@ +package gaia + +import "time" + +// AppVersionConfig 应用版本全局配置(仅链接 Token,与具体版本解耦) +type AppVersionConfig struct { + Id uint `json:"id" gorm:"primarykey;column:id;comment:id"` + LinkToken *string `json:"link_token,omitempty" gorm:"column:link_token;size:255;comment:链接token,配置后GET /latest需传此token"` + UpdatedAt time.Time `json:"updated_at" gorm:"column:updated_at;comment:更新时间"` +} + +func (AppVersionConfig) TableName() string { + return "app_version_config" +} diff --git a/admin/server/model/gaia/app_version_download.go b/admin/server/model/gaia/app_version_download.go new file mode 100644 index 000000000..ddc0a0edb --- /dev/null +++ b/admin/server/model/gaia/app_version_download.go @@ -0,0 +1,21 @@ +package gaia + +import "time" + +// Platform: darwin | win32 | linux +// Arch: x64 | arm64 + +// AppVersionDownload 某版本的各平台/架构安装包 +type AppVersionDownload struct { + Id uint `json:"id" gorm:"primarykey;column:id;comment:id"` + ReleaseId uint `json:"release_id" gorm:"column:release_id;not null;index;comment:关联的发布id"` + Platform string `json:"platform" gorm:"column:platform;size:32;not null;comment:平台 darwin|win32|linux"` + Arch string `json:"arch" gorm:"column:arch;size:16;not null;comment:架构 x64|arm64"` + DownloadUrl string `json:"download_url" gorm:"column:download_url;size:1024;not null;comment:下载地址(完整URL或相对路径)"` + FileName string `json:"file_name" gorm:"column:file_name;size:256;comment:原始文件名"` + CreatedAt time.Time `json:"created_at" gorm:"column:created_at;comment:创建时间"` +} + +func (AppVersionDownload) TableName() string { + return "app_version_downloads" +} diff --git a/admin/server/model/gaia/app_version_release.go b/admin/server/model/gaia/app_version_release.go new file mode 100644 index 000000000..5d3110f7d --- /dev/null +++ b/admin/server/model/gaia/app_version_release.go @@ -0,0 +1,16 @@ +package gaia + +import "time" + +// AppVersionRelease 应用版本发布(多版本列表中的一条) +type AppVersionRelease struct { + Id uint `json:"id" gorm:"primarykey;column:id;comment:id"` + Version string `json:"version" gorm:"column:version;size:64;not null;comment:版本号"` + ReleaseNotes string `json:"release_notes" gorm:"column:release_notes;type:text;comment:更新说明"` + CreatedAt time.Time `json:"created_at" gorm:"column:created_at;comment:创建时间"` + UpdatedAt time.Time `json:"updated_at" gorm:"column:updated_at;comment:更新时间"` +} + +func (AppVersionRelease) TableName() string { + return "app_version_releases" +} diff --git a/admin/server/model/gaia/request/app_version.go b/admin/server/model/gaia/request/app_version.go new file mode 100644 index 000000000..eff2797b2 --- /dev/null +++ b/admin/server/model/gaia/request/app_version.go @@ -0,0 +1,29 @@ +package request + +// AppVersionTokenConfig 仅设置链接 Token(全局) +type AppVersionTokenConfig struct { + LinkToken *string `json:"link_token,omitempty"` // 空或 "********" 不更新,"" 清除 +} + +// AppVersionTokenReveal 输入登录密码以查看明文 Token +type AppVersionTokenReveal struct { + Password string `json:"password" binding:"required"` +} + +// AppVersionReleaseCreate 新增版本 +type AppVersionReleaseCreate struct { + Version string `json:"version" binding:"required"` // 版本号 + ReleaseNotes string `json:"release_notes"` // 更新说明 +} + +// AppVersionReleaseUpdate 更新版本信息 +type AppVersionReleaseUpdate struct { + Version string `json:"version" binding:"required"` + ReleaseNotes string `json:"release_notes"` +} + +// AppVersionDeleteQuery 删除某平台/架构包 +type AppVersionDeleteQuery struct { + Platform string `form:"platform" binding:"required,oneof=darwin win32 linux"` + Arch string `form:"arch" binding:"required,oneof=x64 arm64"` +} diff --git a/admin/server/model/gaia/response/app_version.go b/admin/server/model/gaia/response/app_version.go new file mode 100644 index 000000000..09be8596e --- /dev/null +++ b/admin/server/model/gaia/response/app_version.go @@ -0,0 +1,42 @@ +package response + +// LatestVersionResponse 客户端 GET /latest 返回(Electron 更新检测) +type LatestVersionResponse struct { + Version string `json:"version"` + ReleaseNotes string `json:"releaseNotes"` + DownloadUrl string `json:"downloadUrl"` +} + +// AppVersionTokenConfig 管理端获取的全局 Token 配置(脱敏) +type AppVersionTokenConfig struct { + LinkToken *string `json:"link_token,omitempty"` +} + +// ReleaseListItem 版本列表项 +type ReleaseListItem struct { + Id uint `json:"id"` + Version string `json:"version"` + ReleaseNotes string `json:"release_notes"` + CreatedAt string `json:"created_at"` + Downloads []DownloadItem `json:"downloads"` +} + +// ReleaseDetail 单个版本详情(含包列表) +type ReleaseDetail struct { + Id uint `json:"id"` + Version string `json:"version"` + ReleaseNotes string `json:"release_notes"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Downloads []DownloadItem `json:"downloads"` +} + +// DownloadItem 单个平台/架构的安装包信息 +type DownloadItem struct { + Id uint `json:"id"` + Platform string `json:"platform"` + Arch string `json:"arch"` + DownloadUrl string `json:"download_url"` + FileName string `json:"file_name"` + CreatedAt string `json:"created_at,omitempty"` +} diff --git a/admin/server/router/gaia/app_version.go b/admin/server/router/gaia/app_version.go new file mode 100644 index 000000000..2964949a3 --- /dev/null +++ b/admin/server/router/gaia/app_version.go @@ -0,0 +1,26 @@ +package gaia + +import ( + "github.com/gin-gonic/gin" +) + +type AppVersionRouter struct{} + +// InitAppVersionRouter 公开:GET /latest、GET /releases(版本列表);鉴权:/gaia/app-version/* +func (r *AppVersionRouter) InitAppVersionRouter(publicGroup, privateGroup *gin.RouterGroup) { + publicGroup.GET("/latest", appVersionApi.GetLatest) + publicGroup.GET("/releases", appVersionApi.ListReleases) // 公开:版本列表,无需登录 + + appVersion := privateGroup.Group("gaia/app-version") + { + appVersion.GET("token", appVersionApi.GetTokenConfig) // 全局 Token 配置(脱敏) + appVersion.PUT("token", appVersionApi.SetTokenConfig) + appVersion.POST("token/reveal", appVersionApi.RevealToken) // 密码验证后查看明文 Token + appVersion.GET("releases", appVersionApi.ListReleases) // 版本列表 + appVersion.POST("releases", appVersionApi.CreateRelease) // 新增版本 + appVersion.GET("releases/:id", appVersionApi.GetRelease) // 版本详情 + appVersion.PUT("releases/:id", appVersionApi.UpdateRelease) // 更新版本信息 + appVersion.POST("releases/:id/upload", appVersionApi.UploadToRelease) // 上传安装包(自动识别平台/架构) + appVersion.DELETE("releases/:id/download", appVersionApi.DeleteDownload) // 删除某包 + } +} diff --git a/admin/server/router/gaia/enter.go b/admin/server/router/gaia/enter.go index ab5375e5f..a41915933 100644 --- a/admin/server/router/gaia/enter.go +++ b/admin/server/router/gaia/enter.go @@ -9,6 +9,7 @@ type RouterGroup struct { SystemRouter TestRouter WorkflowRouter + AppVersionRouter } var ( @@ -20,3 +21,4 @@ var systemApi = api.ApiGroupApp.GaiaApiGroup.SystemApi var quotaApi = api.ApiGroupApp.GaiaApiGroup.QuotaApi var testApi = api.ApiGroupApp.GaiaApiGroup.TestApi var batchWorkflowApi = api.ApiGroupApp.GaiaApiGroup.BatchWorkflowApi +var appVersionApi = api.ApiGroupApp.GaiaApiGroup.AppVersionApi \ No newline at end of file diff --git a/admin/server/service/gaia/app_version.go b/admin/server/service/gaia/app_version.go new file mode 100644 index 000000000..a4d5e6380 --- /dev/null +++ b/admin/server/service/gaia/app_version.go @@ -0,0 +1,260 @@ +package gaia + +import ( + "errors" + "fmt" + "mime/multipart" + "time" + + "github.com/flipped-aurora/gin-vue-admin/server/global" + "github.com/flipped-aurora/gin-vue-admin/server/model/gaia" + "github.com/flipped-aurora/gin-vue-admin/server/model/gaia/request" + "github.com/flipped-aurora/gin-vue-admin/server/model/gaia/response" + "github.com/flipped-aurora/gin-vue-admin/server/model/system" + "github.com/flipped-aurora/gin-vue-admin/server/utils" + "github.com/flipped-aurora/gin-vue-admin/server/utils/upload" + "gorm.io/gorm" +) + +const tokenMask = "********" + +type AppVersionService struct{} + +// getOrCreateConfig 获取或创建全局配置(单例) +func (s *AppVersionService) getOrCreateConfig() (*gaia.AppVersionConfig, error) { + var cfg gaia.AppVersionConfig + err := global.GVA_DB.First(&cfg).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + if err = global.GVA_DB.Create(&cfg).Error; err != nil { + return nil, err + } + return &cfg, nil + } + return nil, err + } + return &cfg, nil +} + +// downloadsToItems 将下载记录转为 API 返回的 DownloadItem 列表 +func (s *AppVersionService) downloadsToItems(releaseID uint) ([]response.DownloadItem, error) { + var downloads []gaia.AppVersionDownload + if err := global.GVA_DB.Where("release_id = ?", releaseID).Find(&downloads).Error; err != nil { + return nil, err + } + items := make([]response.DownloadItem, 0, len(downloads)) + for _, d := range downloads { + items = append(items, response.DownloadItem{ + Id: d.Id, + Platform: d.Platform, + Arch: d.Arch, + DownloadUrl: d.DownloadUrl, + FileName: d.FileName, + CreatedAt: d.CreatedAt.Format(time.RFC3339), + }) + } + return items, nil +} + +// GetLatest 客户端「最新版本」;token 从全局 config 校验。仅必填 platform;arch 可选:未传则取该平台第一个包,传了但无匹配则按 platform 取第一个。 +func (s *AppVersionService) GetLatest(platform, arch, token string) (*response.LatestVersionResponse, int) { + cfg, err := s.getOrCreateConfig() + if err != nil { + return nil, 500 + } + if cfg.LinkToken != nil && *cfg.LinkToken != "" && (token == "" || token != *cfg.LinkToken) { + return nil, 401 + } + + var release gaia.AppVersionRelease + if err = global.GVA_DB.Order("id DESC").First(&release).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, 404 + } + return nil, 500 + } + + var download gaia.AppVersionDownload + if arch != "" { + err = global.GVA_DB.Where("release_id = ? AND platform = ? AND arch = ?", release.Id, platform, arch).First(&download).Error + } + if arch == "" || errors.Is(err, gorm.ErrRecordNotFound) { + err = global.GVA_DB.Where("release_id = ? AND platform = ?", release.Id, platform).First(&download).Error + } + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, 404 + } + return nil, 500 + } + + return &response.LatestVersionResponse{ + Version: release.Version, + ReleaseNotes: release.ReleaseNotes, + DownloadUrl: download.DownloadUrl, + }, 200 +} + +// GetTokenConfig 管理端获取全局 Token(脱敏) +func (s *AppVersionService) GetTokenConfig() (*response.AppVersionTokenConfig, error) { + cfg, err := s.getOrCreateConfig() + if err != nil { + return nil, err + } + var linkToken *string + if cfg.LinkToken != nil && *cfg.LinkToken != "" { + mask := tokenMask + linkToken = &mask + } + return &response.AppVersionTokenConfig{LinkToken: linkToken}, nil +} + +// SetTokenConfig 管理端设置全局 Token +func (s *AppVersionService) SetTokenConfig(req request.AppVersionTokenConfig) error { + cfg, err := s.getOrCreateConfig() + if err != nil { + return err + } + if req.LinkToken == nil || *req.LinkToken == tokenMask { + return nil + } + if *req.LinkToken == "" { + cfg.LinkToken = nil + } else { + cfg.LinkToken = req.LinkToken + } + return global.GVA_DB.Save(cfg).Error +} + +// RevealToken 验证当前用户登录密码后返回明文 Token +func (s *AppVersionService) RevealToken(userID uint, password string) (string, error) { + var user system.SysUser + if err := global.GVA_DB.Select("password").First(&user, userID).Error; err != nil { + return "", err + } + if !utils.BcryptCheck(password, user.Password) { + return "", errors.New("密码错误") + } + cfg, err := s.getOrCreateConfig() + if err != nil { + return "", err + } + if cfg.LinkToken == nil || *cfg.LinkToken == "" { + return "", nil + } + return *cfg.LinkToken, nil +} + +// ListReleases 版本列表(按 id 倒序) +func (s *AppVersionService) ListReleases() ([]response.ReleaseListItem, error) { + var releases []gaia.AppVersionRelease + if err := global.GVA_DB.Order("id DESC").Find(&releases).Error; err != nil { + return nil, err + } + result := make([]response.ReleaseListItem, 0, len(releases)) + for _, r := range releases { + items, err := s.downloadsToItems(r.Id) + if err != nil { + return nil, err + } + result = append(result, response.ReleaseListItem{ + Id: r.Id, + Version: r.Version, + ReleaseNotes: r.ReleaseNotes, + CreatedAt: r.CreatedAt.Format(time.RFC3339), + Downloads: items, + }) + } + return result, nil +} + +// CreateRelease 新增版本 +func (s *AppVersionService) CreateRelease(req request.AppVersionReleaseCreate) (*gaia.AppVersionRelease, error) { + release := gaia.AppVersionRelease{Version: req.Version, ReleaseNotes: req.ReleaseNotes} + if err := global.GVA_DB.Create(&release).Error; err != nil { + return nil, err + } + return &release, nil +} + +// GetReleaseByID 获取单个版本详情 +func (s *AppVersionService) GetReleaseByID(id uint) (*response.ReleaseDetail, error) { + var release gaia.AppVersionRelease + if err := global.GVA_DB.First(&release, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("版本不存在") + } + return nil, err + } + items, err := s.downloadsToItems(release.Id) + if err != nil { + return nil, err + } + return &response.ReleaseDetail{ + Id: release.Id, + Version: release.Version, + ReleaseNotes: release.ReleaseNotes, + CreatedAt: release.CreatedAt.Format(time.RFC3339), + UpdatedAt: release.UpdatedAt.Format(time.RFC3339), + Downloads: items, + }, nil +} + +// UpdateRelease 更新版本信息 +func (s *AppVersionService) UpdateRelease(id uint, req request.AppVersionReleaseUpdate) error { + var release gaia.AppVersionRelease + if err := global.GVA_DB.First(&release, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("版本不存在") + } + return err + } + release.Version = req.Version + release.ReleaseNotes = req.ReleaseNotes + return global.GVA_DB.Save(&release).Error +} + +// UploadPackageToRelease 上传安装包到指定版本,根据文件名自动推断 platform/arch +func (s *AppVersionService) UploadPackageToRelease(releaseID uint, file *multipart.FileHeader, buildDownloadUrl func(path string) string) error { + platform, arch := utils.InferPlatformArch(file.Filename) + if platform == "" || arch == "" { + return fmt.Errorf("无法从文件名推断平台/架构,请使用 .dmg/.exe/.deb/.AppImage 等常见安装包") + } + var release gaia.AppVersionRelease + if err := global.GVA_DB.First(&release, releaseID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("版本不存在") + } + return err + } + oss := upload.NewOss() + filePath, _, err := oss.UploadFile(file) + if err != nil { + return err + } + fullURL := buildDownloadUrl(filePath) + var download gaia.AppVersionDownload + err = global.GVA_DB.Where("release_id = ? AND platform = ? AND arch = ?", releaseID, platform, arch).First(&download).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + download = gaia.AppVersionDownload{ + ReleaseId: releaseID, + Platform: platform, + Arch: arch, + DownloadUrl: fullURL, + FileName: file.Filename, + } + return global.GVA_DB.Create(&download).Error + } + return err + } + download.DownloadUrl = fullURL + download.FileName = file.Filename + return global.GVA_DB.Save(&download).Error +} + +// DeleteDownload 删除指定版本下某 platform/arch 的安装包记录 +func (s *AppVersionService) DeleteDownload(releaseID uint, platform, arch string) error { + return global.GVA_DB.Where("release_id = ? AND platform = ? AND arch = ?", releaseID, platform, arch). + Delete(&gaia.AppVersionDownload{}).Error +} diff --git a/admin/server/service/gaia/enter.go b/admin/server/service/gaia/enter.go index 2de7015c1..966009e0b 100644 --- a/admin/server/service/gaia/enter.go +++ b/admin/server/service/gaia/enter.go @@ -7,4 +7,6 @@ type ServiceGroup struct { TenantsService TestService BatchWorkflowService + // extned: app version + AppVersionService } diff --git a/admin/server/service/system/sys_menu.go b/admin/server/service/system/sys_menu.go index 4d5a0ea71..364933b0d 100644 --- a/admin/server/service/system/sys_menu.go +++ b/admin/server/service/system/sys_menu.go @@ -137,6 +137,13 @@ func (menuService *MenuService) AddBaseMenu(menu system.SysBaseMenu) error { if !errors.Is(global.GVA_DB.Where("name = ?", menu.Name).First(&system.SysBaseMenu{}).Error, gorm.ErrRecordNotFound) { return errors.New("存在重复name,请修改name") } + // 判断是否pgsql + if global.GVA_CONFIG.System.DbType == "pgsql" { + var logMneu system.SysBaseMenu + if global.GVA_DB.Order("id desc").First(&logMneu).Error == nil { + menu.ID = logMneu.ID + 1 + } + } return global.GVA_DB.Create(&menu).Error } diff --git a/admin/server/source/system/api.go b/admin/server/source/system/api.go index 67559f5df..1ad6b10a9 100644 --- a/admin/server/source/system/api.go +++ b/admin/server/source/system/api.go @@ -219,6 +219,16 @@ func (i *initApi) InitializeData(ctx context.Context) (context.Context, error) { {ApiGroup: "批量处理工作流", Method: "POST", Path: "/gaia/workflow/batch/:id/retry-failed", Description: "仅重试失败的任务"}, {ApiGroup: "批量处理工作流", Method: "POST", Path: "/gaia/workflow/batch/:id/resume", Description: "恢复批量处理"}, {ApiGroup: "批量处理工作流", Method: "GET", Path: "/gaia/workflow/batch/:id/download", Description: "下载结果"}, + {ApiGroup: "应用版本", Method: "GET", Path: "/gaia/app-version/token", Description: "获取链接Token配置"}, + {ApiGroup: "应用版本", Method: "PUT", Path: "/gaia/app-version/token", Description: "设置链接Token"}, + {ApiGroup: "应用版本", Method: "POST", Path: "/gaia/app-version/token/reveal", Description: "密码验证后查看Token"}, + {ApiGroup: "应用版本", Method: "GET", Path: "/gaia/app-version/token/generate", Description: "随机生成Token"}, + {ApiGroup: "应用版本", Method: "GET", Path: "/gaia/app-version/releases", Description: "版本列表"}, + {ApiGroup: "应用版本", Method: "POST", Path: "/gaia/app-version/releases", Description: "新增版本"}, + {ApiGroup: "应用版本", Method: "GET", Path: "/gaia/app-version/releases/:id", Description: "版本详情"}, + {ApiGroup: "应用版本", Method: "PUT", Path: "/gaia/app-version/releases/:id", Description: "更新版本信息"}, + {ApiGroup: "应用版本", Method: "POST", Path: "/gaia/app-version/releases/:id/upload", Description: "上传安装包(自动识别平台架构)"}, + {ApiGroup: "应用版本", Method: "DELETE", Path: "/gaia/app-version/releases/:id/download", Description: "删除指定平台架构包"}, // Extend Stop: batch workflow } if err := db.Create(&entities).Error; err != nil { diff --git a/admin/server/source/system/authorities_menus.go b/admin/server/source/system/authorities_menus.go index a1db47aef..e2d48d921 100644 --- a/admin/server/source/system/authorities_menus.go +++ b/admin/server/source/system/authorities_menus.go @@ -51,6 +51,9 @@ func (i *initMenuAuthority) InitializeData(ctx context.Context) (next context.Co if err = db.Model(&authorities[0]).Association("SysBaseMenus").Append(menus[37:40]); err != nil { return next, err } + if err = db.Model(&authorities[0]).Association("SysBaseMenus").Append(menus[40:41]); err != nil { + return next, err + } if err = db.Model(&authorities[0]).Association("SysBaseMenus").Append(menus[2:5]); err != nil { return next, err } diff --git a/admin/server/source/system/casbin.go b/admin/server/source/system/casbin.go index 2490dc595..5aef73d2e 100644 --- a/admin/server/source/system/casbin.go +++ b/admin/server/source/system/casbin.go @@ -335,6 +335,49 @@ func (i *initCasbin) InitializeData(ctx context.Context) (context.Context, error {Ptype: "p", V0: "1", V1: "/gaia/workflow/batch/:id/resume", V2: "POST"}, {Ptype: "p", V0: "1", V1: "/gaia/workflow/batch/:id/download", V2: "GET"}, // Extend Stop: batch workflow + + // Extend Start: app version (应用版本与多平台包管理) + {Ptype: "p", V0: "888", V1: "/gaia/app-version/token", V2: "GET"}, + {Ptype: "p", V0: "888", V1: "/gaia/app-version/token", V2: "PUT"}, + {Ptype: "p", V0: "888", V1: "/gaia/app-version/token/reveal", V2: "POST"}, + {Ptype: "p", V0: "888", V1: "/gaia/app-version/token/generate", V2: "GET"}, + {Ptype: "p", V0: "888", V1: "/gaia/app-version/releases", V2: "GET"}, + {Ptype: "p", V0: "888", V1: "/gaia/app-version/releases", V2: "POST"}, + {Ptype: "p", V0: "888", V1: "/gaia/app-version/releases/:id", V2: "GET"}, + {Ptype: "p", V0: "888", V1: "/gaia/app-version/releases/:id", V2: "PUT"}, + {Ptype: "p", V0: "888", V1: "/gaia/app-version/releases/:id/upload", V2: "POST"}, + {Ptype: "p", V0: "888", V1: "/gaia/app-version/releases/:id/download", V2: "DELETE"}, + {Ptype: "p", V0: "8881", V1: "/gaia/app-version/token", V2: "GET"}, + {Ptype: "p", V0: "8881", V1: "/gaia/app-version/token", V2: "PUT"}, + {Ptype: "p", V0: "8881", V1: "/gaia/app-version/token/reveal", V2: "POST"}, + {Ptype: "p", V0: "8881", V1: "/gaia/app-version/token/generate", V2: "GET"}, + {Ptype: "p", V0: "8881", V1: "/gaia/app-version/releases", V2: "GET"}, + {Ptype: "p", V0: "8881", V1: "/gaia/app-version/releases", V2: "POST"}, + {Ptype: "p", V0: "8881", V1: "/gaia/app-version/releases/:id", V2: "GET"}, + {Ptype: "p", V0: "8881", V1: "/gaia/app-version/releases/:id", V2: "PUT"}, + {Ptype: "p", V0: "8881", V1: "/gaia/app-version/releases/:id/upload", V2: "POST"}, + {Ptype: "p", V0: "8881", V1: "/gaia/app-version/releases/:id/download", V2: "DELETE"}, + {Ptype: "p", V0: "9528", V1: "/gaia/app-version/token", V2: "GET"}, + {Ptype: "p", V0: "9528", V1: "/gaia/app-version/token", V2: "PUT"}, + {Ptype: "p", V0: "9528", V1: "/gaia/app-version/token/reveal", V2: "POST"}, + {Ptype: "p", V0: "9528", V1: "/gaia/app-version/token/generate", V2: "GET"}, + {Ptype: "p", V0: "9528", V1: "/gaia/app-version/releases", V2: "GET"}, + {Ptype: "p", V0: "9528", V1: "/gaia/app-version/releases", V2: "POST"}, + {Ptype: "p", V0: "9528", V1: "/gaia/app-version/releases/:id", V2: "GET"}, + {Ptype: "p", V0: "9528", V1: "/gaia/app-version/releases/:id", V2: "PUT"}, + {Ptype: "p", V0: "9528", V1: "/gaia/app-version/releases/:id/upload", V2: "POST"}, + {Ptype: "p", V0: "9528", V1: "/gaia/app-version/releases/:id/download", V2: "DELETE"}, + {Ptype: "p", V0: "1", V1: "/gaia/app-version/token", V2: "GET"}, + {Ptype: "p", V0: "1", V1: "/gaia/app-version/token", V2: "PUT"}, + {Ptype: "p", V0: "1", V1: "/gaia/app-version/token/reveal", V2: "POST"}, + {Ptype: "p", V0: "1", V1: "/gaia/app-version/token/generate", V2: "GET"}, + {Ptype: "p", V0: "1", V1: "/gaia/app-version/releases", V2: "GET"}, + {Ptype: "p", V0: "1", V1: "/gaia/app-version/releases", V2: "POST"}, + {Ptype: "p", V0: "1", V1: "/gaia/app-version/releases/:id", V2: "GET"}, + {Ptype: "p", V0: "1", V1: "/gaia/app-version/releases/:id", V2: "PUT"}, + {Ptype: "p", V0: "1", V1: "/gaia/app-version/releases/:id/upload", V2: "POST"}, + {Ptype: "p", V0: "1", V1: "/gaia/app-version/releases/:id/download", V2: "DELETE"}, + // Extend Stop: app version } if err := db.Create(&entities).Error; err != nil { return ctx, errors.Wrap(err, "Casbin 表 ("+i.InitializerName()+") 数据初始化失败!") diff --git a/admin/server/source/system/menu.go b/admin/server/source/system/menu.go index 09d40345b..05c59e92e 100644 --- a/admin/server/source/system/menu.go +++ b/admin/server/source/system/menu.go @@ -93,6 +93,7 @@ func (i *initMenu) InitializeData(ctx context.Context) (next context.Context, er {GVA_MODEL: global.GVA_MODEL{ID: 38}, MenuLevel: 0, Hidden: false, ParentId: 0, Path: "SystemIntegrated", Name: "SystemIntegrated", Component: "view/systemIntegrated/index.vue", Sort: 1, Meta: Meta{Title: "系统集成", Icon: "box"}}, {GVA_MODEL: global.GVA_MODEL{ID: 39}, MenuLevel: 0, Hidden: false, ParentId: 38, Path: "IntegratedDingTalk", Name: "IntegratedDingTalk", Component: "view/systemIntegrated/dingTalk/index.vue", Sort: 1, Meta: Meta{Title: "钉钉", Icon: "turn-off"}}, {GVA_MODEL: global.GVA_MODEL{ID: 40}, MenuLevel: 0, Hidden: false, ParentId: 38, Path: "IntegratedOAuth2", Name: "IntegratedOAuth2", Component: "view/systemIntegrated/oauth2/index.vue", Sort: 2, Meta: Meta{Title: "OAuth2", Icon: "share"}}, + {GVA_MODEL: global.GVA_MODEL{ID: 41}, MenuLevel: 0, Hidden: false, ParentId: 0, Path: "AppVersion", Name: "AppVersion", Component: "view/gaia/appVersion/index.vue", Sort: 10, Meta: Meta{Title: "版本管理", Icon: "upload-filled"}}, // 二开部分 } if err = db.Create(&entities).Error; err != nil { diff --git a/admin/server/utils/app_version_infer.go b/admin/server/utils/app_version_infer.go new file mode 100644 index 000000000..a0fabf240 --- /dev/null +++ b/admin/server/utils/app_version_infer.go @@ -0,0 +1,45 @@ +package utils + +import "strings" + +// InferPlatformArch 根据安装包文件名推断 platform 与 arch +// 返回 platform (darwin|win32|linux), arch (x64|arm64) +// Windows 安装包通常为一个 exe 同时支持 arm/amd64,统一存为 win32+x64,客户端请求 win32 时返回该包 +func InferPlatformArch(filename string) (platform, arch string) { + name := strings.ToLower(filename) + // .dmg → macOS + if strings.HasSuffix(name, ".dmg") { + platform = "darwin" + if strings.Contains(name, "arm64") || strings.Contains(name, "aarch64") { + arch = "arm64" + } else if strings.Contains(name, "x64") || strings.Contains(name, "amd64") || strings.Contains(name, "x86_64") { + arch = "x64" + } else { + arch = "arm64" // 默认 Apple Silicon + } + return + } + // .exe → Windows,不区分架构,统一 x64 + if strings.HasSuffix(name, ".exe") { + platform = "win32" + arch = "x64" + return + } + // .deb → Linux + if strings.HasSuffix(name, ".deb") { + platform = "linux" + if strings.Contains(name, "arm64") || strings.Contains(name, "aarch64") { + arch = "arm64" + } else { + arch = "x64" // amd64 + } + return + } + // .AppImage → Linux,一般为 x64(已转小写故 .appimage) + if strings.HasSuffix(name, ".appimage") { + platform = "linux" + arch = "x64" + return + } + return "", "" +} diff --git a/admin/web/src/api/gaia/appVersion.js b/admin/web/src/api/gaia/appVersion.js new file mode 100644 index 000000000..58b702a56 --- /dev/null +++ b/admin/web/src/api/gaia/appVersion.js @@ -0,0 +1,55 @@ +import service from '@/utils/request' + +/** 获取全局 Token 配置(脱敏) */ +export const getAppVersionToken = () => { + return service({ url: '/gaia/app-version/token', method: 'get' }) +} + +/** 设置全局 Token */ +export const setAppVersionToken = (data) => { + return service({ url: '/gaia/app-version/token', method: 'put', data }) +} + +/** 输入登录密码验证后查看明文 Token */ +export const revealAppVersionToken = (data) => { + return service({ url: '/gaia/app-version/token/reveal', method: 'post', data }) +} + +/** 版本列表 */ +export const getAppVersionReleases = () => { + return service({ url: '/gaia/app-version/releases', method: 'get' }) +} + +/** 新增版本 */ +export const createAppVersionRelease = (data) => { + return service({ url: '/gaia/app-version/releases', method: 'post', data }) +} + +/** 版本详情 */ +export const getAppVersionRelease = (id) => { + return service({ url: `/gaia/app-version/releases/${id}`, method: 'get' }) +} + +/** 更新版本信息 */ +export const updateAppVersionRelease = (id, data) => { + return service({ url: `/gaia/app-version/releases/${id}`, method: 'put', data }) +} + +/** 上传安装包到指定版本(自动识别平台/架构),formData: file */ +export const uploadAppVersionPackage = (releaseId, formData) => { + return service({ + url: `/gaia/app-version/releases/${releaseId}/upload`, + method: 'post', + data: formData, + headers: { 'Content-Type': 'multipart/form-data' } + }) +} + +/** 删除指定版本下某 platform/arch 的包 */ +export const deleteAppVersionDownload = (releaseId, params) => { + return service({ + url: `/gaia/app-version/releases/${releaseId}/download`, + method: 'delete', + params + }) +} diff --git a/admin/web/src/pathInfo.json b/admin/web/src/pathInfo.json index f4a441ecf..580be738c 100644 --- a/admin/web/src/pathInfo.json +++ b/admin/web/src/pathInfo.json @@ -17,6 +17,7 @@ "/src/view/example/customer/customer.vue": "Customer", "/src/view/example/index.vue": "Example", "/src/view/example/upload/upload.vue": "Upload", + "/src/view/gaia/appVersion/index.vue": "AppVersion", "/src/view/gaia/dashboard/components/accountMoneyTable.vue": "AccountMoneyTable", "/src/view/gaia/dashboard/components/appTokenDailyQuotaNumbers.vue": "AppTokenDailyQuotaNumbers", "/src/view/gaia/dashboard/components/appTokenQuota.vue": "AppTokenQuota", diff --git a/admin/web/src/view/gaia/appVersion/index.vue b/admin/web/src/view/gaia/appVersion/index.vue new file mode 100644 index 000000000..bcab2f580 --- /dev/null +++ b/admin/web/src/view/gaia/appVersion/index.vue @@ -0,0 +1,511 @@ + + + + + diff --git a/api/constants/__init__.py b/api/constants/__init__.py index e441395af..4027ca675 100644 --- a/api/constants/__init__.py +++ b/api/constants/__init__.py @@ -60,6 +60,9 @@ DOCUMENT_EXTENSIONS: set[str] = convert_to_lower_and_upper_set(_doc_extensions) COOKIE_NAME_ACCESS_TOKEN = "access_token" COOKIE_NAME_REFRESH_TOKEN = "refresh_token" COOKIE_NAME_CSRF_TOKEN = "csrf_token" +# extend: CVE-2025-63387未授权访问 虽然这个api实际上就是个登录用的 +COOKIE_NAME_LOGIN_CONFIG_TOKEN = "dify_console_login_config" +HEADER_NAME_LOGIN_CONFIG_TOKEN = "X-Login-Config-Token" # webapp COOKIE_NAME_WEBAPP_ACCESS_TOKEN = "webapp_access_token" diff --git a/api/controllers/console/feature.py b/api/controllers/console/feature.py index d3811e2d1..25b17e30b 100644 --- a/api/controllers/console/feature.py +++ b/api/controllers/console/feature.py @@ -1,6 +1,14 @@ -from flask_restx import Resource, fields -from werkzeug.exceptions import Unauthorized +from datetime import datetime, timedelta, timezone +from typing import Optional +import jwt +from flask import make_response, request +from flask_restx import Resource, fields +from werkzeug.exceptions import Forbidden, Unauthorized + +from configs import dify_config +from constants import COOKIE_NAME_LOGIN_CONFIG_TOKEN, HEADER_NAME_LOGIN_CONFIG_TOKEN +from libs.helper import extract_remote_ip from libs.login import current_account_with_tenant, current_user, login_required from services.feature_service import FeatureService @@ -8,6 +16,51 @@ from . import console_ns from .wraps import account_initialization_required, cloud_utm_record, setup_required +def _issue_login_config_jwt(ip: str) -> str: + """extend: CVE-2025-63387 签发 JWT,payload 含 ip 与 1h 过期。""" + payload = { + "ip": ip, + "exp": datetime.now(timezone.utc) + timedelta(hours=1), + } + return jwt.encode(payload, dify_config.SECRET_KEY, algorithm="HS256") + + +def _verify_login_config_token(token: Optional[str]) -> bool: + """extend: CVE-2025-63387 校验 JWT 签名、过期时间,以及当前请求 IP 与 payload.ip 一致。""" + if not token: + return False + try: + payload = jwt.decode(token, dify_config.SECRET_KEY, algorithms=["HS256"]) + except jwt.PyJWTError: + return False + return payload.get("ip") == extract_remote_ip(request) + + +# extend: start CVE-2025-63387未授权访问 +@console_ns.route("/login_config_bootstrap") +class LoginConfigBootstrapApi(Resource): + """extend: CVE-2025-63387未授权访问 虽然这个api实际上就是个登录用的 + + 写入 features 相关 cookie(值为 JWT,含 ip 与 1h 过期), + 同时返回 token 供前端在跨域时通过 Header 携带。 + """ + @console_ns.doc("login_config_bootstrap") + @console_ns.response(200, "Success") + def get(self): + client_ip = extract_remote_ip(request) + token = _issue_login_config_jwt(client_ip) + resp = make_response({"ok": True, "token": token}) + resp.set_cookie( + COOKIE_NAME_LOGIN_CONFIG_TOKEN, + value=token, + max_age=3600, + httponly=True, + samesite="Lax", + ) + return resp +# extend: stop CVE-2025-63387未授权访问 + + @console_ns.route("/features") class FeatureApi(Resource): @console_ns.doc("get_tenant_features") @@ -28,17 +81,25 @@ class FeatureApi(Resource): return FeatureService.get_features(current_tenant_id).model_dump() -@console_ns.route("/system-features") -class SystemFeatureApi(Resource): - @console_ns.doc("get_system_features") - @console_ns.doc(description="Get system-wide feature configuration") + +# extend: start CVE-2025-63387未授权访问 +@console_ns.route("/login_config") +class LoginConfigApi(Resource): + """extend: CVE-2025-63387未授权访问 虽然这个api实际上就是个登录用的 + + 仅当请求带有 login_config_bootstrap 写入的 cookie 时才返回登录配置, + 避免未经过控制台入口的扫描直接获取系统配置。 + """ + @console_ns.doc("get_login_config") + @console_ns.doc(description="Get system-wide login/feature configuration") @console_ns.response( 200, "Success", console_ns.model( - "SystemFeatureResponse", {"features": fields.Raw(description="System feature configuration object")} + "LoginConfigResponse", {"features": fields.Raw(description="System feature configuration object")} ), ) + @console_ns.response(403, "Missing or invalid login_config token") def get(self): """Get system-wide feature configuration @@ -49,10 +110,16 @@ class SystemFeatureApi(Resource): Only non-sensitive configuration data should be returned by this endpoint. """ - # NOTE(QuantumGhost): ideally we should access `current_user.is_authenticated` - # without a try-catch. However, due to the implementation of user loader (the `load_user_from_request` - # in api/extensions/ext_login.py), accessing `current_user.is_authenticated` will - # raise `Unauthorized` exception if authentication token is not provided. + # extend: CVE-2025-63387 支持 Cookie 或 Header 携带 JWT(跨域时 Cookie 可能为 None,用 Header) + token = request.cookies.get(COOKIE_NAME_LOGIN_CONFIG_TOKEN) or request.headers.get( + HEADER_NAME_LOGIN_CONFIG_TOKEN + ) + if not _verify_login_config_token(token): + raise Forbidden( + "Missing or invalid login_config token (cookie or X-Login-Config-Token); " + "call /login_config_bootstrap first." + ) + # extend: stop CVE-2025-63387未授权访问 try: is_authenticated = current_user.is_authenticated except Unauthorized: diff --git a/api/extensions/ext_blueprints.py b/api/extensions/ext_blueprints.py index 1c4ac8034..adbbbcf37 100644 --- a/api/extensions/ext_blueprints.py +++ b/api/extensions/ext_blueprints.py @@ -1,10 +1,22 @@ from configs import dify_config -from constants import HEADER_NAME_APP_CODE, HEADER_NAME_CSRF_TOKEN, HEADER_NAME_PASSPORT +from constants import ( + HEADER_NAME_APP_CODE, + HEADER_NAME_CSRF_TOKEN, + HEADER_NAME_LOGIN_CONFIG_TOKEN, + HEADER_NAME_PASSPORT, +) from dify_app import DifyApp BASE_CORS_HEADERS: tuple[str, ...] = ("Content-Type", HEADER_NAME_APP_CODE, HEADER_NAME_PASSPORT) SERVICE_API_HEADERS: tuple[str, ...] = (*BASE_CORS_HEADERS, "Authorization", "Authorization-extend", "X-App-Code") -AUTHENTICATED_HEADERS: tuple[str, ...] = (*SERVICE_API_HEADERS, HEADER_NAME_CSRF_TOKEN, "Authorization-extend", "X-App-Code") +# extend: CVE-2025-63387 放行 X-Login-Config-Token 以便跨域时用 Header 带 JWT +AUTHENTICATED_HEADERS: tuple[str, ...] = ( + *SERVICE_API_HEADERS, + HEADER_NAME_CSRF_TOKEN, + HEADER_NAME_LOGIN_CONFIG_TOKEN, + "Authorization-extend", + "X-App-Code", +) FILES_HEADERS: tuple[str, ...] = (*BASE_CORS_HEADERS, HEADER_NAME_CSRF_TOKEN, "Authorization-extend") EMBED_HEADERS: tuple[str, ...] = ("Content-Type", HEADER_NAME_APP_CODE, "Authorization-extend", "X-App-Code") EXPOSED_HEADERS: tuple[str, ...] = ("X-Version", "X-Env", "X-Trace-Id") diff --git a/docker/.env.example b/docker/.env.example index 3f170399d..916b4627d 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -6,45 +6,40 @@ # Common Variables # ------------------------------ -# The backend URL of the console API (base only; entrypoint appends /console/api). +# The backend URL of the console API, # used to concatenate the authorization callback. -# Must be an absolute URL (e.g. https://your-domain.com). If empty, web entrypoint falls back to http://127.0.0.1:5001. +# If empty, it is the same domain. # Example: https://api.console.dify.ai -# 外网需要自行配置 -CONSOLE_API_URL=http://127.0.0.1:5001 +CONSOLE_API_URL= # The front-end URL of the console web, # used to concatenate some front-end addresses and for CORS configuration use. # If empty, it is the same domain. # Example: https://console.dify.ai -# 外网需要自行配置 -CONSOLE_WEB_URL=http://127.0.0.1:3000 +CONSOLE_WEB_URL= # Service API Url, # used to display Service API Base Url to the front-end. # If empty, it is the same domain. # Example: https://api.dify.ai -# 外网需要自行配置 -SERVICE_API_URL=http://127.0.0.1:5001 +SERVICE_API_URL= # Trigger external URL # used to display trigger endpoint API Base URL to the front-end. # Example: https://api.dify.ai -# 外网需要自行配置 -TRIGGER_URL=http://127.0.0.1:5001 +TRIGGER_URL=http://localhost -# WebApp API backend Url (base only; entrypoint appends /api). +# WebApp API backend Url, # used to declare the back-end URL for the front-end API. -# Must be an absolute URL. If empty, web entrypoint falls back to http://127.0.0.1:5001. +# If empty, it is the same domain. # Example: https://api.app.dify.ai -# 外网需要自行配置 -APP_API_URL=http://127.0.0.1:5001 +APP_API_URL= # WebApp Url, # used to display WebAPP API Base Url to the front-end. # If empty, it is the same domain. # Example: https://app.dify.ai -APP_WEB_URL=http://127.0.0.1:3000 +APP_WEB_URL= # File preview or download Url prefix. # used to display File preview or download Url to the front-end or as Multi-model inputs; diff --git a/docs/1.11.4升级到1.12.2需要执行的权限SQL.sql b/docs/1.11.4升级到1.12.2需要执行的权限SQL.sql new file mode 100644 index 000000000..ec36df108 --- /dev/null +++ b/docs/1.11.4升级到1.12.2需要执行的权限SQL.sql @@ -0,0 +1,83 @@ +-- ============================================================ +-- 应用版本:菜单、API、Casbin 权限、角色-菜单关联 插入语句 +-- 执行前请确认:1) 表结构已存在 2) 若 id 冲突可调整或改用 INSERT IGNORE / ON CONFLICT +-- ============================================================ + +-- --------------- 1. 菜单 sys_base_menus (应用版本) --------------- +-- 若已存在 id=41 可改为其他未占用 id,或先查 max(id)+1 +INSERT INTO sys_base_menus ( + id, created_at, updated_at, deleted_at, + menu_level, parent_id, path, name, hidden, component, sort, + active_name, keep_alive, default_menu, title, icon, close_tab +) VALUES ( + 41, + NOW(), NOW(), NULL, + 0, 0, 'AppVersion', 'AppVersion', false, 'view/gaia/appVersion/index.vue', 10, + '', false, false, '版本管理', 'upload-filled', false +); +-- MySQL 若主键冲突可改为: INSERT IGNORE INTO ... 或 ON DUPLICATE KEY UPDATE id=id +-- PostgreSQL: INSERT ... ON CONFLICT (id) DO NOTHING; + + +-- --------------- 2. API sys_apis (应用版本相关 8 条) --------------- +-- id 使用自增则可不写 id 列;以下为显式 id,请按当前库最大 id 调整,避免冲突 +-- 查询当前最大 id: SELECT MAX(id) FROM sys_apis; +-- 假设当前最大为 250,则从 251 开始;否则删除 id 列让库自增 +INSERT INTO sys_apis (id, created_at, updated_at, deleted_at, path, description, api_group, method) VALUES +(251, NOW(), NOW(), NULL, '/gaia/app-version/token', '获取链接Token配置', '应用版本', 'GET'), +(252, NOW(), NOW(), NULL, '/gaia/app-version/token', '设置链接Token', '应用版本', 'PUT'), +(253, NOW(), NOW(), NULL, '/gaia/app-version/releases', '版本列表', '应用版本', 'GET'), +(254, NOW(), NOW(), NULL, '/gaia/app-version/releases', '新增版本', '应用版本', 'POST'), +(255, NOW(), NOW(), NULL, '/gaia/app-version/releases/:id', '版本详情', '应用版本', 'GET'), +(256, NOW(), NOW(), NULL, '/gaia/app-version/releases/:id', '更新版本信息', '应用版本', 'PUT'), +(257, NOW(), NOW(), NULL, '/gaia/app-version/releases/:id/upload', '上传安装包(自动识别平台架构)', '应用版本', 'POST'), +(258, NOW(), NOW(), NULL, '/gaia/app-version/releases/:id/download', '删除指定平台架构包', '应用版本', 'DELETE'); +-- 若希望自增 id,可改为(去掉 id 列): +-- INSERT INTO sys_apis (created_at, updated_at, deleted_at, path, description, api_group, method) VALUES +-- (NOW(), NOW(), NULL, '/gaia/app-version/token', '获取链接Token配置', '应用版本', 'GET'), +-- ... 共 8 条; + + +-- --------------- 3. Casbin 规则 casbin_rule (角色 888/8881/9528/1 的接口权限) --------------- +INSERT INTO casbin_rule (ptype, v0, v1, v2) VALUES +('p', '888', '/gaia/app-version/token', 'GET'), +('p', '888', '/gaia/app-version/token', 'PUT'), +('p', '888', '/gaia/app-version/releases', 'GET'), +('p', '888', '/gaia/app-version/releases', 'POST'), +('p', '888', '/gaia/app-version/releases/:id', 'GET'), +('p', '888', '/gaia/app-version/releases/:id', 'PUT'), +('p', '888', '/gaia/app-version/releases/:id/upload', 'POST'), +('p', '888', '/gaia/app-version/releases/:id/download', 'DELETE'), +('p', '8881', '/gaia/app-version/token', 'GET'), +('p', '8881', '/gaia/app-version/token', 'PUT'), +('p', '8881', '/gaia/app-version/releases', 'GET'), +('p', '8881', '/gaia/app-version/releases', 'POST'), +('p', '8881', '/gaia/app-version/releases/:id', 'GET'), +('p', '8881', '/gaia/app-version/releases/:id', 'PUT'), +('p', '8881', '/gaia/app-version/releases/:id/upload', 'POST'), +('p', '8881', '/gaia/app-version/releases/:id/download', 'DELETE'), +('p', '9528', '/gaia/app-version/token', 'GET'), +('p', '9528', '/gaia/app-version/token', 'PUT'), +('p', '9528', '/gaia/app-version/releases', 'GET'), +('p', '9528', '/gaia/app-version/releases', 'POST'), +('p', '9528', '/gaia/app-version/releases/:id', 'GET'), +('p', '9528', '/gaia/app-version/releases/:id', 'PUT'), +('p', '9528', '/gaia/app-version/releases/:id/upload', 'POST'), +('p', '9528', '/gaia/app-version/releases/:id/download', 'DELETE'), +('p', '1', '/gaia/app-version/token', 'GET'), +('p', '1', '/gaia/app-version/token', 'PUT'), +('p', '1', '/gaia/app-version/releases', 'GET'), +('p', '1', '/gaia/app-version/releases', 'POST'), +('p', '1', '/gaia/app-version/releases/:id', 'GET'), +('p', '1', '/gaia/app-version/releases/:id', 'PUT'), +('p', '1', '/gaia/app-version/releases/:id/upload', 'POST'), +('p', '1', '/gaia/app-version/releases/:id/download', 'DELETE'); + + +-- --------------- 4. 角色-菜单关联 sys_authority_menus (让角色 888 拥有「应用版本」菜单) --------------- +-- 表结构:sys_authority_authority_id, sys_base_menu_id(以你库中实际列名为准,有的项目为 authority_id, menu_id) +INSERT INTO sys_authority_menus (sys_authority_authority_id, sys_base_menu_id) VALUES (888, 41); +-- 若需给 8881、9528、1 也加该菜单,可追加: +-- INSERT INTO sys_authority_menus (sys_authority_authority_id, sys_base_menu_id) VALUES (8881, 41); +-- INSERT INTO sys_authority_menus (sys_authority_authority_id, sys_base_menu_id) VALUES (9528, 41); +INSERT INTO sys_authority_menus (sys_authority_authority_id, sys_base_menu_id) VALUES (1, 41); diff --git a/web/.env.example b/web/.env.example index 3ee02f943..37bf78367 100644 --- a/web/.env.example +++ b/web/.env.example @@ -7,11 +7,11 @@ NEXT_PUBLIC_BASE_PATH= # The base URL of console application, refers to the Console base URL of WEB service if console domain is # different from api or web app domain. # example: http://cloud.dify.ai/console/api -NEXT_PUBLIC_API_PREFIX=http://localhost:5001/console/api +NEXT_PUBLIC_API_PREFIX=http://localhost:3000/console/api # The URL for Web APP, refers to the Web App base URL of WEB service if web app domain is different from # console or api domain. # example: http://udify.app/api -NEXT_PUBLIC_PUBLIC_API_PREFIX=http://localhost:5001/api +NEXT_PUBLIC_PUBLIC_API_PREFIX=http://localhost:3000/api # When the frontend and backend run on different subdomains, set NEXT_PUBLIC_COOKIE_DOMAIN=1. NEXT_PUBLIC_COOKIE_DOMAIN= diff --git a/web/config/index.ts b/web/config/index.ts index 08ce14b26..0dd80b613 100644 --- a/web/config/index.ts +++ b/web/config/index.ts @@ -53,16 +53,37 @@ const getStringConfig = ( return defaultValue } -export const API_PREFIX = getStringConfig( +/** + * 在浏览器中,若配置的 API 地址为 localhost 或 127.0.0.1,则改为使用当前页面的 origin, + * 避免 127.0.0.1 与 localhost 不同源导致 cookie 无法携带(登录后 /api/login/status 拿不到 console 的 access_token)。 + */ +function normalizeSameOriginApiPrefix(raw: string): string { + if (typeof globalThis.window === 'undefined' || !raw.startsWith('http')) + return raw + try { + const u = new URL(raw) + if (u.hostname === 'localhost' || u.hostname === '127.0.0.1') + return globalThis.window.location.origin + (u.pathname.replace(/\/$/, '') || '/') + return raw + } + catch { + return raw + } +} + +const _apiPrefixRaw = getStringConfig( process.env.NEXT_PUBLIC_API_PREFIX, DatasetAttr.DATA_API_PREFIX, 'http://localhost:5001/console/api', ) -export const PUBLIC_API_PREFIX = getStringConfig( +const _publicApiPrefixRaw = getStringConfig( process.env.NEXT_PUBLIC_PUBLIC_API_PREFIX, DatasetAttr.DATA_PUBLIC_API_PREFIX, 'http://localhost:5001/api', ) + +export const API_PREFIX = normalizeSameOriginApiPrefix(_apiPrefixRaw) +export const PUBLIC_API_PREFIX = normalizeSameOriginApiPrefix(_publicApiPrefixRaw) export const MARKETPLACE_API_PREFIX = getStringConfig( process.env.NEXT_PUBLIC_MARKETPLACE_API_PREFIX, DatasetAttr.DATA_MARKETPLACE_API_PREFIX, diff --git a/web/context/global-public-context.tsx b/web/context/global-public-context.tsx index 3a570fc7e..193bb992e 100644 --- a/web/context/global-public-context.tsx +++ b/web/context/global-public-context.tsx @@ -4,7 +4,7 @@ import type { SystemFeatures } from '@/types/feature' import { useQuery } from '@tanstack/react-query' import { create } from 'zustand' import Loading from '@/app/components/base/loading' -import { consoleClient } from '@/service/client' +import { consoleClient, setLoginConfigToken } from '@/service/client' import { defaultSystemFeatures } from '@/types/feature' import { fetchSetupStatusWithCache } from '@/utils/setup-status' @@ -21,8 +21,12 @@ export const useGlobalPublicStore = create(set => ({ const systemFeaturesQueryKey = ['systemFeatures'] as const const setupStatusQueryKey = ['setupStatus'] as const +// extend: CVE-2025-63387未授权访问 — 先请求 bootstrap 拿 JWT(cookie + body),跨域时用 Header 带 token 请求 login_config async function fetchSystemFeatures() { - const data = await consoleClient.systemFeatures() + const bootstrapRes = await consoleClient.loginConfigBootstrap() + if (bootstrapRes?.token) + setLoginConfigToken(bootstrapRes.token) + const data = await consoleClient.loginConfig() const { setSystemFeatures } = useGlobalPublicStore.getState() setSystemFeatures({ ...defaultSystemFeatures, ...data }) return data diff --git a/web/contract/console/system.ts b/web/contract/console/system.ts index bce0a8226..8ff1f50ed 100644 --- a/web/contract/console/system.ts +++ b/web/contract/console/system.ts @@ -2,9 +2,19 @@ import type { SystemFeatures } from '@/types/feature' import { type } from '@orpc/contract' import { base } from '../base' -export const systemFeaturesContract = base +// extend: CVE-2025-63387未授权访问 虽然这个api实际上就是个登录用的 +export const loginConfigBootstrapContract = base .route({ - path: '/system-features', + path: '/login_config_bootstrap', + method: 'GET', + }) + .input(type()) + .output(type<{ ok: boolean, token: string }>()) + +// extend: CVE-2025-63387未授权访问 虽然这个api实际上就是个登录用的 — 路径改为 login_config,需先请求 login_config_bootstrap 写入 cookie +export const loginConfigContract = base + .route({ + path: '/login_config', method: 'GET', }) .input(type()) diff --git a/web/contract/router.ts b/web/contract/router.ts index 965c381bd..4828a4ec7 100644 --- a/web/contract/router.ts +++ b/web/contract/router.ts @@ -1,6 +1,7 @@ import type { InferContractRouterInputs } from '@orpc/contract' import { bindPartnerStackContract, invoicesContract } from './console/billing' -import { systemFeaturesContract } from './console/system' +// extend: CVE-2025-63387未授权访问 虽然这个api实际上就是个登录用的 — 路径改为 login_config,需先请求 login_config_bootstrap 写入 cookie +import { loginConfigBootstrapContract, loginConfigContract } from './console/system' import { trialAppDatasetsContract, trialAppInfoContract, trialAppParametersContract, trialAppWorkflowsContract } from './console/try-app' import { collectionPluginsContract, collectionsContract, searchAdvancedContract } from './marketplace' @@ -12,8 +13,10 @@ export const marketplaceRouterContract = { export type MarketPlaceInputs = InferContractRouterInputs +// extend: CVE-2025-63387未授权访问 虽然这个api实际上就是个登录用的 — 路径改为 login_config,需先请求 login_config_bootstrap 写入 cookie export const consoleRouterContract = { - systemFeatures: systemFeaturesContract, + loginConfigBootstrap: loginConfigBootstrapContract, + loginConfig: loginConfigContract, trialApps: { info: trialAppInfoContract, datasets: trialAppDatasetsContract, diff --git a/web/next.config.ts b/web/next.config.ts index fc4dee328..3a672d74c 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -63,6 +63,15 @@ const nextConfig: NextConfig = { }, ] }, + // dev 时把 /console/api 和 /api 代理到 5001 + ...(isDev && { + async rewrites() { + return [ + { source: '/console/api/:path*', destination: 'http://localhost:5001/console/api/:path*' }, + { source: '/api/:path*', destination: 'http://localhost:5001/api/:path*' }, + ] + }, + }), output: 'standalone', compiler: { removeConsole: isDev ? false : { exclude: ['warn', 'error'] }, diff --git a/web/service/client.ts b/web/service/client.ts index c9c92ddd1..a2cf56453 100644 --- a/web/service/client.ts +++ b/web/service/client.ts @@ -15,10 +15,23 @@ import { } from '@/contract/router' import { request } from './base' +// extend: CVE-2025-63387 跨域时 Cookie 可能为 None,用 Header 携带 JWT +let loginConfigToken: string | null = null +export function setLoginConfigToken(token: string | null) { + loginConfigToken = token +} + const getMarketplaceHeaders = () => new Headers({ 'X-Dify-Version': !IS_MARKETPLACE ? APP_VERSION : '999.0.0', }) +const getConsoleHeaders = () => { + const h = new Headers() + if (loginConfigToken) + h.set('X-Login-Config-Token', loginConfigToken) + return h +} + const marketplaceLink = new OpenAPILink(marketplaceRouterContract, { url: MARKETPLACE_API_PREFIX, headers: () => (getMarketplaceHeaders()), @@ -40,6 +53,7 @@ export const marketplaceQuery = createTanstackQueryUtils(marketplaceClient, { pa const consoleLink = new OpenAPILink(consoleRouterContract, { url: API_PREFIX, + headers: () => getConsoleHeaders(), fetch: (input, init) => { return request( input.url, diff --git a/web/service/common.ts b/web/service/common.ts index 2c4443e44..b2785f173 100644 --- a/web/service/common.ts +++ b/web/service/common.ts @@ -308,9 +308,9 @@ export const fetchSupportRetrievalMethods = (url: string): Promise(url) } +// extend: CVE-2025-63387未授权访问 虽然这个api实际上就是个登录用的 — 路径改为 login_config,需先请求 login_config_bootstrap 写入 cookie export const getSystemFeatures = (): Promise => { - // extend: 解决登录状况不刷新 - return get(`/system-features?time=${(Math.round(new Date() / 1000)).toString()}`) + return get(`/login_config?time=${(Math.round(new Date() / 1000)).toString()}`) } export const enableModel = (url: string, body: { model: string, model_type: ModelTypeEnum }): Promise =>