diff --git a/cmd/picgo.go b/cmd/picgo.go new file mode 100644 index 0000000..b813e1a --- /dev/null +++ b/cmd/picgo.go @@ -0,0 +1,111 @@ +package cmd + +import ( + "fmt" + "github.com/spf13/cobra" + "log" + "net/http" + "os" + "picgo/configs" + "picgo/corelib" + "picgo/model" + "picgo/router" + "strings" +) + +var rootCmd = &cobra.Command{ + Use: "root", + PersistentPreRunE: func(cmd *cobra.Command, args []string) (err error) { + fmt.Println("args:") + fmt.Println(strings.Join(args, " ")) + if len(os.Args) < 1 { + log.Panic("请指定配置文件路径") + return + } + configFile := args[0] + fmt.Println("配置文件路径: ", configFile) + // 初始化配置文件 + configs.NewConfig(configFile) + // 初始化日志 + corelib.NewLogger() + // 初始化redis + corelib.NewRedis() + // 初始化mysql + corelib.NewMysql() + // 初始化session store + corelib.NewSessionStore() + return + }, +} + +var picgoCmd = &cobra.Command{ + Use: "picgo", + Run: func(cmd *cobra.Command, args []string) { + // 创建路由 + r := router.InitRouter() + // 启动HTTP服务器 + log.Println("Serving on http://localhost:8082") + err := http.ListenAndServe(":8082", r) + if err != nil { + log.Fatalf("Failed to start server: %v", err) + } + + }, +} + +var MigrateCmd = &cobra.Command{ + Use: "migrate", + Run: func(cmd *cobra.Command, args []string) { + var err error + // 自动迁移数据库 + if err = corelib.DbMysql.AutoMigrate(&model.SysUser{}); err != nil { + panic("SysUser 迁移数据库失败") + } + if err = corelib.DbMysql.AutoMigrate(&model.Domain{}); err != nil { + panic("Domain 迁移数据库失败") + } + if err = corelib.DbMysql.AutoMigrate(&model.Upload{}); err != nil { + panic("Upload 迁移数据库失败") + } + // 初始化管理员用户 + err = initSysUser() + if err != nil { + fmt.Println("初始化管理员用户失败", err) + } + + }, +} + +func init() { + rootCmd.AddCommand(picgoCmd) + rootCmd.AddCommand(MigrateCmd) +} + +func Execute() { + err := rootCmd.Execute() + if err != nil { + return + } +} + +// 初始化超级管理员 +func initSysUser() error { + var ( + hashPassword string + err error + ) + userName := "admin001" + password := "Admin#123456" + salt := corelib.GenerateSalt() + if hashPassword, err = corelib.HashPassword(password, salt); err != nil { + corelib.Logger.Errorln("initSystem 生成密码失败: ", err) + return err + } + user := model.SysUser{Username: userName, Password: hashPassword, Salt: salt, IsSuper: 1} + if err = corelib.DbMysql.Create(&user).Error; err != nil { + corelib.Logger.Errorln("initSystem 初始化管理员用户失败: ", err) + return err + } + return nil + +} diff --git a/configs/configs.go b/configs/configs.go index ec94a77..db05445 100644 --- a/configs/configs.go +++ b/configs/configs.go @@ -7,6 +7,10 @@ import ( ) type Configs struct { + Server `yaml:"server"` + Redis `yaml:"redis"` + Mysql `yaml:"mysql"` + Captcha `json:"captcha"` } // Settings 定义配置结构体 diff --git a/corelib/encryption.go b/corelib/encryption.go new file mode 100644 index 0000000..1102f55 --- /dev/null +++ b/corelib/encryption.go @@ -0,0 +1,34 @@ +package corelib + +import ( + "crypto/md5" + "encoding/hex" + "golang.org/x/crypto/bcrypt" +) + +// Md5Hash md5加密 +func Md5Hash(data string) string { + md5Hash := md5.New() + md5Hash.Write([]byte(data)) + return hex.EncodeToString(md5Hash.Sum(nil)) +} + +// HashPassword 使用Bcrypt哈希密码和盐值 +func HashPassword(password, salt string) (string, error) { + var ( + err error + hashedPassword []byte + ) + passwordWithSalt := append([]byte(password), []byte(salt)...) + if hashedPassword, err = bcrypt.GenerateFromPassword(passwordWithSalt, bcrypt.DefaultCost); err != nil { + return "", err + } + return string(hashedPassword), nil +} + +// ComparePasswords 验证密码是否匹配哈希值和盐值 +func ComparePasswords(hashedPassword, userInput, salt string) bool { + passwordWithSalt := append([]byte(userInput), []byte(salt)...) + err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), passwordWithSalt) + return err == nil +} diff --git a/corelib/log.go b/corelib/log.go new file mode 100644 index 0000000..4dfdb5e --- /dev/null +++ b/corelib/log.go @@ -0,0 +1,53 @@ +package corelib + +import ( + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "gopkg.in/natefinch/lumberjack.v2" + "log" + "os" + "path" + "picgo/configs" +) + +var Logger *zap.SugaredLogger + +func NewLogger() { + writeSyncer := getLogWriter() + encoder := getEncoder() + core := zapcore.NewCore(encoder, writeSyncer, zapcore.DebugLevel) + logger := zap.New(core, zap.AddCaller()) + Logger = logger.Sugar() +} + +func getEncoder() zapcore.Encoder { + encoderConfig := zap.NewProductionEncoderConfig() + encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder + encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder + return zapcore.NewConsoleEncoder(encoderConfig) +} + +func getLogWriter() zapcore.WriteSyncer { + var ( + err error + currentDir string + ) + // 获取当前工作目录 + if currentDir, err = os.Getwd(); err != nil { + log.Panic("获取当前工作目录失败: ", err) + } + logPath := path.Join(currentDir, configs.Settings.Server.LogPath) + // Filename: 日志文件的位置 + // MaxSize:在进行切割之前,日志文件的最大大小(以 MB 为单位) + // MaxBackups:保留旧文件的最大个数 + // MaxAges:保留旧文件的最大天数 + // Compress:是否压缩 / 归档旧文件 + lumberJackLogger := &lumberjack.Logger{ + Filename: logPath, + MaxSize: 50, + MaxBackups: 10, + MaxAge: 1, + Compress: false, + } + return zapcore.AddSync(lumberJackLogger) +} diff --git a/corelib/msyql.go b/corelib/msyql.go new file mode 100644 index 0000000..4961de7 --- /dev/null +++ b/corelib/msyql.go @@ -0,0 +1,49 @@ +package corelib + +import ( + "database/sql" + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" + "gorm.io/gorm/schema" + "picgo/configs" + "time" +) + +var ( + DbMysql *gorm.DB +) + +// NewMysql 创建数据库连接 +func NewMysql() { + var ( + err error + db *gorm.DB + sqlDB *sql.DB + dsn string + ) + dsn = configs.Settings.Mysql.MysqlDNS + + if db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{ + // 配置日志级别,打印出所有的sql + Logger: logger.Default.LogMode(logger.Info), + NamingStrategy: schema.NamingStrategy{ + //TablePrefix: configs.Setting.TablePrefix, // 表前缀 + SingularTable: true, // 设置全局表名禁用复数 + }, + }); err != nil { + Logger.Panicln("数据库连接失败: ", err) + } + + if sqlDB, err = db.DB(); err != nil { + Logger.Panicln("数据库连接失败: ", err) + } + + // SetMaxIdleConns 设置空闲连接池中连接的最大数量 + sqlDB.SetMaxIdleConns(configs.Settings.Mysql.MaxIdleConns) + // SetMaxOpenConns 设置打开数据库连接的最大数量。 + sqlDB.SetMaxOpenConns(configs.Settings.Mysql.MaxOpenConns) + // SetConnMaxLifetime 设置了连接可复用的最大时间。 + sqlDB.SetConnMaxLifetime(time.Hour) + DbMysql = db +} diff --git a/corelib/rand.go b/corelib/rand.go new file mode 100644 index 0000000..d8efc3f --- /dev/null +++ b/corelib/rand.go @@ -0,0 +1,48 @@ +package corelib + +import ( + "math/rand" + "time" +) + +func randomString(n int, allowedChars []rune) string { + r := rand.New(rand.NewSource(time.Now().UnixNano())) + b := make([]rune, n) + for i := range b { + b[i] = allowedChars[r.Intn(len(allowedChars))] + } + return string(b) +} + +// 生成随机字符串 +func generateRandom(randType int, length int) string { + var res string + const special = "~!@#$%^&*()_+=-" + const number = "0123456789" + const capitalLetter = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + const lowerLetter = "abcdefghijklmnopqrstuvwxyz" + switch { + case randType == 1: // 特俗符号 + res = special + case randType == 2: // 数字 + res = number + case randType == 3: // 大写字母 + res = capitalLetter + case randType == 4: // 小写字母 + res = lowerLetter + case randType == 5: // 大写字母+小写字母 + res = capitalLetter + lowerLetter + case randType == 6: // 大写字母+小写字母+数字 + res = capitalLetter + lowerLetter + number + default: // 大写字母+小写字母+数字+特殊符号 + res = capitalLetter + lowerLetter + number + special + + } + return randomString(length, []rune(res)) +} + +// GenerateSalt 生成一个随机盐 +func GenerateSalt() string { + const saltSize = 16 // 16字节的随机盐 + return generateRandom(0, saltSize) +} diff --git a/corelib/redis.go b/corelib/redis.go new file mode 100644 index 0000000..65d7963 --- /dev/null +++ b/corelib/redis.go @@ -0,0 +1,29 @@ +package corelib + +import ( + "context" + "fmt" + "github.com/redis/go-redis/v9" + "picgo/configs" +) + +var ( + RdbClient *redis.Client + AdminCaptchaKey = "admin:captcha:" // 验证码存储key +) + +func NewRedis() { + RdbClient = redis.NewClient(&redis.Options{ + Addr: configs.Settings.Redis.Addr, // Redis服务器地址 + Password: configs.Settings.Redis.Password, // 密码,如果没有密码可以为空 + DB: configs.Settings.Redis.DB, // 使用默认的数据库 + PoolSize: configs.Settings.Redis.PoolSize, // 连接池大小 + MinIdleConns: configs.Settings.Redis.MinidleConns, // 最小空闲连接数 + }) + + // 测试连接 + _, err := RdbClient.Ping(context.Background()).Result() + if err != nil { + panic(fmt.Sprintf("Failed to connect to Redis: %v", err)) + } +} diff --git a/corelib/regexp.go b/corelib/regexp.go new file mode 100644 index 0000000..4ddc4ab --- /dev/null +++ b/corelib/regexp.go @@ -0,0 +1,38 @@ +package corelib + +import ( + "github.com/dlclark/regexp2" +) + +// 执行正则 +func regexpRun(rx string, txt string) (ok bool) { + var ( + err error + compile *regexp2.Regexp + matchString bool + ) + if compile, err = regexp2.Compile(rx, 0); err != nil { + Logger.Error("regexp compile error:", err) + return false + } + if matchString, err = compile.MatchString(txt); err != nil { + Logger.Error("regexp compile error:", err) + return false + } + return matchString +} + +// IsCaptcha 验证码正则 +func IsCaptcha(code string) (ok bool) { + return regexpRun("^[0-9a-zA-Z]{1,5}$", code) +} + +// CheckUsername 用户名正则 +func CheckUsername(username string) (ok bool) { + return regexpRun("^[a-zA-Z0-9_-]{6,16}$", username) +} + +// CheckPassword 用户名正则 +func CheckPassword(password string) (ok bool) { + return regexpRun("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[$@$!#%*?&])[A-Za-z\\d$@#$!%*?&]{8,16}", password) +} diff --git a/corelib/session.go b/corelib/session.go new file mode 100644 index 0000000..3ed0617 --- /dev/null +++ b/corelib/session.go @@ -0,0 +1,18 @@ +package corelib + +import ( + "gopkg.in/boj/redistore.v1" + "picgo/configs" +) + +var SessionStore *redistore.RediStore + +func NewSessionStore() { + // Fetch new store. + store, err := redistore.NewRediStore(10, "tcp", configs.Settings.Redis.Addr, "", []byte(configs.Settings.Redis.Password)) + if err != nil { + panic(err) + } + SessionStore = store + defer store.Close() +} diff --git a/corelib/template.go b/corelib/template.go new file mode 100644 index 0000000..c8cc54a --- /dev/null +++ b/corelib/template.go @@ -0,0 +1,21 @@ +package corelib + +import ( + "html/template" + "net/http" +) + +func TemplateHandler(w http.ResponseWriter, r *http.Request, data any, filenames ...string) { + // 解析模板文件 + tmpl, err := template.ParseFiles(filenames...) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // 渲染模板并写入响应 + err = tmpl.Execute(w, data) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} diff --git a/go.mod b/go.mod index 81b8fb6..d5b0139 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,35 @@ module picgo go 1.22 -require gopkg.in/yaml.v3 v3.0.1 // indirect +require ( + github.com/bwmarrin/snowflake v0.3.0 + github.com/dlclark/regexp2 v1.11.2 + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 + github.com/llgcode/draw2d v0.0.0-20240627062922-0ed1ff131195 + github.com/redis/go-redis/v9 v9.5.4 + github.com/spf13/cobra v1.8.1 + go.uber.org/zap v1.27.0 + golang.org/x/crypto v0.25.0 + gopkg.in/natefinch/lumberjack.v2 v2.2.1 + gopkg.in/yaml.v3 v3.0.1 + gorm.io/driver/mysql v1.5.7 + gorm.io/gorm v1.25.11 +) + +require ( + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/garyburd/redigo v1.6.4 // indirect + github.com/go-sql-driver/mysql v1.7.0 // indirect + github.com/gorilla/csrf v1.7.2 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/gorilla/sessions v1.3.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/spf13/pflag v1.0.5 // indirect + go.uber.org/multierr v1.10.0 // indirect + golang.org/x/image v0.18.0 // indirect + golang.org/x/text v0.16.0 // indirect + gopkg.in/boj/redistore.v1 v1.0.0-20160128113310-fc113767cd6b // indirect +) diff --git a/go.sum b/go.sum index 4bc0337..3461472 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,73 @@ +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0= +github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dlclark/regexp2 v1.11.2 h1:/u628IuisSTwri5/UKloiIsH8+qF2Pu7xEQX+yIKg68= +github.com/dlclark/regexp2 v1.11.2/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/garyburd/redigo v1.6.4 h1:LFu2R3+ZOPgSMWMOL+saa/zXRjw0ID2G8FepO53BGlg= +github.com/garyburd/redigo v1.6.4/go.mod h1:rTb6epsqigu3kYKBnaF028A7Tf/Aw5s0cqA47doKKqw= +github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +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/gorilla/csrf v1.7.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI= +github.com/gorilla/csrf v1.7.2/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/gorilla/sessions v1.3.0 h1:XYlkq7KcpOB2ZhHBPv5WpjMIxrQosiZanfoy1HLZFzg= +github.com/gorilla/sessions v1.3.0/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/llgcode/draw2d v0.0.0-20240627062922-0ed1ff131195 h1:Vdz2cBh5Fw2MYHWi3ED2PraDQaWEUhNCr1XFHrP4N5A= +github.com/llgcode/draw2d v0.0.0-20240627062922-0ed1ff131195/go.mod h1:1Vk0LDW6jG5cGc2D9RQUxHaE0vYhTvIwSo9mOL6K4/U= +github.com/llgcode/ps v0.0.0-20210114104736-f4b0c5d1e02e h1:ZAvbj5hI/G/EbAYAcj4yCXUNiFKefEhH0qfImDDD0/8= +github.com/llgcode/ps v0.0.0-20210114104736-f4b0c5d1e02e/go.mod h1:1l8ky+Ew27CMX29uG+a2hNOKpeNYEQjjtiALiBlFQbY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.5.4 h1:vOFYDKKVgrI5u++QvnMT7DksSMYg7Aw/Np4vLJLKLwY= +github.com/redis/go-redis/v9 v9.5.4/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= +golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= +golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= +golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +gopkg.in/boj/redistore.v1 v1.0.0-20160128113310-fc113767cd6b h1:U/Uqd1232+wrnHOvWNaxrNqn/kFnr4yu4blgPtQt0N8= +gopkg.in/boj/redistore.v1 v1.0.0-20160128113310-fc113767cd6b/go.mod h1:fgfIZMlsafAHpspcks2Bul+MWUNw/2dyQmjC2faKjtg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= +gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= +gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg= +gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= diff --git a/handler/captcha.go b/handler/captcha.go new file mode 100644 index 0000000..e6fa48a --- /dev/null +++ b/handler/captcha.go @@ -0,0 +1,85 @@ +package handler + +import ( + "bytes" + "image" + "image/png" + "net" + "net/http" + "picgo/corelib" + "picgo/corelib/captcha" + "strings" +) + +func CaptchaHandler(w http.ResponseWriter, r *http.Request) { + var ( + res []byte + err error + ) + captchaId := getCaptchaId(r) + if res, err = generateCaptcha(captchaId); err != nil { + corelib.WriteJsonResponse(w, 1030, "生产验证码失败", nil) + return + } + // 设置响应头 + w.Header().Set("Content-Type", "image/png") + // 将图片文件内容写入响应 + _, err = w.Write(res) + if err != nil { + http.Error(w, "Failed to serve image.", http.StatusInternalServerError) + } +} + +// user agent + client ip md5 生成验证码key +func getCaptchaId(r *http.Request) string { + clientIP := GetRealIP(r) + userAgent := r.Header.Get("User-Agent") + return corelib.Md5Hash(clientIP + userAgent) +} + +// 生成验证码图片 +func generateCaptcha(captchaId string) ([]byte, error) { + var ( + b bytes.Buffer + img *image.RGBA + err error + ) + if img, err = captcha.GenerateCaptcha(captchaId); err != nil { + corelib.Logger.Fatalln("生成验证码错误", err) + return nil, err + //return nil, corelib.CAPTCHA_GENERATE_ERROR + } + if err = png.Encode(&b, img); err != nil { + corelib.Logger.Fatalln("验证码encode错误", err) + return nil, err + //return nil, corelib.CAPTCHA_GENERATE_ERROR + } + return b.Bytes(), nil +} + +// GetRealIP 获取用户的真实IP地址 +func GetRealIP(r *http.Request) string { + // 检查 X-Forwarded-For 头 + xff := r.Header.Get("X-Forwarded-For") + if xff != "" { + // X-Forwarded-For 头可以包含多个IP地址,取第一个 + ips := strings.Split(xff, ",") + if len(ips) > 0 { + return strings.TrimSpace(ips[0]) + } + } + + // 检查 X-Real-IP 头 + xri := r.Header.Get("X-Real-IP") + if xri != "" { + return xri + } + + // 如果没有这些头,则使用远程地址 + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + + return ip +} diff --git a/handler/index.go b/handler/index.go index abeebd1..72636d2 100644 --- a/handler/index.go +++ b/handler/index.go @@ -1 +1,19 @@ package handler + +import ( + "net/http" + "picgo/corelib" +) + +func IndexHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" { + data := struct { + Title string + Active string + }{ + Title: "Admin Dashboard", + Active: r.URL.Path, + } + corelib.TemplateHandler(w, r, data, "view/layout.html", "view/index.html") + } +} diff --git a/handler/login.go b/handler/login.go index abeebd1..9fc4192 100644 --- a/handler/login.go +++ b/handler/login.go @@ -1 +1,66 @@ package handler + +import ( + "encoding/json" + "net/http" + "picgo/configs" + "picgo/corelib" + "picgo/corelib/captcha" + "picgo/model" +) + +func LoginHandler(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + + corelib.TemplateHandler(w, r, nil, "view/login.html") + case http.MethodPost: + loginService(w, r) + default: + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + } +} + +func loginService(w http.ResponseWriter, r *http.Request) { + var ( + res model.LoginRequest + user model.SysUser + ) + + err := json.NewDecoder(r.Body).Decode(&res) + if err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + cid := getCaptchaId(r) + if ok := captcha.Verify(cid, res.Captcha); !ok { + corelib.WriteJsonResponse(w, 1040, "验证码错误", nil) + return + } + if user, err = sysUserSelectDataByUsername(res.Username); err != nil { + corelib.WriteJsonResponse(w, 1041, "用户不存在", nil) + return + } + // 验证用户名密码 + if !corelib.ComparePasswords(user.Password, res.Password, user.Salt) { + corelib.WriteJsonResponse(w, 1042, "用户名或密码错误", nil) + return + } + session, _ := corelib.SessionStore.Get(r, configs.Settings.Server.SessionName) + session.Values["username"] = user.Username + if err = session.Save(r, w); err != nil { + corelib.WriteJsonResponse(w, 1043, "回话保存失败", nil) + return + } + w.Header().Set("Content-Type", "application/json") + corelib.WriteJsonResponse(w, 200, "登录成功", nil) +} + +// sysUserSelectDataByUsername 通过用户名查询用户 +func sysUserSelectDataByUsername(userName string) (model.SysUser, error) { + var user model.SysUser + if err := corelib.DbMysql.Model(model.SysUser{Username: userName}).First(&user).Error; err != nil { + return user, err + } + return user, nil +} diff --git a/handler/profile.go b/handler/profile.go new file mode 100644 index 0000000..71e316d --- /dev/null +++ b/handler/profile.go @@ -0,0 +1,19 @@ +package handler + +import ( + "net/http" + "picgo/corelib" +) + +func ProfileHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" { + data := struct { + Title string + Active string + }{ + Title: "Admin Dashboard", + Active: r.URL.Path, + } + corelib.TemplateHandler(w, r, data, "view/layout.html", "view/profile.html") + } +} diff --git a/handler/settings.go b/handler/settings.go new file mode 100644 index 0000000..ef339af --- /dev/null +++ b/handler/settings.go @@ -0,0 +1,19 @@ +package handler + +import ( + "net/http" + "picgo/corelib" +) + +func SettingsHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" { + data := struct { + Title string + Active string + }{ + Title: "Admin Dashboard", + Active: r.URL.Path, + } + corelib.TemplateHandler(w, r, data, "view/layout.html", "view/settings.html") + } +} diff --git a/handler/upload.go b/handler/upload.go index 7c038cf..f418800 100644 --- a/handler/upload.go +++ b/handler/upload.go @@ -6,13 +6,14 @@ import ( "net/http" "os" "path/filepath" + "picgo/configs" "picgo/corelib" ) func UploadFileHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "POST" { - // 解析表单数据,限制最大内存为32MB - err := r.ParseMultipartForm(32 << 20) + // 解析表单数据,限制最大内存为3MB 1024*1024*3 + err := r.ParseMultipartForm(configs.Settings.Server.UploadMaxSize << 20) if err != nil { corelib.WriteJsonResponse(w, 1020, "文件大于32MB", nil) return @@ -28,7 +29,7 @@ func UploadFileHandler(w http.ResponseWriter, r *http.Request) { defer file.Close() // 保存文件到本地 - f, err := os.OpenFile(filepath.Join("./static/pic", handler.Filename), os.O_WRONLY|os.O_CREATE, 0666) + f, err := os.OpenFile(filepath.Join(configs.Settings.Server.UploadPath, handler.Filename), os.O_WRONLY|os.O_CREATE, 0666) if err != nil { fmt.Println("Error saving file") fmt.Println(err) @@ -46,7 +47,8 @@ func UploadFileHandler(w http.ResponseWriter, r *http.Request) { return } //fmt.Fprintf(w, "File %s uploaded successfully!", handler.Filename) - corelib.Success(w) + corelib.WriteJsonResponse(w, 200, "文件上传成功", nil) + //corelib.Success(w) } else { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } diff --git a/main.go b/main.go index 6918904..e10a6e6 100644 --- a/main.go +++ b/main.go @@ -1,18 +1,7 @@ package main -import ( - "log" - "net/http" - "picgo/router" -) +import "picgo/cmd" func main() { - // 创建路由 - r := router.InitRouter() - // 启动HTTP服务器 - log.Println("Serving on http://localhost:8082") - err := http.ListenAndServe(":8082", r) - if err != nil { - log.Fatalf("Failed to start server: %v", err) - } + cmd.Execute() } diff --git a/middleware/auth.go b/middleware/auth.go new file mode 100644 index 0000000..b25be6c --- /dev/null +++ b/middleware/auth.go @@ -0,0 +1,23 @@ +package middleware + +import ( + "log" + "net/http" + "time" +) + +// LoginMiddleware 登录 // 添加日志中间件到路由器 使用:r.Handle("/", LoginMiddleware(http.HandlerFunc(handler))) +func LoginMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // 记录请求时间和路径 + start := time.Now() + log.Printf("Started %s %s", r.Method, r.URL.Path) + + // 调用下一个处理程序 + next.ServeHTTP(w, r) + + // 记录请求处理时间 + duration := time.Since(start) + log.Printf("Completed %s %s in %v", r.Method, r.URL.Path, duration) + }) +} diff --git a/model/base.go b/model/base.go new file mode 100644 index 0000000..238dd2b --- /dev/null +++ b/model/base.go @@ -0,0 +1,26 @@ +package model + +import ( + "github.com/bwmarrin/snowflake" + "gorm.io/gorm" + "picgo/configs" + "time" +) + +type BaseModel struct { + ID int64 `gorm:"primary_key;unique;not null;comment:'主键'" json:"id"` + CreatedAt time.Time `gorm:"not null;type:timestamp;default:CURRENT_TIMESTAMP;comment:'创建时间'"` + UpdatedAt time.Time `gorm:"not null;type:timestamp;default:CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP;comment:'更新时间'"` + DeletedAt gorm.DeletedAt `gorm:"index"` +} + +// BeforeCreate 在创建用户之前为其生成分布式ID +func (base *BaseModel) BeforeCreate(tx *gorm.DB) (err error) { + var node *snowflake.Node + nodeId := configs.Settings.Server.NodeId + if node, err = snowflake.NewNode(nodeId); err != nil { + return err + } + base.ID = node.Generate().Int64() + return nil +} diff --git a/model/domin.go b/model/domin.go new file mode 100644 index 0000000..83118fb --- /dev/null +++ b/model/domin.go @@ -0,0 +1,13 @@ +package model + +// Domain cdn访问域名 +type Domain struct { + BaseModel + Name string `gorm:"size:64;unique;not null;column:name;comment:'域名'" json:"name"` + Remark string `gorm:"size:64;column:remark;comment:'备注'" json:"remark"` +} + +// TableName sets the custom table name for the User model. +func (Domain) TableName() string { + return "domain" +} diff --git a/model/login_request.go b/model/login_request.go new file mode 100644 index 0000000..2522f08 --- /dev/null +++ b/model/login_request.go @@ -0,0 +1,7 @@ +package model + +type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` + Captcha string `json:"captcha"` +} diff --git a/model/upload.go b/model/upload.go new file mode 100644 index 0000000..75c8451 --- /dev/null +++ b/model/upload.go @@ -0,0 +1,13 @@ +package model + +// Upload cdn访问域名 +type Upload struct { + BaseModel + FileKey string `gorm:"size:64;unique;not null;column:filekey;comment:'文件路径'" json:"fileKey"` + Remark string `gorm:"size:64;column:remark;comment:'备注'" json:"remark"` +} + +// TableName sets the custom table name for the User model. +func (Upload) TableName() string { + return "upload" +} diff --git a/model/user.go b/model/user.go new file mode 100644 index 0000000..0d9c280 --- /dev/null +++ b/model/user.go @@ -0,0 +1,16 @@ +package model + +// SysUser represents a user in the database. +type SysUser struct { + BaseModel + Username string `gorm:"size:32;unique;not null;column:username;comment:'用户名'" json:"username"` + Password string `gorm:"size:64;not null;column:password;comment:'密码'" json:"password"` + Salt string `gorm:"size:16;not null;column:salt;comment:'盐'" json:"salt"` + IsSuper int `gorm:"type:TINYINT(1);default:0;column:is_super;comment:'是否是超级管理员-0:否,1:是'" json:"is_super"` + Remark string `gorm:"size:64;column:remark;comment:'备注'" json:"remark"` +} + +// TableName sets the custom table name for the User model. +func (SysUser) TableName() string { + return "sys_user" +} diff --git a/router/router.go b/router/router.go index 85340ba..0a23745 100644 --- a/router/router.go +++ b/router/router.go @@ -9,7 +9,12 @@ func InitRouter() *http.ServeMux { var mux *http.ServeMux // 创建新的路由器 mux = http.NewServeMux() + mux.HandleFunc("/", handler.IndexHandler) + mux.HandleFunc("/settings", handler.SettingsHandler) + mux.HandleFunc("/profile", handler.ProfileHandler) mux.HandleFunc("/static/", handler.StaticHandler) + mux.HandleFunc("/login", handler.LoginHandler) mux.HandleFunc("/api/v1/upload", handler.UploadFileHandler) + mux.HandleFunc("/captcha", handler.CaptchaHandler) return mux } diff --git a/static/css/custom.css b/static/css/custom.css new file mode 100644 index 0000000..d0d6981 --- /dev/null +++ b/static/css/custom.css @@ -0,0 +1,19 @@ +.navbar-nav .nav-link { + font-size: 1.1em; + padding: 10px 20px; +} + +.sidebar .nav-link { + font-size: 1.1em; + padding: 10px 15px; +} + +.sidebar .nav-link:hover { + background-color: #f8f9fa; + color: #495057; +} + +.sidebar .nav-link.active { + background-color: #e9ecef; + color: #007bff; +} diff --git a/static/js/login.js b/static/js/login.js new file mode 100644 index 0000000..a1f3974 --- /dev/null +++ b/static/js/login.js @@ -0,0 +1,37 @@ +function Login() { + this.captchaUrl = "/captcha" + this.userName = "" + this.password = "" + this.captcha = "" +} + +Login.prototype.RefreshCaptcha = function () { + let self = this; + $("#captcha-img").click(function () { + let timestamp = new Date().getTime(); + self.captchaUrl = "/captcha" + "?t=" + timestamp + $("#captcha-img").attr('src', self.captchaUrl); //显示图片 + }) +} + + +Login.prototype.run = function () { + this.RefreshCaptcha() +} + +// 构造执行入口 +$(function () { + feather.replace() + // 模板过滤方法 + // if (window.template) { + // template.defaults.imports.domainSubstring = function (dateValue) { + // if (dateValue.length > 40 ) { + // return dateValue.substring(0, 37) + "..." + // } else { + // return dateValue + // } + // } + // } + let login = new Login() + login.run() +}) \ No newline at end of file diff --git a/view/index.html b/view/index.html index 8b0b771..aad3071 100644 --- a/view/index.html +++ b/view/index.html @@ -1,217 +1,4 @@ - - - -
- - - - - - -编号 | -域名 | -Boce | -备注 | -操作 | -
---|
Welcome to the Admin Panel.
+{{end}} diff --git a/view/layout.html b/view/layout.html new file mode 100644 index 0000000..93bad1f --- /dev/null +++ b/view/layout.html @@ -0,0 +1,73 @@ + + + + + +