2
0
mirror of https://github.com/acepanel/panel.git synced 2026-02-04 07:57:21 +08:00

feat: command and user service

This commit is contained in:
耗子
2023-06-22 16:51:37 +08:00
parent 6481c2e65e
commit c6e34bdab9
21 changed files with 573 additions and 57 deletions

52
.gitlab-ci.yml Normal file
View File

@@ -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

View File

@@ -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
}

View File

@@ -16,6 +16,7 @@ func (kernel *Kernel) Schedule() []schedule.Event {
func (kernel *Kernel) Commands() []console.Command {
return []console.Command{
&commands.Panel{},
&commands.Monitoring{},
}
}

View File

@@ -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,
})
}

View File

@@ -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,
})
}

View File

@@ -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:''"`
}

View File

@@ -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:''"`
}

View File

@@ -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:''"`
}

View File

@@ -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:''"`
}

View File

@@ -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:''"`
}

View File

@@ -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:''"`
}

2
app/services/backup.go Normal file
View File

@@ -0,0 +1,2 @@
// Package services 备份服务
package services

39
app/services/user.go Normal file
View File

@@ -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
}

39
app/services/user_test.go Normal file
View File

@@ -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())
}

2
app/services/website.go Normal file
View File

@@ -0,0 +1,2 @@
// Package services 网站服务
package services

View File

@@ -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.

1
database/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
panel.db

2
go.mod
View File

@@ -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
)

2
go.sum
View File

@@ -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=

View File

@@ -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
}

View File

@@ -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"))
}