diff --git a/go.mod b/go.mod index ed86d60..a2fa04e 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,11 @@ module github.com/synctv-org/synctv go 1.21 require ( + github.com/Boostport/mjml-go v0.14.6 github.com/caarlos0/env/v9 v9.0.0 github.com/cavaliergopher/grab/v3 v3.0.1 + github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 + github.com/emersion/go-smtp v0.21.0 github.com/gin-contrib/cors v1.7.1 github.com/gin-gonic/gin v1.9.1 github.com/glebarez/sqlite v1.11.0 @@ -24,6 +27,7 @@ require ( github.com/json-iterator/go v1.1.12 github.com/maruel/natural v1.1.1 github.com/mitchellh/go-homedir v1.1.0 + github.com/mojocn/base64Captcha v1.3.6 github.com/natefinch/lumberjack v2.0.0+incompatible github.com/quic-go/quic-go v0.42.0 github.com/sirupsen/logrus v1.9.3 @@ -32,7 +36,7 @@ require ( github.com/synctv-org/vendors v0.3.3-0.20240329144101-a35c08863ac6 github.com/ulule/limiter/v3 v3.11.2 github.com/zencoder/go-dash/v3 v3.0.3 - github.com/zijiren233/gencontainer v0.0.0-20240214185550-64325761736f + github.com/zijiren233/gencontainer v0.0.0-20240331174346-b5e420773df7 github.com/zijiren233/go-colorable v0.0.0-20230930131441-997304c961cb github.com/zijiren233/livelib v0.3.1 github.com/zijiren233/stream v0.5.2 @@ -54,6 +58,7 @@ require ( cloud.google.com/go/compute/metadata v0.2.3 // indirect filippo.io/edwards25519 v1.1.0 // indirect github.com/BurntSushi/toml v1.3.2 // indirect + github.com/andybalholm/brotli v1.1.0 // indirect github.com/armon/go-metrics v0.4.1 // indirect github.com/bytedance/sonic v1.11.3 // indirect github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect @@ -71,6 +76,7 @@ require ( github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/pprof v0.0.0-20240327155427-868f304927ed // indirect @@ -106,6 +112,7 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/quic-go/qpack v0.4.0 // indirect github.com/spf13/pflag v1.0.5 // indirect + github.com/tetratelabs/wazero v1.6.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect go.etcd.io/etcd/api/v3 v3.5.12 // indirect @@ -114,6 +121,7 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect golang.org/x/arch v0.7.0 // indirect + golang.org/x/image v0.13.0 // indirect golang.org/x/mod v0.16.0 // indirect golang.org/x/net v0.22.0 // indirect golang.org/x/sync v0.6.0 // indirect diff --git a/go.sum b/go.sum index 13083bb..9b5b14e 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGB cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/Boostport/mjml-go v0.14.6 h1:pzDN2KPqy9smNzYesHzsIFflA3eyZf24jNYSuBHbtB8= +github.com/Boostport/mjml-go v0.14.6/go.mod h1:dP8/GHUYxLGi1S+GCkhAB0ANcRx6rR8+Pc91JjtKLLU= github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= @@ -11,6 +13,8 @@ github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuy github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA= @@ -54,6 +58,11 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 h1:hH4PQfOndHDlpzYfLAAfl63E8Le6F2+EL/cdhlkyRJY= +github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-smtp v0.21.0 h1:ZDZmX9aFUuPlD1lpoT0nC/nozZuIkSCyQIyxdijjCy0= +github.com/emersion/go-smtp v0.21.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= github.com/envoyproxy/go-control-plane v0.12.0 h1:4X+VP1GHd1Mhj6IB5mMeGbLCleqxjletLK6K0rbxyZI= github.com/envoyproxy/go-control-plane v0.12.0/go.mod h1:ZBTaoJ23lqITozF0M6G4/IragXCQKCnYbmlmtHvwRG0= github.com/envoyproxy/protoc-gen-validate v1.0.4 h1:gVPz/FMfvh57HdSJQyvBtF00j8JU4zdyUgIUNhlgg0A= @@ -112,6 +121,8 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +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/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -271,6 +282,8 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 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/mojocn/base64Captcha v1.3.6 h1:gZEKu1nsKpttuIAQgWHO+4Mhhls8cAKyiV2Ew03H+Tw= +github.com/mojocn/base64Captcha v1.3.6/go.mod h1:i5CtHvm+oMbj1UzEPXaA8IH/xHFZ3DGY3Wh3dBpZ28E= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/natefinch/lumberjack v2.0.0+incompatible h1:4QJd3OLAMgj7ph+yZTuX13Ld4UpgHp07nNdFX7mqFfM= github.com/natefinch/lumberjack v2.0.0+incompatible/go.mod h1:Wi9p2TTF5DG5oU+6YfsmYQpsTIOm0B1VNzQg9Mw6nPk= @@ -345,6 +358,8 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/synctv-org/vendors v0.3.3-0.20240329144101-a35c08863ac6 h1:EdhhBGmjrL/9pTnp+oGT+lPD8hBYE0NH70HeopPAsNE= github.com/synctv-org/vendors v0.3.3-0.20240329144101-a35c08863ac6/go.mod h1:VF4uTsi7KBpBXaSV0ycAs2fo7KS08eF9hIB/ugvzIbY= +github.com/tetratelabs/wazero v1.6.0 h1:z0H1iikCdP8t+q341xqepY4EWvHEw8Es7tlqiVzlP3g= +github.com/tetratelabs/wazero v1.6.0/go.mod h1:0U0G41+ochRKoPKCJlh0jMg1CHkyfK8kDqiirMmKY8A= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= @@ -357,8 +372,8 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zencoder/go-dash/v3 v3.0.3 h1:xqwGJ2fJCSArwONGx6sY26Z1lxQ7zTURoxdRjCpuodM= github.com/zencoder/go-dash/v3 v3.0.3/go.mod h1:30R5bKy1aUYY45yesjtZ9l8trNc2TwNqbS17WVQmCzk= -github.com/zijiren233/gencontainer v0.0.0-20240214185550-64325761736f h1:2zrspSbXHBnJmWhhkr1kphJ0Ql2yciAkIzYDu3e8cn0= -github.com/zijiren233/gencontainer v0.0.0-20240214185550-64325761736f/go.mod h1:V5oL7PrZxgisuLCblFWd89Jg99O8vM1n58llcxZ2hDY= +github.com/zijiren233/gencontainer v0.0.0-20240331174346-b5e420773df7 h1:ymsEhM4NrTiZx/nyJb5CQRVOmdgZm6L6hHcRUErXYVQ= +github.com/zijiren233/gencontainer v0.0.0-20240331174346-b5e420773df7/go.mod h1:V5oL7PrZxgisuLCblFWd89Jg99O8vM1n58llcxZ2hDY= github.com/zijiren233/go-colorable v0.0.0-20230930131441-997304c961cb h1:0DyOxf/TbbGodHhOVHNoPk+7v/YBJACs22gKpKlatWw= github.com/zijiren233/go-colorable v0.0.0-20230930131441-997304c961cb/go.mod h1:6TCzjDiQ8+5gWZiwsC3pnA5M0vUy2jV2Y7ciHJh729g= github.com/zijiren233/livelib v0.3.1 h1:vNGQFeVyk1qrXTO/lqyRs0oC6cLzMD6yo2Jdym3XNpI= @@ -397,6 +412,8 @@ golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw= golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ= +golang.org/x/image v0.13.0 h1:3cge/F/QTkNLauhf2QoE9zp+7sr+ZcL4HnoZmdwg9sg= +golang.org/x/image v0.13.0/go.mod h1:6mmbMOeV28HuMTgA6OSRkdXKYw/t5W9Uwn2Yv1r3Yxk= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= diff --git a/internal/captcha/captcha.go b/internal/captcha/captcha.go new file mode 100644 index 0000000..76a6f0b --- /dev/null +++ b/internal/captcha/captcha.go @@ -0,0 +1,13 @@ +package captcha + +import ( + "github.com/mojocn/base64Captcha" +) + +var ( + Captcha *base64Captcha.Captcha +) + +func init() { + Captcha = base64Captcha.NewCaptcha(base64Captcha.DefaultDriverDigit, base64Captcha.DefaultMemStore) +} diff --git a/internal/db/user.go b/internal/db/user.go index 6df4c3f..d2f5feb 100644 --- a/internal/db/user.go +++ b/internal/db/user.go @@ -57,6 +57,18 @@ func WithRegisteredByProvider(b bool) CreateUserConfig { } } +func WithEmail(email string) CreateUserConfig { + return func(u *model.User) { + u.Email = email + } +} + +func WithRegisteredByEmail(b bool) CreateUserConfig { + return func(u *model.User) { + u.RegisteredByEmail = b + } +} + func CreateUserWithHashedPassword(username string, hashedPassword []byte, conf ...CreateUserConfig) (*model.User, error) { if username == "" { return nil, errors.New("username cannot be empty") @@ -128,8 +140,10 @@ func CreateOrLoadUserWithHashedPassword(username string, hashedPassword []byte, // 只有当provider和puid没有找到对应的user时才会创建 func CreateOrLoadUserWithProvider(username, password string, p provider.OAuth2Provider, puid string, conf ...CreateUserConfig) (*model.User, error) { + if puid == "" { + return nil, errors.New("provider user id cannot be empty") + } var user model.User - if err := db.Where("id = (?)", db.Table("user_providers").Where("provider = ? AND provider_user_id = ?", p, puid).Select("user_id")).First(&user).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return CreateUser(username, password, append(conf, WithSetProvider(p, puid), WithRegisteredByProvider(true))...) @@ -141,12 +155,40 @@ func CreateOrLoadUserWithProvider(username, password string, p provider.OAuth2Pr } } +func CreateOrLoadUserWithEmail(username, password, email string, conf ...CreateUserConfig) (*model.User, error) { + if email == "" { + return nil, errors.New("email cannot be empty") + } + var user model.User + if err := db.Where("email = ?", email).First(&user).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return CreateUser(username, password, append(conf, WithEmail(email), WithRegisteredByEmail(true))...) + } else { + return nil, err + } + } + return &user, nil +} + +func CreateUserWithEmail(username, password, email string, conf ...CreateUserConfig) (*model.User, error) { + if email == "" { + return nil, errors.New("email cannot be empty") + } + return CreateUser(username, password, append(conf, WithEmail(email), WithRegisteredByEmail(true))...) +} + func GetUserByProvider(p provider.OAuth2Provider, puid string) (*model.User, error) { var user model.User err := db.Where("id = (?)", db.Table("user_providers").Where("provider = ? AND provider_user_id = ?", p, puid).Select("user_id")).First(&user).Error return &user, HandleNotFound(err, "user") } +func GetUserByEmail(email string) (*model.User, error) { + var user model.User + err := db.Where("email = ?", email).First(&user).Error + return &user, HandleNotFound(err, "user") +} + func GetProviderUserID(p provider.OAuth2Provider, puid string) (string, error) { var userProvider model.UserProvider err := db.Where("provider = ? AND provider_user_id = ?", p, puid).Select("user_id").First(&userProvider).Error @@ -368,3 +410,24 @@ func SetUserHashedPassword(id string, hashedPassword []byte) error { err := db.Model(&model.User{}).Where("id = ?", id).Update("hashed_password", hashedPassword).Error return HandleNotFound(err, "user") } + +func BindEmail(id string, email string) error { + err := db.Model(&model.User{}).Where("id = ?", id).Update("email", email).Error + return HandleNotFound(err, "user") +} + +func UnbindEmail(uid string) error { + return Transactional(func(tx *gorm.DB) error { + user := model.User{} + if err := tx.Where("id = ?", uid).First(&user).Error; err != nil { + return HandleNotFound(err, "user") + } + if user.Email == "" { + return errors.New("user has no email") + } + if user.RegisteredByEmail { + return errors.New("user must have one email") + } + return tx.Model(&model.User{}).Where("id = ?", uid).Update("email", "").Error + }) +} diff --git a/internal/email/email.go b/internal/email/email.go new file mode 100644 index 0000000..d2593be --- /dev/null +++ b/internal/email/email.go @@ -0,0 +1,378 @@ +package email + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/url" + "text/template" + "time" + + "github.com/Boostport/mjml-go" + log "github.com/sirupsen/logrus" + email_template "github.com/synctv-org/synctv/internal/email/template" + "github.com/synctv-org/synctv/internal/model" + "github.com/synctv-org/synctv/internal/settings" + "github.com/synctv-org/synctv/utils" + "github.com/zijiren233/gencontainer/synccache" + "github.com/zijiren233/stream" +) + +var ( + ErrEmailNotEnabled = errors.New("email is not enabled") + emailCaptcha *synccache.SyncCache[string, string] = synccache.NewSyncCache[string, string](time.Minute * 5) +) + +var ( + EnableEmail = settings.NewBoolSetting( + "enable_email", + false, + model.SettingGroupEmail, + settings.WithAfterSetBool(func(bs settings.BoolSetting, b bool) { + if !b { + closeSmtpPool() + } + }), + ) + DisableUserSignup = settings.NewBoolSetting( + "email_disable_user_signup", + false, + model.SettingGroupEmail, + ) + SignupNeedReview = settings.NewBoolSetting( + "email_signup_need_review", + false, + model.SettingGroupEmail, + ) + RetrievePasswordUrlPath = settings.NewStringSetting( + "email_retrieve_password_url_path", + "/web/retrievePassword", + model.SettingGroupEmail, + ) + EmailSignupWhiteListEnable = settings.NewBoolSetting( + "email_signup_white_list_enable", + false, + model.SettingGroupEmail, + ) + EmailSignupWhiteList = settings.NewStringSetting( + "email_signup_white_list", + `gmail.com,qq.com,163.com,yahoo.com,sina.com,126.com,outlook.com,yeah.net,foxmail.com`, + model.SettingGroupEmail, + ) +) + +var ( + testTemplate *template.Template + captchaTemplate *template.Template + retrievePasswordTemplate *template.Template +) + +func init() { + body, err := mjml.ToHTML( + context.Background(), + stream.BytesToString(email_template.TestMjml), + mjml.WithMinify(true), + ) + if err != nil { + log.Fatalf("mjml test template error: %v", err) + } + t, err := template.New("").Parse(body) + if err != nil { + log.Fatalf("parse test template error: %v", err) + } + testTemplate = t + + body, err = mjml.ToHTML( + context.Background(), + stream.BytesToString(email_template.CaptchaMjml), + mjml.WithMinify(true), + ) + if err != nil { + log.Fatalf("mjml captcha template error: %v", err) + } + t, err = template.New("").Parse(body) + if err != nil { + log.Fatalf("parse captcha template error: %v", err) + } + captchaTemplate = t + + body, err = mjml.ToHTML( + context.Background(), + stream.BytesToString(email_template.RetrievePasswordMjml), + mjml.WithMinify(true), + ) + if err != nil { + log.Fatalf("mjml retrieve password template error: %v", err) + } + t, err = template.New("").Parse(body) + if err != nil { + log.Fatalf("parse retrieve password template error: %v", err) + } + retrievePasswordTemplate = t +} + +type testPayload struct { + Username string + Year int +} + +type captchaPayload struct { + Captcha string + + Year int +} + +type retrievePasswordPayload struct { + Host string + Url string + + Year int +} + +func SendBindCaptchaEmail(userID, userEmail string) error { + if !EnableEmail.Get() { + return ErrEmailNotEnabled + } + + if userID == "" { + return errors.New("user id is empty") + } + + if userEmail == "" { + return errors.New("email is empty") + } + + pool, err := getSmtpPool() + if err != nil { + return err + } + + entry, loaded := emailCaptcha.LoadOrStore( + fmt.Sprintf("bind:%s:%s", userID, userEmail), + utils.RandString(6), + time.Minute*5, + ) + if loaded { + entry.SetExpiration(time.Now().Add(time.Minute * 5)) + } + + out := bytes.NewBuffer(nil) + err = captchaTemplate.Execute(out, captchaPayload{ + Captcha: entry.Value(), + Year: time.Now().Year(), + }) + if err != nil { + return err + } + + return pool.SendEmail( + []string{userEmail}, + "SyncTV Verification Code", + out.String(), + ) +} + +func VerifyBindCaptchaEmail(userID, userEmail, captcha string) (bool, error) { + if !EnableEmail.Get() { + return false, ErrEmailNotEnabled + } + + if userID == "" { + return false, errors.New("user id is empty") + } + + if userEmail == "" { + return false, errors.New("email is empty") + } + + if captcha == "" { + return false, errors.New("captcha is empty") + } + + key := fmt.Sprintf("bind:%s:%s", userID, userEmail) + + if emailCaptcha.CompareValueAndDelete( + key, + captcha, + ) { + return true, nil + } + + return false, nil +} + +func SendTestEmail(username, email string) error { + if !EnableEmail.Get() { + return ErrEmailNotEnabled + } + + if email == "" { + return errors.New("email is empty") + } + + pool, err := getSmtpPool() + if err != nil { + return err + } + + out := bytes.NewBuffer(nil) + err = testTemplate.Execute(out, testPayload{ + Username: username, + Year: time.Now().Year(), + }) + if err != nil { + return err + } + + return pool.SendEmail( + []string{email}, + "SyncTV Test Email", + out.String(), + ) +} + +func SendSignupCaptchaEmail(email string) error { + if !EnableEmail.Get() { + return ErrEmailNotEnabled + } + + if email == "" { + return errors.New("email is empty") + } + + pool, err := getSmtpPool() + if err != nil { + return err + } + + entry, loaded := emailCaptcha.LoadOrStore( + fmt.Sprintf("signup:%s", email), + utils.RandString(6), + time.Minute*5, + ) + if loaded { + entry.SetExpiration(time.Now().Add(time.Minute * 5)) + } + + out := bytes.NewBuffer(nil) + err = captchaTemplate.Execute(out, captchaPayload{ + Captcha: entry.Value(), + Year: time.Now().Year(), + }) + if err != nil { + return err + } + + return pool.SendEmail( + []string{email}, + "SyncTV Signup Verification Code", + out.String(), + ) +} + +func VerifySignupCaptchaEmail(email, captcha string) (bool, error) { + if !EnableEmail.Get() { + return false, ErrEmailNotEnabled + } + + if email == "" { + return false, errors.New("email is empty") + } + + if captcha == "" { + return false, errors.New("captcha is empty") + } + + if emailCaptcha.CompareValueAndDelete( + fmt.Sprintf("signup:%s", email), + captcha, + ) { + return true, nil + } + + return false, nil +} + +func SendRetrievePasswordCaptchaEmail(userID, email, host string) error { + if !EnableEmail.Get() { + return ErrEmailNotEnabled + } + + if userID == "" { + return errors.New("user id is empty") + } + + if email == "" { + return errors.New("email is empty") + } + + u, err := url.Parse(host) + if err != nil { + return err + } + u.Path = RetrievePasswordUrlPath.Get() + + pool, err := getSmtpPool() + if err != nil { + return err + } + + entry, loaded := emailCaptcha.LoadOrStore( + fmt.Sprintf("retrieve_password:%s:%s", userID, email), + utils.RandString(6), + time.Minute*5, + ) + if loaded { + entry.SetExpiration(time.Now().Add(time.Minute * 5)) + } + + q := u.Query() + q.Set("userID", userID) + q.Set("captcha", entry.Value()) + q.Set("email", email) + u.RawQuery = q.Encode() + + out := bytes.NewBuffer(nil) + err = retrievePasswordTemplate.Execute(out, retrievePasswordPayload{ + Host: host, + Url: u.String(), + Year: time.Now().Year(), + }) + if err != nil { + return err + } + + return pool.SendEmail( + []string{email}, + "SyncTV Retrieve Password Verification Code", + out.String(), + ) +} + +func VerifyRetrievePasswordCaptchaEmail(userID, email, captcha string) (bool, error) { + if !EnableEmail.Get() { + return false, ErrEmailNotEnabled + } + + if userID == "" { + return false, errors.New("user id is empty") + } + + if email == "" { + return false, errors.New("email is empty") + } + + if captcha == "" { + return false, errors.New("captcha is empty") + } + + if emailCaptcha.CompareValueAndDelete( + fmt.Sprintf("retrieve_password:%s:%s", userID, email), + captcha, + ) { + return true, nil + } + + return false, nil +} diff --git a/internal/email/smtp.go b/internal/email/smtp.go new file mode 100644 index 0000000..d66a7f7 --- /dev/null +++ b/internal/email/smtp.go @@ -0,0 +1,168 @@ +package email + +import ( + "fmt" + "strings" + "sync" + + "github.com/synctv-org/synctv/internal/model" + "github.com/synctv-org/synctv/internal/settings" + "github.com/synctv-org/synctv/utils/smtp" +) + +var ( + smtpPool *smtp.SmtpPool + configChanged bool + lock sync.Mutex +) + +var ( + smtpHost = settings.NewStringSetting( + "smtp_host", + "", + model.SettingGroupEmail, + settings.WithAfterSetString(func(ss settings.StringSetting, s string) { + lock.Lock() + defer lock.Unlock() + configChanged = true + }), + ) + smtpPort = settings.NewInt64Setting( + "smtp_port", + 587, + model.SettingGroupEmail, + settings.WithValidatorInt64(func(i int64) error { + if i <= 0 { + return fmt.Errorf("smtp port must be greater than 0") + } + if i > 65535 { + return fmt.Errorf("smtp port must be less than 65535") + } + return nil + }), + settings.WithAfterSetInt64(func(ss settings.Int64Setting, i int64) { + lock.Lock() + defer lock.Unlock() + configChanged = true + }), + ) + smtpProtocol = settings.NewStringSetting( + "smtp_protocol", + "TLS", + model.SettingGroupEmail, + settings.WithValidatorString(func(s string) error { + s = strings.ToLower(s) + switch s { + case "tcp", "tls", "ssl", "": + return nil + default: + return fmt.Errorf("smtp protocol must be tcp, tls or ssl") + } + }), + settings.WithAfterSetString(func(ss settings.StringSetting, s string) { + lock.Lock() + defer lock.Unlock() + configChanged = true + }), + ) + smtpUsername = settings.NewStringSetting( + "smtp_username", + "", + model.SettingGroupEmail, + settings.WithAfterSetString(func(ss settings.StringSetting, s string) { + lock.Lock() + defer lock.Unlock() + configChanged = true + }), + ) + smtpPassword = settings.NewStringSetting( + "smtp_password", + "", + model.SettingGroupEmail, + settings.WithAfterSetString(func(ss settings.StringSetting, s string) { + lock.Lock() + defer lock.Unlock() + configChanged = true + }), + ) + smtpFrom = settings.NewStringSetting( + "smtp_from", + "", + model.SettingGroupEmail, + settings.WithAfterSetString(func(ss settings.StringSetting, s string) { + lock.Lock() + defer lock.Unlock() + + if smtpPool != nil { + smtpPool.SetFrom(s) + } + }), + ) + smtpPoolSize = settings.NewInt64Setting( + "smtp_pool_size", + 10, + model.SettingGroupEmail, + settings.WithValidatorInt64(func(i int64) error { + if i <= 0 { + return fmt.Errorf("smtp pool size must be greater than 0") + } + if i > 100 { + return fmt.Errorf("smtp pool size must be less than 100") + } + return nil + }), + settings.WithAfterSetInt64(func(ss settings.Int64Setting, i int64) { + lock.Lock() + defer lock.Unlock() + configChanged = true + }), + ) +) + +func newSmtpConfig() *smtp.SmtpConfig { + return &smtp.SmtpConfig{ + Host: smtpHost.Get(), + Port: uint32(smtpPort.Get()), + Protocol: smtpProtocol.Get(), + Username: smtpUsername.Get(), + Password: smtpPassword.Get(), + From: smtpFrom.Get(), + } +} + +func newSmtpPool() (*smtp.SmtpPool, error) { + return smtp.NewSmtpPool(newSmtpConfig(), int(smtpPoolSize.Get())) +} + +func getSmtpPool() (*smtp.SmtpPool, error) { + lock.Lock() + defer lock.Unlock() + + if configChanged { + configChanged = false + if smtpPool != nil { + smtpPool.Close() + smtpPool = nil + } + } + + if smtpPool == nil { + pool, err := newSmtpPool() + if err != nil { + return nil, err + } + smtpPool = pool + } + + return smtpPool, nil +} + +func closeSmtpPool() { + lock.Lock() + defer lock.Unlock() + + if smtpPool != nil { + smtpPool.Close() + smtpPool = nil + } +} diff --git a/internal/email/template/captcha.mjml b/internal/email/template/captcha.mjml new file mode 100644 index 0000000..38eca03 --- /dev/null +++ b/internal/email/template/captcha.mjml @@ -0,0 +1,39 @@ + + + .indent div { + text-indent: 2em; + } + .code div { + text-shadow: 0 0 11px #bdbdff; + } + .footer div { + text-shadow: 0 0 5px #fef0df; + } + iframe { + border:none + } + + + + + SyncTV + + + + + 验证码: + 你的验证码为: + {{ .Captcha }} + 该验证码有效期为5分钟,如果您并没有访问过我们的网站,或没有进行上述操作,请忽略这封邮件。 + + + + + Copyright {{ .Year }} SyncTV All + Rights Reserved. + + + + \ No newline at end of file diff --git a/internal/email/template/embed.go b/internal/email/template/embed.go new file mode 100644 index 0000000..b1c922d --- /dev/null +++ b/internal/email/template/embed.go @@ -0,0 +1,14 @@ +package email_template + +import _ "embed" + +var ( + //go:embed test.mjml + TestMjml []byte + + //go:embed captcha.mjml + CaptchaMjml []byte + + //go:embed retrieve_password.mjml + RetrievePasswordMjml []byte +) diff --git a/internal/email/template/retrieve_password.mjml b/internal/email/template/retrieve_password.mjml new file mode 100644 index 0000000..4cabe7e --- /dev/null +++ b/internal/email/template/retrieve_password.mjml @@ -0,0 +1,39 @@ + + + .indent div { + text-indent: 2em; + } + .code div { + text-shadow: 0 0 11px #bdbdff; + } + .footer div { + text-shadow: 0 0 5px #fef0df; + } + iframe { + border:none + } + + + + + SyncTV + + + + + 忘记密码: + Hi! 你在 SyncTV 中提交了重置密码的请求,请前往修改: + 前往站点修改 + 该验证码有效期为5分钟,如果您并没有访问过我们的网站,或没有进行上述操作,请忽略这封邮件。 + + + + + Copyright {{ .Year }} SyncTV All + Rights Reserved. + + + + \ No newline at end of file diff --git a/internal/email/template/test.mjml b/internal/email/template/test.mjml new file mode 100644 index 0000000..ac7cf69 --- /dev/null +++ b/internal/email/template/test.mjml @@ -0,0 +1,39 @@ + + + .indent div { + text-indent: 2em; + } + .code div { + text-shadow: 0 0 11px #bdbdff; + } + .footer div { + text-shadow: 0 0 5px #fef0df; + } + iframe { + border:none + } + + + + + SyncTV + + + + + 测试邮件: + Dear {{ .Username }}. + 这是一封测试邮件。This is a test email. + + + + + Copyright {{ .Year }} SyncTV All + Rights Reserved. + + + + \ No newline at end of file diff --git a/internal/model/setting.go b/internal/model/setting.go index 5970f7d..37bb3b3 100644 --- a/internal/model/setting.go +++ b/internal/model/setting.go @@ -21,6 +21,7 @@ const ( SettingGroupDatabase SettingGroup = "database" SettingGroupServer SettingGroup = "server" SettingGroupOauth2 SettingGroup = "oauth2" + SettingGroupEmail SettingGroup = "email" ) type Setting struct { diff --git a/internal/model/user.go b/internal/model/user.go index 786b08e..27cf219 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -43,9 +43,11 @@ type User struct { CreatedAt time.Time UpdatedAt time.Time RegisteredByProvider bool `gorm:"not null;default:false"` + RegisteredByEmail bool `gorm:"not null;default:false"` UserProviders []UserProvider `gorm:"foreignKey:UserID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` Username string `gorm:"not null;uniqueIndex;type:varchar(32)"` HashedPassword []byte `gorm:"not null"` + Email string `gorm:"type:varchar(128);uniqueIndex:,where:email <> ''"` Role Role `gorm:"not null;default:2"` RoomUserRelations []RoomUserRelation `gorm:"foreignKey:UserID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` Rooms []Room `gorm:"foreignKey:CreatorID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` diff --git a/internal/op/user.go b/internal/op/user.go index ac8f3b6..3488d95 100644 --- a/internal/op/user.go +++ b/internal/op/user.go @@ -7,6 +7,7 @@ import ( "github.com/synctv-org/synctv/internal/cache" "github.com/synctv-org/synctv/internal/db" + "github.com/synctv-org/synctv/internal/email" "github.com/synctv-org/synctv/internal/model" "github.com/synctv-org/synctv/internal/provider" "github.com/synctv-org/synctv/internal/settings" @@ -288,3 +289,52 @@ func (u *User) BindProvider(p provider.OAuth2Provider, pid string) error { } return nil } + +func (u *User) SendBindCaptchaEmail(e string) error { + return email.SendBindCaptchaEmail(u.ID, e) +} + +func (u *User) VerifyBindCaptchaEmail(e, captcha string) (bool, error) { + return email.VerifyBindCaptchaEmail(u.ID, e, captcha) +} + +func (u *User) BindEmail(e string) error { + err := db.BindEmail(u.ID, e) + if err != nil { + return err + } + u.Email = e + return nil +} + +func (u *User) UnbindEmail() error { + err := db.UnbindEmail(u.ID) + if err != nil { + return err + } + u.Email = "" + return nil +} + +func (u *User) SendTestEmail() error { + if u.Email == "" { + return errors.New("unbound email") + } + + return email.SendTestEmail(u.Username, u.Email) +} + +func (u *User) SendRetrievePasswordCaptchaEmail(host string) error { + if u.Email == "" { + return errors.New("unbound email") + } + + return email.SendRetrievePasswordCaptchaEmail(u.ID, u.Email, host) +} + +func (u *User) VerifyRetrievePasswordCaptchaEmail(e, captcha string) (bool, error) { + if u.Email != e { + return false, errors.New("email has changed, please resend the captcha email") + } + return email.VerifyRetrievePasswordCaptchaEmail(u.ID, e, captcha) +} diff --git a/internal/op/users.go b/internal/op/users.go index ed19ce0..e593437 100644 --- a/internal/op/users.go +++ b/internal/op/users.go @@ -43,6 +43,15 @@ func LoadOrInitUserByID(id string) (*UserEntry, error) { return LoadOrInitUser(user) } +func LoadOrInitUserByEmail(email string) (*UserEntry, error) { + u, err := db.GetUserByEmail(email) + if err != nil { + return nil, err + } + + return LoadOrInitUser(u) +} + func LoadUserByUsername(username string) (*UserEntry, error) { u, err := db.GetUserByUsername(username) if err != nil { @@ -77,9 +86,6 @@ func CreateUser(username string, password string, conf ...db.CreateUserConfig) ( } func CreateOrLoadUserWithProvider(username, password string, p provider.OAuth2Provider, pid string, conf ...db.CreateUserConfig) (*UserEntry, error) { - if username == "" { - return nil, errors.New("username cannot be empty") - } u, err := db.CreateOrLoadUserWithProvider(username, password, p, pid, conf...) if err != nil { return nil, err @@ -88,6 +94,24 @@ func CreateOrLoadUserWithProvider(username, password string, p provider.OAuth2Pr return LoadOrInitUser(u) } +func CreateOrLoadUserWithEmail(username, password, email string, conf ...db.CreateUserConfig) (*UserEntry, error) { + u, err := db.CreateOrLoadUserWithEmail(username, password, email, conf...) + if err != nil { + return nil, err + } + + return LoadOrInitUser(u) +} + +func CreateUserWithEmail(username, password, email string, conf ...db.CreateUserConfig) (*UserEntry, error) { + u, err := db.CreateUserWithEmail(username, password, email, conf...) + if err != nil { + return nil, err + } + + return LoadOrInitUser(u) +} + func GetUserByProvider(p provider.OAuth2Provider, pid string) (*UserEntry, error) { u, err := db.GetUserByProvider(p, pid) if err != nil { diff --git a/internal/settings/bool.go b/internal/settings/bool.go index 1c91947..27b5570 100644 --- a/internal/settings/bool.go +++ b/internal/settings/bool.go @@ -27,6 +27,7 @@ type Bool struct { defaultValue bool value uint32 beforeInit, beforeSet func(BoolSetting, bool) (bool, error) + afterInit, afterSet func(BoolSetting, bool) } type BoolSettingOption func(*Bool) @@ -49,6 +50,18 @@ func WithBeforeSetBool(beforeSet func(BoolSetting, bool) (bool, error)) BoolSett } } +func WithAfterInitBool(afterInit func(BoolSetting, bool)) BoolSettingOption { + return func(s *Bool) { + s.SetAfterInit(afterInit) + } +} + +func WithAfterSetBool(afterSet func(BoolSetting, bool)) BoolSettingOption { + return func(s *Bool) { + s.SetAfterSet(afterSet) + } +} + func newBool(name string, value bool, group model.SettingGroup, options ...BoolSettingOption) *Bool { b := &Bool{ setting: setting{ @@ -77,6 +90,14 @@ func (b *Bool) SetBeforeSet(beforeSet func(BoolSetting, bool) (bool, error)) { b.beforeSet = beforeSet } +func (b *Bool) SetAfterInit(afterInit func(BoolSetting, bool)) { + b.afterInit = afterInit +} + +func (b *Bool) SetAfterSet(afterSet func(BoolSetting, bool)) { + b.afterSet = afterSet +} + func (b *Bool) set(value bool) { if value { atomic.StoreUint32(&b.value, 1) @@ -103,6 +124,11 @@ func (b *Bool) Init(value string) error { } b.set(v) + + if b.afterInit != nil { + b.afterInit(b, v) + } + return nil } @@ -149,6 +175,11 @@ func (b *Bool) SetString(value string) error { } b.set(v) + + if b.afterSet != nil { + b.afterSet(b, v) + } + return nil } @@ -166,6 +197,11 @@ func (b *Bool) Set(v bool) (err error) { } b.set(v) + + if b.afterSet != nil { + b.afterSet(b, v) + } + return } diff --git a/internal/settings/floate64.go b/internal/settings/floate64.go index b1349ce..02f3099 100644 --- a/internal/settings/floate64.go +++ b/internal/settings/floate64.go @@ -29,6 +29,7 @@ type Float64 struct { value uint64 validator func(float64) error beforeInit, beforeSet func(Float64Setting, float64) (float64, error) + afterInit, afterSet func(Float64Setting, float64) } type Float64SettingOption func(*Float64) @@ -57,6 +58,18 @@ func WithBeforeSetFloat64(beforeSet func(Float64Setting, float64) (float64, erro } } +func WithAfterInitFloat64(afterInit func(Float64Setting, float64)) Float64SettingOption { + return func(s *Float64) { + s.SetAfterInit(afterInit) + } +} + +func WithAfterSetFloat64(afterSet func(Float64Setting, float64)) Float64SettingOption { + return func(s *Float64) { + s.SetAfterSet(afterSet) + } +} + func newFloat64(name string, value float64, group model.SettingGroup, options ...Float64SettingOption) *Float64 { f := &Float64{ setting: setting{ @@ -85,6 +98,14 @@ func (f *Float64) SetBeforeSet(beforeSet func(Float64Setting, float64) (float64, f.beforeSet = beforeSet } +func (f *Float64) SetAfterInit(afterInit func(Float64Setting, float64)) { + f.afterInit = afterInit +} + +func (f *Float64) SetAfterSet(afterSet func(Float64Setting, float64)) { + f.afterSet = afterSet +} + func (f *Float64) Parse(value string) (float64, error) { v, err := strconv.ParseFloat(value, 64) if err != nil { @@ -114,6 +135,11 @@ func (f *Float64) Init(value string) error { } f.set(v) + + if f.afterInit != nil { + f.afterInit(f, v) + } + return nil } @@ -152,6 +178,11 @@ func (f *Float64) SetString(value string) error { } f.set(v) + + if f.afterSet != nil { + f.afterSet(f, v) + } + return nil } @@ -180,6 +211,11 @@ func (f *Float64) Set(v float64) (err error) { } f.set(v) + + if f.afterSet != nil { + f.afterSet(f, v) + } + return } diff --git a/internal/settings/int64.go b/internal/settings/int64.go index 842d442..57576a4 100644 --- a/internal/settings/int64.go +++ b/internal/settings/int64.go @@ -28,6 +28,7 @@ type Int64 struct { value int64 validator func(int64) error beforeInit, beforeSet func(Int64Setting, int64) (int64, error) + afterInit, afterSet func(Int64Setting, int64) } type Int64SettingOption func(*Int64) @@ -56,6 +57,18 @@ func WithBeforeSetInt64(beforeSet func(Int64Setting, int64) (int64, error)) Int6 } } +func WithAfterInitInt64(afterInit func(Int64Setting, int64)) Int64SettingOption { + return func(s *Int64) { + s.SetAfterInit(afterInit) + } +} + +func WithAfterSetInt64(afterSet func(Int64Setting, int64)) Int64SettingOption { + return func(s *Int64) { + s.SetAfterSet(afterSet) + } +} + func newInt64(name string, value int64, group model.SettingGroup, options ...Int64SettingOption) *Int64 { i := &Int64{ setting: setting{ @@ -84,6 +97,14 @@ func (i *Int64) SetBeforeSet(beforeSet func(Int64Setting, int64) (int64, error)) i.beforeSet = beforeSet } +func (i *Int64) SetAfterInit(afterInit func(Int64Setting, int64)) { + i.afterInit = afterInit +} + +func (i *Int64) SetAfterSet(afterSet func(Int64Setting, int64)) { + i.afterSet = afterSet +} + func (i *Int64) Parse(value string) (int64, error) { v, err := strconv.ParseInt(value, 10, 64) if err != nil { @@ -113,6 +134,11 @@ func (i *Int64) Init(value string) error { } i.set(v) + + if i.afterInit != nil { + i.afterInit(i, v) + } + return nil } @@ -151,6 +177,11 @@ func (i *Int64) SetString(value string) error { } i.set(v) + + if i.afterSet != nil { + i.afterSet(i, v) + } + return nil } @@ -179,6 +210,11 @@ func (i *Int64) Set(v int64) (err error) { } i.set(v) + + if i.afterSet != nil { + i.afterSet(i, v) + } + return } diff --git a/internal/settings/setting.go b/internal/settings/setting.go index 2c82ae8..ed90a6e 100644 --- a/internal/settings/setting.go +++ b/internal/settings/setting.go @@ -31,6 +31,16 @@ func SetValue(name string, value any) error { if !ok { return fmt.Errorf("setting %s not found", name) } + switch s.Type() { + case model.SettingTypeBool: + return s.(BoolSetting).Set(json.Wrap(value).ToBool()) + case model.SettingTypeInt64: + return s.(Int64Setting).Set(json.Wrap(value).ToInt64()) + case model.SettingTypeFloat64: + return s.(Float64Setting).Set(json.Wrap(value).ToFloat64()) + case model.SettingTypeString: + return s.(StringSetting).Set(json.Wrap(value).ToString()) + } return s.SetString(json.Wrap(value).ToString()) } diff --git a/internal/settings/string.go b/internal/settings/string.go index d5b02b8..3e8cc68 100644 --- a/internal/settings/string.go +++ b/internal/settings/string.go @@ -28,6 +28,7 @@ type String struct { value string validator func(string) error beforeInit, beforeSet func(StringSetting, string) (string, error) + afterInit, afterSet func(StringSetting, string) } type StringSettingOption func(*String) @@ -56,6 +57,18 @@ func WithBeforeSetString(beforeSet func(StringSetting, string) (string, error)) } } +func WithAfterInitString(afterInit func(StringSetting, string)) StringSettingOption { + return func(s *String) { + s.SetAfterInit(afterInit) + } +} + +func WithAfterSetString(afterSet func(StringSetting, string)) StringSettingOption { + return func(s *String) { + s.SetAfterSet(afterSet) + } +} + func newString(name string, value string, group model.SettingGroup, options ...StringSettingOption) *String { s := &String{ setting: setting{ @@ -84,6 +97,14 @@ func (s *String) SetBeforeSet(beforeSet func(StringSetting, string) (string, err s.beforeSet = beforeSet } +func (s *String) SetAfterInit(afterInit func(StringSetting, string)) { + s.afterInit = afterInit +} + +func (s *String) SetAfterSet(afterSet func(StringSetting, string)) { + s.afterSet = afterSet +} + func (s *String) Parse(value string) (string, error) { if s.validator != nil { return value, s.validator(value) @@ -109,6 +130,11 @@ func (s *String) Init(value string) error { } s.set(v) + + if s.afterInit != nil { + s.afterInit(s, v) + } + return nil } @@ -147,6 +173,11 @@ func (s *String) SetString(value string) error { } s.set(v) + + if s.afterSet != nil { + s.afterSet(s, v) + } + return nil } @@ -177,6 +208,11 @@ func (s *String) Set(v string) (err error) { } s.set(v) + + if s.afterSet != nil { + s.afterSet(s, v) + } + return } diff --git a/server/handlers/init.go b/server/handlers/init.go index 9b65172..3286d69 100644 --- a/server/handlers/init.go +++ b/server/handlers/init.go @@ -2,6 +2,8 @@ package handlers import ( "github.com/gin-gonic/gin" + "github.com/synctv-org/synctv/internal/model" + "github.com/synctv-org/synctv/internal/settings" "github.com/synctv-org/synctv/server/handlers/vendors" "github.com/synctv-org/synctv/server/handlers/vendors/vendorAlist" "github.com/synctv-org/synctv/server/handlers/vendors/vendorBilibili" @@ -10,6 +12,14 @@ import ( "github.com/synctv-org/synctv/utils" ) +var ( + HOST = settings.NewStringSetting( + "host", + "", + model.SettingGroupServer, + ) +) + func Init(e *gin.Engine) { api := e.Group("/api") @@ -200,6 +210,18 @@ func initMovie(movie *gin.RouterGroup, needAuthMovie *gin.RouterGroup) { func initUser(user *gin.RouterGroup, needAuthUser *gin.RouterGroup) { user.POST("/login", LoginUser) + user.GET("/signup/email/captcha", GetUserSignupEmailStep1Captcha) + + user.POST("/signup/email/captcha", SendUserSignupEmailCaptcha) + + user.POST("/signup/email", UserSignupEmail) + + user.GET("/retrieve/email/captcha", GetUserRetrievePasswordEmailStep1Captcha) + + user.POST("/retrieve/email/captcha", SendUserRetrievePasswordEmailCaptcha) + + user.POST("/retrieve/email", UserRetrievePasswordEmail) + needAuthUser.POST("/logout", LogoutUser) needAuthUser.GET("/me", Me) @@ -211,6 +233,16 @@ func initUser(user *gin.RouterGroup, needAuthUser *gin.RouterGroup) { needAuthUser.POST("/password", SetUserPassword) needAuthUser.GET("/providers", UserBindProviders) + + needAuthUser.GET("/bind/email/captcha", GetUserBindEmailStep1Captcha) + + needAuthUser.POST("/bind/email/captcha", SendUserBindEmailCaptcha) + + needAuthUser.POST("/bind/email", UserBindEmail) + + needAuthUser.POST("/unbind/email", UserUnbindEmail) + + needAuthUser.POST("/bind/email/test", UserSendTestEmail) } func initVendor(vendor *gin.RouterGroup) { diff --git a/server/handlers/movie.go b/server/handlers/movie.go index 45e83d8..1f72ffa 100644 --- a/server/handlers/movie.go +++ b/server/handlers/movie.go @@ -321,6 +321,9 @@ func NewPublishKey(ctx *gin.Context) { } host := settings.CustomPublishHost.Get() + if host == "" { + host = HOST.Get() + } if host == "" { host = ctx.Request.Host } diff --git a/server/handlers/public.go b/server/handlers/public.go index e678099..5bdeaca 100644 --- a/server/handlers/public.go +++ b/server/handlers/public.go @@ -1,10 +1,23 @@ package handlers import ( + "strings" + "github.com/gin-gonic/gin" + "github.com/synctv-org/synctv/internal/email" "github.com/synctv-org/synctv/server/model" ) +type publicSettings struct { + EmailWhitelistEnabled bool `json:"emailWhitelistEnabled"` + EmailWhitelist []string `json:"emailWhitelist,omitempty"` +} + func Settings(ctx *gin.Context) { - ctx.JSON(200, model.NewApiDataResp(gin.H{})) + ctx.JSON(200, model.NewApiDataResp( + &publicSettings{ + EmailWhitelistEnabled: email.EmailSignupWhiteListEnable.Get(), + EmailWhitelist: strings.Split(email.EmailSignupWhiteList.Get(), ","), + }, + )) } diff --git a/server/handlers/user.go b/server/handlers/user.go index 6197080..2f7c34b 100644 --- a/server/handlers/user.go +++ b/server/handlers/user.go @@ -1,18 +1,25 @@ package handlers import ( + "math/rand" "net/http" + "strings" + "time" "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" + "github.com/synctv-org/synctv/internal/captcha" "github.com/synctv-org/synctv/internal/db" + "github.com/synctv-org/synctv/internal/email" dbModel "github.com/synctv-org/synctv/internal/model" "github.com/synctv-org/synctv/internal/op" "github.com/synctv-org/synctv/internal/provider" "github.com/synctv-org/synctv/internal/provider/providers" + "github.com/synctv-org/synctv/internal/settings" "github.com/synctv-org/synctv/server/middlewares" "github.com/synctv-org/synctv/server/model" "github.com/synctv-org/synctv/utils" + "golang.org/x/exp/slices" "gorm.io/gorm" ) @@ -24,6 +31,7 @@ func Me(ctx *gin.Context) { Username: user.Username, Role: user.Role, CreatedAt: user.CreatedAt.UnixMilli(), + Email: user.Email, })) } @@ -236,3 +244,336 @@ func UserBindProviders(ctx *gin.Context) { ctx.JSON(http.StatusOK, resp) } + +func GetUserBindEmailStep1Captcha(ctx *gin.Context) { + // user := ctx.MustGet("user").(*op.UserEntry).Value() + log := ctx.MustGet("log").(*logrus.Entry) + + id, data, _, err := captcha.Captcha.Generate() + if err != nil { + log.Errorf("failed to generate captcha: %v", err) + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewApiErrorResp(err)) + return + } + + ctx.JSON(http.StatusOK, model.NewApiDataResp(&model.GetUserBindEmailStep1CaptchaResp{ + CaptchaID: id, + CaptchaBase64: data, + })) +} + +func SendUserBindEmailCaptcha(ctx *gin.Context) { + user := ctx.MustGet("user").(*op.UserEntry).Value() + log := ctx.MustGet("log").(*logrus.Entry) + + req := model.UserSendBindEmailCaptchaReq{} + if err := model.Decode(ctx, &req); err != nil { + log.Errorf("failed to decode request: %v", err) + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorResp(err)) + return + } + + if !captcha.Captcha.Verify( + req.CaptchaID, + req.Answer, + true, + ) { + log.Errorf("captcha verify failed") + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorStringResp("captcha verify failed")) + return + } + + if err := user.SendBindCaptchaEmail(req.Email); err != nil { + log.Errorf("failed to send email captcha: %v", err) + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewApiErrorResp(err)) + return + } + + ctx.Status(http.StatusNoContent) +} + +func UserBindEmail(ctx *gin.Context) { + user := ctx.MustGet("user").(*op.UserEntry).Value() + log := ctx.MustGet("log").(*logrus.Entry) + + req := model.UserBindEmailReq{} + if err := model.Decode(ctx, &req); err != nil { + log.Errorf("failed to decode request: %v", err) + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorResp(err)) + return + } + + if ok, err := user.VerifyBindCaptchaEmail(req.Email, req.Captcha); err != nil || !ok { + log.Errorf("email captcha verify failed") + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorStringResp("email captcha verify failed")) + return + } + + err := user.BindEmail(req.Email) + if err != nil { + log.Errorf("failed to bind email: %v", err) + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewApiErrorResp(err)) + return + } + + ctx.Status(http.StatusNoContent) +} + +func UserUnbindEmail(ctx *gin.Context) { + user := ctx.MustGet("user").(*op.UserEntry).Value() + log := ctx.MustGet("log").(*logrus.Entry) + + err := user.UnbindEmail() + if err != nil { + log.Errorf("failed to unbind email: %v", err) + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewApiErrorResp(err)) + return + } + + ctx.Status(http.StatusNoContent) +} + +func UserSendTestEmail(ctx *gin.Context) { + user := ctx.MustGet("user").(*op.UserEntry).Value() + log := ctx.MustGet("log").(*logrus.Entry) + + if err := user.SendTestEmail(); err != nil { + log.Errorf("failed to send test email: %v", err) + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewApiErrorResp(err)) + return + } + + ctx.Status(http.StatusNoContent) +} + +func GetUserSignupEmailStep1Captcha(ctx *gin.Context) { + log := ctx.MustGet("log").(*logrus.Entry) + + id, data, _, err := captcha.Captcha.Generate() + if err != nil { + log.Errorf("failed to generate captcha: %v", err) + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewApiErrorResp(err)) + return + } + + ctx.JSON(http.StatusOK, model.NewApiDataResp(&model.GetUserBindEmailStep1CaptchaResp{ + CaptchaID: id, + CaptchaBase64: data, + })) +} + +func SendUserSignupEmailCaptcha(ctx *gin.Context) { + log := ctx.MustGet("log").(*logrus.Entry) + + if settings.DisableUserSignup.Get() || email.DisableUserSignup.Get() { + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorStringResp("user signup disabled")) + return + } + + req := model.SendUserSignupEmailCaptchaReq{} + if err := model.Decode(ctx, &req); err != nil { + log.Errorf("failed to decode request: %v", err) + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorResp(err)) + return + } + + if !captcha.Captcha.Verify( + req.CaptchaID, + req.Answer, + true, + ) { + log.Errorf("captcha verify failed") + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorStringResp("captcha verify failed")) + return + } + + if email.EmailSignupWhiteListEnable.Get() { + _, after, found := strings.Cut(req.Email, "@") + if !found { + log.Errorf("email format error") + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorStringResp("email format error")) + return + } + if !slices.Contains( + strings.Split(email.EmailSignupWhiteList.Get(), ","), + after, + ) { + log.Errorf("email(%s) sub(%s) not in white list", req.Email, after) + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorStringResp("email not in white list")) + return + } + } + + _, err := op.LoadOrInitUserByEmail(req.Email) + if err == nil { + log.Errorf("email already exists") + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorStringResp("email already exists")) + return + } + + if err := email.SendSignupCaptchaEmail(req.Email); err != nil { + log.Errorf("failed to send email captcha: %v", err) + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewApiErrorResp(err)) + return + } + + ctx.Status(http.StatusNoContent) +} + +func UserSignupEmail(ctx *gin.Context) { + log := ctx.MustGet("log").(*logrus.Entry) + + if settings.DisableUserSignup.Get() || email.DisableUserSignup.Get() { + log.Errorf("user signup disabled") + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorStringResp("user signup disabled")) + return + } + + req := model.UserSignupEmailReq{} + if err := model.Decode(ctx, &req); err != nil { + log.Errorf("failed to decode request: %v", err) + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorResp(err)) + return + } + + ok, err := email.VerifySignupCaptchaEmail(req.Email, req.Captcha) + if err != nil { + log.Errorf("failed to verify email captcha: %v", err) + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewApiErrorResp(err)) + return + } + if !ok { + log.Errorf("email captcha verify failed") + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorStringResp("email captcha verify failed")) + return + } + + var user *op.UserEntry + if settings.SignupNeedReview.Get() || email.SignupNeedReview.Get() { + user, err = op.CreateUserWithEmail(req.Email, utils.RandString(16), req.Email, db.WithRole(dbModel.RolePending)) + } else { + user, err = op.CreateUserWithEmail(req.Email, utils.RandString(16), req.Email) + } + if err != nil { + log.Errorf("failed to create user: %v", err) + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorResp(err)) + return + } + + token, err := middlewares.NewAuthUserToken(user.Value()) + if err != nil { + log.Errorf("failed to generate token: %v", err) + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorResp(err)) + return + } + + ctx.JSON(http.StatusOK, model.NewApiDataResp(gin.H{ + "token": token, + })) +} + +func GetUserRetrievePasswordEmailStep1Captcha(ctx *gin.Context) { + log := ctx.MustGet("log").(*logrus.Entry) + + id, data, _, err := captcha.Captcha.Generate() + if err != nil { + log.Errorf("failed to generate captcha: %v", err) + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewApiErrorResp(err)) + return + } + + ctx.JSON(http.StatusOK, model.NewApiDataResp(&model.GetUserBindEmailStep1CaptchaResp{ + CaptchaID: id, + CaptchaBase64: data, + })) +} + +func SendUserRetrievePasswordEmailCaptcha(ctx *gin.Context) { + log := ctx.MustGet("log").(*logrus.Entry) + + req := model.SendUserRetrievePasswordEmailCaptchaReq{} + if err := model.Decode(ctx, &req); err != nil { + log.Errorf("failed to decode request: %v", err) + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorResp(err)) + return + } + + if !captcha.Captcha.Verify( + req.CaptchaID, + req.Answer, + true, + ) { + log.Errorf("captcha verify failed") + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorStringResp("captcha verify failed")) + return + } + + user, err := op.LoadOrInitUserByEmail(req.Email) + if err != nil { + log.Errorf("failed to load or init user by email: %v", err) + time.Sleep(time.Duration(rand.Intn(1500)) + time.Second*3) + ctx.Status(http.StatusNoContent) + return + } + + host := HOST.Get() + if host == "" { + host = ctx.Request.Host + } + if host == "" { + log.Error("failed to get host on send retrieve password email") + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewApiErrorStringResp("failed to get host")) + return + } + + if err := user.Value().SendRetrievePasswordCaptchaEmail(host); err != nil { + log.Errorf("failed to send email captcha: %v", err) + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewApiErrorResp(err)) + return + } + + ctx.Status(http.StatusNoContent) +} + +func UserRetrievePasswordEmail(ctx *gin.Context) { + log := ctx.MustGet("log").(*logrus.Entry) + + req := model.UserRetrievePasswordEmailReq{} + if err := model.Decode(ctx, &req); err != nil { + log.Errorf("failed to decode request: %v", err) + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorResp(err)) + return + } + + userE, err := op.LoadOrInitUserByID(req.UserID) + if err != nil { + log.Errorf("failed to get user by email: %v", err) + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorResp(err)) + return + } + user := userE.Value() + + if ok, err := user.VerifyRetrievePasswordCaptchaEmail(req.Email, req.Captcha); err != nil || !ok { + log.Errorf("email captcha verify failed") + ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorStringResp("email captcha verify failed")) + return + } + + err = user.SetPassword(req.Password) + if err != nil { + log.Errorf("failed to set password: %v", err) + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewApiErrorResp(err)) + return + } + + token, err := middlewares.NewAuthUserToken(user) + if err != nil { + log.Errorf("failed to generate token: %v", err) + ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewApiErrorResp(err)) + return + } + + ctx.JSON(http.StatusOK, model.NewApiDataResp(gin.H{ + "token": token, + })) +} diff --git a/server/model/api.go b/server/model/api.go index 6b1e304..882328f 100644 --- a/server/model/api.go +++ b/server/model/api.go @@ -1,9 +1,17 @@ package model import ( + "regexp" "time" ) +var ( + alnumReg = regexp.MustCompile(`^[[:alnum:]]+$`) + alnumPrintReg = regexp.MustCompile(`^[[:print:][:alnum:]]+$`) + alnumPrintHanReg = regexp.MustCompile(`^[[:print:][:alnum:]\p{Han}]+$`) + emailReg = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) +) + type ApiResp struct { Time int64 `json:"time"` Error string `json:"error,omitempty"` diff --git a/server/model/room.go b/server/model/room.go index 2a43cad..3470695 100644 --- a/server/model/room.go +++ b/server/model/room.go @@ -3,7 +3,6 @@ package model import ( "errors" "fmt" - "regexp" json "github.com/json-iterator/go" @@ -19,17 +18,6 @@ var ( ErrPasswordTooLong = errors.New("password too long") ErrPasswordHasInvalidChar = errors.New("password has invalid char") - - ErrEmptyUserId = errors.New("empty user id") - ErrEmptyUsername = errors.New("empty username") - ErrUsernameTooLong = errors.New("username too long") - ErrUsernameHasInvalidChar = errors.New("username has invalid char") -) - -var ( - alnumReg = regexp.MustCompile(`^[[:alnum:]]+$`) - alnumPrintReg = regexp.MustCompile(`^[[:print:][:alnum:]]+$`) - alnumPrintHanReg = regexp.MustCompile(`^[[:print:][:alnum:]\p{Han}]+$`) ) type FormatEmptyPasswordError string diff --git a/server/model/user.go b/server/model/user.go index 35736e6..5c2e3af 100644 --- a/server/model/user.go +++ b/server/model/user.go @@ -9,6 +9,13 @@ import ( "github.com/synctv-org/synctv/internal/provider" ) +var ( + ErrEmptyUserId = errors.New("empty user id") + ErrEmptyUsername = errors.New("empty username") + ErrUsernameTooLong = errors.New("username too long") + ErrUsernameHasInvalidChar = errors.New("username has invalid char") +) + type SetUserPasswordReq struct { Password string `json:"password"` } @@ -61,6 +68,7 @@ type UserInfoResp struct { Username string `json:"username"` Role dbModel.Role `json:"role"` CreatedAt int64 `json:"createdAt"` + Email string `json:"email"` } type SetUsernameReq struct { @@ -101,3 +109,90 @@ type UserBindProviderResp map[provider.OAuth2Provider]struct { ProviderUserID string `json:"providerUserID"` CreatedAt int64 `json:"createdAt"` } + +type GetUserBindEmailStep1CaptchaResp struct { + CaptchaID string `json:"captchaID"` + CaptchaBase64 string `json:"captchaBase64"` +} + +type UserSendBindEmailCaptchaReq struct { + Email string `json:"email"` + CaptchaID string `json:"captchaID"` + Answer string `json:"answer"` +} + +func (u *UserSendBindEmailCaptchaReq) Decode(ctx *gin.Context) error { + return json.NewDecoder(ctx.Request.Body).Decode(u) +} + +var ( + ErrEmailTooLong = errors.New("email is too long") + ErrInvalidEmail = errors.New("invalid email") +) + +func (u *UserSendBindEmailCaptchaReq) Validate() error { + if u.Email == "" { + return errors.New("email is empty") + } else if len(u.Email) > 128 { + return ErrEmailTooLong + } else if !emailReg.MatchString(u.Email) { + return ErrInvalidEmail + } + if u.CaptchaID == "" { + return errors.New("captcha id is empty") + } + if u.Answer == "" { + return errors.New("answer is empty") + } + return nil +} + +type UserBindEmailReq struct { + Email string `json:"email"` + Captcha string `json:"captcha"` +} + +func (u *UserBindEmailReq) Decode(ctx *gin.Context) error { + return json.NewDecoder(ctx.Request.Body).Decode(u) +} + +func (u *UserBindEmailReq) Validate() error { + if u.Email == "" { + return errors.New("email is empty") + } else if len(u.Email) > 128 { + return ErrEmailTooLong + } else if !emailReg.MatchString(u.Email) { + return ErrInvalidEmail + } + if u.Captcha == "" { + return errors.New("captcha is empty") + } + return nil +} + +type SendUserSignupEmailCaptchaReq = UserSendBindEmailCaptchaReq + +type UserSignupEmailReq = UserBindEmailReq + +type SendUserRetrievePasswordEmailCaptchaReq = UserSendBindEmailCaptchaReq + +type UserRetrievePasswordEmailReq struct { + UserID string `json:"userID"` + Email string `json:"email"` + Captcha string `json:"captcha"` + Password string `json:"password"` +} + +func (u *UserRetrievePasswordEmailReq) Decode(ctx *gin.Context) error { + return json.NewDecoder(ctx.Request.Body).Decode(u) +} + +func (u *UserRetrievePasswordEmailReq) Validate() error { + if u.UserID == "" { + return errors.New("userID is empty") + } + if u.Captcha == "" { + return errors.New("captcha is empty") + } + return nil +} diff --git a/utils/smtp/format.go b/utils/smtp/format.go new file mode 100644 index 0000000..1ec9822 --- /dev/null +++ b/utils/smtp/format.go @@ -0,0 +1,35 @@ +package smtp + +import ( + "encoding/base64" + "fmt" + "strings" + + smtp "github.com/emersion/go-smtp" + "github.com/zijiren233/stream" +) + +func FormatMail(from string, to []string, subject string, body any) string { + return fmt.Sprintf( + "From: %s\r\nTo: %s\r\nSubject: =?UTF-8?B?%s?=\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n%v", + from, + strings.Join(to, ","), + base64.StdEncoding.EncodeToString(stream.StringToBytes(subject)), + body, + ) +} + +func SendEmail(cli *smtp.Client, from string, to []string, subject, body string) error { + return cli.SendMail( + from, + to, + strings.NewReader( + FormatMail( + from, + to, + subject, + body, + ), + ), + ) +} diff --git a/utils/smtp/smtpool.go b/utils/smtp/smtpool.go new file mode 100644 index 0000000..8dd7698 --- /dev/null +++ b/utils/smtp/smtpool.go @@ -0,0 +1,176 @@ +package smtp + +import ( + "fmt" + "runtime" + "strings" + "sync" + + "github.com/emersion/go-sasl" + smtp "github.com/emersion/go-smtp" +) + +type SmtpConfig struct { + Host string + Port uint32 + Protocol string + Username string + Password string + From string +} + +func validateSmtpConfig(c *SmtpConfig) error { + if c == nil { + return fmt.Errorf("smtp config is nil") + } + if c.Host == "" { + return fmt.Errorf("smtp host is empty") + } + if c.Port == 0 { + return fmt.Errorf("smtp port is empty") + } + if c.Username == "" { + return fmt.Errorf("smtp username is empty") + } + if c.Password == "" { + return fmt.Errorf("smtp password is empty") + } + if c.From == "" { + return fmt.Errorf("smtp from is empty") + } + return nil +} + +func newSmtpClient(c *SmtpConfig) (*smtp.Client, error) { + var ( + cli *smtp.Client + err error + ) + + switch strings.ToUpper(c.Protocol) { + case "TLS", "SSL": + cli, err = smtp.DialStartTLS(fmt.Sprintf("%s:%d", c.Host, c.Port), nil) + default: + cli, err = smtp.Dial(fmt.Sprintf("%s:%d", c.Host, c.Port)) + } + if err != nil { + return nil, fmt.Errorf("dial smtp server failed: %w", err) + } + + err = cli.Auth(sasl.NewLoginClient(c.Username, c.Password)) + if err != nil { + cli.Close() + return nil, fmt.Errorf("auth failed: %w", err) + } + + return cli, nil +} + +var ErrSmtpPoolClosed = fmt.Errorf("smtp pool is closed") + +type SmtpPool struct { + mu sync.Mutex + clients []*smtp.Client + c *SmtpConfig + max int + active int + closed bool +} + +func NewSmtpPool(c *SmtpConfig, max int) (*SmtpPool, error) { + err := validateSmtpConfig(c) + if err != nil { + return nil, err + } + return &SmtpPool{ + clients: make([]*smtp.Client, 0, max), + c: c, + max: max, + }, nil +} + +func (p *SmtpPool) Get() (*smtp.Client, error) { + p.mu.Lock() + if p.closed { + p.mu.Unlock() + return nil, ErrSmtpPoolClosed + } + + if len(p.clients) > 0 { + cli := p.clients[len(p.clients)-1] + p.clients = p.clients[:len(p.clients)-1] + p.mu.Unlock() + if cli.Noop() != nil { + cli.Close() + return p.Get() + } + p.mu.Lock() + p.active++ + p.mu.Unlock() + return cli, nil + } + + if p.active >= p.max { + p.mu.Unlock() + runtime.Gosched() + return p.Get() + } + + cli, err := newSmtpClient(p.c) + if err != nil { + p.mu.Unlock() + return nil, err + } + + p.active++ + p.mu.Unlock() + return cli, nil +} + +func (p *SmtpPool) Put(cli *smtp.Client) { + if cli == nil { + return + } + + noopErr := cli.Noop() + + p.mu.Lock() + defer p.mu.Unlock() + + p.active-- + + if p.closed || noopErr != nil { + cli.Close() + return + } + + p.clients = append(p.clients, cli) +} + +func (p *SmtpPool) Close() { + p.mu.Lock() + defer p.mu.Unlock() + + p.closed = true + + for _, cli := range p.clients { + cli.Close() + } + p.clients = nil +} + +func (p *SmtpPool) SendEmail(to []string, subject, body string) error { + cli, err := p.Get() + if err != nil { + return err + } + defer p.Put(cli) + return SendEmail(cli, p.c.From, to, subject, body) +} + +func (p *SmtpPool) SetFrom(from string) { + p.mu.Lock() + defer p.mu.Unlock() + + p.c.From = from +}