✨ Push email & notification localization
This commit is contained in:
@ -127,7 +127,7 @@ func updateAccountLanguage(c *fiber.Ctx) error {
|
||||
user := c.Locals("user").(models.Account)
|
||||
|
||||
var data struct {
|
||||
Language string `json:"language" validate:"required"`
|
||||
Language string `json:"language" validate:"required,bcp47_language_tag"`
|
||||
}
|
||||
|
||||
if err := exts.BindAndValidate(c, &data); err != nil {
|
||||
|
@ -39,7 +39,7 @@ func requestFactorToken(c *fiber.Ctx) error {
|
||||
return fiber.NewError(fiber.StatusNotFound, err.Error())
|
||||
}
|
||||
|
||||
if sent, err := services.GetFactorCode(factor); err != nil {
|
||||
if sent, err := services.GetFactorCode(factor, c.IP()); err != nil {
|
||||
return fiber.NewError(fiber.StatusNotFound, err.Error())
|
||||
} else if !sent {
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
|
@ -17,21 +17,6 @@ import (
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
const EmailPasswordTemplate = `Dear %s,
|
||||
|
||||
We hope this message finds you well.
|
||||
As part of our ongoing commitment to ensuring the security of your account, we require you to complete the login process by entering the verification code below:
|
||||
|
||||
Your Login Verification Code: %s
|
||||
|
||||
Please use the provided code within the next 2 hours to complete your login.
|
||||
If you did not request this code, please update your information, maybe your username or email has been leak.
|
||||
|
||||
Thank you for your cooperation in helping us maintain the security of your account.
|
||||
|
||||
Best regards,
|
||||
%s`
|
||||
|
||||
func GetPasswordTypeFactor(userId uint) (models.AuthFactor, error) {
|
||||
var factor models.AuthFactor
|
||||
err := database.C.Where(models.AuthFactor{
|
||||
@ -69,7 +54,7 @@ func CountUserFactor(userId uint) int64 {
|
||||
return count
|
||||
}
|
||||
|
||||
func GetFactorCode(factor models.AuthFactor) (bool, error) {
|
||||
func GetFactorCode(factor models.AuthFactor, ip string) (bool, error) {
|
||||
switch factor.Type {
|
||||
case models.InAppNotifyFactor:
|
||||
var user models.Account
|
||||
@ -91,10 +76,11 @@ func GetFactorCode(factor models.AuthFactor) (bool, error) {
|
||||
|
||||
err = PushNotification(models.Notification{
|
||||
Topic: "passport.security.otp",
|
||||
Title: "Your login one-time-password",
|
||||
Body: fmt.Sprintf("`%s` is your login verification code. It will expires in 30 minutes.", secret),
|
||||
Title: GetLocalizedString("subjectLoginOneTimePassword", user.Language),
|
||||
Body: fmt.Sprintf(GetLocalizedString("shortBodyLoginOneTimePassword", user.Language), secret),
|
||||
Account: user,
|
||||
AccountID: user.ID,
|
||||
Metadata: map[string]any{"secret": secret},
|
||||
}, true)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Uint("factor", factor.ID).Msg("Failed to delivery one-time-password via notify...")
|
||||
@ -119,8 +105,14 @@ func GetFactorCode(factor models.AuthFactor) (bool, error) {
|
||||
log.Info().Uint("factor", factor.ID).Str("secret", secret).Msg("Published one-time-password to JetStream...")
|
||||
}
|
||||
|
||||
subject := fmt.Sprintf("[%s] Login verification code", viper.GetString("name"))
|
||||
content := fmt.Sprintf(EmailPasswordTemplate, user.Name, secret, viper.GetString("maintainer"))
|
||||
subject := fmt.Sprintf("[%s] %s", viper.GetString("name"), GetLocalizedString("subjectLoginOneTimePassword", user.Language))
|
||||
|
||||
content := RenderLocalizedTemplateHTML("email-otp.tmpl", user.Language, map[string]any{
|
||||
"Code": secret,
|
||||
"User": user,
|
||||
"IP": ip,
|
||||
"Date": time.Now().Format(time.DateTime),
|
||||
})
|
||||
|
||||
err = gap.Px.PushEmail(pushkit.EmailDeliverRequest{
|
||||
To: user.GetPrimaryEmail().Content,
|
||||
@ -148,7 +140,7 @@ func CheckFactor(factor models.AuthFactor, code string) error {
|
||||
fmt.Errorf("invalid password"),
|
||||
)
|
||||
case models.TimeOtpFactor:
|
||||
lo.Ternary(
|
||||
return lo.Ternary(
|
||||
totp.Validate(code, factor.Secret),
|
||||
nil,
|
||||
fmt.Errorf("invalid verification code"),
|
||||
|
100
pkg/internal/services/i18n.go
Normal file
100
pkg/internal/services/i18n.go
Normal file
@ -0,0 +1,100 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/goccy/go-json"
|
||||
"github.com/nicksnyder/go-i18n/v2/i18n"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/spf13/viper"
|
||||
"golang.org/x/text/language"
|
||||
htmpl "html/template"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const FallbackLanguage = "en-US"
|
||||
|
||||
var LocaleBundle *i18n.Bundle
|
||||
|
||||
func LoadLocalization() error {
|
||||
LocaleBundle = i18n.NewBundle(language.English)
|
||||
LocaleBundle.RegisterUnmarshalFunc("json", json.Unmarshal)
|
||||
|
||||
var count int
|
||||
|
||||
basePath := viper.GetString("locales_dir")
|
||||
if entries, err := os.ReadDir(basePath); err != nil {
|
||||
return fmt.Errorf("unable to read locales directory: %v", err)
|
||||
} else {
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
if _, err := LocaleBundle.LoadMessageFile(filepath.Join(basePath, entry.Name())); err != nil {
|
||||
return fmt.Errorf("unable to load localization file %s: %v", entry.Name(), err)
|
||||
} else {
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Info().Int("locales", count).Msg("Loaded localization files...")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetLocalizer(lang string) *i18n.Localizer {
|
||||
return i18n.NewLocalizer(LocaleBundle, lang)
|
||||
}
|
||||
|
||||
func GetLocalizedString(name string, lang string) string {
|
||||
localizer := GetLocalizer(lang)
|
||||
msg, err := localizer.LocalizeMessage(&i18n.Message{
|
||||
ID: name,
|
||||
})
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("lang", lang).Str("name", name).Msg("Failed to localize string...")
|
||||
return name
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
func GetLocalizedTemplatePath(name string, lang string) string {
|
||||
basePath := viper.GetString("templates_dir")
|
||||
filePath := filepath.Join(basePath, lang, name)
|
||||
|
||||
if _, err := os.Stat(filePath); errors.Is(err, os.ErrNotExist) {
|
||||
// Fallback to English
|
||||
filePath = filepath.Join(basePath, FallbackLanguage, name)
|
||||
return filePath
|
||||
}
|
||||
|
||||
return filePath
|
||||
}
|
||||
|
||||
func GetLocalizedTemplateHTML(name string, lang string) *htmpl.Template {
|
||||
path := GetLocalizedTemplatePath(name, lang)
|
||||
tmpl, err := htmpl.ParseFiles(path)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("lang", lang).Str("name", name).Msg("Failed to load localized template...")
|
||||
return nil
|
||||
}
|
||||
|
||||
return tmpl
|
||||
}
|
||||
|
||||
func RenderLocalizedTemplateHTML(name string, lang string, data any) string {
|
||||
tmpl := GetLocalizedTemplateHTML(name, lang)
|
||||
if tmpl == nil {
|
||||
return ""
|
||||
}
|
||||
buf := new(strings.Builder)
|
||||
err := tmpl.Execute(buf, data)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("lang", lang).Str("name", name).Msg("Failed to render localized template...")
|
||||
return ""
|
||||
}
|
||||
return buf.String()
|
||||
}
|
@ -14,54 +14,6 @@ import (
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
const ConfirmRegistrationTemplate = `Dear %s,
|
||||
|
||||
Thank you for choosing to register with %s. We are excited to welcome you to our community and appreciate your trust in us.
|
||||
|
||||
Your registration details have been successfully received, and you are now a valued member of %s. Here are the confirm link of your registration:
|
||||
|
||||
%s
|
||||
|
||||
As a confirmed registered member, you will have access to all our services.
|
||||
We encourage you to explore our services and take full advantage of the resources available to you.
|
||||
|
||||
Once again, thank you for choosing us. We look forward to serving you and hope you have a positive experience with us.
|
||||
|
||||
Best regards,
|
||||
%s`
|
||||
|
||||
const ResetPasswordTemplate = `Dear %s,
|
||||
|
||||
We received a request to reset the password for your account at %s. If you did not request a password reset, please ignore this email.
|
||||
|
||||
To confirm your password reset request and create a new password, please use the link below:
|
||||
|
||||
%s
|
||||
|
||||
This link will expire in 24 hours. If you do not reset your password within this time frame, you will need to submit a new password reset request.
|
||||
|
||||
If you have any questions or need further assistance, please do not hesitate to contact our support team.
|
||||
|
||||
Best regards,
|
||||
%s`
|
||||
|
||||
const DeleteAccountTemplate = `Dear %s,
|
||||
|
||||
We received a request to delete your account at %s. If you did not request a account deletion, please change your account password right now.
|
||||
If you changed your mind, please ignore this email.
|
||||
|
||||
To confirm your account deletion request, please use the link below:
|
||||
|
||||
%s
|
||||
|
||||
This link will expire in 24 hours. If you do not use that link within this time frame, you will need to submit an account deletion request.
|
||||
|
||||
If you have any questions or need further assistance, please do not hesitate to contact our support team.
|
||||
Also, if you want to let us know why you decided to delete your account, send email us (lily@solsynth.dev) and tell us how could we improve our user experience.
|
||||
|
||||
Best regards,
|
||||
%s`
|
||||
|
||||
func ValidateMagicToken(code string, mode models.MagicTokenType) (models.MagicToken, error) {
|
||||
var tk models.MagicToken
|
||||
if err := database.C.Where(models.MagicToken{Code: code, Type: mode}).First(&tk).Error; err != nil {
|
||||
@ -110,35 +62,25 @@ func NotifyMagicToken(token models.MagicToken) error {
|
||||
switch token.Type {
|
||||
case models.ConfirmMagicToken:
|
||||
link := fmt.Sprintf("%s/flow/accounts/confirm?code=%s", viper.GetString("frontend_app"), token.Code)
|
||||
subject = fmt.Sprintf("[%s] Confirm your registration", viper.GetString("name"))
|
||||
content = fmt.Sprintf(
|
||||
ConfirmRegistrationTemplate,
|
||||
user.Name,
|
||||
viper.GetString("name"),
|
||||
viper.GetString("maintainer"),
|
||||
link,
|
||||
viper.GetString("maintainer"),
|
||||
)
|
||||
subject = fmt.Sprintf("[%s] %s", viper.GetString("name"), GetLocalizedString("subjectConfirmRegistration", user.Language))
|
||||
content = RenderLocalizedTemplateHTML("register-confirm.tmpl", user.Language, map[string]any{
|
||||
"User": user,
|
||||
"Link": link,
|
||||
})
|
||||
case models.ResetPasswordMagicToken:
|
||||
link := fmt.Sprintf("%s/flow/accounts/password-reset?code=%s", viper.GetString("frontend_app"), token.Code)
|
||||
subject = fmt.Sprintf("[%s] Reset your password", viper.GetString("name"))
|
||||
content = fmt.Sprintf(
|
||||
ResetPasswordTemplate,
|
||||
user.Name,
|
||||
viper.GetString("name"),
|
||||
link,
|
||||
viper.GetString("maintainer"),
|
||||
)
|
||||
subject = fmt.Sprintf("[%s] %s", viper.GetString("name"), GetLocalizedString("subjectResetPassword", user.Language))
|
||||
content = RenderLocalizedTemplateHTML("reset-password.tmpl", user.Language, map[string]any{
|
||||
"User": user,
|
||||
"Link": link,
|
||||
})
|
||||
case models.DeleteAccountMagicToken:
|
||||
link := fmt.Sprintf("%s/flow/accounts/deletion?code=%s", viper.GetString("frontend_app"), token.Code)
|
||||
subject = fmt.Sprintf("[%s] Confirm your account deletion", viper.GetString("name"))
|
||||
content = fmt.Sprintf(
|
||||
DeleteAccountTemplate,
|
||||
user.Name,
|
||||
viper.GetString("name"),
|
||||
link,
|
||||
viper.GetString("maintainer"),
|
||||
)
|
||||
subject = fmt.Sprintf("[%s] %s", viper.GetString("name"), GetLocalizedString("subjectDeleteAccount", user.Language))
|
||||
content = RenderLocalizedTemplateHTML("confirm-deletion.tmpl", user.Language, map[string]any{
|
||||
"User": user,
|
||||
"Link": link,
|
||||
})
|
||||
default:
|
||||
return fmt.Errorf("unsupported magic token type to notify")
|
||||
}
|
||||
|
Reference in New Issue
Block a user