diff --git a/.github/workflows/issue-auto-reply.yml b/.github/workflows/issue-auto-reply.yml index 4c163644..0de5a001 100644 --- a/.github/workflows/issue-auto-reply.yml +++ b/.github/workflows/issue-auto-reply.yml @@ -14,7 +14,7 @@ jobs: pull-requests: write runs-on: ubuntu-latest steps: - - name: bug or enhancement + - name: ✏️ Feature if: github.event.label.name == '✏️ Feature' uses: actions-cool/issues-helper@v3 with: @@ -24,5 +24,21 @@ jobs: body: | Hi @${{ github.event.issue.user.login }} 👋 - 我们认为您的反馈非常有价值!如果有兴趣欢迎提交 PR,请包含相应的测试用例、文档等,并确保 CI 通过,感谢和期待您的贡献! - We think your feedback is very valuable! If you are interested, please submit a PR, please include test cases, documentation, etc., and ensure that the CI is passed, thank you and look forward to your contribution! + 我们认为您的建议非常有价值!欢迎提交 PR,请包含相应的测试用例、文档等,并确保 CI 通过,感谢和期待您的贡献! + We think your suggestion is very valuable! Welcome to submit a PR, please include test cases, documentation, etc., and ensure that the CI is passed, thank you and look forward to your contribution! + + "Talk is cheap, Show me the Code." - Linus Torvalds + - name: ☢️ Bug + if: github.event.label.name == '☢️ Bug' + uses: actions-cool/issues-helper@v3 + with: + actions: 'create-comment' + token: ${{ secrets.GITHUB_TOKEN }} + issue-number: ${{ github.event.issue.number }} + body: | + Hi @${{ github.event.issue.user.login }} 👋 + + 我们认为您的反馈非常有价值!欢迎提交 PR,请包含相应的测试用例、文档等,并确保 CI 通过,感谢和期待您的贡献! + We think your feedback is very valuable! Welcome to submit a PR, please include test cases, documentation, etc., and ensure that the CI is passed, thank you and look forward to your contribution! + + "Talk is cheap, Show me the Code." - Linus Torvalds diff --git a/app/http/controllers/plugin_controller.go b/app/http/controllers/plugin_controller.go new file mode 100644 index 00000000..83eceeec --- /dev/null +++ b/app/http/controllers/plugin_controller.go @@ -0,0 +1,246 @@ +package controllers + +import ( + "github.com/goravel/framework/contracts/queue" + "github.com/goravel/framework/facades" + "panel/app/jobs" + "sync" + + "github.com/goravel/framework/contracts/http" + + "panel/app/models" + "panel/app/services" +) + +type PluginController struct { + plugin services.Plugin +} + +func NewPluginController() *PluginController { + return &PluginController{ + plugin: services.NewPluginImpl(), + } +} + +// List 列出所有插件 +func (r *PluginController) List(ctx http.Context) { + plugins := r.plugin.All() + installedPlugins, err := r.plugin.AllInstalled() + if err != nil { + Error(ctx, http.StatusInternalServerError, "系统内部错误") + } + + var lock sync.RWMutex + installedPluginsMap := make(map[string]models.Plugin) + + for _, p := range installedPlugins { + lock.Lock() + installedPluginsMap[p.Slug] = p + lock.Unlock() + } + + type plugin struct { + Name string `json:"name"` + Author string `json:"author"` + Description string `json:"description"` + Slug string `json:"slug"` + Version string `json:"version"` + Requires []string `json:"requires"` + Excludes []string `json:"excludes"` + Installed bool `json:"installed"` + InstalledVersion string `json:"installed_version"` + Show bool `json:"show"` + } + + var p []plugin + for _, item := range plugins { + installed, installedVersion, show := false, "", false + if _, ok := installedPluginsMap[item.Slug]; ok { + installed = true + installedVersion = installedPluginsMap[item.Slug].Version + show = installedPluginsMap[item.Slug].Show + } + p = append(p, plugin{ + Name: item.Name, + Author: item.Author, + Description: item.Description, + Slug: item.Slug, + Version: item.Version, + Requires: item.Requires, + Excludes: item.Excludes, + Installed: installed, + InstalledVersion: installedVersion, + Show: show, + }) + } + + Success(ctx, p) +} + +// Install 安装插件 +func (r *PluginController) Install(ctx http.Context) { + slug := ctx.Request().Input("slug") + plugins := r.plugin.All() + + var plugin services.PanelPlugin + check := false + for _, item := range plugins { + if item.Slug == slug { + check = true + plugin = item + break + } + } + if !check { + Error(ctx, http.StatusBadRequest, "插件不存在") + return + } + + var installedPlugin models.Plugin + if err := facades.Orm().Query().Where("slug", slug).First(&installedPlugin); err != nil { + Error(ctx, http.StatusInternalServerError, "系统内部错误") + return + } + if installedPlugin.ID != 0 { + Error(ctx, http.StatusBadRequest, "插件已安装") + } + + var task models.Task + task.Name = "安装插件 " + plugin.Name + task.Status = models.TaskStatusWaiting + task.Shell = "bash scripts/plugins/" + plugin.Slug + "/install.sh >> /tmp/" + plugin.Slug + ".log 2>&1" + task.Log = "/tmp/" + plugin.Slug + ".log" + if err := facades.Orm().Query().Create(&task); err != nil { + facades.Log().Error("[面板][PluginController] 创建任务失败: " + err.Error()) + Error(ctx, http.StatusInternalServerError, "系统内部错误") + return + } + + processTask(task.ID) + Success(ctx, "任务已提交") +} + +// Uninstall 卸载插件 +func (r *PluginController) Uninstall(ctx http.Context) { + slug := ctx.Request().Input("slug") + plugins := r.plugin.All() + + var plugin services.PanelPlugin + check := false + for _, item := range plugins { + if item.Slug == slug { + check = true + plugin = item + break + } + } + if !check { + Error(ctx, http.StatusBadRequest, "插件不存在") + return + } + + var installedPlugin models.Plugin + if err := facades.Orm().Query().Where("slug", slug).First(&installedPlugin); err != nil { + Error(ctx, http.StatusInternalServerError, "系统内部错误") + return + } + if installedPlugin.ID == 0 { + Error(ctx, http.StatusBadRequest, "插件未安装") + } + + var task models.Task + task.Name = "卸载插件 " + plugin.Name + task.Status = models.TaskStatusWaiting + task.Shell = "bash scripts/plugins/" + plugin.Slug + "/uninstall.sh >> /tmp/" + plugin.Slug + ".log 2>&1" + task.Log = "/tmp/" + plugin.Slug + ".log" + if err := facades.Orm().Query().Create(&task); err != nil { + facades.Log().Error("[面板][PluginController] 创建任务失败: " + err.Error()) + Error(ctx, http.StatusInternalServerError, "系统内部错误") + return + } + + processTask(task.ID) + Success(ctx, "任务已提交") +} + +// Update 更新插件 +func (r *PluginController) Update(ctx http.Context) { + slug := ctx.Request().Input("slug") + plugins := r.plugin.All() + + var plugin services.PanelPlugin + check := false + for _, item := range plugins { + if item.Slug == slug { + check = true + plugin = item + break + } + } + if !check { + Error(ctx, http.StatusBadRequest, "插件不存在") + return + } + + var installedPlugin models.Plugin + if err := facades.Orm().Query().Where("slug", slug).First(&installedPlugin); err != nil { + Error(ctx, http.StatusInternalServerError, "系统内部错误") + return + } + if installedPlugin.ID == 0 { + Error(ctx, http.StatusBadRequest, "插件未安装") + } + + var task models.Task + task.Name = "更新插件 " + plugin.Name + task.Status = models.TaskStatusWaiting + task.Shell = "bash scripts/plugins/" + plugin.Slug + "/update.sh >> /tmp/" + plugin.Slug + ".log 2>&1" + task.Log = "/tmp/" + plugin.Slug + ".log" + if err := facades.Orm().Query().Create(&task); err != nil { + facades.Log().Error("[面板][PluginController] 创建任务失败: " + err.Error()) + Error(ctx, http.StatusInternalServerError, "系统内部错误") + return + } + + processTask(task.ID) + Success(ctx, "任务已提交") +} + +// UpdateShow 更新插件首页显示状态 +func (r *PluginController) UpdateShow(ctx http.Context) { + slug := ctx.Request().Input("slug") + show := ctx.Request().InputBool("show") + + var plugin models.Plugin + if err := facades.Orm().Query().Where("slug", slug).First(&plugin); err != nil { + facades.Log().Error("[面板][PluginController] 查询插件失败: " + err.Error()) + Error(ctx, http.StatusInternalServerError, "系统内部错误") + return + } + if plugin.ID == 0 { + Error(ctx, http.StatusBadRequest, "插件未安装") + return + } + + plugin.Show = show + if err := facades.Orm().Query().Save(&plugin); err != nil { + facades.Log().Error("[面板][PluginController] 更新插件失败: " + err.Error()) + Error(ctx, http.StatusInternalServerError, "系统内部错误") + return + } + + Success(ctx, "操作成功") +} + +// processTask 处理任务 +func processTask(taskID uint) { + go func() { + err := facades.Queue().Job(&jobs.ProcessTask{}, []queue.Arg{ + {Type: "uint", Value: taskID}, + }).Dispatch() + if err != nil { + facades.Log().Error("[面板][PluginController] 运行任务失败: " + err.Error()) + return + } + }() +} diff --git a/plugins/openresty/http/controllers/openresty_controller.go b/app/http/controllers/plugins/openresty_controller.go similarity index 98% rename from plugins/openresty/http/controllers/openresty_controller.go rename to app/http/controllers/plugins/openresty_controller.go index 73ba2a55..85c8d679 100644 --- a/plugins/openresty/http/controllers/openresty_controller.go +++ b/app/http/controllers/plugins/openresty_controller.go @@ -1,4 +1,4 @@ -package controllers +package plugins import ( "os" @@ -29,6 +29,8 @@ func NewOpenrestyController() *OpenRestyController { // Status 获取运行状态 func (r *OpenRestyController) Status(ctx http.Context) { + Check(ctx, "openresty") + cmd := exec.Command("bash", "-c", "systemctl status openresty | grep Active | grep -v grep | awk '{print $2}'") out, err := cmd.CombinedOutput() if err != nil { @@ -51,6 +53,8 @@ func (r *OpenRestyController) Status(ctx http.Context) { // Reload 重载配置 func (r *OpenRestyController) Reload(ctx http.Context) { + Check(ctx, "openresty") + cmd := exec.Command("bash", "-c", "systemctl reload openresty") _, err := cmd.CombinedOutput() if err != nil { @@ -82,6 +86,8 @@ func (r *OpenRestyController) Reload(ctx http.Context) { // Start 启动OpenResty func (r *OpenRestyController) Start(ctx http.Context) { + Check(ctx, "openresty") + cmd := exec.Command("bash", "-c", "systemctl start openresty") _, err := cmd.CombinedOutput() if err != nil { @@ -113,6 +119,8 @@ func (r *OpenRestyController) Start(ctx http.Context) { // Stop 停止OpenResty func (r *OpenRestyController) Stop(ctx http.Context) { + Check(ctx, "openresty") + cmd := exec.Command("bash", "-c", "systemctl stop openresty") _, err := cmd.CombinedOutput() if err != nil { diff --git a/app/http/controllers/plugins/plugins.go b/app/http/controllers/plugins/plugins.go new file mode 100644 index 00000000..c18b7a45 --- /dev/null +++ b/app/http/controllers/plugins/plugins.go @@ -0,0 +1,52 @@ +package plugins + +import ( + "sync" + + "github.com/goravel/framework/contracts/http" + "github.com/goravel/framework/facades" + + "panel/app/services" +) + +// Check 检查插件是否可用 +func Check(ctx http.Context, slug string) { + plugin := services.NewPluginImpl().GetBySlug(slug) + installedPlugin := services.NewPluginImpl().GetInstalledBySlug(slug) + installedPlugins, err := services.NewPluginImpl().AllInstalled() + if err != nil { + facades.Log().Error("[面板][插件] 获取已安装插件失败") + ctx.Request().AbortWithStatusJson(http.StatusInternalServerError, "系统内部错误") + } + + if installedPlugin.Version != plugin.Version || installedPlugin.Slug != plugin.Slug { + ctx.Request().AbortWithStatusJson(http.StatusForbidden, "插件 "+slug+" 需要更新至 "+plugin.Version+" 版本") + } + + var lock sync.RWMutex + pluginsMap := make(map[string]bool) + + for _, p := range installedPlugins { + lock.Lock() + pluginsMap[p.Slug] = true + lock.Unlock() + } + + for _, require := range plugin.Requires { + lock.RLock() + _, requireFound := pluginsMap[require] + lock.RUnlock() + if !requireFound { + ctx.Request().AbortWithStatusJson(http.StatusForbidden, "插件 "+slug+" 需要依赖 "+require+" 插件") + } + } + + for _, exclude := range plugin.Excludes { + lock.RLock() + _, excludeFound := pluginsMap[exclude] + lock.RUnlock() + if excludeFound { + ctx.Request().AbortWithStatusJson(http.StatusForbidden, "插件 "+slug+" 不兼容 "+exclude+" 插件") + } + } +} diff --git a/app/http/middleware/jwt.go b/app/http/middleware/jwt.go index f6e2f991..b92860ab 100644 --- a/app/http/middleware/jwt.go +++ b/app/http/middleware/jwt.go @@ -13,7 +13,7 @@ import ( // Jwt 确保通过 JWT 鉴权 func Jwt() http.Middleware { return func(ctx http.Context) { - token := ctx.Request().Input("access_token", "") + token := ctx.Request().Header("access_token", ctx.Request().Input("access_token", "")) if len(token) == 0 { ctx.Request().AbortWithStatusJson(http.StatusUnauthorized, http.Json{ "code": 401, diff --git a/app/jobs/process_task.go b/app/jobs/process_task.go new file mode 100644 index 00000000..22679f01 --- /dev/null +++ b/app/jobs/process_task.go @@ -0,0 +1,84 @@ +package jobs + +import ( + "os/exec" + "time" + + "github.com/goravel/framework/facades" + + "panel/app/models" +) + +// ProcessTask 处理面板任务 +type ProcessTask struct { +} + +// Signature The name and signature of the job. +func (receiver *ProcessTask) Signature() string { + return "process_task" +} + +// Handle Execute the job. +func (receiver *ProcessTask) Handle(args ...any) error { + taskID, ok := args[0].(uint) + if !ok { + facades.Log().Error("[面板][ProcessTask] 任务ID参数错误") + return nil + } + + for { + if !haveRunningTask() { + break + } + time.Sleep(5 * time.Second) + } + + var task models.Task + if err := facades.Orm().Query().Where("id = ?", taskID).Get(&task); err != nil { + facades.Log().Errorf("[面板][ProcessTask] 获取任务%d失败: %s", taskID, err.Error()) + return nil + } + + task.Status = models.TaskStatusRunning + if err := facades.Orm().Query().Save(&task); err != nil { + facades.Log().Errorf("[面板][ProcessTask] 更新任务%d失败: %s", taskID, err.Error()) + return nil + } + + facades.Log().Infof("[面板][ProcessTask] 开始执行任务%d", taskID) + cmd := exec.Command("bash", "-c", task.Shell) + err := cmd.Run() + if err != nil { + task.Status = models.TaskStatusFailed + if err := facades.Orm().Query().Save(&task); err != nil { + facades.Log().Errorf("[面板][ProcessTask] 更新任务%d失败: %s", taskID, err.Error()) + return nil + } + facades.Log().Errorf("[面板][ProcessTask] 任务%d执行失败: %s", taskID, err.Error()) + return nil + } + + task.Status = models.TaskStatusSuccess + if err := facades.Orm().Query().Save(&task); err != nil { + facades.Log().Errorf("[面板][ProcessTask] 更新任务%d失败: %s", taskID, err.Error()) + return nil + } + + facades.Log().Infof("[面板][ProcessTask] 任务%d执行成功", taskID) + return nil +} + +// haveRunningTask 是否有任务正在执行 +func haveRunningTask() bool { + var task models.Task + if err := facades.Orm().Query().Where("status = ?", models.TaskStatusRunning).Get(&task); err != nil { + facades.Log().Error("[面板][ProcessTask] 获取任务失败: " + err.Error()) + return true + } + + if task.ID != 0 { + return true + } + + return false +} diff --git a/app/providers/queue_service_provider.go b/app/providers/queue_service_provider.go index d65f2113..0ca292ea 100644 --- a/app/providers/queue_service_provider.go +++ b/app/providers/queue_service_provider.go @@ -4,6 +4,8 @@ import ( "github.com/goravel/framework/contracts/foundation" "github.com/goravel/framework/contracts/queue" "github.com/goravel/framework/facades" + + "panel/app/jobs" ) type QueueServiceProvider struct { @@ -18,5 +20,7 @@ func (receiver *QueueServiceProvider) Boot(app foundation.Application) { } func (receiver *QueueServiceProvider) Jobs() []queue.Job { - return []queue.Job{} + return []queue.Job{ + &jobs.ProcessTask{}, + } } diff --git a/app/providers/route_service_provider.go b/app/providers/route_service_provider.go index 032d1e24..d7255c3e 100644 --- a/app/providers/route_service_provider.go +++ b/app/providers/route_service_provider.go @@ -20,6 +20,7 @@ func (receiver *RouteServiceProvider) Boot(app foundation.Application) { receiver.configureRateLimiting() routes.Web() + routes.Plugin() } func (receiver *RouteServiceProvider) configureRateLimiting() { diff --git a/app/services/plugin.go b/app/services/plugin.go new file mode 100644 index 00000000..72764dfb --- /dev/null +++ b/app/services/plugin.go @@ -0,0 +1,79 @@ +package services + +import ( + "github.com/goravel/framework/facades" + + "panel/app/models" + "panel/plugins/openresty" +) + +// PanelPlugin 插件元数据结构 +type PanelPlugin struct { + Name string + Author string + Description string + Slug string + Version string + Requires []string + Excludes []string +} + +type Plugin interface { + AllInstalled() ([]models.Plugin, error) + All() []PanelPlugin +} + +type PluginImpl struct { +} + +func NewPluginImpl() *PluginImpl { + return &PluginImpl{} +} + +// AllInstalled 获取已安装的所有插件 +func (r *PluginImpl) AllInstalled() ([]models.Plugin, error) { + var plugins []models.Plugin + if err := facades.Orm().Query().Get(&plugins); err != nil { + return plugins, err + } + + return plugins, nil +} + +// All 获取所有插件 +func (r *PluginImpl) All() []PanelPlugin { + var p []PanelPlugin + + p = append(p, PanelPlugin{ + Name: openresty.Name, + Author: openresty.Author, + Description: openresty.Description, + Slug: openresty.Slug, + Version: openresty.Version, + Requires: openresty.Requires, + Excludes: openresty.Excludes, + }) + + return p +} + +// GetBySlug 根据slug获取插件 +func (r *PluginImpl) GetBySlug(slug string) PanelPlugin { + for _, item := range r.All() { + if item.Slug == slug { + return item + } + } + + return PanelPlugin{} +} + +// GetInstalledBySlug 根据slug获取已安装的插件 +func (r *PluginImpl) GetInstalledBySlug(slug string) models.Plugin { + var plugin models.Plugin + if err := facades.Orm().Query().Where("slug", slug).Get(&plugin); err != nil { + return plugin + } + + return plugin +} diff --git a/bootstrap/plugins.go b/bootstrap/plugins.go deleted file mode 100644 index 82394537..00000000 --- a/bootstrap/plugins.go +++ /dev/null @@ -1,7 +0,0 @@ -package bootstrap - -import "panel/plugins/openresty" - -func Plugins() { - openresty.Boot() -} diff --git a/config/queue.go b/config/queue.go index ff79c6d3..a13007df 100644 --- a/config/queue.go +++ b/config/queue.go @@ -8,7 +8,7 @@ func init() { config := facades.Config() config.Add("queue", map[string]any{ // Default Queue Connection Name - "default": config.Env("QUEUE_CONNECTION", "sync"), + "default": "sync", // Queue Connections // @@ -18,11 +18,6 @@ func init() { "sync": map[string]any{ "driver": "sync", }, - "redis": map[string]any{ - "driver": "redis", - "connection": "default", - "queue": config.Env("REDIS_QUEUE", "default"), - }, }, }) } diff --git a/main.go b/main.go index 30e4b37b..d548c45d 100644 --- a/main.go +++ b/main.go @@ -25,9 +25,6 @@ func main() { // 启动框架 bootstrap.Boot() - // 加载插件 - bootstrap.Plugins() - // 启动 HTTP 服务 go func() { if err := facades.Route().Run(); err != nil { diff --git a/plugins/openresty/openresty.go b/plugins/openresty/openresty.go index be7371f1..1b5af222 100644 --- a/plugins/openresty/openresty.go +++ b/plugins/openresty/openresty.go @@ -1,15 +1,11 @@ package openresty -const ( +var ( Name = "OpenResty" Author = "耗子" Description = "OpenResty® 是一款基于 NGINX 和 LuaJIT 的 Web 平台。" Slug = "openresty" Version = "1.21.4.1" - Requires = "" - Excludes = "" + Requires = []string{} + Excludes = []string{} ) - -func Boot() { - Route() -} diff --git a/public/panel/adminui/src/modules/view.js b/public/panel/adminui/src/modules/view.js index ee2e9794..b6a74b5e 100644 --- a/public/panel/adminui/src/modules/view.js +++ b/public/panel/adminui/src/modules/view.js @@ -63,8 +63,10 @@ layui.define(['laytpl', 'layer'], function (exports) { delete options.success delete options.error + options.data = JSON.stringify(options.data) + return $.ajax($.extend({ - type: 'get', dataType: 'json', success: function (res) { + type: 'get', dataType: 'json', contentType: 'application/json', success: function (res) { var statusCode = response.statusCode //只有 response 的 code 一切正常才执行 done diff --git a/public/panel/views/plugin.html b/public/panel/views/plugin.html new file mode 100644 index 00000000..8aab854d --- /dev/null +++ b/public/panel/views/plugin.html @@ -0,0 +1,184 @@ +
这是耗子Linux面板的OpenResty默认页面!
+当您看到此页面,说明该域名尚未与站点绑定。
+ + +EOF + +# 写入站点停止页 +cat >${openrestyPath}/html/stop.html <该网站已被管理员停止访问!
+当您看到此页面,说明该网站已被管理员停止对外访问,请联系管理员了解详情。
+ + +EOF + +# 处理文件权限 +chmod 755 ${openrestyPath} +chmod 644 ${openrestyPath}/html +chmod -R 755 /www/wwwroot +chown -R www:www /www/wwwroot +chmod -R 644 /www/server/vhost + +# 写入无php配置文件 +echo "" >${openrestyPath}/conf/enable-php-00.conf +# 写入代理默认配置文件 +cat >${openrestyPath}/conf/proxy.conf <