diff --git a/app/console/commands/monitoring.go b/app/console/commands/monitoring.go index 41fd9125..cef36a0e 100644 --- a/app/console/commands/monitoring.go +++ b/app/console/commands/monitoring.go @@ -9,6 +9,7 @@ import ( "github.com/goravel/framework/contracts/console/command" "github.com/goravel/framework/facades" "github.com/goravel/framework/support/carbon" + "panel/app/models" "panel/packages/helpers" ) diff --git a/app/console/kernel.go b/app/console/kernel.go index 275f6dba..e96ee7f9 100644 --- a/app/console/kernel.go +++ b/app/console/kernel.go @@ -3,6 +3,7 @@ package console import ( "github.com/goravel/framework/contracts/console" "github.com/goravel/framework/contracts/schedule" + "panel/app/console/commands" ) diff --git a/app/http/controllers/user_controller.go b/app/http/controllers/user_controller.go index 6edfc4a4..fa84e1c0 100644 --- a/app/http/controllers/user_controller.go +++ b/app/http/controllers/user_controller.go @@ -3,8 +3,9 @@ package controllers import ( "github.com/goravel/framework/contracts/http" "github.com/goravel/framework/facades" - models "panel/app/Models" + "panel/app/http/requests" + "panel/app/models" ) type UserController struct { @@ -88,7 +89,21 @@ func (r *UserController) Login(ctx http.Context) { } 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": "登录已过期", + }) + return + } + ctx.Response().Success().Json(http.Json{ - "Hello": "Goravel", + "code": 0, + "message": "获取用户信息成功", + "data": http.Json{ + "username": user.Username, + "email": user.Email, + }, }) } diff --git a/app/http/requests/login_request.go b/app/http/requests/login_request.go index 4617d0f9..44c42ab4 100644 --- a/app/http/requests/login_request.go +++ b/app/http/requests/login_request.go @@ -8,6 +8,9 @@ import ( type LoginRequest struct { Username string `json:"username" form:"username"` Password string `json:"password" form:"password"` + + CaptchaID string `json:"captcha_id" form:"captcha_id"` + Captcha string `json:"captcha" form:"captcha"` } func (r *LoginRequest) Authorize(ctx http.Context) error { @@ -18,6 +21,7 @@ func (r *LoginRequest) Rules(ctx http.Context) map[string]string { return map[string]string{ "login": "required", "password": "required|min_len:8", + "captcha": "captcha:true", } } @@ -26,6 +30,7 @@ func (r *LoginRequest) Messages(ctx http.Context) map[string]string { "login.required": "登录名不能为空", "password.required": "密码不能为空", "password.min_len": "密码长度不能小于 8 位", + "captcha.captcha": "验证码错误", } } diff --git a/app/providers/validation_service_provider.go b/app/providers/validation_service_provider.go index 583a29ec..20878e26 100644 --- a/app/providers/validation_service_provider.go +++ b/app/providers/validation_service_provider.go @@ -4,6 +4,8 @@ import ( "github.com/goravel/framework/contracts/foundation" "github.com/goravel/framework/contracts/validation" "github.com/goravel/framework/facades" + + "panel/app/rules" ) type ValidationServiceProvider struct { @@ -20,5 +22,9 @@ func (receiver *ValidationServiceProvider) Boot(app foundation.Application) { } func (receiver *ValidationServiceProvider) rules() []validation.Rule { - return []validation.Rule{} + return []validation.Rule{ + &rules.Exists{}, + &rules.NotExists{}, + &rules.Captcha{}, + } } diff --git a/app/rules/captcha.go b/app/rules/captcha.go new file mode 100644 index 00000000..f8637ba5 --- /dev/null +++ b/app/rules/captcha.go @@ -0,0 +1,46 @@ +package rules + +import ( + "strconv" + + "github.com/goravel/framework/contracts/validation" + + "panel/packages/captcha" +) + +type Captcha struct { +} + +// Signature The name of the rule. +func (receiver *Captcha) Signature() string { + return "captcha" +} + +// Passes Determine if the validation rule passes. +func (receiver *Captcha) Passes(data validation.Data, val any, options ...any) bool { + captchaID, exist := data.Get("captcha_id") + if !exist { + return false + } + + // 第一个参数(如果有),是否清除验证码,如 false + clear := true + var err error + if len(options) > 0 { + clear, err = strconv.ParseBool(options[0].(string)) + if err != nil { + clear = true + } + } + + if !captcha.NewCaptcha().VerifyCaptcha(captchaID.(string), val.(string), clear) { + return false + } + + return true +} + +// Message Get the validation error message. +func (receiver *Captcha) Message() string { + return "" +} diff --git a/app/rules/exists.go b/app/rules/exists.go new file mode 100644 index 00000000..e1e4e64b --- /dev/null +++ b/app/rules/exists.go @@ -0,0 +1,54 @@ +package rules + +import ( + "github.com/goravel/framework/contracts/validation" + "github.com/goravel/framework/facades" +) + +type Exists struct { +} + +// Signature The name of the rule. +func (receiver *Exists) Signature() string { + return "exists" +} + +// Passes Determine if the validation rule passes. +func (receiver *Exists) Passes(_ validation.Data, val any, options ...any) bool { + + // 第一个参数,表名称,如 categories + tableName := options[0].(string) + // 第二个参数,字段名称,如 id + fieldName := options[1].(string) + // 用户请求过来的数据 + requestValue, ok := val.(string) + if !ok { + return false + } + + // 判断是否为空 + if len(requestValue) == 0 { + return false + } + + // 判断是否存在 + var count int64 + query := facades.Orm().Query().Table(tableName).Where(fieldName, requestValue) + // 判断第三个参数及之后的参数是否存在 + if len(options) > 2 { + for i := 2; i < len(options); i++ { + query = query.OrWhere(options[i].(string), requestValue) + } + } + err := query.Count(&count) + if err != nil { + return false + } + + return count != 0 +} + +// Message Get the validation error message. +func (receiver *Exists) Message() string { + return "记录不存在" +} diff --git a/app/rules/not_exists.go b/app/rules/not_exists.go new file mode 100644 index 00000000..ad61fa6a --- /dev/null +++ b/app/rules/not_exists.go @@ -0,0 +1,54 @@ +package rules + +import ( + "github.com/goravel/framework/contracts/validation" + "github.com/goravel/framework/facades" +) + +type NotExists struct { +} + +// Signature The name of the rule. +func (receiver *NotExists) Signature() string { + return "not_exists" +} + +// Passes Determine if the validation rule passes. +func (receiver *NotExists) Passes(_ validation.Data, val any, options ...any) bool { + + // 第一个参数,表名称,如 categories + tableName := options[0].(string) + // 第二个参数,字段名称,如 id + fieldName := options[1].(string) + // 用户请求过来的数据 + requestValue, ok := val.(string) + if !ok { + return false + } + + // 判断是否为空 + if len(requestValue) == 0 { + return false + } + + // 判断是否存在 + var count int64 + query := facades.Orm().Query().Table(tableName).Where(fieldName, requestValue) + // 判断第三个参数及之后的参数是否存在 + if len(options) > 2 { + for i := 2; i < len(options); i++ { + query = query.OrWhere(options[i].(string), requestValue) + } + } + err := query.Count(&count) + if err != nil { + return false + } + + return count == 0 +} + +// Message Get the validation error message. +func (receiver *NotExists) Message() string { + return "记录已存在" +} diff --git a/config/captcha.go b/config/captcha.go new file mode 100644 index 00000000..43f0896e --- /dev/null +++ b/config/captcha.go @@ -0,0 +1,34 @@ +package config + +import ( + "github.com/goravel/framework/facades" +) + +func init() { + config := facades.Config() + config.Add("captcha", map[string]any{ + // 验证码图片高度 + "height": 60, + + // 验证码图片宽度 + "width": 120, + + // 验证码的长度 + "length": 6, + + // 数字的最大倾斜角度 + "maxskew": 0.6, + + // 图片背景里的混淆点数量 + "dotcount": 40, + + // 过期时间,单位是分钟 + "expire_time": 5, + + // debug 模式下的过期时间,方便本地开发调试 + "debug_expire_time": 3600, + + // 非 production 环境,使用此 key 可跳过验证,方便测试 + "testing_key": "captcha_skip_test", + }) +} diff --git a/go.mod b/go.mod index 57e9c10f..df151155 100644 --- a/go.mod +++ b/go.mod @@ -39,6 +39,7 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect github.com/gabriel-vasile/mimetype v1.4.2 // indirect + github.com/gertd/go-pluralize v0.2.1 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/gin-contrib/static v0.0.1 // indirect github.com/gin-gonic/gin v1.9.1 // indirect @@ -58,6 +59,7 @@ require ( github.com/golang-module/carbon/v2 v2.2.3 // indirect github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect github.com/golang-sql/sqlexp v0.1.0 // indirect + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/golang/snappy v0.0.4 // indirect @@ -79,6 +81,7 @@ require ( github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/hcl v1.0.0 // indirect + github.com/iancoleman/strcase v0.2.0 // indirect github.com/imdario/mergo v0.3.13 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect @@ -101,6 +104,7 @@ require ( github.com/moby/term v0.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/mojocn/base64Captcha v1.3.5 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.0.2 // indirect github.com/opencontainers/runc v1.1.5 // indirect @@ -143,6 +147,7 @@ require ( go.uber.org/atomic v1.11.0 // indirect golang.org/x/arch v0.3.0 // indirect golang.org/x/crypto v0.10.0 // indirect + golang.org/x/image v0.0.0-20190802002840-cff245a6509b // indirect golang.org/x/mod v0.10.0 // indirect golang.org/x/net v0.11.0 // indirect golang.org/x/oauth2 v0.7.0 // indirect diff --git a/go.sum b/go.sum index ed9b670a..1e2124e7 100644 --- a/go.sum +++ b/go.sum @@ -153,6 +153,8 @@ github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4 github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= +github.com/gertd/go-pluralize v0.2.1 h1:M3uASbVjMnTsPb0PNqg+E/24Vwigyo/tvyMTtAlLgiA= +github.com/gertd/go-pluralize v0.2.1/go.mod h1:rbYaKDbsXxmRfr8uygAEKhOWsjyrrqrkHVpZvoOp8zk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= @@ -242,6 +244,8 @@ github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0kt github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -362,6 +366,8 @@ github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/iancoleman/strcase v0.2.0 h1:05I4QRnGpI0m37iZQRuskXh+w77mr6Z41lwQzuHLwW0= +github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk= @@ -454,6 +460,8 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8= +github.com/mojocn/base64Captcha v1.3.5 h1:Qeilr7Ta6eDtG4S+tQuZ5+hO+QHbiGAJdi4PfoagaA0= +github.com/mojocn/base64Captcha v1.3.5/go.mod h1:/tTTXn4WTpX9CfrmipqRytCpJ27Uw3G6I7NcP2WwcmY= github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= @@ -665,6 +673,8 @@ golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EH golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 h1:pVgRXcIictcr+lBQIFeiwuwtDIs4eL21OuM9nyAADmo= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190501045829-6d32002ffd75/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b h1:+qEpEAPhDZ1o0x3tHzZTQDArnOixOzGD9HUJfcg0mb4= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= diff --git a/packages/captcha/captcha.go b/packages/captcha/captcha.go new file mode 100644 index 00000000..e0ae692a --- /dev/null +++ b/packages/captcha/captcha.go @@ -0,0 +1,62 @@ +// Package captcha 处理图片验证码逻辑 +package captcha + +import ( + "sync" + + "github.com/goravel/framework/facades" + "github.com/mojocn/base64Captcha" +) + +type Captcha struct { + Base64Captcha *base64Captcha.Captcha +} + +// once 确保 internalCaptcha 对象只初始化一次 +var once sync.Once + +// internalCaptcha 内部使用的 Captcha 对象 +var internalCaptcha *Captcha + +// NewCaptcha 单例模式获取 +func NewCaptcha() *Captcha { + once.Do(func() { + // 初始化 Captcha 对象 + internalCaptcha = &Captcha{} + + // 使用 Cache facade 进行存储,并配置存储 Key 的前缀 + store := CacheStore{ + KeyPrefix: facades.Config().GetString("app.name") + ":captcha:", + } + + // 配置 base64Captcha 驱动信息 + driver := base64Captcha.NewDriverDigit( + facades.Config().GetInt("captcha.height"), // 宽 + facades.Config().GetInt("captcha.width"), // 高 + facades.Config().GetInt("captcha.length"), // 长度 + facades.Config().Get("captcha.maxskew").(float64), // 数字的最大倾斜角度 + facades.Config().GetInt("captcha.dotcount"), // 图片背景里的混淆点数量 + ) + + // 实例化 base64Captcha 并赋值给内部使用的 internalCaptcha 对象 + internalCaptcha.Base64Captcha = base64Captcha.NewCaptcha(driver, &store) + }) + + return internalCaptcha +} + +// GenerateCaptcha 生成图片验证码 +func (c *Captcha) GenerateCaptcha() (id string, b64s string, err error) { + return c.Base64Captcha.Generate() +} + +// VerifyCaptcha 验证验证码是否正确 +func (c *Captcha) VerifyCaptcha(id string, answer string, clear bool) (match bool) { + + // 方便本地和 API 自动测试 + if facades.Config().GetBool("app.debug") && id == facades.Config().GetString("captcha.testing_key") { + return true + } + // 这样方便用户多次提交,防止表单提交错误需要多次输入图片验证码 + return c.Base64Captcha.Verify(id, answer, clear) +} diff --git a/packages/captcha/store_cache.go b/packages/captcha/store_cache.go new file mode 100644 index 00000000..5c348089 --- /dev/null +++ b/packages/captcha/store_cache.go @@ -0,0 +1,45 @@ +package captcha + +import ( + "time" + + "github.com/goravel/framework/facades" +) + +// CacheStore 实现 base64Captcha.Store interface +type CacheStore struct { + KeyPrefix string +} + +// Set 实现 base64Captcha.Store interface 的 Set 方法 +func (s *CacheStore) Set(key string, value string) error { + + ExpireTime := time.Minute * time.Duration(facades.Config().GetInt("captcha.expire_time")) + // 方便本地开发调试 + if facades.Config().GetBool("app.debug") { + ExpireTime = time.Minute * time.Duration(facades.Config().GetInt("captcha.debug_expire_time")) + } + + err := facades.Cache().Put(s.KeyPrefix+key, value, ExpireTime) + if err != nil { + return err + } + + return nil +} + +// Get 实现 base64Captcha.Store interface 的 Get 方法 +func (s *CacheStore) Get(key string, clear bool) string { + key = s.KeyPrefix + key + val := facades.Cache().Get(key, "").(string) + if clear { + facades.Cache().Forget(key) + } + return val +} + +// Verify 实现 base64Captcha.Store interface 的 Verify 方法 +func (s *CacheStore) Verify(key, answer string, clear bool) bool { + v := s.Get(key, clear) + return v == answer +} diff --git a/packages/str/str.go b/packages/str/str.go new file mode 100644 index 00000000..00b6f86e --- /dev/null +++ b/packages/str/str.go @@ -0,0 +1,32 @@ +// Package str 字符串辅助方法 +package str + +import ( + "github.com/gertd/go-pluralize" + "github.com/iancoleman/strcase" +) + +// Plural 转为复数 user -> users +func Plural(word string) string { + return pluralize.NewClient().Plural(word) +} + +// Singular 转为单数 users -> user +func Singular(word string) string { + return pluralize.NewClient().Singular(word) +} + +// Snake 转为 snake_case,如 TopicComment -> topic_comment +func Snake(s string) string { + return strcase.ToSnake(s) +} + +// Camel 转为 CamelCase,如 topic_comment -> TopicComment +func Camel(s string) string { + return strcase.ToCamel(s) +} + +// LowerCamel 转为 lowerCamelCase,如 TopicComment -> topicComment +func LowerCamel(s string) string { + return strcase.ToLowerCamel(s) +}