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