added messages templates
This commit is contained in:
parent
e8dc967e6a
commit
7cd1f64d0d
29 changed files with 1266 additions and 84 deletions
122
internal/app/auth/login_code_message_template_test.go
Normal file
122
internal/app/auth/login_code_message_template_test.go
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/identity"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
// TestLoginCodeMessageTemplateResolutionPrecedence exercises
|
||||
// WithLoginCodeMessageTemplate/resolveLoginCodeMessageTemplate's precedence
|
||||
// (panel override > env default > compiled-in default) end to end through
|
||||
// SignUp's bootstrap recordLoginMessage path (phone channel, no owner/dialog
|
||||
// yet -- see service.go's rec.Channel == codeChannelPhone branch), mirroring
|
||||
// how welcome_message_test.go exercises WithLoginWelcomeMessages.
|
||||
func TestLoginCodeMessageTemplateResolutionPrecedence(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
newSvc := func(store *identity.Store, envDefault string) (*Service, *memory.MessageStore) {
|
||||
dialogs := memory.NewDialogStore()
|
||||
messages := memory.NewMessageStore(dialogs)
|
||||
return NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, nil, "12345",
|
||||
WithLoginMessages(messages, dialogs),
|
||||
WithLoginCodeMessageTemplate(store, envDefault),
|
||||
), messages
|
||||
}
|
||||
|
||||
// No override, no env default: falls back to the compiled-in default.
|
||||
t.Run("compiled-in default", func(t *testing.T) {
|
||||
svc, messages := newSvc(nil, "")
|
||||
u := signUpPhoneForLoginCodeMessage(t, ctx, svc, "+15550771001")
|
||||
body := codeMessageBody(t, ctx, messages, u.ID)
|
||||
if !strings.Contains(body, "Login code: 12345") {
|
||||
t.Fatalf("body = %q, want compiled-in default rendering", body)
|
||||
}
|
||||
})
|
||||
|
||||
// Env default set, no panel override: env default wins.
|
||||
t.Run("env default", func(t *testing.T) {
|
||||
svc, messages := newSvc(nil, "Env says your code is {{code}}.")
|
||||
u := signUpPhoneForLoginCodeMessage(t, ctx, svc, "+15550771002")
|
||||
body := codeMessageBody(t, ctx, messages, u.ID)
|
||||
if body != "Env says your code is 12345." {
|
||||
t.Fatalf("body = %q, want env-default rendering", body)
|
||||
}
|
||||
})
|
||||
|
||||
// Panel override set: wins over both the env default and the compiled-in
|
||||
// default, and is read fresh (not cached) -- see resolveLoginCodeMessageTemplate.
|
||||
t.Run("panel override wins and is read fresh", func(t *testing.T) {
|
||||
store := identity.NewStore(t.TempDir())
|
||||
svc, messages := newSvc(store, "Env says your code is {{code}}.")
|
||||
|
||||
u := signUpPhoneForLoginCodeMessage(t, ctx, svc, "+15550771003")
|
||||
body := codeMessageBody(t, ctx, messages, u.ID)
|
||||
if body != "Env says your code is 12345." {
|
||||
t.Fatalf("body before override = %q, want env-default rendering", body)
|
||||
}
|
||||
|
||||
if err := store.SetLoginCodeMessageTemplate("Panel says your code is {{code}}."); err != nil {
|
||||
t.Fatalf("SetLoginCodeMessageTemplate: %v", err)
|
||||
}
|
||||
u2 := signUpPhoneForLoginCodeMessage(t, ctx, svc, "+15550771004")
|
||||
body2 := codeMessageBody(t, ctx, messages, u2.ID)
|
||||
if body2 != "Panel says your code is 12345." {
|
||||
t.Fatalf("body after override = %q, want panel-override rendering with no restart", body2)
|
||||
}
|
||||
})
|
||||
|
||||
// A saved-but-somehow-invalid panel override (missing {{code}} --
|
||||
// bypassing the admin-API validation, e.g. a hand-edited identity.json)
|
||||
// must never reach a client with the code silently missing: defense in
|
||||
// depth falls back to the compiled-in default instead.
|
||||
t.Run("invalid panel override falls back safely", func(t *testing.T) {
|
||||
store := identity.NewStore(t.TempDir())
|
||||
if err := store.SetLoginCodeMessageTemplate("no placeholder here"); err != nil {
|
||||
t.Fatalf("SetLoginCodeMessageTemplate: %v", err)
|
||||
}
|
||||
svc, messages := newSvc(store, "")
|
||||
u := signUpPhoneForLoginCodeMessage(t, ctx, svc, "+15550771005")
|
||||
body := codeMessageBody(t, ctx, messages, u.ID)
|
||||
if !strings.Contains(body, "12345") {
|
||||
t.Fatalf("body = %q, want the code delivered via fallback despite invalid override", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func signUpPhoneForLoginCodeMessage(t *testing.T, ctx context.Context, svc *Service, phone string) domain.User {
|
||||
t.Helper()
|
||||
hash, err := svc.SendCode(ctx, phone)
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode: %v", err)
|
||||
}
|
||||
verifyCodeForSignUp(t, svc, phone, hash, "12345")
|
||||
u, _, err := svc.SignUp(ctx, domain.Authorization{}, phone, hash, "Code", "Template")
|
||||
if err != nil {
|
||||
t.Fatalf("SignUp: %v", err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// codeMessageBody finds the login-code delivery message among the user's
|
||||
// full message history (not the dialog summary, which only tracks each
|
||||
// peer's single top message -- SignUp's welcome message overwrites the
|
||||
// 777000 dialog's top message right after the login-code one is written).
|
||||
func codeMessageBody(t *testing.T, ctx context.Context, messages *memory.MessageStore, userID int64) string {
|
||||
t.Helper()
|
||||
list, err := messages.ListByUser(ctx, userID, domain.MessageFilter{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("ListByUser: %v", err)
|
||||
}
|
||||
for _, msg := range list.Messages {
|
||||
if strings.Contains(msg.Body, "12345") {
|
||||
return msg.Body
|
||||
}
|
||||
}
|
||||
t.Fatalf("no login-code message (containing 12345) found among %d messages for user %d", len(list.Messages), userID)
|
||||
return ""
|
||||
}
|
||||
|
|
@ -17,8 +17,8 @@ import (
|
|||
"github.com/iamxvbaba/td/bin"
|
||||
mtcrypto "github.com/iamxvbaba/td/crypto"
|
||||
|
||||
"telesrv/internal/branding"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/identity"
|
||||
"telesrv/internal/otpdelivery"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
|
@ -114,6 +114,21 @@ type Service struct {
|
|||
// stickerSets/defaultStickerSetID:新注册账号默认安装的贴纸集(见 WithDefaultStickerSet)。
|
||||
stickerSets userStickerSetInstaller
|
||||
defaultStickerSetID int64
|
||||
// welcomeMessageIdentity 是 identity.Store 的共享实例,recordWelcomeMessage
|
||||
// 每次调用都重新读取(不缓存),使 admin 面板对登录通知模板的修改无需重启即可
|
||||
// 生效 -- 与 identity 包自身的设计契约一致。nil 时该来源被跳过,直接落到
|
||||
// welcomeMessage{Phone,Email}Default。
|
||||
welcomeMessageIdentity *identity.Store
|
||||
welcomeMessagePhoneDefault string
|
||||
welcomeMessageEmailDefault string
|
||||
// loginCodeMessageIdentity/loginCodeMessageEnvDefault mirror
|
||||
// welcomeMessageIdentity/welcomeMessage{Phone,Email}Default above, for
|
||||
// the 777000 login-code delivery message instead of the post-sign-in
|
||||
// welcome notification -- see WithLoginCodeMessageTemplate and
|
||||
// resolveLoginCodeMessageTemplate. There is only one env default (not
|
||||
// per-method) because the login-code message never varies by channel.
|
||||
loginCodeMessageIdentity *identity.Store
|
||||
loginCodeMessageEnvDefault string
|
||||
}
|
||||
|
||||
type loginEmailStore interface {
|
||||
|
|
@ -147,6 +162,53 @@ func WithLoginMessages(messages store.MessageStore, dialogs store.DialogStore) O
|
|||
}
|
||||
}
|
||||
|
||||
// WithLoginWelcomeMessages configures the resolution chain for the 777000
|
||||
// login-notification message's template text (see
|
||||
// domain.ResolveWelcomeMessageTemplate): store is read fresh on every
|
||||
// recordWelcomeMessage call (never cached, so admin-panel edits apply with
|
||||
// no restart), and phoneDefault/emailDefault are the config-supplied env-var
|
||||
// fallbacks (Config.WelcomeMessage{Phone,Email}Template), used whenever the
|
||||
// panel hasn't set an override. A nil store just skips that source.
|
||||
func WithLoginWelcomeMessages(store *identity.Store, phoneDefault, emailDefault string) Option {
|
||||
return func(s *Service) {
|
||||
s.welcomeMessageIdentity = store
|
||||
s.welcomeMessagePhoneDefault = phoneDefault
|
||||
s.welcomeMessageEmailDefault = emailDefault
|
||||
}
|
||||
}
|
||||
|
||||
// WithLoginCodeMessageTemplate configures the resolution chain for the
|
||||
// 777000 login-code delivery message's template text (see
|
||||
// domain.ResolveLoginCodeMessageTemplate): store is read fresh on every
|
||||
// deliverLoginCode/recordLoginMessage call (never cached, so admin-panel
|
||||
// edits apply with no restart), and envDefault is the config-supplied
|
||||
// env-var fallback (Config.LoginCodeMessageTemplate), used whenever the
|
||||
// panel hasn't set an override. A nil store just skips that source. Callers
|
||||
// normally pass the same *identity.Store instance already wired via
|
||||
// WithLoginWelcomeMessages, since both read/write the same identity.json.
|
||||
func WithLoginCodeMessageTemplate(store *identity.Store, envDefault string) Option {
|
||||
return func(s *Service) {
|
||||
s.loginCodeMessageIdentity = store
|
||||
s.loginCodeMessageEnvDefault = envDefault
|
||||
}
|
||||
}
|
||||
|
||||
// resolveLoginCodeMessageTemplate resolves the 777000 login-code message
|
||||
// template fresh on every call (never cached), mirroring
|
||||
// recordWelcomeMessage's "always read fresh" contract so an admin-panel
|
||||
// edit takes effect with no restart. Every caller of
|
||||
// domain.OfficialLoginCodeMessage in this service must go through here
|
||||
// rather than hardcoding its own copy of the template.
|
||||
func (s *Service) resolveLoginCodeMessageTemplate() string {
|
||||
panelOverride := ""
|
||||
if s.loginCodeMessageIdentity != nil {
|
||||
if info, err := s.loginCodeMessageIdentity.Get(); err == nil {
|
||||
panelOverride = info.LoginCodeMessageTemplate
|
||||
}
|
||||
}
|
||||
return domain.ResolveLoginCodeMessageTemplate(panelOverride, s.loginCodeMessageEnvDefault)
|
||||
}
|
||||
|
||||
// WithLoginCodeDelivery 注入已有账号 app-code 的 durable 投递边界。
|
||||
// 实现必须以 user_id + phone_code_hash 幂等,并原子写入 777000
|
||||
// message/dialog/user update event/dispatch outbox。
|
||||
|
|
@ -590,6 +652,7 @@ func (s *Service) deliverLoginCode(ctx context.Context, userID int64, phoneCodeH
|
|||
UserID: userID,
|
||||
PhoneCodeHash: phoneCodeHash,
|
||||
Code: code,
|
||||
Template: s.resolveLoginCodeMessageTemplate(),
|
||||
Date: int(now.Unix()),
|
||||
ExpiresAt: now.Add(s.codeTTL).Unix(),
|
||||
}); err != nil {
|
||||
|
|
@ -1579,31 +1642,24 @@ func (s *Service) passwordNeeded(ctx context.Context, userID int64) (bool, error
|
|||
return found && settings.HasPassword, nil
|
||||
}
|
||||
|
||||
func loginMessageTemplate() string {
|
||||
return `Login code: %s. Do not give this code to anyone, even if they say they are from ` + branding.ProductName + `!
|
||||
|
||||
This code can be used to log in to your ` + branding.ProductName + ` account. We never ask it for anything else.
|
||||
|
||||
If you didn't request this code by trying to log in on another device, simply ignore this message.`
|
||||
}
|
||||
|
||||
// recordLoginMessage writes the 777000 login-code message for the
|
||||
// bootstrap "new phone-channel account" path (see SignUp's rec.Channel ==
|
||||
// codeChannelPhone branch), where no owner/dialog exists yet so
|
||||
// WithLoginCodeDelivery's durable idempotent path cannot be used. It builds
|
||||
// the message the same way deliverLoginCode does -- via
|
||||
// domain.OfficialLoginCodeMessage with a freshly resolved template (see
|
||||
// resolveLoginCodeMessageTemplate) -- rather than keeping its own separate
|
||||
// copy of the template/entity logic, so an admin-panel edit and the
|
||||
// {{code}}-placeholder entity-offset fix apply here too.
|
||||
func (s *Service) recordLoginMessage(ctx context.Context, userID int64, code string) (domain.Message, error) {
|
||||
if s.messages == nil || s.dialogs == nil {
|
||||
return domain.Message{}, nil
|
||||
}
|
||||
body := fmt.Sprintf(loginMessageTemplate(), code)
|
||||
codeOffset := len("Login code: ")
|
||||
msg, err := s.messages.Create(ctx, domain.Message{
|
||||
OwnerUserID: userID,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
|
||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
|
||||
Date: int(time.Now().Unix()),
|
||||
Body: body,
|
||||
Entities: []domain.MessageEntity{
|
||||
{Type: domain.MessageEntityBold, Offset: 0, Length: len("Login code:")},
|
||||
{Type: domain.MessageEntityBold, Offset: codeOffset, Length: len(code)},
|
||||
},
|
||||
})
|
||||
base, err := domain.OfficialLoginCodeMessage(userID, s.resolveLoginCodeMessageTemplate(), code, int(time.Now().Unix()))
|
||||
if err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
msg, err := s.messages.Create(ctx, base)
|
||||
if err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
|
|
@ -1626,7 +1682,24 @@ func (s *Service) recordWelcomeMessage(ctx context.Context, u domain.User) {
|
|||
if s == nil || s.messages == nil || s.dialogs == nil {
|
||||
return
|
||||
}
|
||||
msg, err := domain.OfficialWelcomeMessage(u.ID, domain.SignInMethodLabel(u), int(time.Now().Unix()))
|
||||
method := domain.LoginMethodFromLabel(domain.SignInMethodLabel(u))
|
||||
envDefault := s.welcomeMessagePhoneDefault
|
||||
if method == domain.LoginMethodEmail {
|
||||
envDefault = s.welcomeMessageEmailDefault
|
||||
}
|
||||
panelOverride := ""
|
||||
if s.welcomeMessageIdentity != nil {
|
||||
if info, err := s.welcomeMessageIdentity.Get(); err == nil {
|
||||
if method == domain.LoginMethodEmail {
|
||||
panelOverride = info.WelcomeMessageEmailTemplate
|
||||
} else {
|
||||
panelOverride = info.WelcomeMessagePhoneTemplate
|
||||
}
|
||||
}
|
||||
}
|
||||
template := domain.ResolveWelcomeMessageTemplate(method, panelOverride, envDefault)
|
||||
body := domain.RenderWelcomeMessageTemplate(template)
|
||||
msg, err := domain.OfficialWelcomeMessage(u.ID, body, int(time.Now().Unix()))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ func TestEmailSignupSignUpWritesWelcomeMessageMentioningEmail(t *testing.T) {
|
|||
if len(list.Messages) != 1 {
|
||||
t.Fatalf("messages = %+v, want exactly the welcome message (email channel skips the code-echo message)", list.Messages)
|
||||
}
|
||||
if !strings.Contains(list.Messages[0].Body, "Welcome to OwpenGram") || !strings.Contains(list.Messages[0].Body, "via email") {
|
||||
if !strings.Contains(list.Messages[0].Body, "Welcome to") || !strings.Contains(list.Messages[0].Body, "email address") {
|
||||
t.Fatalf("welcome message body = %q, want greeting mentioning email", list.Messages[0].Body)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -191,6 +191,23 @@ type Config struct {
|
|||
|
||||
// DevAuthCode 是开发固定验证码;生产短信/风控不在当前范围内。
|
||||
DevAuthCode string
|
||||
// WelcomeMessagePhoneTemplate/WelcomeMessageEmailTemplate are the
|
||||
// fallback templates for the 777000 login-notification message sent on
|
||||
// every completed phone/email sign-in (see
|
||||
// domain.ResolveWelcomeMessageTemplate), used whenever the admin panel
|
||||
// hasn't set an override in internal/identity.Store. Support the
|
||||
// {{server_name}} placeholder. Defaults to the compiled-in copy in
|
||||
// domain.DefaultWelcomeMessage{Phone,Email}Template.
|
||||
WelcomeMessagePhoneTemplate string
|
||||
WelcomeMessageEmailTemplate string
|
||||
// LoginCodeMessageTemplate is the fallback template for the 777000
|
||||
// login-code delivery message sent for every login code, regardless of
|
||||
// channel (see domain.ResolveLoginCodeMessageTemplate), used whenever
|
||||
// the admin panel hasn't set an override in internal/identity.Store.
|
||||
// Supports {{server_name}} and requires the {{code}} placeholder
|
||||
// exactly once. Defaults to the compiled-in copy in
|
||||
// domain.DefaultLoginCodeMessageTemplate.
|
||||
LoginCodeMessageTemplate string
|
||||
// AuthCodeTTL 是登录/注册/邮箱验证 code 的有效期。
|
||||
AuthCodeTTL time.Duration
|
||||
// PhoneCodeLength 是使用外部 provider 时生成的短信验证码长度。development
|
||||
|
|
@ -882,32 +899,35 @@ func Load() (Config, error) {
|
|||
RedisPassword: envOr("TELESRV_REDIS_PASSWORD", ""),
|
||||
RedisDB: envIntOr("TELESRV_REDIS_DB", 0),
|
||||
|
||||
DevAuthCode: envOr("TELESRV_DEV_AUTH_CODE", "12345"),
|
||||
AuthCodeTTL: envDurationOr("TELESRV_AUTH_CODE_TTL", 5*time.Minute),
|
||||
PhoneCodeLength: envIntOr("TELESRV_PHONE_CODE_LENGTH", 5),
|
||||
AuthCodeMaxAttempts: envIntOr("TELESRV_AUTH_CODE_MAX_ATTEMPTS", 5),
|
||||
AuthCodePhoneRateLimit: envIntOr("TELESRV_AUTH_CODE_PHONE_RATE_LIMIT", 5),
|
||||
AuthCodeAuthKeyRateLimit: envIntOr("TELESRV_AUTH_CODE_AUTH_KEY_RATE_LIMIT", 20),
|
||||
AuthCodeRateWindow: envDurationOr("TELESRV_AUTH_CODE_RATE_WINDOW", 10*time.Minute),
|
||||
LoginEmailEnable: envBoolOr("TELESRV_LOGIN_EMAIL_ENABLE", false),
|
||||
LoginEmailRequireSetup: envBoolOr("TELESRV_LOGIN_EMAIL_REQUIRE_SETUP", false),
|
||||
EmailSignupEnable: envBoolOr("TELESRV_EMAIL_SIGNUP_ENABLE", false),
|
||||
EmailSignupPhonePrefixes: envListOr("TELESRV_EMAIL_SIGNUP_PHONE_PREFIXES", []string{"888"}),
|
||||
LoginEmailCodeLength: envIntOr("TELESRV_LOGIN_EMAIL_CODE_LENGTH", 6),
|
||||
PhoneCodeDeliveryProvider: strings.ToLower(strings.TrimSpace(envOr("TELESRV_PHONE_CODE_DELIVERY_PROVIDER", "development"))),
|
||||
EmailCodeDeliveryProvider: strings.ToLower(strings.TrimSpace(envOr("TELESRV_EMAIL_CODE_DELIVERY_PROVIDER", "smtp"))),
|
||||
OTPWebhookURL: envOr("TELESRV_OTP_WEBHOOK_URL", ""),
|
||||
OTPWebhookSecret: envOr("TELESRV_OTP_WEBHOOK_SECRET", ""),
|
||||
OTPWebhookTimeout: envDurationOr("TELESRV_OTP_WEBHOOK_TIMEOUT", 5*time.Second),
|
||||
SMTPHost: envOr("TELESRV_SMTP_HOST", ""),
|
||||
SMTPPort: envIntOr("TELESRV_SMTP_PORT", 587),
|
||||
SMTPUsername: envOr("TELESRV_SMTP_USERNAME", ""),
|
||||
SMTPPassword: envOr("TELESRV_SMTP_PASSWORD", ""),
|
||||
SMTPFrom: envOr("TELESRV_SMTP_FROM", ""),
|
||||
SMTPFromName: envOr("TELESRV_SMTP_FROM_NAME", branding.ProductName),
|
||||
SMTPTLSMode: strings.ToLower(strings.TrimSpace(envOr("TELESRV_SMTP_TLS", "starttls"))),
|
||||
SMTPTimeout: envDurationOr("TELESRV_SMTP_TIMEOUT", 10*time.Second),
|
||||
LangPackSeedDir: envOr("TELESRV_LANGPACK_SEED_DIR", "data/langpack"),
|
||||
DevAuthCode: envOr("TELESRV_DEV_AUTH_CODE", "12345"),
|
||||
WelcomeMessagePhoneTemplate: envOr("TELESRV_WELCOME_MESSAGE_PHONE_TEMPLATE", domain.DefaultWelcomeMessagePhoneTemplate),
|
||||
WelcomeMessageEmailTemplate: envOr("TELESRV_WELCOME_MESSAGE_EMAIL_TEMPLATE", domain.DefaultWelcomeMessageEmailTemplate),
|
||||
LoginCodeMessageTemplate: envOr("TELESRV_LOGIN_CODE_MESSAGE_TEMPLATE", domain.DefaultLoginCodeMessageTemplate),
|
||||
AuthCodeTTL: envDurationOr("TELESRV_AUTH_CODE_TTL", 5*time.Minute),
|
||||
PhoneCodeLength: envIntOr("TELESRV_PHONE_CODE_LENGTH", 5),
|
||||
AuthCodeMaxAttempts: envIntOr("TELESRV_AUTH_CODE_MAX_ATTEMPTS", 5),
|
||||
AuthCodePhoneRateLimit: envIntOr("TELESRV_AUTH_CODE_PHONE_RATE_LIMIT", 5),
|
||||
AuthCodeAuthKeyRateLimit: envIntOr("TELESRV_AUTH_CODE_AUTH_KEY_RATE_LIMIT", 20),
|
||||
AuthCodeRateWindow: envDurationOr("TELESRV_AUTH_CODE_RATE_WINDOW", 10*time.Minute),
|
||||
LoginEmailEnable: envBoolOr("TELESRV_LOGIN_EMAIL_ENABLE", false),
|
||||
LoginEmailRequireSetup: envBoolOr("TELESRV_LOGIN_EMAIL_REQUIRE_SETUP", false),
|
||||
EmailSignupEnable: envBoolOr("TELESRV_EMAIL_SIGNUP_ENABLE", false),
|
||||
EmailSignupPhonePrefixes: envListOr("TELESRV_EMAIL_SIGNUP_PHONE_PREFIXES", []string{"888"}),
|
||||
LoginEmailCodeLength: envIntOr("TELESRV_LOGIN_EMAIL_CODE_LENGTH", 6),
|
||||
PhoneCodeDeliveryProvider: strings.ToLower(strings.TrimSpace(envOr("TELESRV_PHONE_CODE_DELIVERY_PROVIDER", "development"))),
|
||||
EmailCodeDeliveryProvider: strings.ToLower(strings.TrimSpace(envOr("TELESRV_EMAIL_CODE_DELIVERY_PROVIDER", "smtp"))),
|
||||
OTPWebhookURL: envOr("TELESRV_OTP_WEBHOOK_URL", ""),
|
||||
OTPWebhookSecret: envOr("TELESRV_OTP_WEBHOOK_SECRET", ""),
|
||||
OTPWebhookTimeout: envDurationOr("TELESRV_OTP_WEBHOOK_TIMEOUT", 5*time.Second),
|
||||
SMTPHost: envOr("TELESRV_SMTP_HOST", ""),
|
||||
SMTPPort: envIntOr("TELESRV_SMTP_PORT", 587),
|
||||
SMTPUsername: envOr("TELESRV_SMTP_USERNAME", ""),
|
||||
SMTPPassword: envOr("TELESRV_SMTP_PASSWORD", ""),
|
||||
SMTPFrom: envOr("TELESRV_SMTP_FROM", ""),
|
||||
SMTPFromName: envOr("TELESRV_SMTP_FROM_NAME", branding.ProductName),
|
||||
SMTPTLSMode: strings.ToLower(strings.TrimSpace(envOr("TELESRV_SMTP_TLS", "starttls"))),
|
||||
SMTPTimeout: envDurationOr("TELESRV_SMTP_TIMEOUT", 10*time.Second),
|
||||
LangPackSeedDir: envOr("TELESRV_LANGPACK_SEED_DIR", "data/langpack"),
|
||||
// s3 (MinIO by default, see deploy/docker-compose.yml's minio service) is
|
||||
// the default blob backend; localfs remains fully supported as an
|
||||
// explicit opt-in (TELESRV_BLOB_BACKEND=localfs).
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ func TestServiceIdentityAndLoginMessageUseOwpenGramBrand(t *testing.T) {
|
|||
if serviceUser.FirstName != "OwpenGram" || serviceUser.Username != "" {
|
||||
t.Fatalf("service user = %+v, want OwpenGram identity with no username", serviceUser)
|
||||
}
|
||||
message, err := OfficialLoginCodeMessage(42, "12345", 1)
|
||||
message, err := OfficialLoginCodeMessage(42, "", "12345", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("build login message: %v", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,87 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"telesrv/internal/branding"
|
||||
)
|
||||
|
||||
func officialLoginCodeMessageTemplate() string {
|
||||
return `Login code: %s. Do not give this code to anyone, even if they say they are from ` + branding.ProductName + `!
|
||||
// loginCodeTemplateCodePlaceholder marks where the actual OTP code is
|
||||
// substituted into a (possibly admin-edited) login-code message template.
|
||||
// Unlike the old hardcoded "Login code: %s" format, the template body is no
|
||||
// longer fixed, so the substituted code's bold MessageEntity offset/length
|
||||
// must be computed dynamically from wherever the placeholder actually lands
|
||||
// -- see OfficialLoginCodeMessage. It must appear exactly once in any
|
||||
// template that reaches OfficialLoginCodeMessage (see
|
||||
// ValidateLoginCodeMessageTemplate): zero occurrences would silently drop
|
||||
// the code from the message entirely, and two-or-more is ambiguous about
|
||||
// which occurrence is "the" code.
|
||||
const loginCodeTemplateCodePlaceholder = "{{code}}"
|
||||
|
||||
This code can be used to log in to your ` + branding.ProductName + ` account. We never ask it for anything else.
|
||||
// DefaultLoginCodeMessageTemplate is the built-in, final-fallback copy for
|
||||
// the 777000 login-code delivery message. It is sent for every login code
|
||||
// regardless of delivery channel (phone SMS or email) -- see the
|
||||
// LoginCodeDeliveryStore implementations in internal/store/{postgres,memory},
|
||||
// which all pass the same code through unconditionally, and
|
||||
// internal/app/auth's recordLoginMessage (the new-account bootstrap path).
|
||||
// Unlike DefaultWelcomeMessage{Phone,Email}Template there is only one
|
||||
// template: the message never varies by channel. Supports {{server_name}}
|
||||
// (see RenderWelcomeMessageTemplate) and requires the {{code}} placeholder
|
||||
// exactly once (see ValidateLoginCodeMessageTemplate).
|
||||
const DefaultLoginCodeMessageTemplate = `Login code: {{code}}. Do not give this code to anyone, even if they say they are from {{server_name}}!
|
||||
|
||||
This code can be used to log in to your {{server_name}} account. We never ask it for anything else.
|
||||
|
||||
If you didn't request this code by trying to log in on another device, simply ignore this message.`
|
||||
|
||||
// ErrLoginCodeMessageTemplateMissingCode is returned when a candidate
|
||||
// login-code message template does not contain the {{code}} placeholder
|
||||
// exactly once -- see ValidateLoginCodeMessageTemplate. The admin-API layer
|
||||
// (cmd/telesrv-admin) must reject a save with this error outright rather
|
||||
// than silently accepting it: a template with zero {{code}} occurrences
|
||||
// would never deliver the actual OTP to the user at all.
|
||||
var ErrLoginCodeMessageTemplateMissingCode = errors.New("login code message template must contain the {{code}} placeholder exactly once")
|
||||
|
||||
// ValidateLoginCodeMessageTemplate requires the {{code}} placeholder to
|
||||
// appear exactly once. Zero occurrences is a functional break (the OTP
|
||||
// itself would never reach the user), and two-or-more is ambiguous (which
|
||||
// occurrence gets the bold entity and the substitution?) -- both are
|
||||
// rejected outright, never silently patched around by e.g. appending the
|
||||
// code somewhere the admin didn't put it.
|
||||
func ValidateLoginCodeMessageTemplate(template string) error {
|
||||
if strings.Count(template, loginCodeTemplateCodePlaceholder) != 1 {
|
||||
return ErrLoginCodeMessageTemplateMissingCode
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolveLoginCodeMessageTemplate picks the final template body, in order:
|
||||
// an explicit admin-panel override (panelOverride, as stored raw in
|
||||
// identity.Info -- empty means "not configured"), then an explicit env-var
|
||||
// default (envDefault, empty means "not configured"), then the compiled-in
|
||||
// DefaultLoginCodeMessageTemplate. It is a pure function so the precedence
|
||||
// logic can be unit-tested without touching the identity store or config --
|
||||
// those live in internal/app/auth, which resolves this fresh on every
|
||||
// login-code delivery (never cached) so an admin-panel edit takes effect
|
||||
// immediately, mirroring ResolveWelcomeMessageTemplate. Unlike that
|
||||
// resolver there is no per-method branching: every login code, regardless
|
||||
// of delivery channel, uses the same template.
|
||||
//
|
||||
// This does not itself validate the {{code}} placeholder -- callers that
|
||||
// persist an override (the admin API) must call
|
||||
// ValidateLoginCodeMessageTemplate before saving. OfficialLoginCodeMessage
|
||||
// re-validates whatever it resolves to anyway, as defense in depth against
|
||||
// an invalid value that reached here some other way (a hand-edited
|
||||
// identity.json, an out-of-band env var change).
|
||||
func ResolveLoginCodeMessageTemplate(panelOverride, envDefault string) string {
|
||||
if t := strings.TrimSpace(panelOverride); t != "" {
|
||||
return panelOverride
|
||||
}
|
||||
if t := strings.TrimSpace(envDefault); t != "" {
|
||||
return envDefault
|
||||
}
|
||||
return DefaultLoginCodeMessageTemplate
|
||||
}
|
||||
|
||||
// LoginCodeDeliveryRequest describes one durable 777000 login-code delivery.
|
||||
|
|
@ -23,7 +91,15 @@ type LoginCodeDeliveryRequest struct {
|
|||
UserID int64
|
||||
PhoneCodeHash string
|
||||
Code string
|
||||
Date int
|
||||
// Template is the already-resolved login-code message template (see
|
||||
// ResolveLoginCodeMessageTemplate) -- resolving it requires the identity
|
||||
// store and config, both of which live above internal/store, so callers
|
||||
// (internal/app/auth) do that and pass the final template text in here,
|
||||
// the same division of responsibility OfficialWelcomeMessage's body
|
||||
// parameter uses. Empty falls back to DefaultLoginCodeMessageTemplate
|
||||
// (see OfficialLoginCodeMessage).
|
||||
Template string
|
||||
Date int
|
||||
// ExpiresAt is the unix second after which the compact idempotency receipt
|
||||
// may be reclaimed. It must cover the corresponding code's usable lifetime.
|
||||
ExpiresAt int64
|
||||
|
|
@ -39,12 +115,33 @@ type LoginCodeDeliveryResult struct {
|
|||
// OfficialLoginCodeMessage builds the account-visible incoming message from
|
||||
// Telegram's official notification account. Persistence assigns ID, UID and
|
||||
// Pts atomically.
|
||||
func OfficialLoginCodeMessage(userID int64, code string, date int) (Message, error) {
|
||||
//
|
||||
// template is rendered ({{server_name}} substituted, then {{code}} replaced
|
||||
// with the actual code) and the resulting bold MessageEntity is positioned
|
||||
// dynamically from wherever {{code}} actually landed after substitution --
|
||||
// never assumed from a fixed prefix, since template is admin-editable (see
|
||||
// ValidateLoginCodeMessageTemplate). A template that is empty or fails
|
||||
// validation falls back to DefaultLoginCodeMessageTemplate instead of ever
|
||||
// shipping a message with no code in it.
|
||||
func OfficialLoginCodeMessage(userID int64, template, code string, date int) (Message, error) {
|
||||
if userID <= 0 || IsSystemUserID(userID) || strings.TrimSpace(code) == "" || len(code) > 64 || date < 0 || date > math.MaxInt32 {
|
||||
return Message{}, fmt.Errorf("%w: user=%d code_length=%d date=%d", ErrLoginCodeDeliveryInvalid, userID, len(code), date)
|
||||
}
|
||||
body := fmt.Sprintf(officialLoginCodeMessageTemplate(), code)
|
||||
codeOffset := len("Login code: ")
|
||||
if strings.TrimSpace(template) == "" || ValidateLoginCodeMessageTemplate(template) != nil {
|
||||
template = DefaultLoginCodeMessageTemplate
|
||||
}
|
||||
rendered := RenderWelcomeMessageTemplate(template)
|
||||
idx := strings.Index(rendered, loginCodeTemplateCodePlaceholder)
|
||||
if idx < 0 {
|
||||
// Unreachable in practice: template was just validated (or is the
|
||||
// compiled-in default) to contain the placeholder exactly once, and
|
||||
// {{server_name}} substitution cannot remove or relocate an
|
||||
// unrelated placeholder. Guarded anyway rather than ever ship a
|
||||
// message silently missing its code.
|
||||
rendered = DefaultLoginCodeMessageTemplate
|
||||
idx = strings.Index(rendered, loginCodeTemplateCodePlaceholder)
|
||||
}
|
||||
body := rendered[:idx] + code + rendered[idx+len(loginCodeTemplateCodePlaceholder):]
|
||||
return Message{
|
||||
OwnerUserID: userID,
|
||||
Peer: Peer{Type: PeerTypeUser, ID: OfficialSystemUserID},
|
||||
|
|
@ -52,8 +149,7 @@ func OfficialLoginCodeMessage(userID int64, code string, date int) (Message, err
|
|||
Date: date,
|
||||
Body: body,
|
||||
Entities: []MessageEntity{
|
||||
{Type: MessageEntityBold, Offset: 0, Length: len("Login code:")},
|
||||
{Type: MessageEntityBold, Offset: codeOffset, Length: len(code)},
|
||||
{Type: MessageEntityBold, Offset: automaticEntityUTF16Length(rendered[:idx]), Length: automaticEntityUTF16Length(code)},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
|
|
|||
132
internal/domain/login_code_delivery_test.go
Normal file
132
internal/domain/login_code_delivery_test.go
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateLoginCodeMessageTemplate(t *testing.T) {
|
||||
if err := ValidateLoginCodeMessageTemplate("Your code is {{code}}."); err != nil {
|
||||
t.Fatalf("exactly one {{code}} should be valid, got %v", err)
|
||||
}
|
||||
if err := ValidateLoginCodeMessageTemplate("No placeholder here."); !errors.Is(err, ErrLoginCodeMessageTemplateMissingCode) {
|
||||
t.Fatalf("zero occurrences should be rejected, got %v", err)
|
||||
}
|
||||
if err := ValidateLoginCodeMessageTemplate("{{code}} and again {{code}}."); !errors.Is(err, ErrLoginCodeMessageTemplateMissingCode) {
|
||||
t.Fatalf("two occurrences should be rejected, got %v", err)
|
||||
}
|
||||
if err := ValidateLoginCodeMessageTemplate(""); !errors.Is(err, ErrLoginCodeMessageTemplateMissingCode) {
|
||||
t.Fatalf("empty template should be rejected, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLoginCodeMessageTemplatePrecedence(t *testing.T) {
|
||||
const panel = "panel override {{code}}"
|
||||
const env = "env default {{code}}"
|
||||
|
||||
if got := ResolveLoginCodeMessageTemplate(panel, env); got != panel {
|
||||
t.Fatalf("panel override should win, got %q", got)
|
||||
}
|
||||
if got := ResolveLoginCodeMessageTemplate("", env); got != env {
|
||||
t.Fatalf("env default should win when panel unset, got %q", got)
|
||||
}
|
||||
if got := ResolveLoginCodeMessageTemplate(" ", env); got != env {
|
||||
t.Fatalf("whitespace-only panel override should be treated as unset, got %q", got)
|
||||
}
|
||||
if got := ResolveLoginCodeMessageTemplate("", ""); got != DefaultLoginCodeMessageTemplate {
|
||||
t.Fatalf("built-in default should be the final fallback, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfficialLoginCodeMessageDynamicEntityOffset(t *testing.T) {
|
||||
SetOfficialSystemUserDisplayName("")
|
||||
defer SetOfficialSystemUserDisplayName("")
|
||||
|
||||
// {{code}} is nowhere near a fixed prefix here -- it sits at the end of
|
||||
// a sentence, after other text -- proving the entity offset is computed
|
||||
// from where the placeholder actually landed, not assumed from a
|
||||
// hardcoded "Login code: " prefix the way the old %s-based
|
||||
// implementation did.
|
||||
template := "Please do not share your one-time code, which is: {{code}} -- thanks!"
|
||||
msg, err := OfficialLoginCodeMessage(7, template, "998877", 1700000000)
|
||||
if err != nil {
|
||||
t.Fatalf("OfficialLoginCodeMessage: %v", err)
|
||||
}
|
||||
wantBody := "Please do not share your one-time code, which is: 998877 -- thanks!"
|
||||
if msg.Body != wantBody {
|
||||
t.Fatalf("body = %q, want %q", msg.Body, wantBody)
|
||||
}
|
||||
if len(msg.Entities) != 1 {
|
||||
t.Fatalf("expected exactly one entity, got %d: %+v", len(msg.Entities), msg.Entities)
|
||||
}
|
||||
entity := msg.Entities[0]
|
||||
if entity.Type != MessageEntityBold {
|
||||
t.Fatalf("expected bold entity, got %v", entity.Type)
|
||||
}
|
||||
wantOffset := automaticEntityUTF16Length("Please do not share your one-time code, which is: ")
|
||||
if entity.Offset != wantOffset {
|
||||
t.Fatalf("offset = %d, want %d", entity.Offset, wantOffset)
|
||||
}
|
||||
if entity.Length != automaticEntityUTF16Length("998877") {
|
||||
t.Fatalf("length = %d, want %d", entity.Length, automaticEntityUTF16Length("998877"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfficialLoginCodeMessageOffsetShiftsWithServerNameSubstitution(t *testing.T) {
|
||||
SetOfficialSystemUserDisplayName("A Much Longer Custom Server Name")
|
||||
defer SetOfficialSystemUserDisplayName("")
|
||||
|
||||
// {{server_name}} is substituted BEFORE {{code}}'s position is located,
|
||||
// so a longer server name shifts the code's offset. If the offset math
|
||||
// were still relying on a fixed/original position (e.g. computed
|
||||
// against the raw un-substituted template), this would land on the
|
||||
// wrong text.
|
||||
template := "Server {{server_name}} says your code is {{code}}."
|
||||
msg, err := OfficialLoginCodeMessage(7, template, "42", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("OfficialLoginCodeMessage: %v", err)
|
||||
}
|
||||
wantBody := "Server A Much Longer Custom Server Name says your code is 42."
|
||||
if msg.Body != wantBody {
|
||||
t.Fatalf("body = %q, want %q", msg.Body, wantBody)
|
||||
}
|
||||
wantOffset := automaticEntityUTF16Length("Server A Much Longer Custom Server Name says your code is ")
|
||||
if len(msg.Entities) != 1 || msg.Entities[0].Offset != wantOffset {
|
||||
t.Fatalf("entities = %+v, want single bold entity at offset %d", msg.Entities, wantOffset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfficialLoginCodeMessageFallsBackWhenTemplateInvalid(t *testing.T) {
|
||||
SetOfficialSystemUserDisplayName("")
|
||||
defer SetOfficialSystemUserDisplayName("")
|
||||
|
||||
// Defense in depth: a template that somehow reaches here without
|
||||
// {{code}} (or with it more than once) must never ship a message
|
||||
// silently missing the actual OTP -- it falls back to the compiled-in
|
||||
// default instead.
|
||||
for _, template := range []string{"", "no placeholder", "{{code}} twice {{code}}"} {
|
||||
msg, err := OfficialLoginCodeMessage(7, template, "13579", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("template %q: OfficialLoginCodeMessage: %v", template, err)
|
||||
}
|
||||
if !strings.Contains(msg.Body, "13579") {
|
||||
t.Fatalf("template %q: fallback body missing code: %q", template, msg.Body)
|
||||
}
|
||||
if len(msg.Entities) != 1 || msg.Entities[0].Length != automaticEntityUTF16Length("13579") {
|
||||
t.Fatalf("template %q: unexpected entities: %+v", template, msg.Entities)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfficialLoginCodeMessageValidation(t *testing.T) {
|
||||
if _, err := OfficialLoginCodeMessage(0, "{{code}}", "12345", 1); !errors.Is(err, ErrLoginCodeDeliveryInvalid) {
|
||||
t.Fatalf("expected invalid user id to be rejected, got %v", err)
|
||||
}
|
||||
if _, err := OfficialLoginCodeMessage(7, "{{code}}", "", 1); !errors.Is(err, ErrLoginCodeDeliveryInvalid) {
|
||||
t.Fatalf("expected empty code to be rejected, got %v", err)
|
||||
}
|
||||
if _, err := OfficialLoginCodeMessage(OfficialSystemUserID, "{{code}}", "12345", 1); !errors.Is(err, ErrLoginCodeDeliveryInvalid) {
|
||||
t.Fatalf("expected system user id to be rejected, got %v", err)
|
||||
}
|
||||
}
|
||||
70
internal/domain/login_welcome_template.go
Normal file
70
internal/domain/login_welcome_template.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
package domain
|
||||
|
||||
import "strings"
|
||||
|
||||
// LoginMethod distinguishes the two sign-in channels the login-welcome
|
||||
// message template can be customized per: phone (SMS/app-code) and email
|
||||
// (email-signup accounts, see SignInMethodLabel). There is no third axis --
|
||||
// no signup-vs-signin distinction, no 2FA-vs-not distinction -- because
|
||||
// recordWelcomeMessage's callers never carry more than this.
|
||||
type LoginMethod string
|
||||
|
||||
const (
|
||||
LoginMethodPhone LoginMethod = "phone"
|
||||
LoginMethodEmail LoginMethod = "email"
|
||||
)
|
||||
|
||||
// LoginMethodFromLabel maps SignInMethodLabel's human-readable string back
|
||||
// to a LoginMethod, so callers that already computed the label (for the
|
||||
// {{...}} template's own historical "via %s" wording) don't need to
|
||||
// recompute it from the User a second time.
|
||||
func LoginMethodFromLabel(label string) LoginMethod {
|
||||
if label == "email" {
|
||||
return LoginMethodEmail
|
||||
}
|
||||
return LoginMethodPhone
|
||||
}
|
||||
|
||||
// DefaultWelcomeMessagePhoneTemplate and DefaultWelcomeMessageEmailTemplate
|
||||
// are the built-in, final-fallback copy for the login-notification message
|
||||
// sent from the official system account (777000) on every completed
|
||||
// sign-in. They are deliberately separate strings (not one template with a
|
||||
// substituted method name) so each reads naturally in its own channel.
|
||||
//
|
||||
// {{server_name}} is replaced with the server's current effective display
|
||||
// name (see ResolveWelcomeMessageTemplate / RenderWelcomeMessageTemplate).
|
||||
const (
|
||||
DefaultWelcomeMessagePhoneTemplate = "👋 Welcome to {{server_name}}!\n\nA new sign-in to your account was just completed using your phone number.\n\nIf this was you, no action is needed. If it wasn't, please revoke this session immediately from Settings → Privacy and Security → Active Sessions."
|
||||
|
||||
DefaultWelcomeMessageEmailTemplate = "👋 Welcome to {{server_name}}!\n\nA new sign-in to your account was just completed using your email address.\n\nIf this was you, no action is needed. If it wasn't, please revoke this session immediately from Settings → Privacy and Security → Active Sessions."
|
||||
)
|
||||
|
||||
// ResolveWelcomeMessageTemplate picks the final template body for the given
|
||||
// login method, in order: an explicit admin-panel override (panelOverride,
|
||||
// as stored raw in identity.Info -- empty means "not configured"), then an
|
||||
// explicit env-var default (envDefault, empty means "not configured"), then
|
||||
// the compiled-in default for that method. It is a pure function so the
|
||||
// precedence logic can be unit-tested without touching the identity store
|
||||
// or config -- those live in internal/app/auth, which calls this on every
|
||||
// recordWelcomeMessage invocation (never cached) so an admin-panel edit
|
||||
// takes effect immediately.
|
||||
func ResolveWelcomeMessageTemplate(method LoginMethod, panelOverride, envDefault string) string {
|
||||
if t := strings.TrimSpace(panelOverride); t != "" {
|
||||
return panelOverride
|
||||
}
|
||||
if t := strings.TrimSpace(envDefault); t != "" {
|
||||
return envDefault
|
||||
}
|
||||
if method == LoginMethodEmail {
|
||||
return DefaultWelcomeMessageEmailTemplate
|
||||
}
|
||||
return DefaultWelcomeMessagePhoneTemplate
|
||||
}
|
||||
|
||||
// RenderWelcomeMessageTemplate substitutes the {{server_name}} placeholder
|
||||
// in template with the server's current effective display name. It is a
|
||||
// literal, single-placeholder replacement -- no templating engine, since
|
||||
// there's exactly one substitution to make.
|
||||
func RenderWelcomeMessageTemplate(template string) string {
|
||||
return strings.ReplaceAll(template, "{{server_name}}", officialSystemDisplayName())
|
||||
}
|
||||
57
internal/domain/login_welcome_template_test.go
Normal file
57
internal/domain/login_welcome_template_test.go
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestResolveWelcomeMessageTemplatePrecedence(t *testing.T) {
|
||||
const panel = "panel override"
|
||||
const env = "env default"
|
||||
|
||||
if got := ResolveWelcomeMessageTemplate(LoginMethodPhone, panel, env); got != panel {
|
||||
t.Fatalf("panel override should win, got %q", got)
|
||||
}
|
||||
if got := ResolveWelcomeMessageTemplate(LoginMethodPhone, "", env); got != env {
|
||||
t.Fatalf("env default should win when panel unset, got %q", got)
|
||||
}
|
||||
if got := ResolveWelcomeMessageTemplate(LoginMethodPhone, " ", env); got != env {
|
||||
t.Fatalf("whitespace-only panel override should be treated as unset, got %q", got)
|
||||
}
|
||||
if got := ResolveWelcomeMessageTemplate(LoginMethodPhone, "", ""); got != DefaultWelcomeMessagePhoneTemplate {
|
||||
t.Fatalf("built-in phone default should be the final fallback, got %q", got)
|
||||
}
|
||||
if got := ResolveWelcomeMessageTemplate(LoginMethodEmail, "", ""); got != DefaultWelcomeMessageEmailTemplate {
|
||||
t.Fatalf("built-in email default should be the final fallback, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginMethodFromLabel(t *testing.T) {
|
||||
if LoginMethodFromLabel("email") != LoginMethodEmail {
|
||||
t.Fatal("expected email label to map to LoginMethodEmail")
|
||||
}
|
||||
if LoginMethodFromLabel("phone number") != LoginMethodPhone {
|
||||
t.Fatal("expected phone label to map to LoginMethodPhone")
|
||||
}
|
||||
if LoginMethodFromLabel("") != LoginMethodPhone {
|
||||
t.Fatal("expected unknown label to default to LoginMethodPhone")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderWelcomeMessageTemplateSubstitutesServerName(t *testing.T) {
|
||||
SetOfficialSystemUserDisplayName("")
|
||||
defer SetOfficialSystemUserDisplayName("")
|
||||
|
||||
got := RenderWelcomeMessageTemplate("Hello from {{server_name}}!")
|
||||
if got != "Hello from OwpenGram!" {
|
||||
t.Fatalf("expected default branding.ProductName substitution, got %q", got)
|
||||
}
|
||||
|
||||
SetOfficialSystemUserDisplayName("Custom Server")
|
||||
got = RenderWelcomeMessageTemplate("Hello from {{server_name}}!")
|
||||
if got != "Hello from Custom Server!" {
|
||||
t.Fatalf("expected custom display name substitution, got %q", got)
|
||||
}
|
||||
|
||||
// No placeholder present -- must be a no-op.
|
||||
if got := RenderWelcomeMessageTemplate("no placeholder here"); got != "no placeholder here" {
|
||||
t.Fatalf("expected no-op when placeholder absent, got %q", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -124,6 +124,19 @@ func SetOfficialSystemUserDisplayName(name string) {
|
|||
officialSystemUserDisplayName = strings.TrimSpace(name)
|
||||
}
|
||||
|
||||
// officialSystemDisplayName returns the official system account's current
|
||||
// effective display name: the operator's custom override if set via
|
||||
// SetOfficialSystemUserDisplayName, else branding.ProductName. Shared by
|
||||
// OfficialSystemUser (777000's FirstName) and the login-welcome-message
|
||||
// {{server_name}} placeholder (see login_welcome_template.go) so both stay
|
||||
// consistent with each other.
|
||||
func officialSystemDisplayName() string {
|
||||
if officialSystemUserDisplayName != "" {
|
||||
return officialSystemUserDisplayName
|
||||
}
|
||||
return branding.ProductName
|
||||
}
|
||||
|
||||
// botFatherPhotoDCID/Stripped 由 files.Service.SeedBotFatherAvatar 在启动时
|
||||
// 通过 SetBotFatherAvatar 写入一次;写入前 BotFatherUser() 不带头像(PhotoID==0)。
|
||||
var (
|
||||
|
|
@ -189,15 +202,11 @@ func SetVerifyBotAvatar(dcID int, stripped []byte) {
|
|||
// config.ReservedUsernames (which it now is, by default, precisely because
|
||||
// nothing keeps another account from claiming it once this one has none).
|
||||
func OfficialSystemUser() User {
|
||||
name := branding.ProductName
|
||||
if officialSystemUserDisplayName != "" {
|
||||
name = officialSystemUserDisplayName
|
||||
}
|
||||
u := User{
|
||||
ID: OfficialSystemUserID,
|
||||
AccessHash: 6599886787491911851,
|
||||
Phone: "42777",
|
||||
FirstName: name,
|
||||
FirstName: officialSystemDisplayName(),
|
||||
Verified: true,
|
||||
Support: true,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,8 +10,6 @@ import (
|
|||
"time"
|
||||
)
|
||||
|
||||
const officialWelcomeMessageTemplate = "👋 Welcome to OwpenGram!\n\nYou just signed in via %s.\n\nIf this wasn't you, revoke this session from \"Settings > Privacy and Security > Active sessions\" immediately."
|
||||
|
||||
// OfficialWelcomeMessage builds the account-visible incoming message sent
|
||||
// from the official system account on every completed sign-in (SignUp and
|
||||
// every subsequent SignIn/SignInWithEmail), regardless of delivery channel.
|
||||
|
|
@ -19,17 +17,23 @@ const officialWelcomeMessageTemplate = "👋 Welcome to OwpenGram!\n\nYou just s
|
|||
// to send unconditionally — it exists to give the account owner (and, on a
|
||||
// self-hosted single-admin server, that's usually also "the admin") a
|
||||
// visible record of every session start.
|
||||
func OfficialWelcomeMessage(userID int64, method string, date int) (Message, error) {
|
||||
method = strings.TrimSpace(method)
|
||||
if userID <= 0 || IsSystemUserID(userID) || method == "" || date < 0 || date > math.MaxInt32 {
|
||||
return Message{}, fmt.Errorf("%w: user=%d method=%q date=%d", ErrLoginCodeDeliveryInvalid, userID, method, date)
|
||||
//
|
||||
// body is the already-resolved, already-{{server_name}}-substituted message
|
||||
// text (see ResolveWelcomeMessageTemplate / RenderWelcomeMessageTemplate in
|
||||
// login_welcome_template.go) -- resolving it requires the identity store and
|
||||
// config, both of which live above this package, so callers (internal/app/auth)
|
||||
// do that and pass the final text in here.
|
||||
func OfficialWelcomeMessage(userID int64, body string, date int) (Message, error) {
|
||||
body = strings.TrimSpace(body)
|
||||
if userID <= 0 || IsSystemUserID(userID) || body == "" || date < 0 || date > math.MaxInt32 {
|
||||
return Message{}, fmt.Errorf("%w: user=%d date=%d", ErrLoginCodeDeliveryInvalid, userID, date)
|
||||
}
|
||||
return Message{
|
||||
OwnerUserID: userID,
|
||||
Peer: Peer{Type: PeerTypeUser, ID: OfficialSystemUserID},
|
||||
From: Peer{Type: PeerTypeUser, ID: OfficialSystemUserID},
|
||||
Date: date,
|
||||
Body: fmt.Sprintf(officialWelcomeMessageTemplate, method),
|
||||
Body: body,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,26 @@ type Info struct {
|
|||
// icon has been uploaded. Kept alongside Name/Description so Store can
|
||||
// find the icon file without a directory listing.
|
||||
IconExt string `json:"icon_ext,omitempty"`
|
||||
// WelcomeMessagePhoneTemplate/WelcomeMessageEmailTemplate are raw
|
||||
// admin-panel overrides for the login-notification message sent from
|
||||
// the official system account (777000) on every completed phone/email
|
||||
// sign-in -- see domain.ResolveWelcomeMessageTemplate. Empty means "not
|
||||
// configured": the resolver falls through to the TELESRV_WELCOME_MESSAGE_*
|
||||
// env var, then the compiled-in default. Deliberately stored raw (not
|
||||
// pre-resolved), so a deployment that never touches the panel keeps
|
||||
// tracking whatever the fallback currently is, including future changes
|
||||
// to the compiled-in default.
|
||||
WelcomeMessagePhoneTemplate string `json:"welcome_message_phone_template,omitempty"`
|
||||
WelcomeMessageEmailTemplate string `json:"welcome_message_email_template,omitempty"`
|
||||
// LoginCodeMessageTemplate is the raw admin-panel override for the
|
||||
// 777000 login-code delivery message (see
|
||||
// domain.ResolveLoginCodeMessageTemplate). Unlike the welcome-message
|
||||
// templates above there is only one -- the message never varies by
|
||||
// delivery channel. Empty means "not configured": the resolver falls
|
||||
// through to the TELESRV_LOGIN_CODE_MESSAGE_TEMPLATE env var, then the
|
||||
// compiled-in default. Stored raw, same "not pre-resolved" contract as
|
||||
// the welcome-message overrides.
|
||||
LoginCodeMessageTemplate string `json:"login_code_message_template,omitempty"`
|
||||
}
|
||||
|
||||
// Store reads/writes Info and the icon file under a directory (typically
|
||||
|
|
@ -80,6 +100,42 @@ func (s *Store) SetText(name, description string) error {
|
|||
return s.save(info)
|
||||
}
|
||||
|
||||
// SetWelcomeMessageTemplates updates the login-notification template
|
||||
// overrides, preserving whatever name/description/icon is already
|
||||
// configured. An empty string in either argument clears that method's
|
||||
// override (falls back to the env var / compiled-in default -- see Info's
|
||||
// field comments), following the same "empty means unset" convention as the
|
||||
// rest of Info.
|
||||
func (s *Store) SetWelcomeMessageTemplates(phone, email string) error {
|
||||
info, err := s.Get()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info.WelcomeMessagePhoneTemplate = strings.TrimSpace(phone)
|
||||
info.WelcomeMessageEmailTemplate = strings.TrimSpace(email)
|
||||
return s.save(info)
|
||||
}
|
||||
|
||||
// SetLoginCodeMessageTemplate updates the login-code delivery message's
|
||||
// admin-panel override, preserving whatever else is already configured. An
|
||||
// empty string clears the override (falls back to the env var / compiled-in
|
||||
// default -- same "empty means unset" convention as the rest of Info).
|
||||
// Unlike SetWelcomeMessageTemplates there is no per-method split: every
|
||||
// login code, regardless of delivery channel, uses the same template.
|
||||
//
|
||||
// Callers must validate template with domain.ValidateLoginCodeMessageTemplate
|
||||
// before calling this -- this method does not itself reject a template
|
||||
// missing the {{code}} placeholder, since internal/identity does not depend
|
||||
// on internal/domain (see the package doc comment).
|
||||
func (s *Store) SetLoginCodeMessageTemplate(template string) error {
|
||||
info, err := s.Get()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info.LoginCodeMessageTemplate = strings.TrimSpace(template)
|
||||
return s.save(info)
|
||||
}
|
||||
|
||||
// SetIcon replaces the icon file (removing any previous one under a
|
||||
// different extension) and records its extension in identity.json.
|
||||
// ext must include the leading dot (e.g. ".png").
|
||||
|
|
|
|||
|
|
@ -93,3 +93,107 @@ func TestStorePreservesIconAcrossTextEdits(t *testing.T) {
|
|||
t.Fatalf("icon lost after unrelated SetText: ext=%q ok=%v", ext, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreWelcomeMessageTemplatesRoundTrip(t *testing.T) {
|
||||
s := NewStore(t.TempDir())
|
||||
|
||||
info, err := s.Get()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.WelcomeMessagePhoneTemplate != "" || info.WelcomeMessageEmailTemplate != "" {
|
||||
t.Fatalf("expected empty overrides before any write, got %+v", info)
|
||||
}
|
||||
|
||||
if err := s.SetWelcomeMessageTemplates(" Custom phone template ", "Custom email template"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err = s.Get()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.WelcomeMessagePhoneTemplate != "Custom phone template" || info.WelcomeMessageEmailTemplate != "Custom email template" {
|
||||
t.Fatalf("got %+v", info)
|
||||
}
|
||||
|
||||
// Clearing one override (empty string) must not disturb the other.
|
||||
if err := s.SetWelcomeMessageTemplates("", "Custom email template"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err = s.Get()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.WelcomeMessagePhoneTemplate != "" || info.WelcomeMessageEmailTemplate != "Custom email template" {
|
||||
t.Fatalf("got %+v after clearing phone override", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreLoginCodeMessageTemplateRoundTrip(t *testing.T) {
|
||||
s := NewStore(t.TempDir())
|
||||
|
||||
info, err := s.Get()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.LoginCodeMessageTemplate != "" {
|
||||
t.Fatalf("expected empty override before any write, got %+v", info)
|
||||
}
|
||||
|
||||
if err := s.SetLoginCodeMessageTemplate(" Custom code template {{code}} "); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err = s.Get()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.LoginCodeMessageTemplate != "Custom code template {{code}}" {
|
||||
t.Fatalf("got %+v", info)
|
||||
}
|
||||
|
||||
// Clearing (empty string) resets to "unset".
|
||||
if err := s.SetLoginCodeMessageTemplate(""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err = s.Get()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.LoginCodeMessageTemplate != "" {
|
||||
t.Fatalf("expected override cleared, got %+v", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreLoginCodeMessageTemplatePreservedAcrossTextEdits(t *testing.T) {
|
||||
s := NewStore(t.TempDir())
|
||||
if err := s.SetLoginCodeMessageTemplate("code tpl {{code}}"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.SetText("New Name", "New description"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := s.Get()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.LoginCodeMessageTemplate != "code tpl {{code}}" {
|
||||
t.Fatalf("login code message template lost after unrelated SetText: %+v", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreWelcomeMessageTemplatesPreservedAcrossTextEdits(t *testing.T) {
|
||||
s := NewStore(t.TempDir())
|
||||
if err := s.SetWelcomeMessageTemplates("phone tpl", "email tpl"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.SetText("New Name", "New description"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := s.Get()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.WelcomeMessagePhoneTemplate != "phone tpl" || info.WelcomeMessageEmailTemplate != "email tpl" {
|
||||
t.Fatalf("welcome message templates lost after unrelated SetText: %+v", info)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,11 +52,24 @@ func SameLoginCodeFingerprint(stored []byte, expected [sha256.Size]byte) bool {
|
|||
// RestoreLoginCodeDeliveryMessage reconstructs the immutable first result from
|
||||
// a compact receipt. The secret code is not duplicated in the receipt: exact
|
||||
// replay has already proven the supplied code fingerprint matches.
|
||||
func RestoreLoginCodeDeliveryMessage(userID int64, code string, date int, privateMessageID int64, messageBoxID, pts int) (domain.Message, error) {
|
||||
//
|
||||
// template is the caller's currently-resolved login-code message template
|
||||
// (see domain.ResolveLoginCodeMessageTemplate), not a historical snapshot of
|
||||
// whatever template was in effect at original-delivery time -- the receipt
|
||||
// does not persist that. In the ordinary case (a same-request or
|
||||
// near-immediate idempotent retry, e.g. resendCode) the template cannot have
|
||||
// changed in between, so this is a no-op distinction; if an admin edits the
|
||||
// template in the narrow window between the original delivery and a later
|
||||
// replay of the same phone_code_hash, the replay's reconstructed Body/Entities
|
||||
// reflect the *current* template rather than the one actually persisted in
|
||||
// the messages table, mirroring this codebase's established "identity is
|
||||
// always read fresh, never versioned" convention (see internal/identity's
|
||||
// package doc comment) rather than a regression specific to this function.
|
||||
func RestoreLoginCodeDeliveryMessage(userID int64, template, code string, date int, privateMessageID int64, messageBoxID, pts int) (domain.Message, error) {
|
||||
if privateMessageID <= 0 || messageBoxID <= 0 || messageBoxID > domain.MaxMessageBoxID || pts <= 0 {
|
||||
return domain.Message{}, fmt.Errorf("restore login code delivery: %w: uid=%d box=%d pts=%d", domain.ErrLoginCodeDeliveryInvalid, privateMessageID, messageBoxID, pts)
|
||||
}
|
||||
msg, err := domain.OfficialLoginCodeMessage(userID, code, date)
|
||||
msg, err := domain.OfficialLoginCodeMessage(userID, template, code, date)
|
||||
if err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,11 +49,12 @@ func TestLoginCodeDeliveryKeyAndFingerprint(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestRestoreLoginCodeDeliveryMessage(t *testing.T) {
|
||||
got, err := RestoreLoginCodeDeliveryMessage(1000000001, "12345", 1700000000, 91, 7, 12)
|
||||
const template = "Your code is {{code}}."
|
||||
got, err := RestoreLoginCodeDeliveryMessage(1000000001, template, "12345", 1700000000, 91, 7, 12)
|
||||
if err != nil {
|
||||
t.Fatalf("RestoreLoginCodeDeliveryMessage: %v", err)
|
||||
}
|
||||
want, err := domain.OfficialLoginCodeMessage(1000000001, "12345", 1700000000)
|
||||
want, err := domain.OfficialLoginCodeMessage(1000000001, template, "12345", 1700000000)
|
||||
if err != nil {
|
||||
t.Fatalf("OfficialLoginCodeMessage: %v", err)
|
||||
}
|
||||
|
|
@ -61,7 +62,7 @@ func TestRestoreLoginCodeDeliveryMessage(t *testing.T) {
|
|||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("restored message = %+v, want %+v", got, want)
|
||||
}
|
||||
if _, err := RestoreLoginCodeDeliveryMessage(1000000001, "12345", 1700000000, 0, 7, 12); !errors.Is(err, domain.ErrLoginCodeDeliveryInvalid) {
|
||||
if _, err := RestoreLoginCodeDeliveryMessage(1000000001, template, "12345", 1700000000, 0, 7, 12); !errors.Is(err, domain.ErrLoginCodeDeliveryInvalid) {
|
||||
t.Fatalf("invalid uid err = %v, want ErrLoginCodeDeliveryInvalid", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ func (s *LoginCodeDeliveryStore) DeliverLoginCodeMessage(_ context.Context, req
|
|||
if req.ExpiresAt <= int64(req.Date) {
|
||||
return domain.LoginCodeDeliveryResult{}, fmt.Errorf("memory login code receipt expiry: %w: date=%d expires_at=%d", domain.ErrLoginCodeDeliveryInvalid, req.Date, req.ExpiresAt)
|
||||
}
|
||||
base, err := domain.OfficialLoginCodeMessage(req.UserID, req.Code, req.Date)
|
||||
base, err := domain.OfficialLoginCodeMessage(req.UserID, req.Template, req.Code, req.Date)
|
||||
if err != nil {
|
||||
return domain.LoginCodeDeliveryResult{}, err
|
||||
}
|
||||
|
|
@ -70,6 +70,7 @@ func (s *LoginCodeDeliveryStore) DeliverLoginCodeMessage(_ context.Context, req
|
|||
}
|
||||
msg, err := store.RestoreLoginCodeDeliveryMessage(
|
||||
receipt.userID,
|
||||
req.Template,
|
||||
req.Code,
|
||||
receipt.messageDate,
|
||||
receipt.privateMessageID,
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ func (s *MessageStore) DeliverLoginCodeMessage(ctx context.Context, req domain.L
|
|||
if req.ExpiresAt <= int64(req.Date) {
|
||||
return domain.LoginCodeDeliveryResult{}, fmt.Errorf("login code receipt expiry: %w: date=%d expires_at=%d", domain.ErrLoginCodeDeliveryInvalid, req.Date, req.ExpiresAt)
|
||||
}
|
||||
base, err := domain.OfficialLoginCodeMessage(req.UserID, req.Code, req.Date)
|
||||
base, err := domain.OfficialLoginCodeMessage(req.UserID, req.Template, req.Code, req.Date)
|
||||
if err != nil {
|
||||
return domain.LoginCodeDeliveryResult{}, err
|
||||
}
|
||||
|
|
@ -99,6 +99,7 @@ func (s *MessageStore) DeliverLoginCodeMessage(ctx context.Context, req domain.L
|
|||
}
|
||||
msg, err := store.RestoreLoginCodeDeliveryMessage(
|
||||
receipt.userID,
|
||||
req.Template,
|
||||
req.Code,
|
||||
receipt.messageDate,
|
||||
receipt.privateMessageID,
|
||||
|
|
@ -266,6 +267,7 @@ func (s *MessageStore) recoverLoginCodeDeliveryAfterCommitError(
|
|||
}
|
||||
msg, err := store.RestoreLoginCodeDeliveryMessage(
|
||||
receipt.userID,
|
||||
req.Template,
|
||||
req.Code,
|
||||
receipt.messageDate,
|
||||
receipt.privateMessageID,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue