diff --git a/core/app/repo/setting.go b/core/app/repo/setting.go index 5f74819ef981..f3ea380de4d2 100644 --- a/core/app/repo/setting.go +++ b/core/app/repo/setting.go @@ -24,6 +24,7 @@ type ISettingRepo interface { GetValueByKey(key string) (string, error) Create(key, value string) error Update(key, value string) error + UpdateIfMatch(key, oldValue, value string) (bool, error) UpdateOrCreate(key, value string) error DefaultMenu() error } @@ -89,6 +90,20 @@ func (u *SettingRepo) Update(key, value string) error { return nil } +func (u *SettingRepo) UpdateIfMatch(key, oldValue, value string) (bool, error) { + result := global.DB.Model(&model.Setting{}). + Where("key = ? AND value = ?", key, oldValue). + Updates(map[string]interface{}{"value": value}) + if result.Error != nil { + return false, result.Error + } + if result.RowsAffected == 0 { + return false, nil + } + settingCache.Set(key, value, settingTTL) + return true, nil +} + func (u *SettingRepo) UpdateOrCreate(key, value string) error { var setting model.Setting result := global.DB.Where("key = ?", key).First(&setting) diff --git a/core/app/service/setting.go b/core/app/service/setting.go index 687a78ab635b..30841415b721 100644 --- a/core/app/service/setting.go +++ b/core/app/service/setting.go @@ -32,6 +32,7 @@ import ( "github.com/1Panel-dev/1Panel/core/utils/controller" "github.com/1Panel-dev/1Panel/core/utils/encrypt" "github.com/1Panel-dev/1Panel/core/utils/firewall" + "github.com/1Panel-dev/1Panel/core/utils/menutree" "github.com/1Panel-dev/1Panel/core/utils/passkey" "github.com/1Panel-dev/1Panel/core/utils/req_helper/proxy_local" "github.com/1Panel-dev/1Panel/core/utils/xpack" @@ -80,15 +81,7 @@ func (u *SettingService) GetSettingInfo() (*dto.SettingInfo, error) { for _, set := range setting { settingMap[set.Key] = set.Value } - if hideMenu, ok := settingMap["HideMenu"]; ok && len(hideMenu) > 0 { - var menus []dto.ShowMenu - if err := json.Unmarshal([]byte(hideMenu), &menus); err == nil { - sortShowMenus(menus) - if sortedBytes, err := json.Marshal(menus); err == nil { - settingMap["HideMenu"] = string(sortedBytes) - } - } - } + repairAndSortHideMenu(settingMap) var info dto.SettingInfo arr, err := json.Marshal(settingMap) if err != nil { @@ -123,15 +116,7 @@ func (u *SettingService) GetSettingBaseInfo() (*dto.SettingBaseInfo, error) { for _, set := range setting { settingMap[set.Key] = set.Value } - if hideMenu, ok := settingMap["HideMenu"]; ok && len(hideMenu) > 0 { - var menus []dto.ShowMenu - if err := json.Unmarshal([]byte(hideMenu), &menus); err == nil { - sortShowMenus(menus) - if sortedBytes, err := json.Marshal(menus); err == nil { - settingMap["HideMenu"] = string(sortedBytes) - } - } - } + repairAndSortHideMenu(settingMap) var info dto.SettingBaseInfo arr, err := json.Marshal(settingMap) if err != nil { @@ -152,6 +137,36 @@ func (u *SettingService) GetSettingBaseInfo() (*dto.SettingBaseInfo, error) { return &info, err } +func repairAndSortHideMenu(settingMap map[string]string) { + hideMenu, ok := settingMap["HideMenu"] + if !ok || strings.TrimSpace(hideMenu) == "" { + return + } + var menus []dto.ShowMenu + if err := json.Unmarshal([]byte(hideMenu), &menus); err != nil || len(menus) == 0 { + return + } + + menus, changed := menutree.EnsureXpackAppMenus(menus, nil) + if changed { + repairedBytes, err := json.Marshal(menus) + if err != nil { + global.LOG.Warnf("marshal repaired HideMenu failed, err: %v", err) + } else { + updated, err := settingRepo.UpdateIfMatch("HideMenu", hideMenu, string(repairedBytes)) + if err != nil { + global.LOG.Warnf("persist repaired HideMenu failed, err: %v", err) + } else if !updated { + global.LOG.Debug("skip persisting repaired HideMenu because the setting changed concurrently") + } + } + } + sortShowMenus(menus) + if sortedBytes, err := json.Marshal(menus); err == nil { + settingMap["HideMenu"] = string(sortedBytes) + } +} + func sortShowMenus(menus []dto.ShowMenu) { for i := range menus { if len(menus[i].Children) > 0 { @@ -171,7 +186,7 @@ func (u *SettingService) Update(c *gin.Context, key, value string) error { if err != nil { return err } - if oldVal.Value == value { + if oldVal.Value == value && key != "HideMenu" { return nil } sessionLifeTime := 0 @@ -192,6 +207,13 @@ func (u *SettingService) Update(c *gin.Context, key, value string) error { if err := json.Unmarshal([]byte(value), &menus); err != nil { return err } + var previousMenus []dto.ShowMenu + _ = json.Unmarshal([]byte(oldVal.Value), &previousMenus) + menus, _ = menutree.PreserveMissingMenus(menus, previousMenus) + if len(menus) == 0 { + return fmt.Errorf("hide menu cannot be empty") + } + menus, _ = menutree.EnsureXpackAppMenus(menus, previousMenus) for i := 0; i < len(menus); i++ { if menus[i].Label == "Home-Menu" || menus[i].Label == "App-Menu" || menus[i].Label == "Setting-Menu" { menus[i].IsShow = true @@ -203,6 +225,9 @@ func (u *SettingService) Update(c *gin.Context, key, value string) error { } value = string(menuItem) } + if oldVal.Value == value { + return nil + } if err := settingRepo.Update(key, value); err != nil { return err diff --git a/core/init/migration/migrate.go b/core/init/migration/migrate.go index 073dae8124c1..2bb0e8116c11 100644 --- a/core/init/migration/migrate.go +++ b/core/init/migration/migrate.go @@ -53,6 +53,7 @@ func Init() { migrations.AddMenuAccordionSetting, migrations.AddAPITrustedProxiesSetting, migrations.AddWebsiteTemplateMenu, + migrations.RepairXpackAppMenus, }) if err := m.Migrate(); err != nil { global.LOG.Error(err) diff --git a/core/init/migration/migrations/init.go b/core/init/migration/migrations/init.go index b6bbd7a48ae6..c5a42f7e6dc5 100644 --- a/core/init/migration/migrations/init.go +++ b/core/init/migration/migrations/init.go @@ -18,6 +18,7 @@ import ( "github.com/1Panel-dev/1Panel/core/utils/common" "github.com/1Panel-dev/1Panel/core/utils/ctl_conf" "github.com/1Panel-dev/1Panel/core/utils/encrypt" + "github.com/1Panel-dev/1Panel/core/utils/menutree" "github.com/go-gormigrate/gormigrate/v2" "gorm.io/gorm" ) @@ -1314,3 +1315,39 @@ var AddWebsiteTemplateMenu = &gormigrate.Migration{ }, "SSL") }, } + +var RepairXpackAppMenus = &gormigrate.Migration{ + ID: "20260818-repair-xapp-upage-hide-menu", + Migrate: func(tx *gorm.DB) error { + var setting model.Setting + if err := tx.Where("key = ?", "HideMenu").First(&setting).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return tx.Create(&model.Setting{Key: "HideMenu", Value: helper.LoadMenus()}).Error + } + return err + } + + updateValue := func(value string) error { + return tx.Model(&setting).Update("value", value).Error + } + + if strings.TrimSpace(setting.Value) == "" { + return updateValue(helper.LoadMenus()) + } + + var menus []dto.ShowMenu + if err := json.Unmarshal([]byte(setting.Value), &menus); err != nil || len(menus) == 0 { + return updateValue(helper.LoadMenus()) + } + + updatedMenus, changed := menutree.EnsureXpackAppMenus(menus, nil) + if !changed { + return nil + } + updatedJSON, err := json.Marshal(updatedMenus) + if err != nil { + return err + } + return updateValue(string(updatedJSON)) + }, +} diff --git a/core/utils/menutree/hide_menu_integrity.go b/core/utils/menutree/hide_menu_integrity.go new file mode 100644 index 000000000000..315c00078820 --- /dev/null +++ b/core/utils/menutree/hide_menu_integrity.go @@ -0,0 +1,267 @@ +package menutree + +import "github.com/1Panel-dev/1Panel/core/app/dto" + +const ( + xpackMenuID = "11" + xpackMenuLabel = "Xpack-Menu" +) + +func PreserveMissingMenus(menus, fallback []dto.ShowMenu) ([]dto.ShowMenu, bool) { + updated := cloneMenus(menus) + changed := preserveMissingMenus(&updated, &updated, fallback) + return updated, changed +} + +func preserveMissingMenus(root, current *[]dto.ShowMenu, fallback []dto.ShowMenu) bool { + changed := false + for _, previous := range fallback { + menuIndex := findMatchingMenu(*current, previous) + if menuIndex < 0 { + if containsMenu(*root, previous) { + continue + } + *current = append(*current, cloneMenu(previous)) + changed = true + continue + } + if preserveMissingMenus(root, &(*current)[menuIndex].Children, previous.Children) { + changed = true + } + } + return changed +} + +func EnsureXpackAppMenus(menus, fallback []dto.ShowMenu) ([]dto.ShowMenu, bool) { + updated := cloneMenus(menus) + parentIndex := findXpackMenu(updated) + fallbackParentIndex := findXpackMenu(fallback) + + changed := false + if parentIndex < 0 { + parent := defaultXpackMenu() + if fallbackParentIndex >= 0 { + parent = cloneMenu(fallback[fallbackParentIndex]) + } + updated = append(updated, parent) + parentIndex = len(updated) - 1 + changed = true + } + + required := []dto.ShowMenu{defaultXAppMenu(), defaultUpageMenu()} + for _, canonical := range required { + parentIndex = findXpackMenu(updated) + var childChanged bool + updated, childChanged = ensureSystemMenu( + updated, + parentIndex, + fallback, + canonical, + ) + changed = changed || childChanged + } + + return updated, changed +} + +func defaultXpackMenu() dto.ShowMenu { + return dto.ShowMenu{ + ID: xpackMenuID, + Disabled: false, + Title: "xpack.menu", + IsShow: true, + Label: xpackMenuLabel, + Sort: 1100, + } +} + +func defaultXAppMenu() dto.ShowMenu { + return dto.ShowMenu{ + ID: "118", + Disabled: false, + Title: "xpack.app.app", + IsShow: true, + Label: "XApp", + Path: "/xpack/app", + Sort: 100, + } +} + +func defaultUpageMenu() dto.ShowMenu { + return dto.ShowMenu{ + ID: "119", + Disabled: false, + Title: "xpack.upage", + IsShow: true, + Label: "Upage", + Path: "/xpack/upage", + Sort: 800, + } +} + +func findXpackMenu(menus []dto.ShowMenu) int { + for i := range menus { + if menus[i].ID == xpackMenuID { + return i + } + } + for i := range menus { + if menus[i].Label == xpackMenuLabel { + return i + } + } + return -1 +} + +func findMenu(menus []dto.ShowMenu, canonical dto.ShowMenu) int { + for i := range menus { + if menus[i].ID == canonical.ID { + return i + } + } + for i := range menus { + if menus[i].Label == canonical.Label { + return i + } + } + for i := range menus { + if menus[i].Path == canonical.Path { + return i + } + } + return -1 +} + +func findMatchingMenu(menus []dto.ShowMenu, target dto.ShowMenu) int { + if target.ID != "" { + for i := range menus { + if menus[i].ID == target.ID { + return i + } + } + } + if target.Label != "" { + for i := range menus { + if menus[i].Label == target.Label { + return i + } + } + } + if target.Path != "" { + for i := range menus { + if menus[i].Path == target.Path { + return i + } + } + } + return -1 +} + +func containsMenu(menus []dto.ShowMenu, target dto.ShowMenu) bool { + if findMatchingMenu(menus, target) >= 0 { + return true + } + for i := range menus { + if containsMenu(menus[i].Children, target) { + return true + } + } + return false +} + +func ensureSystemMenu(menus []dto.ShowMenu, parentIndex int, fallback []dto.ShowMenu, canonical dto.ShowMenu) ([]dto.ShowMenu, bool) { + selected := findSystemMenu(menus, canonical) + if selected == nil { + newItem := canonical + if fallbackItem := findSystemMenu(fallback, canonical); fallbackItem != nil { + newItem = cloneMenu(*fallbackItem) + normalizeSystemFields(&newItem, canonical) + } + menus[parentIndex].Children = append(menus[parentIndex].Children, newItem) + return menus, true + } + + changed := normalizeSystemFields(selected, canonical) + deduplicated, deduplicatedChanged := deduplicateSystemMenu(menus, selected, canonical) + return deduplicated, changed || deduplicatedChanged +} + +func findSystemMenu(menus []dto.ShowMenu, canonical dto.ShowMenu) *dto.ShowMenu { + matchers := []func(dto.ShowMenu) bool{ + func(menu dto.ShowMenu) bool { return menu.ID == canonical.ID }, + func(menu dto.ShowMenu) bool { return menu.Label == canonical.Label }, + func(menu dto.ShowMenu) bool { return menu.Path == canonical.Path }, + } + for _, matches := range matchers { + if menu := findMenuBy(menus, matches); menu != nil { + return menu + } + } + return nil +} + +func findMenuBy(menus []dto.ShowMenu, matches func(dto.ShowMenu) bool) *dto.ShowMenu { + for i := range menus { + if matches(menus[i]) { + return &menus[i] + } + if menu := findMenuBy(menus[i].Children, matches); menu != nil { + return menu + } + } + return nil +} + +func deduplicateSystemMenu(menus []dto.ShowMenu, selected *dto.ShowMenu, canonical dto.ShowMenu) ([]dto.ShowMenu, bool) { + deduplicated := make([]dto.ShowMenu, 0, len(menus)) + changed := false + for i := range menus { + menu := &menus[i] + if menu != selected && matchesSystemIdentity(*menu, canonical) { + changed = true + continue + } + children, childChanged := deduplicateSystemMenu(menu.Children, selected, canonical) + if childChanged { + menu.Children = children + changed = true + } + deduplicated = append(deduplicated, *menu) + } + return deduplicated, changed +} + +func matchesSystemIdentity(menu, canonical dto.ShowMenu) bool { + return menu.ID == canonical.ID || menu.Label == canonical.Label || menu.Path == canonical.Path +} + +func normalizeSystemFields(menu *dto.ShowMenu, canonical dto.ShowMenu) bool { + changed := menu.ID != canonical.ID || + menu.Label != canonical.Label || + menu.Disabled != canonical.Disabled || + menu.Title != canonical.Title || + menu.Path != canonical.Path + + menu.ID = canonical.ID + menu.Label = canonical.Label + menu.Disabled = canonical.Disabled + menu.Title = canonical.Title + menu.Path = canonical.Path + return changed +} + +func cloneMenus(menus []dto.ShowMenu) []dto.ShowMenu { + if menus == nil { + return nil + } + cloned := make([]dto.ShowMenu, len(menus)) + for i := range menus { + cloned[i] = cloneMenu(menus[i]) + } + return cloned +} + +func cloneMenu(menu dto.ShowMenu) dto.ShowMenu { + menu.Children = cloneMenus(menu.Children) + return menu +}