From dded5e26aa68c24bb13143ac5ad913d9af1c3919 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=97=E5=AD=90?= Date: Thu, 19 Sep 2024 01:17:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E8=BF=9C=E7=A8=8B?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=20(#180)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: api基本完成 * feat: 阶段性提交 * feat: 初步支持远程插件 * feat: 支持远程插件 * fix: tests --- internal/apps/fail2ban/init.go | 12 +- internal/apps/openresty/init.go | 13 +- internal/biz/{plugin.go => app.go} | 20 +- internal/biz/cache.go | 22 ++ internal/bootstrap/conf.go | 7 + internal/data/app.go | 301 ++++++++++++++++++++++ internal/data/cache.go | 41 +++ internal/data/plugin.go | 226 ---------------- internal/data/setting.go | 10 +- internal/migration/v1.go | 5 +- internal/route/http.go | 26 +- internal/service/{plugin.go => app.go} | 57 ++-- internal/service/info.go | 26 +- pkg/api/api.go | 14 +- pkg/api/api_test.go | 26 +- pkg/api/app.go | 30 ++- pkg/api/rewrite.go | 33 +++ pkg/api/version.go | 16 +- pkg/apploader/{plugin.go => apploader.go} | 20 +- pkg/str/string.go | 8 +- pkg/str/string_test.go | 2 +- pkg/types/app.go | 9 + pkg/types/plugin.go | 18 -- 23 files changed, 580 insertions(+), 362 deletions(-) rename internal/biz/{plugin.go => app.go} (60%) create mode 100644 internal/biz/cache.go create mode 100644 internal/data/app.go create mode 100644 internal/data/cache.go delete mode 100644 internal/data/plugin.go rename internal/service/{plugin.go => app.go} (65%) create mode 100644 pkg/api/rewrite.go rename pkg/apploader/{plugin.go => apploader.go} (65%) create mode 100644 pkg/types/app.go delete mode 100644 pkg/types/plugin.go diff --git a/internal/apps/fail2ban/init.go b/internal/apps/fail2ban/init.go index d0fab733..9f6900d9 100644 --- a/internal/apps/fail2ban/init.go +++ b/internal/apps/fail2ban/init.go @@ -8,16 +8,8 @@ import ( ) func init() { - apploader.Register(&types.Plugin{ - Slug: "fail2ban", - Name: "Fail2ban", - Description: "Fail2ban 扫描系统日志文件并从中找出多次尝试失败的IP地址,将该IP地址加入防火墙的拒绝访问列表中", - Version: "1.0.2", - Requires: []string{}, - Excludes: []string{}, - Install: `bash /www/panel/scripts/fail2ban/install.sh`, - Uninstall: `bash /www/panel/scripts/fail2ban/uninstall.sh`, - Update: `bash /www/panel/scripts/fail2ban/update.sh`, + apploader.Register(&types.App{ + Slug: "fail2ban", Route: func(r chi.Router) { service := NewService() r.Get("/jails", service.List) diff --git a/internal/apps/openresty/init.go b/internal/apps/openresty/init.go index 0e591ce6..4f3d8ab0 100644 --- a/internal/apps/openresty/init.go +++ b/internal/apps/openresty/init.go @@ -8,17 +8,8 @@ import ( ) func init() { - apploader.Register(&types.Plugin{ - Order: -100, - Slug: "openresty", - Name: "OpenResty", - Description: "OpenResty® 是一款基于 NGINX 和 LuaJIT 的 Web 平台", - Version: "1.25.3.1", - Requires: []string{}, - Excludes: []string{}, - Install: "bash /www/panel/scripts/openresty/install.sh", - Uninstall: "bash /www/panel/scripts/openresty/uninstall.sh", - Update: "bash /www/panel/scripts/openresty/install.sh", + apploader.Register(&types.App{ + Slug: "openresty", Route: func(r chi.Router) { service := NewService() r.Get("/load", service.Load) diff --git a/internal/biz/plugin.go b/internal/biz/app.go similarity index 60% rename from internal/biz/plugin.go rename to internal/biz/app.go index 21a5a65a..1cc3d16d 100644 --- a/internal/biz/plugin.go +++ b/internal/biz/app.go @@ -3,10 +3,10 @@ package biz import ( "github.com/golang-module/carbon/v2" - "github.com/TheTNB/panel/pkg/types" + "github.com/TheTNB/panel/pkg/api" ) -type Plugin struct { +type App struct { ID uint `gorm:"primaryKey" json:"id"` Slug string `gorm:"not null;unique" json:"slug"` Version string `gorm:"not null" json:"version"` @@ -16,15 +16,17 @@ type Plugin struct { UpdatedAt carbon.DateTime `json:"updated_at"` } -type PluginRepo interface { - All() []*types.Plugin - Installed() ([]*Plugin, error) - Get(slug string) (*types.Plugin, error) - GetInstalled(slug string) (*Plugin, error) - GetInstalledAll(cond ...string) ([]*Plugin, error) - IsInstalled(cond ...string) (bool, error) +type AppRepo interface { + All() api.Apps + Get(slug string) (*api.App, error) + Installed() ([]*App, error) + GetInstalled(slug string) (*App, error) + GetInstalledAll(query string, cond ...string) ([]*App, error) + GetHomeShow() ([]map[string]string, error) + IsInstalled(query string, cond ...string) (bool, error) Install(slug string) error Uninstall(slug string) error Update(slug string) error UpdateShow(slug string, show bool) error + UpdateCache() error } diff --git a/internal/biz/cache.go b/internal/biz/cache.go new file mode 100644 index 00000000..78ece624 --- /dev/null +++ b/internal/biz/cache.go @@ -0,0 +1,22 @@ +package biz + +import "github.com/golang-module/carbon/v2" + +type CacheKey string + +const ( + CacheKeyApps CacheKey = "apps" + CacheKeyRewrites CacheKey = "rewrites" +) + +type Cache struct { + Key CacheKey `gorm:"primaryKey" json:"key"` + Value string `gorm:"not null" json:"value"` + CreatedAt carbon.DateTime `json:"created_at"` + UpdatedAt carbon.DateTime `json:"updated_at"` +} + +type CacheRepo interface { + Get(key CacheKey, defaultValue ...string) (string, error) + Set(key CacheKey, value string) error +} diff --git a/internal/bootstrap/conf.go b/internal/bootstrap/conf.go index f6867e04..b0482475 100644 --- a/internal/bootstrap/conf.go +++ b/internal/bootstrap/conf.go @@ -3,6 +3,7 @@ package bootstrap import ( "fmt" + "github.com/golang-module/carbon/v2" "github.com/knadh/koanf/parsers/yaml" "github.com/knadh/koanf/providers/file" "github.com/knadh/koanf/v2" @@ -21,4 +22,10 @@ func initGlobal() { panel.Root = panel.Conf.MustString("app.root") panel.Version = panel.Conf.MustString("app.version") panel.Locale = panel.Conf.MustString("app.locale") + carbon.SetDefault(carbon.Default{ + Layout: carbon.DateTimeLayout, + Timezone: carbon.PRC, + WeekStartsAt: carbon.Sunday, + Locale: "zh-CN", + }) } diff --git a/internal/data/app.go b/internal/data/app.go new file mode 100644 index 00000000..a312b1ac --- /dev/null +++ b/internal/data/app.go @@ -0,0 +1,301 @@ +package data + +import ( + "encoding/json" + "errors" + "fmt" + "slices" + + "github.com/TheTNB/panel/internal/biz" + "github.com/TheTNB/panel/internal/job" + "github.com/TheTNB/panel/internal/panel" + "github.com/TheTNB/panel/pkg/api" + "github.com/TheTNB/panel/pkg/apploader" +) + +type appRepo struct { + cacheRepo biz.CacheRepo + taskRepo biz.TaskRepo + api *api.API +} + +func NewAppRepo() biz.AppRepo { + return &appRepo{ + cacheRepo: NewCacheRepo(), + taskRepo: NewTaskRepo(), + api: api.NewAPI(panel.Version), + } +} + +func (r *appRepo) All() api.Apps { + cached, err := r.cacheRepo.Get(biz.CacheKeyApps) + if err != nil { + return nil + } + var apps api.Apps + if err = json.Unmarshal([]byte(cached), &apps); err != nil { + return nil + } + return apps +} + +func (r *appRepo) Get(slug string) (*api.App, error) { + for app := range slices.Values(r.All()) { + if app.Slug == slug { + return app, nil + } + } + return nil, errors.New("应用不存在") +} + +func (r *appRepo) Installed() ([]*biz.App, error) { + var apps []*biz.App + if err := panel.Orm.Find(&apps).Error; err != nil { + return nil, err + } + + return apps, nil + +} + +func (r *appRepo) GetInstalled(slug string) (*biz.App, error) { + app := new(biz.App) + if err := panel.Orm.Where("slug = ?", slug).First(app).Error; err != nil { + return nil, err + } + + return app, nil +} + +func (r *appRepo) GetInstalledAll(query string, cond ...string) ([]*biz.App, error) { + var apps []*biz.App + if err := panel.Orm.Where(query, cond).Find(&apps).Error; err != nil { + return nil, err + } + + return apps, nil +} + +func (r *appRepo) GetHomeShow() ([]map[string]string, error) { + var apps []*biz.App + if err := panel.Orm.Where("show = ?", true).Order("show_order").Find(&apps).Error; err != nil { + return nil, err + } + + var filtered []map[string]string + for app := range slices.Values(apps) { + loaded, err := r.Get(app.Slug) + if err != nil { + continue + } + filtered = append(filtered, map[string]string{ + "name": loaded.Name, + "description": loaded.Description, + "slug": loaded.Slug, + "icon": loaded.Icon, + "version": app.Version, + }) + } + + return filtered, nil +} + +func (r *appRepo) IsInstalled(query string, cond ...string) (bool, error) { + var count int64 + if len(cond) == 0 { + if err := panel.Orm.Model(&biz.App{}).Where("slug = ?", query).Count(&count).Error; err != nil { + return false, err + } + } else { + if err := panel.Orm.Model(&biz.App{}).Where(query, cond).Count(&count).Error; err != nil { + return false, err + } + } + + return count > 0, nil +} + +func (r *appRepo) Install(slug string) error { + app, err := r.Get(slug) + if err != nil { + return err + } + + if installed, _ := r.IsInstalled(slug); installed { + return errors.New("应用已安装") + } + + var shellUrl string + for version := range slices.Values(app.Versions) { + if version.PanelVersion == panel.Version { + shellUrl = version.Install + break + } + } + if shellUrl == "" { + return fmt.Errorf("应用 %s 不支持当前面板版本", app.Name) + } + + if err = r.preCheck(app); err != nil { + return err + } + + task := new(biz.Task) + task.Name = "安装应用 " + app.Name + task.Status = biz.TaskStatusWaiting + task.Shell = fmt.Sprintf("%s >> /tmp/%s.log 2>&1", shellUrl, app.Slug) + task.Log = "/tmp/" + app.Slug + ".log" + + if err = panel.Orm.Create(task).Error; err != nil { + return err + } + err = panel.Queue.Push(job.NewProcessTask(r.taskRepo), []any{ + task.ID, + }) + + return err +} + +func (r *appRepo) Uninstall(slug string) error { + app, err := r.Get(slug) + if err != nil { + return err + } + + if installed, _ := r.IsInstalled(slug); !installed { + return errors.New("应用未安装") + } + + var shellUrl string + for version := range slices.Values(app.Versions) { + if version.PanelVersion == panel.Version { + shellUrl = version.Uninstall + break + } + } + if shellUrl == "" && len(app.Versions) > 0 { + shellUrl = app.Versions[0].Uninstall + } + if shellUrl == "" { + return fmt.Errorf("无法获取应用 %s 的卸载脚本", app.Name) + } + + if err = r.preCheck(app); err != nil { + return err + } + + task := new(biz.Task) + task.Name = "卸载应用 " + app.Name + task.Status = biz.TaskStatusWaiting + task.Shell = fmt.Sprintf("%s >> /tmp/%s.log 2>&1", shellUrl, app.Slug) + task.Log = "/tmp/" + app.Slug + ".log" + + if err = panel.Orm.Create(task).Error; err != nil { + return err + } + err = panel.Queue.Push(job.NewProcessTask(r.taskRepo), []any{ + task.ID, + }) + + return err +} + +func (r *appRepo) Update(slug string) error { + app, err := r.Get(slug) + if err != nil { + return err + } + + if installed, _ := r.IsInstalled(slug); !installed { + return errors.New("应用未安装") + } + + var shellUrl string + for version := range slices.Values(app.Versions) { + if version.PanelVersion == panel.Version { + shellUrl = version.Update + break + } + } + if shellUrl == "" { + return fmt.Errorf("应用 %s 不支持当前面板版本", app.Name) + } + + if err = r.preCheck(app); err != nil { + return err + } + + task := new(biz.Task) + task.Name = "更新应用 " + app.Name + task.Status = biz.TaskStatusWaiting + task.Shell = fmt.Sprintf("%s >> /tmp/%s.log 2>&1", shellUrl, app.Slug) + task.Log = "/tmp/" + app.Slug + ".log" + + if err = panel.Orm.Create(task).Error; err != nil { + return err + } + err = panel.Queue.Push(job.NewProcessTask(r.taskRepo), []any{ + task.ID, + }) + + return err +} + +func (r *appRepo) UpdateShow(slug string, show bool) error { + app, err := r.GetInstalled(slug) + if err != nil { + return err + } + + app.Show = show + + return panel.Orm.Save(app).Error +} + +func (r *appRepo) UpdateCache() error { + remote, err := r.api.Apps() + if err != nil { + return err + } + + // 去除本地不存在的应用 + *remote = slices.Clip(slices.DeleteFunc(*remote, func(app *api.App) bool { + _, err = apploader.Get(app.Slug) + return err != nil + })) + + encoded, err := json.Marshal(remote) + if err != nil { + return err + } + + return r.cacheRepo.Set(biz.CacheKeyApps, string(encoded)) +} + +func (r *appRepo) preCheck(app *api.App) error { + installedPlugins, err := r.Installed() + if err != nil { + return err + } + + appsMap := make(map[string]bool) + for _, p := range installedPlugins { + appsMap[p.Slug] = true + } + + for _, require := range app.Requires { + _, requireFound := appsMap[require] + if !requireFound { + return fmt.Errorf("应用 %s 需要依赖 %s 应用", app.Name, require) + } + } + + for _, exclude := range app.Excludes { + _, excludeFound := appsMap[exclude] + if excludeFound { + return fmt.Errorf("应用 %s 不兼容 %s 应用", app.Name, exclude) + } + } + + return nil +} diff --git a/internal/data/cache.go b/internal/data/cache.go new file mode 100644 index 00000000..82637c6a --- /dev/null +++ b/internal/data/cache.go @@ -0,0 +1,41 @@ +package data + +import ( + "errors" + + "gorm.io/gorm" + + "github.com/TheTNB/panel/internal/biz" + "github.com/TheTNB/panel/internal/panel" +) + +type cacheRepo struct{} + +func NewCacheRepo() biz.CacheRepo { + return &cacheRepo{} +} + +func (r *cacheRepo) Get(key biz.CacheKey, defaultValue ...string) (string, error) { + cache := new(biz.Cache) + if err := panel.Orm.Where("key = ?", key).First(cache).Error; err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + return "", err + } + } + + if cache.Value == "" && len(defaultValue) > 0 { + return defaultValue[0], nil + } + + return cache.Value, nil +} + +func (r *cacheRepo) Set(key biz.CacheKey, value string) error { + cache := new(biz.Cache) + if err := panel.Orm.Where(biz.Cache{Key: key}).FirstOrInit(cache).Error; err != nil { + return err + } + + cache.Value = value + return panel.Orm.Save(cache).Error +} diff --git a/internal/data/plugin.go b/internal/data/plugin.go deleted file mode 100644 index 436503da..00000000 --- a/internal/data/plugin.go +++ /dev/null @@ -1,226 +0,0 @@ -package data - -import ( - "errors" - "fmt" - - "github.com/TheTNB/panel/internal/biz" - "github.com/TheTNB/panel/internal/job" - "github.com/TheTNB/panel/internal/panel" - "github.com/TheTNB/panel/pkg/apploader" - "github.com/TheTNB/panel/pkg/types" -) - -type pluginRepo struct { - taskRepo biz.TaskRepo -} - -func NewPluginRepo() biz.PluginRepo { - return &pluginRepo{ - taskRepo: NewTaskRepo(), - } -} - -func (r *pluginRepo) All() []*types.Plugin { - return apploader.All() -} - -func (r *pluginRepo) Installed() ([]*biz.Plugin, error) { - var plugins []*biz.Plugin - if err := panel.Orm.Find(&plugins).Error; err != nil { - return nil, err - } - - return plugins, nil - -} - -func (r *pluginRepo) Get(slug string) (*types.Plugin, error) { - return apploader.Get(slug) -} - -func (r *pluginRepo) GetInstalled(slug string) (*biz.Plugin, error) { - plugin := new(biz.Plugin) - if err := panel.Orm.Where("slug = ?", slug).First(plugin).Error; err != nil { - return nil, err - } - - return plugin, nil -} - -func (r *pluginRepo) GetInstalledAll(cond ...string) ([]*biz.Plugin, error) { - var plugins []*biz.Plugin - if err := panel.Orm.Where(cond).Find(&plugins).Error; err != nil { - return nil, err - } - - return plugins, nil -} - -func (r *pluginRepo) IsInstalled(cond ...string) (bool, error) { - var count int64 - if err := panel.Orm.Model(&biz.Plugin{}).Where(cond).Count(&count).Error; err != nil { - return false, err - } - - return count > 0, nil -} - -func (r *pluginRepo) Install(slug string) error { - plugin, err := r.Get(slug) - if err != nil { - return err - } - installedPlugins, err := r.Installed() - if err != nil { - return err - } - - if installed, _ := r.IsInstalled("slug = ?", slug); installed { - return errors.New("插件已安装") - } - - pluginsMap := make(map[string]bool) - - for _, p := range installedPlugins { - pluginsMap[p.Slug] = true - } - - for _, require := range plugin.Requires { - _, requireFound := pluginsMap[require] - if !requireFound { - return errors.New("插件 " + slug + " 需要依赖 " + require + " 插件") - } - } - - for _, exclude := range plugin.Excludes { - _, excludeFound := pluginsMap[exclude] - if excludeFound { - return errors.New("插件 " + slug + " 不兼容 " + exclude + " 插件") - } - } - - task := new(biz.Task) - task.Name = "安装插件 " + plugin.Name - task.Status = biz.TaskStatusWaiting - task.Shell = fmt.Sprintf("%s >> /tmp/%s.log 2>&1", plugin.Install, plugin.Slug) - task.Log = "/tmp/" + plugin.Slug + ".log" - - if err = panel.Orm.Create(task).Error; err != nil { - return err - } - err = panel.Queue.Push(job.NewProcessTask(r.taskRepo), []any{ - task.ID, - }) - - return err -} - -func (r *pluginRepo) Uninstall(slug string) error { - plugin, err := r.Get(slug) - if err != nil { - return err - } - installedPlugins, err := r.Installed() - if err != nil { - return err - } - - if installed, _ := r.IsInstalled("slug = ?", slug); !installed { - return errors.New("插件未安装") - } - pluginsMap := make(map[string]bool) - - for _, p := range installedPlugins { - pluginsMap[p.Slug] = true - } - - for _, require := range plugin.Requires { - _, requireFound := pluginsMap[require] - if !requireFound { - return errors.New("插件 " + slug + " 需要依赖 " + require + " 插件") - } - } - - for _, exclude := range plugin.Excludes { - _, excludeFound := pluginsMap[exclude] - if excludeFound { - return errors.New("插件 " + slug + " 不兼容 " + exclude + " 插件") - } - } - - task := new(biz.Task) - task.Name = "卸载插件 " + plugin.Name - task.Status = biz.TaskStatusWaiting - task.Shell = fmt.Sprintf("%s >> /tmp/%s.log 2>&1", plugin.Uninstall, plugin.Slug) - task.Log = "/tmp/" + plugin.Slug + ".log" - - if err = panel.Orm.Create(task).Error; err != nil { - return err - } - err = panel.Queue.Push(job.NewProcessTask(r.taskRepo), []any{ - task.ID, - }) - - return err -} - -func (r *pluginRepo) Update(slug string) error { - plugin, err := r.Get(slug) - if err != nil { - return err - } - installedPlugins, err := r.Installed() - if err != nil { - return err - } - - if installed, _ := r.IsInstalled("slug = ?", slug); !installed { - return errors.New("插件未安装") - } - pluginsMap := make(map[string]bool) - - for _, p := range installedPlugins { - pluginsMap[p.Slug] = true - } - - for _, require := range plugin.Requires { - _, requireFound := pluginsMap[require] - if !requireFound { - return errors.New("插件 " + slug + " 需要依赖 " + require + " 插件") - } - } - - for _, exclude := range plugin.Excludes { - _, excludeFound := pluginsMap[exclude] - if excludeFound { - return errors.New("插件 " + slug + " 不兼容 " + exclude + " 插件") - } - } - - task := new(biz.Task) - task.Name = "更新插件 " + plugin.Name - task.Status = biz.TaskStatusWaiting - task.Shell = fmt.Sprintf("%s >> /tmp/%s.log 2>&1", plugin.Update, plugin.Slug) - task.Log = "/tmp/" + plugin.Slug + ".log" - - if err = panel.Orm.Create(task).Error; err != nil { - return err - } - err = panel.Queue.Push(job.NewProcessTask(r.taskRepo), []any{ - task.ID, - }) - - return err -} - -func (r *pluginRepo) UpdateShow(slug string, show bool) error { - plugin, err := r.GetInstalled(slug) - if err != nil { - return err - } - - plugin.Show = show - - return panel.Orm.Save(plugin).Error -} diff --git a/internal/data/setting.go b/internal/data/setting.go index 94a668ef..111f87ce 100644 --- a/internal/data/setting.go +++ b/internal/data/setting.go @@ -1,6 +1,10 @@ package data import ( + "errors" + + "gorm.io/gorm" + "github.com/TheTNB/panel/internal/biz" "github.com/TheTNB/panel/internal/http/request" "github.com/TheTNB/panel/internal/panel" @@ -15,7 +19,9 @@ func NewSettingRepo() biz.SettingRepo { func (r *settingRepo) Get(key biz.SettingKey, defaultValue ...string) (string, error) { setting := new(biz.Setting) if err := panel.Orm.Where("key = ?", key).First(setting).Error; err != nil { - return "", err + if !errors.Is(err, gorm.ErrRecordNotFound) { + return "", err + } } if setting.Value == "" && len(defaultValue) > 0 { @@ -27,7 +33,7 @@ func (r *settingRepo) Get(key biz.SettingKey, defaultValue ...string) (string, e func (r *settingRepo) Set(key biz.SettingKey, value string) error { setting := new(biz.Setting) - if err := panel.Orm.Where("key = ?", key).First(setting).Error; err != nil { + if err := panel.Orm.Where("key = ?", key).FirstOrInit(setting).Error; err != nil { return err } diff --git a/internal/migration/v1.go b/internal/migration/v1.go index 8bcbdbc8..75fbb7e5 100644 --- a/internal/migration/v1.go +++ b/internal/migration/v1.go @@ -12,13 +12,14 @@ func init() { ID: "20240812-init", Migrate: func(tx *gorm.DB) error { return tx.AutoMigrate( + &biz.Cache{}, &biz.Cert{}, &biz.CertDNS{}, &biz.CertAccount{}, &biz.Cron{}, &biz.Database{}, &biz.Monitor{}, - &biz.Plugin{}, + &biz.App{}, &biz.Setting{}, &biz.Task{}, &biz.User{}, @@ -33,7 +34,7 @@ func init() { &biz.Cron{}, &biz.Database{}, &biz.Monitor{}, - &biz.Plugin{}, + &biz.App{}, &biz.Setting{}, &biz.Task{}, &biz.User{}, diff --git a/internal/route/http.go b/internal/route/http.go index 6a94c34f..145becaf 100644 --- a/internal/route/http.go +++ b/internal/route/http.go @@ -113,15 +113,16 @@ func Http(r chi.Router) { }) }) - r.Route("/plugin", func(r chi.Router) { - r.Use(middleware.MustLogin) - plugin := service.NewPluginService() - r.Get("/list", plugin.List) - r.Post("/install", plugin.Install) - r.Post("/uninstall", plugin.Uninstall) - r.Post("/update", plugin.Update) - r.Post("/updateShow", plugin.UpdateShow) - r.Get("/isInstalled", plugin.IsInstalled) + r.Route("/app", func(r chi.Router) { + //r.Use(middleware.MustLogin) + app := service.NewAppService() + r.Get("/list", app.List) + r.Post("/install", app.Install) + r.Post("/uninstall", app.Uninstall) + r.Post("/update", app.Update) + r.Post("/updateShow", app.UpdateShow) + r.Get("/isInstalled", app.IsInstalled) + r.Get("/updateCache", app.UpdateCache) }) r.Route("/cron", func(r chi.Router) { @@ -263,11 +264,16 @@ func Http(r chi.Router) { r.Post("/start", systemctl.Start) r.Post("/stop", systemctl.Stop) }) - }) r.With(middleware.MustLogin).Mount("/swagger", httpSwagger.Handler()) r.NotFound(func(writer http.ResponseWriter, request *http.Request) { + // /api 开头的返回 404 + if request.URL.Path[:4] == "/api" { + http.NotFound(writer, request) + return + } + // 其他返回前端页面 frontend, _ := fs.Sub(embed.PublicFS, "frontend") spaHandler := func(fs http.FileSystem) http.HandlerFunc { fileServer := http.FileServer(fs) diff --git a/internal/service/plugin.go b/internal/service/app.go similarity index 65% rename from internal/service/plugin.go rename to internal/service/app.go index 1309283c..7c4d0f49 100644 --- a/internal/service/plugin.go +++ b/internal/service/app.go @@ -8,26 +8,27 @@ import ( "github.com/TheTNB/panel/internal/biz" "github.com/TheTNB/panel/internal/data" "github.com/TheTNB/panel/internal/http/request" + "github.com/TheTNB/panel/pkg/str" ) -type PluginService struct { - pluginRepo biz.PluginRepo +type AppService struct { + appRepo biz.AppRepo } -func NewPluginService() *PluginService { - return &PluginService{ - pluginRepo: data.NewPluginRepo(), +func NewAppService() *AppService { + return &AppService{ + appRepo: data.NewAppRepo(), } } -func (s *PluginService) List(w http.ResponseWriter, r *http.Request) { - plugins := s.pluginRepo.All() - installedPlugins, err := s.pluginRepo.Installed() +func (s *AppService) List(w http.ResponseWriter, r *http.Request) { + plugins := s.appRepo.All() + installedPlugins, err := s.appRepo.Installed() if err != nil { Error(w, http.StatusInternalServerError, err.Error()) return } - installedPluginsMap := make(map[string]*biz.Plugin) + installedPluginsMap := make(map[string]*biz.App) for _, p := range installedPlugins { installedPluginsMap[p.Slug] = p @@ -47,7 +48,10 @@ func (s *PluginService) List(w http.ResponseWriter, r *http.Request) { var pluginArr []plugin for _, item := range plugins { - installed, installedVersion, show := false, "", false + installed, installedVersion, currentVersion, show := false, "", "", false + if str.FirstElement(item.Versions) != nil { + currentVersion = str.FirstElement(item.Versions).Version + } if _, ok := installedPluginsMap[item.Slug]; ok { installed = true installedVersion = installedPluginsMap[item.Slug].Version @@ -57,7 +61,7 @@ func (s *PluginService) List(w http.ResponseWriter, r *http.Request) { Name: item.Name, Description: item.Description, Slug: item.Slug, - Version: item.Version, + Version: currentVersion, Requires: item.Requires, Excludes: item.Excludes, Installed: installed, @@ -74,14 +78,14 @@ func (s *PluginService) List(w http.ResponseWriter, r *http.Request) { }) } -func (s *PluginService) Install(w http.ResponseWriter, r *http.Request) { +func (s *AppService) Install(w http.ResponseWriter, r *http.Request) { req, err := Bind[request.PluginSlug](r) if err != nil { Error(w, http.StatusUnprocessableEntity, err.Error()) return } - if err = s.pluginRepo.Install(req.Slug); err != nil { + if err = s.appRepo.Install(req.Slug); err != nil { Error(w, http.StatusInternalServerError, err.Error()) return } @@ -89,14 +93,14 @@ func (s *PluginService) Install(w http.ResponseWriter, r *http.Request) { Success(w, nil) } -func (s *PluginService) Uninstall(w http.ResponseWriter, r *http.Request) { +func (s *AppService) Uninstall(w http.ResponseWriter, r *http.Request) { req, err := Bind[request.PluginSlug](r) if err != nil { Error(w, http.StatusUnprocessableEntity, err.Error()) return } - if err = s.pluginRepo.Uninstall(req.Slug); err != nil { + if err = s.appRepo.Uninstall(req.Slug); err != nil { Error(w, http.StatusInternalServerError, err.Error()) return } @@ -104,14 +108,14 @@ func (s *PluginService) Uninstall(w http.ResponseWriter, r *http.Request) { Success(w, nil) } -func (s *PluginService) Update(w http.ResponseWriter, r *http.Request) { +func (s *AppService) Update(w http.ResponseWriter, r *http.Request) { req, err := Bind[request.PluginSlug](r) if err != nil { Error(w, http.StatusUnprocessableEntity, err.Error()) return } - if err = s.pluginRepo.Update(req.Slug); err != nil { + if err = s.appRepo.Update(req.Slug); err != nil { Error(w, http.StatusInternalServerError, err.Error()) return } @@ -119,14 +123,14 @@ func (s *PluginService) Update(w http.ResponseWriter, r *http.Request) { Success(w, nil) } -func (s *PluginService) UpdateShow(w http.ResponseWriter, r *http.Request) { +func (s *AppService) UpdateShow(w http.ResponseWriter, r *http.Request) { req, err := Bind[request.PluginUpdateShow](r) if err != nil { Error(w, http.StatusUnprocessableEntity, err.Error()) return } - if err = s.pluginRepo.UpdateShow(req.Slug, req.Show); err != nil { + if err = s.appRepo.UpdateShow(req.Slug, req.Show); err != nil { Error(w, http.StatusInternalServerError, err.Error()) return } @@ -134,20 +138,20 @@ func (s *PluginService) UpdateShow(w http.ResponseWriter, r *http.Request) { Success(w, nil) } -func (s *PluginService) IsInstalled(w http.ResponseWriter, r *http.Request) { +func (s *AppService) IsInstalled(w http.ResponseWriter, r *http.Request) { req, err := Bind[request.PluginSlug](r) if err != nil { Error(w, http.StatusUnprocessableEntity, err.Error()) return } - plugin, err := s.pluginRepo.Get(req.Slug) + plugin, err := s.appRepo.Get(req.Slug) if err != nil { Error(w, http.StatusInternalServerError, err.Error()) return } - installed, err := s.pluginRepo.IsInstalled(req.Slug) + installed, err := s.appRepo.IsInstalled(req.Slug) if err != nil { Error(w, http.StatusInternalServerError, err.Error()) return @@ -158,3 +162,12 @@ func (s *PluginService) IsInstalled(w http.ResponseWriter, r *http.Request) { "installed": installed, }) } + +func (s *AppService) UpdateCache(w http.ResponseWriter, r *http.Request) { + if err := s.appRepo.UpdateCache(); err != nil { + Error(w, http.StatusInternalServerError, err.Error()) + return + } + + Success(w, nil) +} diff --git a/internal/service/info.go b/internal/service/info.go index 175e42d4..3a5a9036 100644 --- a/internal/service/info.go +++ b/internal/service/info.go @@ -21,7 +21,7 @@ import ( type InfoService struct { taskRepo biz.TaskRepo websiteRepo biz.WebsiteRepo - pluginRepo biz.PluginRepo + appRepo biz.AppRepo settingRepo biz.SettingRepo cronRepo biz.CronRepo } @@ -30,7 +30,7 @@ func NewInfoService() *InfoService { return &InfoService{ taskRepo: data.NewTaskRepo(), websiteRepo: data.NewWebsiteRepo(), - pluginRepo: data.NewPluginRepo(), + appRepo: data.NewAppRepo(), settingRepo: data.NewSettingRepo(), cronRepo: data.NewCronRepo(), } @@ -65,7 +65,13 @@ func (s *InfoService) Panel(w http.ResponseWriter, r *http.Request) { // @Success 200 {object} SuccessResponse // @Router /info/homePlugins [get] func (s *InfoService) HomePlugins(w http.ResponseWriter, r *http.Request) { - Success(w, nil) + apps, err := s.appRepo.GetHomeShow() + if err != nil { + Error(w, http.StatusInternalServerError, "获取首页插件失败") + return + } + + Success(w, apps) } // NowMonitor @@ -113,8 +119,8 @@ func (s *InfoService) CountInfo(w http.ResponseWriter, r *http.Request) { return } - mysqlInstalled, _ := s.pluginRepo.IsInstalled("slug like ?", "mysql%") - postgresqlInstalled, _ := s.pluginRepo.IsInstalled("slug like ?", "postgresql%") + mysqlInstalled, _ := s.appRepo.IsInstalled("slug like ?", "mysql%") + postgresqlInstalled, _ := s.appRepo.IsInstalled("slug like ?", "postgresql%") type database struct { Name string `json:"name"` @@ -180,7 +186,7 @@ func (s *InfoService) CountInfo(w http.ResponseWriter, r *http.Request) { } var ftpCount int64 - ftpInstalled, _ := s.pluginRepo.IsInstalled("slug = ?", "pureftpd") + ftpInstalled, _ := s.appRepo.IsInstalled("slug = ?", "pureftpd") if ftpInstalled { listRaw, err := shell.Execf("pure-pw list") if len(listRaw) != 0 && err == nil { @@ -211,9 +217,9 @@ func (s *InfoService) CountInfo(w http.ResponseWriter, r *http.Request) { // @Success 200 {object} SuccessResponse // @Router /info/installedDbAndPhp [get] func (s *InfoService) InstalledDbAndPhp(w http.ResponseWriter, r *http.Request) { - mysqlInstalled, _ := s.pluginRepo.IsInstalled("slug like ?", "mysql%") - postgresqlInstalled, _ := s.pluginRepo.IsInstalled("slug like ?", "postgresql%") - php, _ := s.pluginRepo.GetInstalledAll("slug like ?", "php%") + mysqlInstalled, _ := s.appRepo.IsInstalled("slug like ?", "mysql%") + postgresqlInstalled, _ := s.appRepo.IsInstalled("slug like ?", "postgresql%") + php, _ := s.appRepo.GetInstalledAll("slug like ?", "php%") var phpData []types.LV var dbData []types.LV @@ -226,7 +232,7 @@ func (s *InfoService) InstalledDbAndPhp(w http.ResponseWriter, r *http.Request) continue } - plugin, _ := s.pluginRepo.Get(p.Slug) + plugin, _ := s.appRepo.Get(p.Slug) phpData = append(phpData, types.LV{Value: strings.ReplaceAll(p.Slug, "php", ""), Label: plugin.Name}) } diff --git a/pkg/api/api.go b/pkg/api/api.go index 6a40fee6..b64916d5 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -7,12 +7,12 @@ import ( "github.com/go-resty/resty/v2" "github.com/shirou/gopsutil/host" - "github.com/TheTNB/panel/internal/panel" "github.com/TheTNB/panel/pkg/copier" ) type API struct { - client *resty.Client + panelVersion string + client *resty.Client } type Response struct { @@ -20,7 +20,10 @@ type Response struct { Data any `json:"data"` } -func NewAPI(url ...string) *API { +func NewAPI(panelVersion string, url ...string) *API { + if len(panelVersion) == 0 { + panic("panel version is required") + } if len(url) == 0 { url = append(url, "https://panel.haozi.net/api") } @@ -33,10 +36,11 @@ func NewAPI(url ...string) *API { client := resty.New() client.SetTimeout(10 * time.Second) client.SetBaseURL(url[0]) - client.SetHeader("User-Agent", fmt.Sprintf("rat-panel/%s %s/%s", panel.Version, hostInfo.Platform, hostInfo.PlatformVersion)) + client.SetHeader("User-Agent", fmt.Sprintf("rat-panel/%s %s/%s", panelVersion, hostInfo.Platform, hostInfo.PlatformVersion)) return &API{ - client: client, + panelVersion: panelVersion, + client: client, } } diff --git a/pkg/api/api_test.go b/pkg/api/api_test.go index 47c1a030..f91a0e23 100644 --- a/pkg/api/api_test.go +++ b/pkg/api/api_test.go @@ -4,8 +4,6 @@ import ( "testing" "github.com/stretchr/testify/suite" - - "github.com/TheTNB/panel/internal/panel" ) type APITestSuite struct { @@ -14,18 +12,32 @@ type APITestSuite struct { } func TestAPITestSuite(t *testing.T) { - panel.Version = "2.3.0" suite.Run(t, &APITestSuite{ - api: NewAPI(), + api: NewAPI("2.3.0"), }) } func (s *APITestSuite) TestGetLatestVersion() { - _, err := s.api.GetLatestVersion() + _, err := s.api.LatestVersion() s.NoError(err) } -func (s *APITestSuite) TestGetVersionsLog() { - _, err := s.api.GetIntermediateVersions() +func (s *APITestSuite) TestGetIntermediateVersions() { + _, err := s.api.IntermediateVersions() + s.NoError(err) +} + +func (s *APITestSuite) TestGetApps() { + _, err := s.api.Apps() + s.NoError(err) +} + +func (s *APITestSuite) TestGetAppBySlug() { + _, err := s.api.AppBySlug("openresty") + s.NoError(err) +} + +func (s *APITestSuite) TestGetRewritesByType() { + _, err := s.api.RewritesByType("nginx") s.NoError(err) } diff --git a/pkg/api/app.go b/pkg/api/app.go index 64ea123b..58ca9520 100644 --- a/pkg/api/app.go +++ b/pkg/api/app.go @@ -16,17 +16,19 @@ type App struct { Requires []string `json:"requires"` Excludes []string `json:"excludes"` Versions []struct { - Url string `json:"url"` - Checksum string `json:"checksum"` + Version string `json:"version"` + Install string `json:"install"` + Uninstall string `json:"uninstall"` + Update string `json:"update"` PanelVersion string `json:"panel_version"` } `json:"versions"` Order int `json:"order"` } -type Apps []App +type Apps []*App -// GetApps 返回所有应用 -func (r *API) GetApps() (*Apps, error) { +// Apps 返回所有应用 +func (r *API) Apps() (*Apps, error) { resp, err := r.client.R().SetResult(&Response{}).Get("/apps") if err != nil { return nil, err @@ -42,3 +44,21 @@ func (r *API) GetApps() (*Apps, error) { return apps, nil } + +// AppBySlug 根据slug返回应用 +func (r *API) AppBySlug(slug string) (*App, error) { + resp, err := r.client.R().SetResult(&Response{}).Get(fmt.Sprintf("/apps/%s", slug)) + if err != nil { + return nil, err + } + if !resp.IsSuccess() { + return nil, fmt.Errorf("failed to get app: %s", resp.String()) + } + + app, err := getResponseData[App](resp) + if err != nil { + return nil, err + } + + return app, nil +} diff --git a/pkg/api/rewrite.go b/pkg/api/rewrite.go new file mode 100644 index 00000000..b5c30115 --- /dev/null +++ b/pkg/api/rewrite.go @@ -0,0 +1,33 @@ +package api + +import ( + "fmt" + "time" +) + +type Rewrite struct { + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Name string `json:"name"` + Type string `json:"type"` + Content string `json:"content"` +} + +type Rewrites []Rewrite + +func (r *API) RewritesByType(typ string) (*Rewrites, error) { + resp, err := r.client.R().SetResult(&Response{}).Get(fmt.Sprintf("/rewrites/%s", typ)) + if err != nil { + return nil, err + } + if !resp.IsSuccess() { + return nil, fmt.Errorf("failed to get rewrites: %s", resp.String()) + } + + rewrites, err := getResponseData[Rewrites](resp) + if err != nil { + return nil, err + } + + return rewrites, nil +} diff --git a/pkg/api/version.go b/pkg/api/version.go index 288230c3..8edd9693 100644 --- a/pkg/api/version.go +++ b/pkg/api/version.go @@ -3,8 +3,6 @@ package api import ( "fmt" "time" - - "github.com/TheTNB/panel/internal/panel" ) type Version struct { @@ -16,8 +14,8 @@ type Version struct { type Versions []Version -// GetLatestVersion 返回最新版本 -func (r *API) GetLatestVersion() (*Version, error) { +// LatestVersion 返回最新版本 +func (r *API) LatestVersion() (*Version, error) { resp, err := r.client.R().SetResult(&Response{}).Get("/versions/latest") if err != nil { return nil, err @@ -34,16 +32,16 @@ func (r *API) GetLatestVersion() (*Version, error) { return version, nil } -// GetIntermediateVersions 返回当前版本之后的所有版本 -func (r *API) GetIntermediateVersions() (*Versions, error) { +// IntermediateVersions 返回当前版本之后的所有版本 +func (r *API) IntermediateVersions() (*Versions, error) { resp, err := r.client.R(). - SetQueryParam("start", panel.Version). - SetResult(&Response{}).Get("/versions/log") + SetQueryParam("start", r.panelVersion). + SetResult(&Response{}).Get("/versions/intermediate") if err != nil { return nil, err } if !resp.IsSuccess() { - return nil, fmt.Errorf("failed to get latest version: %s", resp.String()) + return nil, fmt.Errorf("failed to get intermediate versions: %s", resp.String()) } versions, err := getResponseData[Versions](resp) diff --git a/pkg/apploader/plugin.go b/pkg/apploader/apploader.go similarity index 65% rename from pkg/apploader/plugin.go rename to pkg/apploader/apploader.go index a50775cb..6b9c459e 100644 --- a/pkg/apploader/plugin.go +++ b/pkg/apploader/apploader.go @@ -2,9 +2,7 @@ package apploader import ( - "cmp" "fmt" - "slices" "sync" "github.com/go-chi/chi/v5" @@ -14,37 +12,37 @@ import ( var plugins sync.Map -func Register(plugin *types.Plugin) { +func Register(plugin *types.App) { plugins.Store(plugin.Slug, plugin) } -func Get(slug string) (*types.Plugin, error) { +func Get(slug string) (*types.App, error) { if plugin, ok := plugins.Load(slug); ok { - return plugin.(*types.Plugin), nil + return plugin.(*types.App), nil } return nil, fmt.Errorf("plugin %s not found", slug) } -func All() []*types.Plugin { - var list []*types.Plugin +func All() []*types.App { + var list []*types.App plugins.Range(func(_, plugin any) bool { - if p, ok := plugin.(*types.Plugin); ok { + if p, ok := plugin.(*types.App); ok { list = append(list, p) } return true }) // 排序 - slices.SortFunc(list, func(a, b *types.Plugin) int { + /*slices.SortFunc(list, func(a, b *types.App) int { return cmp.Compare(a.Order, b.Order) - }) + })*/ return list } func Boot(r chi.Router) { plugins.Range(func(_, plugin any) bool { - if p, ok := plugin.(*types.Plugin); ok { + if p, ok := plugin.(*types.App); ok { r.Route(fmt.Sprintf("/api/plugins/%s", p.Slug), p.Route) } return true diff --git a/pkg/str/string.go b/pkg/str/string.go index c9a042f0..fa596ea0 100644 --- a/pkg/str/string.go +++ b/pkg/str/string.go @@ -10,12 +10,12 @@ import ( "unicode/utf8" ) -// FirstElement 安全地获取 args[0],避免 panic: runtime error: index out of range -func FirstElement(args []string) string { +// FirstElement 返回切片的第一个元素 +func FirstElement[T any](args []T) *T { if len(args) > 0 { - return args[0] + return &args[0] } - return "" + return nil } // RandomNumber 生成长度为 length 随机数字字符串 diff --git a/pkg/str/string_test.go b/pkg/str/string_test.go index d3f9735d..f7c6ebdc 100644 --- a/pkg/str/string_test.go +++ b/pkg/str/string_test.go @@ -15,7 +15,7 @@ func TestStringHelperTestSuite(t *testing.T) { } func (s *StringHelperTestSuite) TestFirstElement() { - s.Equal("HaoZi", FirstElement([]string{"HaoZi"})) + s.Equal("HaoZi", *FirstElement([]string{"HaoZi"})) } func (s *StringHelperTestSuite) TestRandomNumber() { diff --git a/pkg/types/app.go b/pkg/types/app.go new file mode 100644 index 00000000..28d8608a --- /dev/null +++ b/pkg/types/app.go @@ -0,0 +1,9 @@ +package types + +import "github.com/go-chi/chi/v5" + +// App 应用元数据结构 +type App struct { + Slug string `json:"slug"` // 插件标识 + Route func(r chi.Router) `json:"-"` // 路由 +} diff --git a/pkg/types/plugin.go b/pkg/types/plugin.go deleted file mode 100644 index df5b0716..00000000 --- a/pkg/types/plugin.go +++ /dev/null @@ -1,18 +0,0 @@ -package types - -import "github.com/go-chi/chi/v5" - -// Plugin 插件元数据结构 -type Plugin struct { - Order int `json:"-"` // 排序 - Slug string `json:"slug"` // 插件标识 - Name string `json:"name"` // 插件名称 - Description string `json:"description"` // 插件描述 - Version string `json:"version"` // 插件版本 - Requires []string `json:"requires"` // 依赖插件 - Excludes []string `json:"excludes"` // 排除插件 - Install string `json:"-"` // 安装命令 - Uninstall string `json:"-"` // 卸载命令 - Update string `json:"-"` // 更新命令 - Route func(r chi.Router) `json:"-"` // 路由 -}