owpengram-server/internal/domain/login_code_delivery.go
2026-09-01 14:40:06 +03:00

155 lines
7.7 KiB
Go

package domain
import (
"errors"
"fmt"
"math"
"strings"
)
// 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}}"
// 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.
// PhoneCodeHash is an opaque idempotency token and must never be persisted in
// plaintext; store implementations persist only its SHA-256 digest.
type LoginCodeDeliveryRequest struct {
UserID int64
PhoneCodeHash string
Code string
// 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
}
// LoginCodeDeliveryResult returns the immutable first delivery. Created is
// false when the same phone_code_hash was already committed and replayed.
type LoginCodeDeliveryResult struct {
Message Message
Created bool
}
// OfficialLoginCodeMessage builds the account-visible incoming message from
// Telegram's official notification account. Persistence assigns ID, UID and
// Pts atomically.
//
// 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)
}
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},
From: Peer{Type: PeerTypeUser, ID: OfficialSystemUserID},
Date: date,
Body: body,
Entities: []MessageEntity{
{Type: MessageEntityBold, Offset: automaticEntityUTF16Length(rendered[:idx]), Length: automaticEntityUTF16Length(code)},
},
}, nil
}