diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 00000000..865fd252 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,52 @@ +image: golang:alpine + +# 在每个任务执行前运行 +before_script: + - mkdir -p .go + - go version + - go env -w GO111MODULE=on + - go env -w GOPROXY=https://goproxy.cn,direct + +.go_cache: + variables: + GOPATH: $CI_PROJECT_DIR/.go + cache: + paths: + - .go/pkg/mod/ + +# 全局变量 +variables: + OUTPUT_NAME: "panel" + GO111MODULE: "on" + GOPROXY: "https://goproxy.cn,direct" + +stages: + - prepare + - build + +golangci_lint: + stage: prepare + image: golangci/golangci-lint:latest-alpine + extends: .go_cache + allow_failure: true + script: + - golangci-lint run --timeout 30m + +unit_test: + stage: prepare + extends: .go_cache + allow_failure: true + script: + - go test -v -coverprofile=coverage.txt -covermode=atomic ./... + +build: + stage: build + extends: .go_cache + script: + - go mod download + - CGO_ENABLED=0 go build -ldflags '-s -w --extldflags "-static -fpic"' -o $OUTPUT_NAME + artifacts: + name: "$OUTPUT_NAME" + paths: + - $OUTPUT_NAME + expire_in: 1 week diff --git a/app/console/commands/panel.go b/app/console/commands/panel.go new file mode 100644 index 00000000..3777e056 --- /dev/null +++ b/app/console/commands/panel.go @@ -0,0 +1,321 @@ +package commands + +import ( + "os" + "regexp" + + "github.com/gookit/color" + "github.com/spf13/cast" + + "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/console/command" + "github.com/goravel/framework/facades" + + "panel/app/models" + "panel/app/services" + "panel/packages/helpers" +) + +type Panel struct { +} + +// Signature The name and signature of the console command. +func (receiver *Panel) Signature() string { + return "panel" +} + +// Description The console command description. +func (receiver *Panel) Description() string { + return "[面板] 命令行" +} + +// Extend The console command extend. +func (receiver *Panel) Extend() command.Extend { + return command.Extend{ + Category: "panel", + } +} + +// Handle Execute the console command. +func (receiver *Panel) Handle(ctx console.Context) error { + action := ctx.Argument(0) + arg1 := ctx.Argument(1) + arg2 := ctx.Argument(2) + + switch action { + case "init": + var check models.User + err := facades.Orm().Query().FirstOrFail(&check) + if err == nil { + color.Redln("面板已初始化") + return nil + } + + settings := []models.Setting{{Key: "name", Value: "耗子Linux面板"}, {Key: "monitor", Value: "1"}, {Key: "monitor_days", Value: "30"}, {Key: "mysql_root_password", Value: ""}, {Key: "postgresql_root_password", Value: ""}} + err = facades.Orm().Query().Create(&settings) + if err != nil { + color.Redln("初始化失败") + return nil + } + + hash, err := facades.Hash().Make(helpers.RandomString(32)) + if err != nil { + color.Redln("初始化失败") + return nil + } + + user := services.NewUserImpl() + _, err = user.Create("admin", hash) + if err != nil { + color.Redln("创建管理员失败") + return nil + } + + color.Greenln("初始化成功") + break + + case "getInfo": + var user models.User + err := facades.Orm().Query().Where("id", 1).FirstOrFail(&user) + if err != nil { + color.Redln("获取管理员信息失败") + return nil + } + + password := helpers.RandomString(16) + hash, err := facades.Hash().Make(password) + if err != nil { + color.Redln("生成密码失败") + return nil + } + + user.Username = helpers.RandomString(8) + user.Password = hash + + err = facades.Orm().Query().Save(&user) + if err != nil { + color.Redln("保存管理员信息失败") + return nil + } + + nginxConf, err := os.ReadFile("/www/server/nginx/conf/nginx.conf") + if err != nil { + color.Redln("获取面板端口失败,请检查Nginx主配置文件") + return nil + } + + match := regexp.MustCompile(`listen\s+(\d+)`).FindStringSubmatch(string(nginxConf)) + if len(match) < 2 { + color.Redln("获取面板端口失败,请检查Nginx主配置文件") + return nil + } + + port := match[1] + color.Greenln("用户名: " + user.Username) + color.Greenln("密码: " + password) + color.Greenln("面板端口: " + port) + break + + case "getPort": + nginxConf, err := os.ReadFile("/www/server/nginx/conf/nginx.conf") + if err != nil { + color.Redln("获取面板端口失败,请检查Nginx主配置文件") + return nil + } + + match := regexp.MustCompile(`listen\s+(\d+)`).FindStringSubmatch(string(nginxConf)) + if len(match) < 2 { + color.Redln("获取面板端口失败,请检查Nginx主配置文件") + return nil + } + + port := match[1] + color.Greenln("面板端口: " + port) + break + + case "writePlugin": + slug := arg1 + version := arg2 + if len(slug) == 0 || len(version) == 0 { + color.Redln("参数错误") + return nil + } + + var plugin models.Plugin + err := facades.Orm().Query().UpdateOrCreate(&plugin, models.Plugin{ + Slug: slug, + }, models.Plugin{ + Version: version, + }) + + if err != nil { + color.Redln("写入插件安装状态失败") + return nil + } + + color.Greenln("写入插件安装状态成功") + break + + case "deletePlugin": + slug := arg1 + if len(slug) == 0 { + color.Redln("参数错误") + return nil + } + + _, err := facades.Orm().Query().Where("slug", slug).Delete(&models.Plugin{}) + if err != nil { + color.Redln("移除插件安装状态失败") + return nil + } + + color.Greenln("移除插件安装状态成功") + break + + case "writeMysqlPassword": + password := arg1 + if len(password) == 0 { + color.Redln("参数错误") + return nil + } + + var setting models.Setting + err := facades.Orm().Query().UpdateOrCreate(&setting, models.Setting{ + Key: "mysql_root_password", + }, models.Setting{ + Value: password, + }) + + if err != nil { + color.Redln("写入MySQL root密码失败") + return nil + } + + color.Greenln("写入MySQL root密码成功") + break + + case "cleanRunningTask": + _, err := facades.Orm().Query().Model(&models.Task{}).Where("status", models.TaskStatusRunning).Update("status", models.TaskStatusFailed) + if err != nil { + color.Redln("清理正在运行的任务失败") + return nil + } + + color.Greenln("清理正在运行的任务成功") + break + + case "backup": + + case "writeSite": + name := arg1 + status := cast.ToBool(arg2) + path := ctx.Argument(3) + php := cast.ToInt(ctx.Argument(4)) + ssl := cast.ToBool(ctx.Argument(5)) + if len(name) == 0 || len(path) == 0 { + color.Redln("参数错误") + return nil + } + + var website models.Website + if err := facades.Orm().Query().Where("name", name).FirstOrFail(&website); err == nil { + color.Redln("网站已存在") + return nil + } + + _, err := os.Stat(path) + if os.IsNotExist(err) { + color.Redln("网站目录不存在") + return nil + } + + err = facades.Orm().Query().Create(&models.Website{ + Name: name, + Status: status, + Path: path, + Php: php, + Ssl: ssl, + }) + if err != nil { + color.Redln("写入网站失败") + return nil + } + + color.Greenln("写入网站成功") + break + + case "deleteSite": + name := arg1 + if len(name) == 0 { + color.Redln("参数错误") + return nil + } + + _, err := facades.Orm().Query().Where("name", name).Delete(&models.Website{}) + if err != nil { + color.Redln("删除网站失败") + return nil + } + + color.Greenln("删除网站成功") + break + + case "writeSetting": + key := arg1 + value := arg2 + if len(key) == 0 || len(value) == 0 { + color.Redln("参数错误") + return nil + } + + var setting models.Setting + err := facades.Orm().Query().UpdateOrCreate(&setting, models.Setting{ + Key: key, + }, models.Setting{ + Value: value, + }) + if err != nil { + color.Redln("写入设置失败") + return nil + } + + color.Greenln("写入设置成功") + break + + case "deleteSetting": + key := arg1 + if len(key) == 0 { + color.Redln("参数错误") + return nil + } + + _, err := facades.Orm().Query().Where("key", key).Delete(&models.Setting{}) + if err != nil { + color.Redln("删除设置失败") + return nil + } + + color.Greenln("删除设置成功") + break + + default: + color.Yellowln("耗子Linux面板命令行工具") + color.Greenln("请使用以下命令:") + color.Greenln("panel update 更新/修复面板到最新版本") + color.Greenln("panel getInfo 重新初始化面板账号信息") + color.Greenln("panel getPort 获取面板访问端口") + color.Greenln("panel cleanRunningTask 强制清理面板正在运行的任务") + color.Greenln("panel backup {website/mysql/postgresql} {name} {path} 备份网站/MySQL数据库/PostgreSQL数据库到指定目录") + color.Redln("以下命令请在开发者指导下使用:") + color.Yellowln("panel init 初始化面板") + color.Yellowln("panel writePlugin {slug} 写入插件安装状态") + color.Yellowln("panel deletePlugin {slug} 移除插件安装状态") + color.Yellowln("panel writeMysqlPassword {password} 写入MySQL root密码") + color.Yellowln("panel writeSite {name} {status} {path} {php} {ssl} 写入网站数据到面板") + color.Yellowln("panel deleteSite {name} 删除面板网站数据") + color.Yellowln("panel writeSetting {name} {value} 写入/更新面板设置数据") + color.Yellowln("panel deleteSetting {name} 删除面板设置数据") + } + + return nil +} diff --git a/app/console/kernel.go b/app/console/kernel.go index e96ee7f9..57f20621 100644 --- a/app/console/kernel.go +++ b/app/console/kernel.go @@ -16,6 +16,7 @@ func (kernel *Kernel) Schedule() []schedule.Event { func (kernel *Kernel) Commands() []console.Command { return []console.Command{ + &commands.Panel{}, &commands.Monitoring{}, } } diff --git a/app/http/controllers/helpers.go b/app/http/controllers/helpers.go new file mode 100644 index 00000000..5c02dcb2 --- /dev/null +++ b/app/http/controllers/helpers.go @@ -0,0 +1,18 @@ +package controllers + +import "github.com/goravel/framework/contracts/http" + +func Success(ctx http.Context, data http.Json) { + ctx.Response().Success().Json(http.Json{ + "code": 0, + "message": "success", + "data": data, + }) +} + +func Error(ctx http.Context, code int, message any) { + ctx.Response().Json(code, http.Json{ + "code": code, + "message": message, + }) +} diff --git a/app/http/controllers/user_controller.go b/app/http/controllers/user_controller.go index fa84e1c0..89244529 100644 --- a/app/http/controllers/user_controller.go +++ b/app/http/controllers/user_controller.go @@ -22,17 +22,11 @@ func (r *UserController) Login(ctx http.Context) { var loginRequest requests.LoginRequest errors, err := ctx.Request().ValidateRequest(&loginRequest) if err != nil { - ctx.Response().Json(http.StatusUnprocessableEntity, http.Json{ - "code": 422, - "message": err.Error(), - }) + Error(ctx, http.StatusUnprocessableEntity, err.Error()) return } if errors != nil { - ctx.Response().Json(http.StatusUnprocessableEntity, http.Json{ - "code": 422, - "message": errors.All(), - }) + Error(ctx, http.StatusUnprocessableEntity, errors.All()) return } @@ -40,31 +34,20 @@ func (r *UserController) Login(ctx http.Context) { err = facades.Orm().Query().Where("username", loginRequest.Username).First(&user) if err != nil { facades.Log().Error("[面板][UserController] 查询用户失败 ", err) - ctx.Response().Json(http.StatusInternalServerError, http.Json{ - "code": 500, - "message": "系统内部错误", - }) + Error(ctx, http.StatusInternalServerError, "系统内部错误") return } if user.ID == 0 || !facades.Hash().Check(loginRequest.Password, user.Password) { - ctx.Response().Json(http.StatusUnauthorized, http.Json{ - "code": 401, - "message": "用户名或密码错误", - }) + Error(ctx, http.StatusUnauthorized, "用户名或密码错误") return } - // 检查密码是否需要重新哈希 if facades.Hash().NeedsRehash(user.Password) { - // 更新密码 user.Password, err = facades.Hash().Make(loginRequest.Password) if err != nil { facades.Log().Error("[面板][UserController] 更新密码失败 ", err) - ctx.Response().Json(http.StatusInternalServerError, http.Json{ - "code": 500, - "message": "系统内部错误", - }) + Error(ctx, http.StatusInternalServerError, "系统内部错误") return } } @@ -72,38 +55,24 @@ func (r *UserController) Login(ctx http.Context) { token, loginErr := facades.Auth().LoginUsingID(ctx, user.ID) if loginErr != nil { facades.Log().Error("[面板][UserController] 登录失败 ", loginErr) - ctx.Response().Json(http.StatusInternalServerError, http.Json{ - "code": 500, - "message": loginErr.Error(), - }) + Error(ctx, http.StatusInternalServerError, loginErr.Error()) return } - ctx.Response().Success().Json(http.Json{ - "code": 0, - "message": "登录成功", - "data": http.Json{ - "access_token": token, - }, + Success(ctx, http.Json{ + "access_token": token, }) } func (r *UserController) Info(ctx http.Context) { user, ok := ctx.Value("user").(models.User) if !ok { - ctx.Request().AbortWithStatusJson(http.StatusUnauthorized, http.Json{ - "code": 401, - "message": "登录已过期", - }) + Error(ctx, http.StatusUnauthorized, "登录已过期") return } - ctx.Response().Success().Json(http.Json{ - "code": 0, - "message": "获取用户信息成功", - "data": http.Json{ - "username": user.Username, - "email": user.Email, - }, + Success(ctx, http.Json{ + "username": user.Username, + "email": user.Email, }) } diff --git a/app/models/cron.go b/app/models/cron.go index a1254187..1a603fe4 100644 --- a/app/models/cron.go +++ b/app/models/cron.go @@ -10,6 +10,6 @@ type Cron struct { Status bool `gorm:"not null;default:false"` Type string `gorm:"not null"` Time string `gorm:"not null"` - Shell string `gorm:"default:null"` - Log string `gorm:"default:null"` + Shell string `gorm:"default:''"` + Log string `gorm:"default:''"` } diff --git a/app/models/database.go b/app/models/database.go index 924c4e9e..f8ef16d6 100644 --- a/app/models/database.go +++ b/app/models/database.go @@ -11,6 +11,6 @@ type Database struct { Host string `gorm:"not null"` Port int `gorm:"not null"` Username string `gorm:"not null"` - Password string `gorm:"default:null"` - Remark string `gorm:"default:null"` + Password string `gorm:"default:''"` + Remark string `gorm:"default:''"` } diff --git a/app/models/setting.go b/app/models/setting.go index 93a64db0..2c310a4b 100644 --- a/app/models/setting.go +++ b/app/models/setting.go @@ -7,5 +7,5 @@ import ( type Setting struct { orm.Model Key string `gorm:"unique;not null"` - Value string `gorm:"default:null"` + Value string `gorm:"default:''"` } diff --git a/app/models/task.go b/app/models/task.go index ffba4afc..9c798f82 100644 --- a/app/models/task.go +++ b/app/models/task.go @@ -4,10 +4,17 @@ import ( "github.com/goravel/framework/database/orm" ) +const ( + TaskStatusWaiting = "waiting" + TaskStatusRunning = "running" + TaskStatusSuccess = "finished" + TaskStatusFailed = "failed" +) + type Task struct { orm.Model Name string `gorm:"not null"` Status string `gorm:"not null;default:'waiting'"` - Shell string `gorm:"default:null"` - Log string `gorm:"default:null"` + Shell string `gorm:"default:''"` + Log string `gorm:"default:''"` } diff --git a/app/models/user.go b/app/models/user.go index fde3d31a..6e3663c1 100644 --- a/app/models/user.go +++ b/app/models/user.go @@ -8,6 +8,5 @@ type User struct { orm.Model Username string `gorm:"unique;not null"` Password string `gorm:"not null"` - Email string `gorm:"default:null"` - orm.SoftDeletes + Email string `gorm:"default:''"` } diff --git a/app/models/website.go b/app/models/website.go index ebcad1bb..ded74bf0 100644 --- a/app/models/website.go +++ b/app/models/website.go @@ -11,5 +11,5 @@ type Website struct { Path string `gorm:"not null"` Php int `gorm:"default:0;not null;index"` Ssl bool `gorm:"default:false;not null;index"` - Remark string `gorm:"default:null"` + Remark string `gorm:"default:''"` } diff --git a/app/services/backup.go b/app/services/backup.go new file mode 100644 index 00000000..c3fe0fa8 --- /dev/null +++ b/app/services/backup.go @@ -0,0 +1,2 @@ +// Package services 备份服务 +package services diff --git a/app/services/user.go b/app/services/user.go new file mode 100644 index 00000000..b4444a61 --- /dev/null +++ b/app/services/user.go @@ -0,0 +1,39 @@ +package services + +import ( + "github.com/goravel/framework/facades" + + "panel/app/models" +) + +type User interface { + Create(name, password string) (models.User, error) + Update(user models.User) (models.User, error) +} + +type UserImpl struct { +} + +func NewUserImpl() *UserImpl { + return &UserImpl{} +} + +func (r *UserImpl) Create(username, password string) (models.User, error) { + user := models.User{ + Username: username, + Password: password, + } + if err := facades.Orm().Query().Create(&user); err != nil { + return user, err + } + + return user, nil +} + +func (r *UserImpl) Update(user models.User) (models.User, error) { + if _, err := facades.Orm().Query().Update(&user); err != nil { + return user, err + } + + return user, nil +} diff --git a/app/services/user_test.go b/app/services/user_test.go new file mode 100644 index 00000000..09e121f5 --- /dev/null +++ b/app/services/user_test.go @@ -0,0 +1,39 @@ +package services + +import ( + "testing" + + "github.com/goravel/framework/testing/mock" + "github.com/stretchr/testify/suite" + + "panel/app/models" +) + +type UserTestSuite struct { + suite.Suite + user User +} + +func TestUserTestSuite(t *testing.T) { + suite.Run(t, &UserTestSuite{ + user: NewUserImpl(), + }) +} + +func (s *UserTestSuite) SetupTest() { + +} + +func (s *UserTestSuite) TestCreate() { + mockOrm, mockDb, _, _ := mock.Orm() + mockOrm.On("Query").Return(mockDb).Once() + mockDb.On("Create", &models.User{ + Username: "haozi", + Password: "123456", + }).Return(nil).Once() + user, err := s.user.Create("haozi", "123456") + s.Nil(err) + s.Equal("haozi", user.Username) + mockOrm.AssertExpectations(s.T()) + mockDb.AssertExpectations(s.T()) +} diff --git a/app/services/website.go b/app/services/website.go new file mode 100644 index 00000000..9ecb2f4c --- /dev/null +++ b/app/services/website.go @@ -0,0 +1,2 @@ +// Package services 网站服务 +package services diff --git a/config/hashing.go b/config/hashing.go index b87d4626..738ce306 100644 --- a/config/hashing.go +++ b/config/hashing.go @@ -14,7 +14,7 @@ func init() { // Default driver is "bcrypt". // // Supported Drivers: "bcrypt", "argon2id" - "driver": "bcrypt", + "driver": "argon2id", // Bcrypt Hashing Options // rounds: The cost factor that should be used to compute the bcrypt hash. diff --git a/database/.gitignore b/database/.gitignore new file mode 100644 index 00000000..de14a48d --- /dev/null +++ b/database/.gitignore @@ -0,0 +1 @@ +panel.db \ No newline at end of file diff --git a/go.mod b/go.mod index df151155..375dad0e 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module panel go 1.18 require ( - github.com/goravel/framework v1.12.2 + github.com/goravel/framework v1.12.3-0.20230622070736-f7260a71f319 google.golang.org/grpc v1.56.0 ) diff --git a/go.sum b/go.sum index 1e2124e7..12c6e84a 100644 --- a/go.sum +++ b/go.sum @@ -348,6 +348,8 @@ github.com/goravel/file-rotatelogs/v2 v2.4.1 h1:ogkeIFcTHSBRUBpZYiyJbpul8hkVXxHP github.com/goravel/file-rotatelogs/v2 v2.4.1/go.mod h1:euk9qr52WrzM8ICs1hecFcR4CZ/ZZOPdacHfvHgbOf0= github.com/goravel/framework v1.12.2 h1:2d+RQEVzcky6ff6LxlcPvnAj0tZPc0Y4yQAXQk88+nA= github.com/goravel/framework v1.12.2/go.mod h1:96GRS8270PKLfJU9zrrLE7XKlp20S2TJ9RB337jBMy4= +github.com/goravel/framework v1.12.3-0.20230622070736-f7260a71f319 h1:xs7YlSAXdSJs0olT59PBwwj+3Rn5nTKASgNc+GUqsV8= +github.com/goravel/framework v1.12.3-0.20230622070736-f7260a71f319/go.mod h1:96GRS8270PKLfJU9zrrLE7XKlp20S2TJ9RB337jBMy4= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= diff --git a/packages/helpers/helpers.go b/packages/helpers/helpers.go index b7cf2924..07f07e2b 100644 --- a/packages/helpers/helpers.go +++ b/packages/helpers/helpers.go @@ -87,10 +87,10 @@ func MD5(str string) string { // FormatBytes 格式化bytes func FormatBytes(size float64) string { - units := []string{"B", "KB", "MB", "GB", "TB"} + units := []string{"B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"} i := 0 - for ; size >= 1024 && i < 4; i++ { + for ; size >= 1024 && i < len(units); i++ { size /= 1024 } diff --git a/packages/helpers/helpers_test.go b/packages/helpers/helpers_test.go new file mode 100644 index 00000000..d839d25c --- /dev/null +++ b/packages/helpers/helpers_test.go @@ -0,0 +1,64 @@ +package helpers + +import ( + "testing" + + "github.com/stretchr/testify/suite" +) + +type HelperTestSuite struct { + suite.Suite +} + +func TestHelperTestSuite(t *testing.T) { + suite.Run(t, &HelperTestSuite{}) +} + +func (s *HelperTestSuite) TestEmpty() { + s.True(Empty("")) + s.True(Empty(nil)) + s.True(Empty([]string{})) + s.True(Empty(map[string]string{})) + s.True(Empty(0)) + s.True(Empty(0.0)) + s.True(Empty(false)) + + s.False(Empty(" ")) + s.False(Empty([]string{"Panel"})) + s.False(Empty(map[string]string{"Panel": "HaoZi"})) + s.False(Empty(1)) + s.False(Empty(1.0)) + s.False(Empty(true)) +} + +func (s *HelperTestSuite) TestFirstElement() { + s.Equal("HaoZi", FirstElement([]string{"HaoZi"})) +} + +func (s *HelperTestSuite) TestRandomNumber() { + s.Len(RandomNumber(10), 10) +} + +func (s *HelperTestSuite) TestRandomString() { + s.Len(RandomString(10), 10) +} + +func (s *HelperTestSuite) TestMD5() { + s.Equal("e10adc3949ba59abbe56e057f20f883e", MD5("123456")) +} + +func (s *HelperTestSuite) TestFormatBytes() { + s.Equal("1.00 B", FormatBytes(1)) + s.Equal("1.00 KB", FormatBytes(1024)) + s.Equal("1.00 MB", FormatBytes(1024*1024)) + s.Equal("1.00 GB", FormatBytes(1024*1024*1024)) + s.Equal("1.00 TB", FormatBytes(1024*1024*1024*1024)) + s.Equal("1.00 PB", FormatBytes(1024*1024*1024*1024*1024)) + s.Equal("1.00 EB", FormatBytes(1024*1024*1024*1024*1024*1024)) + s.Equal("1.00 ZB", FormatBytes(1024*1024*1024*1024*1024*1024*1024)) + s.Equal("1.00 YB", FormatBytes(1024*1024*1024*1024*1024*1024*1024*1024)) +} + +func (s *HelperTestSuite) TestCut() { + s.Equal("aoZ", Cut("H", "i", "HaoZi")) +}