From ab6e0903f591f620ac0c25139bb44b0c57ff7238 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=97=E5=AD=90?= Date: Thu, 8 Jan 2026 23:04:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=20webhook,=20close?= =?UTF-8?q?=20#695?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/ace/wire_gen.go | 4 +- internal/biz/webhook.go | 31 ++ internal/data/data.go | 1 + internal/data/webhook.go | 162 +++++++++++ internal/http/middleware/entrance.go | 8 +- internal/http/request/webhook.go | 21 ++ internal/migration/v1.go | 106 ++----- internal/route/http.go | 15 + internal/service/service.go | 1 + internal/service/webhook.go | 139 +++++++++ web/src/api/panel/webhook/index.ts | 14 + web/src/views/toolbox/IndexView.vue | 3 + web/src/views/toolbox/WebHookView.vue | 394 ++++++++++++++++++++++++++ 13 files changed, 813 insertions(+), 86 deletions(-) create mode 100644 internal/biz/webhook.go create mode 100644 internal/data/webhook.go create mode 100644 internal/http/request/webhook.go create mode 100644 internal/service/webhook.go create mode 100644 web/src/api/panel/webhook/index.ts create mode 100644 web/src/views/toolbox/WebHookView.vue diff --git a/cmd/ace/wire_gen.go b/cmd/ace/wire_gen.go index f4297b79..64e72e1a 100644 --- a/cmd/ace/wire_gen.go +++ b/cmd/ace/wire_gen.go @@ -118,6 +118,8 @@ func initWeb() (*app.Web, error) { systemctlService := service.NewSystemctlService(locale) toolboxSystemService := service.NewToolboxSystemService(locale) toolboxBenchmarkService := service.NewToolboxBenchmarkService(locale) + webHookRepo := data.NewWebHookRepo(locale, db) + webHookService := service.NewWebHookService(webHookRepo) codeserverApp := codeserver.NewApp() dockerApp := docker.NewApp() fail2banApp := fail2ban.NewApp(locale, websiteRepo) @@ -139,7 +141,7 @@ func initWeb() (*app.Web, error) { s3fsApp := s3fs.NewApp(locale) supervisorApp := supervisor.NewApp(locale) loader := bootstrap.NewLoader(codeserverApp, dockerApp, fail2banApp, frpApp, giteaApp, mariadbApp, memcachedApp, minioApp, mysqlApp, nginxApp, openrestyApp, perconaApp, phpmyadminApp, podmanApp, postgresqlApp, pureftpdApp, redisApp, rsyncApp, s3fsApp, supervisorApp) - http := route.NewHttp(config, userService, userTokenService, homeService, taskService, websiteService, databaseService, databaseServerService, databaseUserService, backupService, certService, certDNSService, certAccountService, appService, environmentService, environmentPHPService, cronService, processService, safeService, firewallService, sshService, containerService, containerComposeService, containerNetworkService, containerImageService, containerVolumeService, fileService, monitorService, settingService, systemctlService, toolboxSystemService, toolboxBenchmarkService, loader) + http := route.NewHttp(config, userService, userTokenService, homeService, taskService, websiteService, databaseService, databaseServerService, databaseUserService, backupService, certService, certDNSService, certAccountService, appService, environmentService, environmentPHPService, cronService, processService, safeService, firewallService, sshService, containerService, containerComposeService, containerNetworkService, containerImageService, containerVolumeService, fileService, monitorService, settingService, systemctlService, toolboxSystemService, toolboxBenchmarkService, webHookService, loader) wsService := service.NewWsService(locale, config, logger, sshRepo) ws := route.NewWs(wsService) mux, err := bootstrap.NewRouter(locale, middlewares, http, ws) diff --git a/internal/biz/webhook.go b/internal/biz/webhook.go new file mode 100644 index 00000000..fca5b016 --- /dev/null +++ b/internal/biz/webhook.go @@ -0,0 +1,31 @@ +package biz + +import ( + "time" + + "github.com/acepanel/panel/internal/http/request" +) + +type WebHook struct { + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"not null;default:''" json:"name"` // 钩子名称 + Key string `gorm:"not null;uniqueIndex" json:"key"` // 唯一标识(用于 URL) + Script string `gorm:"not null;default:''" json:"script"` // 脚本内容 + Raw bool `gorm:"not null;default:false" json:"raw"` // 是否以原始格式返回输出 + User string `gorm:"not null;default:''" json:"user"` // 以哪个用户身份执行脚本 + Status bool `gorm:"not null;default:true" json:"status"` // 启用状态 + CallCount uint `gorm:"not null;default:0" json:"call_count"` // 调用次数 + LastCallAt time.Time `json:"last_call_at"` // 上次调用时间 + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type WebHookRepo interface { + List(page, limit uint) ([]*WebHook, int64, error) + Get(id uint) (*WebHook, error) + GetByKey(key string) (*WebHook, error) + Create(req *request.WebHookCreate) (*WebHook, error) + Update(req *request.WebHookUpdate) error + Delete(id uint) error + Call(key string) (string, error) +} diff --git a/internal/data/data.go b/internal/data/data.go index d3a28eed..a430a0c1 100644 --- a/internal/data/data.go +++ b/internal/data/data.go @@ -27,5 +27,6 @@ var ProviderSet = wire.NewSet( NewTaskRepo, NewUserRepo, NewUserTokenRepo, + NewWebHookRepo, NewWebsiteRepo, ) diff --git a/internal/data/webhook.go b/internal/data/webhook.go new file mode 100644 index 00000000..c8638951 --- /dev/null +++ b/internal/data/webhook.go @@ -0,0 +1,162 @@ +package data + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "time" + + "github.com/leonelquinteros/gotext" + "github.com/libtnb/utils/str" + "gorm.io/gorm" + + "github.com/acepanel/panel/internal/app" + "github.com/acepanel/panel/internal/biz" + "github.com/acepanel/panel/internal/http/request" + "github.com/acepanel/panel/pkg/io" +) + +type webhookRepo struct { + t *gotext.Locale + db *gorm.DB +} + +func NewWebHookRepo(t *gotext.Locale, db *gorm.DB) biz.WebHookRepo { + return &webhookRepo{ + t: t, + db: db, + } +} + +func (r *webhookRepo) List(page, limit uint) ([]*biz.WebHook, int64, error) { + webhooks := make([]*biz.WebHook, 0) + var total int64 + err := r.db.Model(&biz.WebHook{}).Order("id desc").Count(&total).Offset(int((page - 1) * limit)).Limit(int(limit)).Find(&webhooks).Error + return webhooks, total, err +} + +func (r *webhookRepo) Get(id uint) (*biz.WebHook, error) { + webhook := new(biz.WebHook) + if err := r.db.Where("id = ?", id).First(webhook).Error; err != nil { + return nil, err + } + return webhook, nil +} + +func (r *webhookRepo) GetByKey(key string) (*biz.WebHook, error) { + webhook := new(biz.WebHook) + if err := r.db.Where("`key` = ?", key).First(webhook).Error; err != nil { + return nil, err + } + return webhook, nil +} + +func (r *webhookRepo) Create(req *request.WebHookCreate) (*biz.WebHook, error) { + if err := os.MkdirAll(r.webhookDir(), 0755); err != nil { + return nil, errors.New(r.t.Get("failed to create webhook directory: %v", err)) + } + + key := str.Random(32) + scriptFile := r.scriptPath(key) + if err := io.Write(scriptFile, req.Script, 0755); err != nil { + return nil, errors.New(r.t.Get("failed to write webhook script: %v", err)) + } + + webhook := &biz.WebHook{ + Name: req.Name, + Key: key, + Script: req.Script, + Raw: req.Raw, + User: req.User, + Status: true, + } + + if err := r.db.Create(webhook).Error; err != nil { + _ = os.Remove(scriptFile) + return nil, err + } + + return webhook, nil +} + +func (r *webhookRepo) Update(req *request.WebHookUpdate) error { + webhook, err := r.Get(req.ID) + if err != nil { + return err + } + + scriptFile := r.scriptPath(webhook.Key) + if err = io.Write(scriptFile, req.Script, 0755); err != nil { + return errors.New(r.t.Get("failed to write webhook script: %v", err)) + } + + return r.db.Model(&biz.WebHook{}).Where("id = ?", req.ID).Updates(map[string]any{ + "name": req.Name, + "script": req.Script, + "raw": req.Raw, + "user": req.User, + "status": req.Status, + }).Error +} + +func (r *webhookRepo) Delete(id uint) error { + webhook, err := r.Get(id) + if err != nil { + return err + } + + scriptFile := r.scriptPath(webhook.Key) + _ = os.Remove(scriptFile) + + return r.db.Delete(&biz.WebHook{}, id).Error +} + +func (r *webhookRepo) Call(key string) (string, error) { + webhook, err := r.GetByKey(key) + if err != nil { + return "", errors.New(r.t.Get("webhook not found")) + } + + if !webhook.Status { + return "", errors.New(r.t.Get("webhook is disabled")) + } + + scriptFile := r.scriptPath(key) + if !io.Exists(scriptFile) { + return "", errors.New(r.t.Get("webhook script not found")) + } + + // 执行脚本 + var cmd *exec.Cmd + if webhook.User == "" || webhook.User == "root" { + cmd = exec.Command("bash", scriptFile) + } else { + cmd = exec.Command("su", "-s", "/bin/bash", "-c", fmt.Sprintf("bash %s", scriptFile), webhook.User) + } + + output, err := cmd.CombinedOutput() + + // 更新调用统计 + _ = r.db.Model(&biz.WebHook{}).Where("`key` = ?", key).Updates(map[string]any{ + "call_count": gorm.Expr("call_count + 1"), + "last_call_at": time.Now(), + }).Error + + if err != nil { + return string(output), fmt.Errorf("script execution failed: %w, output: %s", err, string(output)) + } + + return string(output), nil +} + +// webhookDir 返回 webhook 脚本存储目录 +func (r *webhookRepo) webhookDir() string { + return filepath.Join(app.Root, "server", "webhook") +} + +// scriptPath 返回指定 key 的脚本路径 +func (r *webhookRepo) scriptPath(key string) string { + return filepath.Join(r.webhookDir(), key+".sh") +} diff --git a/internal/http/middleware/entrance.go b/internal/http/middleware/entrance.go index 31be250f..76eb04c3 100644 --- a/internal/http/middleware/entrance.go +++ b/internal/http/middleware/entrance.go @@ -112,7 +112,13 @@ func Entrance(t *gotext.Locale, conf *config.Config, session *sessions.Manager) return } - // 情况四:非调试模式且未通过验证的请求,返回错误 + // 情况四:Webhook 访问,跳过验证 + if strings.HasPrefix(r.URL.Path, "/webhook/") { + next.ServeHTTP(w, r) + return + } + + // 情况五:非调试模式且未通过验证的请求,返回错误 if !conf.App.Debug && sess.Missing("verify_entrance") && r.URL.Path != "/robots.txt" { diff --git a/internal/http/request/webhook.go b/internal/http/request/webhook.go new file mode 100644 index 00000000..99b5a929 --- /dev/null +++ b/internal/http/request/webhook.go @@ -0,0 +1,21 @@ +package request + +type WebHookCreate struct { + Name string `json:"name" form:"name" validate:"required"` + Script string `json:"script" form:"script" validate:"required"` + Raw bool `json:"raw" form:"raw"` + User string `json:"user" form:"user"` +} + +type WebHookUpdate struct { + ID uint `json:"id" form:"id" uri:"id" validate:"required|exists:web_hooks,id"` + Name string `json:"name" form:"name" validate:"required"` + Script string `json:"script" form:"script" validate:"required"` + Raw bool `json:"raw" form:"raw"` + User string `json:"user" form:"user" validate:"required"` + Status bool `json:"status" form:"status"` +} + +type WebHookKey struct { + Key string `json:"key" form:"key" uri:"key" validate:"required"` +} diff --git a/internal/migration/v1.go b/internal/migration/v1.go index 4c74cb55..4833227d 100644 --- a/internal/migration/v1.go +++ b/internal/migration/v1.go @@ -9,107 +9,45 @@ import ( func init() { Migrations = append(Migrations, &gormigrate.Migration{ - ID: "20240812-init", + ID: "20260101-init", Migrate: func(tx *gorm.DB) error { return tx.AutoMigrate( + &biz.App{}, &biz.Cache{}, &biz.Cert{}, - &biz.CertDNS{}, &biz.CertAccount{}, - &biz.Cron{}, - &biz.Monitor{}, - &biz.App{}, - &biz.Setting{}, - &biz.Task{}, - &biz.User{}, - &biz.Website{}, - ) - }, - Rollback: func(tx *gorm.DB) error { - return tx.Migrator().DropTable( - &biz.Cert{}, &biz.CertDNS{}, - &biz.CertAccount{}, &biz.Cron{}, - &biz.Monitor{}, - &biz.App{}, - &biz.Setting{}, - &biz.Task{}, - &biz.User{}, - &biz.Website{}, - ) - }, - }) - Migrations = append(Migrations, &gormigrate.Migration{ - ID: "20241022-ssh", - Migrate: func(tx *gorm.DB) error { - return tx.AutoMigrate( - &biz.SSH{}, - ) - }, - Rollback: func(tx *gorm.DB) error { - return tx.Migrator().DropTable( - &biz.SSH{}, - ) - }, - }) - Migrations = append(Migrations, &gormigrate.Migration{ - ID: "20241124-database", - Migrate: func(tx *gorm.DB) error { - _ = tx.Migrator().DropTable("databases") - return tx.AutoMigrate( &biz.DatabaseServer{}, &biz.DatabaseUser{}, - ) - }, - Rollback: func(tx *gorm.DB) error { - return tx.Migrator().DropTable( - &biz.DatabaseServer{}, - &biz.DatabaseUser{}, - ) - }, - }) - Migrations = append(Migrations, &gormigrate.Migration{ - ID: "20250318-cert-script", - Migrate: func(tx *gorm.DB) error { - return tx.AutoMigrate( - &biz.Cache{}, - &biz.Cert{}, - &biz.CertDNS{}, - &biz.CertAccount{}, - &biz.Cron{}, &biz.Monitor{}, - &biz.App{}, &biz.Setting{}, + &biz.SSH{}, &biz.Task{}, &biz.User{}, - &biz.Website{}, - &biz.SSH{}, - &biz.DatabaseServer{}, - &biz.DatabaseUser{}, - ) - }, - Rollback: func(tx *gorm.DB) error { - return tx.Migrator().DropColumn(&biz.Cert{}, "script") - }, - }) - Migrations = append(Migrations, &gormigrate.Migration{ - ID: "20250514-user-website", - Migrate: func(tx *gorm.DB) error { - return tx.AutoMigrate( - &biz.User{}, - &biz.Website{}, &biz.UserToken{}, + &biz.WebHook{}, + &biz.Website{}, ) }, Rollback: func(tx *gorm.DB) error { - if err := tx.Migrator().DropColumn(&biz.User{}, "two_fa"); err != nil { - return err - } - if err := tx.Migrator().DropColumn(&biz.Website{}, "type"); err != nil { - return err - } - return tx.Migrator().DropTable(&biz.UserToken{}) + return tx.Migrator().DropTable( + &biz.App{}, + &biz.Cert{}, + &biz.CertAccount{}, + &biz.CertDNS{}, + &biz.Cron{}, + &biz.DatabaseServer{}, + &biz.DatabaseUser{}, + &biz.Monitor{}, + &biz.Setting{}, + &biz.SSH{}, + &biz.Task{}, + &biz.User{}, + &biz.UserToken{}, + &biz.WebHook{}, + &biz.Website{}, + ) }, }) } diff --git a/internal/route/http.go b/internal/route/http.go index 137bc80d..49831ff1 100644 --- a/internal/route/http.go +++ b/internal/route/http.go @@ -48,6 +48,7 @@ type Http struct { systemctl *service.SystemctlService toolboxSystem *service.ToolboxSystemService toolboxBenchmark *service.ToolboxBenchmarkService + webhook *service.WebHookService apps *apploader.Loader } @@ -84,6 +85,7 @@ func NewHttp( systemctl *service.SystemctlService, toolboxSystem *service.ToolboxSystemService, toolboxBenchmark *service.ToolboxBenchmarkService, + webhook *service.WebHookService, apps *apploader.Loader, ) *Http { return &Http{ @@ -119,6 +121,7 @@ func NewHttp( systemctl: systemctl, toolboxSystem: toolboxSystem, toolboxBenchmark: toolboxBenchmark, + webhook: webhook, apps: apps, } } @@ -442,11 +445,23 @@ func (route *Http) Register(r *chi.Mux) { r.Post("/test", route.toolboxBenchmark.Test) }) + r.Route("/webhook", func(r chi.Router) { + r.Get("/", route.webhook.List) + r.Post("/", route.webhook.Create) + r.Put("/{id}", route.webhook.Update) + r.Get("/{id}", route.webhook.Get) + r.Delete("/{id}", route.webhook.Delete) + }) + r.Route("/apps", func(r chi.Router) { route.apps.Register(r) }) }) + // WebHook 调用接口 + r.Get("/webhook/{key}", route.webhook.Call) + r.Post("/webhook/{key}", route.webhook.Call) + r.NotFound(func(writer http.ResponseWriter, request *http.Request) { // /api 开头的返回 404 if strings.HasPrefix(request.URL.Path, "/api") { diff --git a/internal/service/service.go b/internal/service/service.go index 5ea91e58..50ac3f25 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -33,6 +33,7 @@ var ProviderSet = wire.NewSet( NewTaskService, NewUserService, NewUserTokenService, + NewWebHookService, NewWebsiteService, NewToolboxSystemService, NewToolboxBenchmarkService, diff --git a/internal/service/webhook.go b/internal/service/webhook.go new file mode 100644 index 00000000..8b8aee0c --- /dev/null +++ b/internal/service/webhook.go @@ -0,0 +1,139 @@ +package service + +import ( + "net/http" + + "github.com/libtnb/chix" + + "github.com/acepanel/panel/internal/biz" + "github.com/acepanel/panel/internal/http/request" +) + +type WebHookService struct { + webhookRepo biz.WebHookRepo +} + +func NewWebHookService(webhook biz.WebHookRepo) *WebHookService { + return &WebHookService{ + webhookRepo: webhook, + } +} + +func (s *WebHookService) List(w http.ResponseWriter, r *http.Request) { + req, err := Bind[request.Paginate](r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, "%v", err) + return + } + + webhooks, total, err := s.webhookRepo.List(req.Page, req.Limit) + if err != nil { + Error(w, http.StatusInternalServerError, "%v", err) + return + } + + Success(w, chix.M{ + "total": total, + "items": webhooks, + }) +} + +func (s *WebHookService) Get(w http.ResponseWriter, r *http.Request) { + req, err := Bind[request.ID](r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, "%v", err) + return + } + + webhook, err := s.webhookRepo.Get(req.ID) + if err != nil { + Error(w, http.StatusInternalServerError, "%v", err) + return + } + + Success(w, webhook) +} + +func (s *WebHookService) Create(w http.ResponseWriter, r *http.Request) { + req, err := Bind[request.WebHookCreate](r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, "%v", err) + return + } + + webhook, err := s.webhookRepo.Create(req) + if err != nil { + Error(w, http.StatusInternalServerError, "%v", err) + return + } + + Success(w, webhook) +} + +func (s *WebHookService) Update(w http.ResponseWriter, r *http.Request) { + req, err := Bind[request.WebHookUpdate](r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, "%v", err) + return + } + + if err = s.webhookRepo.Update(req); err != nil { + Error(w, http.StatusInternalServerError, "%v", err) + return + } + + Success(w, nil) +} + +func (s *WebHookService) Delete(w http.ResponseWriter, r *http.Request) { + req, err := Bind[request.ID](r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, "%v", err) + return + } + + if err = s.webhookRepo.Delete(req.ID); err != nil { + Error(w, http.StatusInternalServerError, "%v", err) + return + } + + Success(w, nil) +} + +// Call 处理 webhook 调用请求 +func (s *WebHookService) Call(w http.ResponseWriter, r *http.Request) { + req, err := Bind[request.WebHookKey](r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, "%v", err) + return + } + + // 获取 webhook 信息以判断返回格式 + webhook, err := s.webhookRepo.GetByKey(req.Key) + if err != nil { + Error(w, http.StatusNotFound, "webhook not found") + return + } + + output, err := s.webhookRepo.Call(req.Key) + if err != nil { + if webhook.Raw { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(output)) + return + } + Error(w, http.StatusInternalServerError, "%v", err) + return + } + + if webhook.Raw { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = w.Write([]byte(output)) + return + } + + Success(w, chix.M{ + "output": output, + }) +} diff --git a/web/src/api/panel/webhook/index.ts b/web/src/api/panel/webhook/index.ts new file mode 100644 index 00000000..4a742e78 --- /dev/null +++ b/web/src/api/panel/webhook/index.ts @@ -0,0 +1,14 @@ +import { http } from '@/utils' + +export default { + // 获取 WebHook 列表 + list: (page: number, limit: number): any => http.Get('/webhook', { params: { page, limit } }), + // 获取 WebHook 信息 + get: (id: number): any => http.Get(`/webhook/${id}`), + // 创建 WebHook + create: (req: any): any => http.Post('/webhook', req), + // 修改 WebHook + update: (id: number, req: any): any => http.Put(`/webhook/${id}`, req), + // 删除 WebHook + delete: (id: number): any => http.Delete(`/webhook/${id}`) +} diff --git a/web/src/views/toolbox/IndexView.vue b/web/src/views/toolbox/IndexView.vue index 02a7d634..806d6635 100644 --- a/web/src/views/toolbox/IndexView.vue +++ b/web/src/views/toolbox/IndexView.vue @@ -6,6 +6,7 @@ defineOptions({ import BenchmarkView from '@/views/toolbox/BenchmarkView.vue' import ProcessView from '@/views/toolbox/ProcessView.vue' import SystemView from '@/views/toolbox/SystemView.vue' +import WebHookView from '@/views/toolbox/WebHookView.vue' import { useGettext } from 'vue3-gettext' const { $gettext } = useGettext() @@ -18,12 +19,14 @@ const current = ref('process') + + diff --git a/web/src/views/toolbox/WebHookView.vue b/web/src/views/toolbox/WebHookView.vue new file mode 100644 index 00000000..7cde63f2 --- /dev/null +++ b/web/src/views/toolbox/WebHookView.vue @@ -0,0 +1,394 @@ + + + + +