added messages templates

This commit is contained in:
onysd 2026-09-01 14:40:06 +03:00
parent e8dc967e6a
commit 7cd1f64d0d
29 changed files with 1266 additions and 84 deletions

View 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 ""
}

View file

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

View file

@ -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)
}
}

View file

@ -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).

View file

@ -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)
}

View file

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

View 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)
}
}

View 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())
}

View 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)
}
}

View file

@ -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,
}

View file

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

View file

@ -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").

View file

@ -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)
}
}

View file

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

View file

@ -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)
}
}

View file

@ -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,

View file

@ -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,