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

@ -40,6 +40,24 @@ TELESRV_PHONE_CODE_LENGTH=5
TELESRV_AUTH_CODE_TTL=5m
# How many wrong guesses are allowed before a code is rejected outright.
TELESRV_AUTH_CODE_MAX_ATTEMPTS=5
# The message sent from the official system account (777000) into a user's
# own chat on every completed sign-in -- a lightweight security notice, not
# the login code itself. {{server_name}} is replaced with the server's
# configured identity name (or product name if unset). Leave empty to use
# the built-in English copy. The admin panel's Server Settings page can
# override these live, without a restart; these env vars are only the
# fallback for when it hasn't been touched.
TELESRV_WELCOME_MESSAGE_PHONE_TEMPLATE=
TELESRV_WELCOME_MESSAGE_EMAIL_TEMPLATE=
# The message sent from the official system account (777000) that carries the
# actual login code -- one template for every delivery channel (SMS or
# email). Must contain the {{code}} placeholder exactly once (that's where
# the real code is inserted and bolded); {{server_name}} is optional and may
# appear any number of times. Leave empty to use the built-in English copy.
# The admin panel's Server Settings page can override this live, without a
# restart (rejecting a save that doesn't contain {{code}} exactly once); this
# env var is only the fallback for when it hasn't been touched.
TELESRV_LOGIN_CODE_MESSAGE_TEMPLATE=
# Where webhook-delivered codes are POSTed, and the shared secret used to
# sign that request (see docs/otp-delivery.md for the exact payload).
TELESRV_OTP_WEBHOOK_URL=

View file

@ -116,6 +116,21 @@ type uiConfig struct {
// directory owpengram-server reads, so an identity edit here is visible
// over /owpengram/server-info immediately (see internal/identity).
IdentityDir string
// WelcomeMessagePhoneDefault/WelcomeMessageEmailDefault mirror
// config.WelcomeMessage{Phone,Email}Template -- the env-var-resolved
// fallback text (TELESRV_WELCOME_MESSAGE_*_TEMPLATE, itself defaulting
// to the compiled-in copy) the running owpengram-server process falls
// back to whenever the identity panel override is unset. Surfaced as
// "the effective default" in the Server Settings login-notifications
// panel, assuming both binaries share the same .env.
WelcomeMessagePhoneDefault string
WelcomeMessageEmailDefault string
// LoginCodeMessageDefault mirrors config.LoginCodeMessageTemplate -- the
// env-var-resolved fallback text (TELESRV_LOGIN_CODE_MESSAGE_TEMPLATE,
// itself defaulting to the compiled-in copy) the running owpengram-server
// process falls back to whenever the identity panel override is unset.
// Same "effective default" contract as WelcomeMessage{Phone,Email}Default.
LoginCodeMessageDefault string
// RepoRoot is where Server Settings' Restart/Update/.env-editing (see
// internal/procctl) operate: bin/, logs/, .env, .env.example and
// .server_panel.json are all expected directly under it, exactly as
@ -167,6 +182,9 @@ func loadConfig() (uiConfig, error) {
Permissions: appCfg.AdminUIPermissions,
HideThirdPartyVerification: appCfg.HideThirdPartyVerification,
IdentityDir: appCfg.IdentityDir,
WelcomeMessagePhoneDefault: appCfg.WelcomeMessagePhoneTemplate,
WelcomeMessageEmailDefault: appCfg.WelcomeMessageEmailTemplate,
LoginCodeMessageDefault: appCfg.LoginCodeMessageTemplate,
RepoRoot: repoRoot,
}, nil
}

View file

@ -178,6 +178,8 @@ func (s *server) routes() http.Handler {
mux.Handle("GET /api/server/identity", s.serverManage(s.handleServerIdentityAPI))
mux.Handle("GET /api/server/icon", s.serverManage(s.handleServerIconAPI))
mux.Handle("POST /api/actions/set-server-identity", s.serverManage(s.handleSetServerIdentityAPI))
mux.Handle("POST /api/actions/set-welcome-message-templates", s.serverManage(s.handleSetWelcomeMessageTemplatesAPI))
mux.Handle("POST /api/actions/set-login-code-message-template", s.serverManage(s.handleSetLoginCodeMessageTemplateAPI))
mux.Handle("POST /api/actions/upload-server-icon", s.serverManage(s.handleUploadServerIconAPI))
mux.Handle("POST /api/actions/remove-server-icon", s.serverManage(s.handleRemoveServerIconAPI))
mux.Handle("GET /api/server/env", s.serverManage(s.handleServerEnvAPI))

View file

@ -8,6 +8,8 @@ import (
"strings"
"telesrv/internal/admin"
"telesrv/internal/domain"
"telesrv/internal/identity"
)
// serverManage gates the whole Server Settings surface -- see
@ -47,13 +49,39 @@ func serverCommandResult(meta admin.CommandMeta, action string, err error, messa
// --- identity (name/description/icon) ---------------------------------
// serverIdentityAPIResponse extends identity.Info's raw fields with the two
// *effective* fallback welcome-message templates -- s.cfg's
// WelcomeMessage{Phone,Email}Default, i.e. this admin process's own reading
// of TELESRV_WELCOME_MESSAGE_*_TEMPLATE (env var, itself defaulting to the
// compiled-in copy), which matches what owpengram-server falls back to
// whenever the panel override is unset, as long as both processes share the
// same .env (see uiConfig.WelcomeMessagePhoneDefault's doc comment). The
// panel needs both: the raw override (possibly empty) to know whether a
// field is "explicitly set", and the default text to show as "(using
// default: ...)" / to restore on Reset.
type serverIdentityAPIResponse struct {
identity.Info
DefaultWelcomeMessagePhoneTemplate string `json:"default_welcome_message_phone_template"`
DefaultWelcomeMessageEmailTemplate string `json:"default_welcome_message_email_template"`
// DefaultLoginCodeMessageTemplate is the effective fallback text for
// the login-code delivery message (s.cfg.LoginCodeMessageDefault) --
// same "raw override + effective default" contract as the two fields
// above, see their doc comment.
DefaultLoginCodeMessageTemplate string `json:"default_login_code_message_template"`
}
func (s *server) handleServerIdentityAPI(w http.ResponseWriter, r *http.Request) {
info, err := s.identity.Get()
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, info)
writeJSON(w, http.StatusOK, serverIdentityAPIResponse{
Info: info,
DefaultWelcomeMessagePhoneTemplate: s.cfg.WelcomeMessagePhoneDefault,
DefaultWelcomeMessageEmailTemplate: s.cfg.WelcomeMessageEmailDefault,
DefaultLoginCodeMessageTemplate: s.cfg.LoginCodeMessageDefault,
})
}
// handleServerIconAPI serves the icon's raw bytes for the panel's own
@ -101,6 +129,84 @@ func (s *server) handleSetServerIdentityAPI(w http.ResponseWriter, r *http.Reque
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.set_identity", err, "server identity updated", details))
}
// --- login-notification templates ---------------------------------------
type setWelcomeMessageTemplatesAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
PhoneTemplate string `json:"phone_template"`
EmailTemplate string `json:"email_template"`
}
// handleSetWelcomeMessageTemplatesAPI sets (or, with an empty string,
// clears) the admin-panel override for the 777000 login-notification
// message's phone/email template -- see identity.Store.SetWelcomeMessageTemplates
// and domain.ResolveWelcomeMessageTemplate. Deliberately a separate endpoint
// from set-server-identity: brand identity (name/description/icon) and
// login-notification copy are different concerns that happen to share the
// same on-disk identity.json, and keeping them as separate actions/buttons
// means editing one never risks silently blanking the other.
func (s *server) handleSetWelcomeMessageTemplatesAPI(w http.ResponseWriter, r *http.Request) {
var body setWelcomeMessageTemplatesAPIRequest
if !decodeAction(w, r, &body) {
return
}
meta := s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-welcome-message-templates")
details := map[string]any{
"phone_template_set": strings.TrimSpace(body.PhoneTemplate) != "",
"email_template_set": strings.TrimSpace(body.EmailTemplate) != "",
}
if meta.DryRun {
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.set_welcome_message_templates", nil, "login-notification templates validated", details))
return
}
err := s.identity.SetWelcomeMessageTemplates(body.PhoneTemplate, body.EmailTemplate)
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.set_welcome_message_templates", err, "login-notification templates updated", details))
}
// --- login-code delivery message template --------------------------------
type setLoginCodeMessageTemplateAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
Template string `json:"template"`
}
// handleSetLoginCodeMessageTemplateAPI sets (or, with an empty string,
// clears) the admin-panel override for the 777000 login-code delivery
// message -- see identity.Store.SetLoginCodeMessageTemplate and
// domain.ResolveLoginCodeMessageTemplate. A dedicated endpoint (not folded
// into set-welcome-message-templates): this message embeds the actual OTP
// code via the {{code}} placeholder, so a save here carries an extra,
// security-relevant validation the login-notification templates don't
// need -- a template missing {{code}} (or containing it more than once)
// would either silently drop the code from the message or leave it
// ambiguous which occurrence carries it, so it is rejected outright with a
// 422 rather than saved. Clearing the override (empty string) is exempt --
// it always resolves to a valid built-in/env default.
func (s *server) handleSetLoginCodeMessageTemplateAPI(w http.ResponseWriter, r *http.Request) {
var body setLoginCodeMessageTemplateAPIRequest
if !decodeAction(w, r, &body) {
return
}
if t := strings.TrimSpace(body.Template); t != "" {
if err := domain.ValidateLoginCodeMessageTemplate(t); err != nil {
writeAPIError(w, http.StatusUnprocessableEntity, err.Error())
return
}
}
meta := s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-login-code-message-template")
details := map[string]any{"template_set": strings.TrimSpace(body.Template) != ""}
if meta.DryRun {
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.set_login_code_message_template", nil, "login-code message template validated", details))
return
}
err := s.identity.SetLoginCodeMessageTemplate(body.Template)
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.set_login_code_message_template", err, "login-code message template updated", details))
}
var allowedServerIconExts = map[string]bool{
".png": true, ".jpg": true, ".jpeg": true, ".webp": true, ".gif": true,
}

View file

@ -0,0 +1,87 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"telesrv/internal/identity"
)
func TestServerIdentityAPIReportsOverridesAndDefaultsSeparately(t *testing.T) {
store := identity.NewStore(t.TempDir())
srv := &server{
cfg: uiConfig{
WelcomeMessagePhoneDefault: "env phone default",
WelcomeMessageEmailDefault: "env email default",
},
identity: store,
}
// Before any override: GET must report empty raw fields (so the UI can
// tell "unset" apart from "explicitly set to the same text as the
// default"), alongside the effective default text.
req := httptest.NewRequest(http.MethodGet, "/api/server/identity", nil)
rec := httptest.NewRecorder()
srv.handleServerIdentityAPI(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp serverIdentityAPIResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.WelcomeMessagePhoneTemplate != "" || resp.WelcomeMessageEmailTemplate != "" {
t.Fatalf("expected empty overrides before any Set, got %+v", resp)
}
if resp.DefaultWelcomeMessagePhoneTemplate != "env phone default" || resp.DefaultWelcomeMessageEmailTemplate != "env email default" {
t.Fatalf("expected effective defaults from cfg, got %+v", resp)
}
// Set an override for phone only.
setReq := httptest.NewRequest(http.MethodPost, "/api/actions/set-welcome-message-templates", strings.NewReader(`{
"reason": "test",
"confirm": true,
"phone_template": "custom phone template"
}`))
setRec := httptest.NewRecorder()
srv.handleSetWelcomeMessageTemplatesAPI(setRec, setReq)
if setRec.Code != http.StatusOK {
t.Fatalf("SET status = %d, body = %s", setRec.Code, setRec.Body.String())
}
req2 := httptest.NewRequest(http.MethodGet, "/api/server/identity", nil)
rec2 := httptest.NewRecorder()
srv.handleServerIdentityAPI(rec2, req2)
var resp2 serverIdentityAPIResponse
if err := json.Unmarshal(rec2.Body.Bytes(), &resp2); err != nil {
t.Fatalf("decode: %v", err)
}
if resp2.WelcomeMessagePhoneTemplate != "custom phone template" {
t.Fatalf("expected phone override to be set, got %+v", resp2)
}
if resp2.WelcomeMessageEmailTemplate != "" {
t.Fatalf("expected email override to stay unset, got %+v", resp2)
}
// Reset (empty string) clears the override back to "unset".
resetReq := httptest.NewRequest(http.MethodPost, "/api/actions/set-welcome-message-templates", strings.NewReader(`{
"reason": "test",
"confirm": true,
"phone_template": ""
}`))
resetRec := httptest.NewRecorder()
srv.handleSetWelcomeMessageTemplatesAPI(resetRec, resetReq)
if resetRec.Code != http.StatusOK {
t.Fatalf("reset status = %d, body = %s", resetRec.Code, resetRec.Body.String())
}
info, err := store.Get()
if err != nil {
t.Fatal(err)
}
if info.WelcomeMessagePhoneTemplate != "" {
t.Fatalf("expected phone override cleared after reset, got %+v", info)
}
}

View file

@ -23,7 +23,7 @@
})();
</script>
<script type="module" crossorigin src="/assets/index-TfcI68oK.js"></script>
<script type="module" crossorigin src="/assets/index-BTi0-ijr.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-0MvM-hpw.css">
</head>
<body>

View file

@ -30,6 +30,7 @@ export function ServerSettingsPage() {
{tab === "settings" ? (
<div className="stacked-sections">
<IdentitySection />
<LoginNotificationsSection />
<EnvSection />
</div>
) : (
@ -127,6 +128,168 @@ function IdentitySection() {
);
}
// --- Login notifications ------------------------------------------------
// LoginNotificationsSection edits the 777000 login-notification message's
// per-method (phone/email) template -- a different concern from brand
// identity above even though both live in the same identity.json (see
// cmd/telesrv-admin/serversettings.go's handleSetWelcomeMessageTemplatesAPI
// doc comment), so it gets its own card and its own save action.
function LoginNotificationsSection() {
const [identity, setIdentity] = useState<ServerIdentity | null>(null);
const [phoneTemplate, setPhoneTemplate] = useState("");
const [emailTemplate, setEmailTemplate] = useState("");
const [codeTemplate, setCodeTemplate] = useState("");
const [error, setError] = useState("");
async function load() {
setError("");
try {
const info = await api.serverIdentity();
setIdentity(info);
setPhoneTemplate(info.welcome_message_phone_template ?? "");
setEmailTemplate(info.welcome_message_email_template ?? "");
setCodeTemplate(info.login_code_message_template ?? "");
} catch (err) {
setError(errorMessage(err));
}
}
useEffect(() => { void load(); }, []);
const phoneIsOverridden = phoneTemplate.trim() !== "";
const emailIsOverridden = emailTemplate.trim() !== "";
const codeIsOverridden = codeTemplate.trim() !== "";
// Mirrors the server-side check in handleSetLoginCodeMessageTemplateAPI --
// disable the save button instead of letting the operator submit a
// template that would silently never deliver the actual OTP code.
const codeOccurrences = (codeTemplate.match(/\{\{code\}\}/g) ?? []).length;
const codeTemplateInvalid = codeIsOverridden && codeOccurrences !== 1;
return (
<section className="section-block">
<SectionHead title={"Login notifications"} />
{error && <Alert>{error}</Alert>}
{!identity ? (
<LoadingSurface label={"Loading login notification templates..."} />
) : (
<div className="card-body">
<p style={{ color: "var(--muted)", marginTop: 0 }}>
{"Sent from the official system account on every completed sign-in. Use "}
<code>{"{{server_name}}"}</code>
{" to insert the server's configured name."}
</p>
<label className="form-field">
<span>
{"Phone sign-in template"}
{" "}
{phoneIsOverridden ? <span className="badge good">{"custom"}</span> : <span className="badge">{"default"}</span>}
</span>
<textarea
rows={4}
value={phoneTemplate}
onChange={(event) => setPhoneTemplate(event.target.value)}
placeholder={identity.default_welcome_message_phone_template}
/>
</label>
<div className="gift-table-actions">
<ActionButton
tone="neutral"
compact
label={"Reset to default"}
path="/api/actions/set-welcome-message-templates"
payload={() => ({ phone_template: "", email_template: emailTemplate })}
disabled={!phoneIsOverridden}
onDone={() => { setPhoneTemplate(""); void load(); }}
/>
</div>
<label className="form-field">
<span>
{"Email sign-in template"}
{" "}
{emailIsOverridden ? <span className="badge good">{"custom"}</span> : <span className="badge">{"default"}</span>}
</span>
<textarea
rows={4}
value={emailTemplate}
onChange={(event) => setEmailTemplate(event.target.value)}
placeholder={identity.default_welcome_message_email_template}
/>
</label>
<div className="gift-table-actions">
<ActionButton
tone="neutral"
compact
label={"Reset to default"}
path="/api/actions/set-welcome-message-templates"
payload={() => ({ phone_template: phoneTemplate, email_template: "" })}
disabled={!emailIsOverridden}
onDone={() => { setEmailTemplate(""); void load(); }}
/>
</div>
<div className="gift-table-actions identity-save-row">
<ActionButton
tone="neutral"
label={"Save login notification templates"}
path="/api/actions/set-welcome-message-templates"
payload={() => ({ phone_template: phoneTemplate, email_template: emailTemplate })}
onDone={() => void load()}
/>
</div>
<p style={{ color: "var(--muted)", marginTop: "1.5em", borderTop: "1px solid var(--line)", paddingTop: "1em" }}>
{"Sent from the official system account with every login code (SMS and email alike). Must contain "}
<code>{"{{code}}"}</code>
{" exactly once -- that's where the actual code is inserted and bolded. "}
<code>{"{{server_name}}"}</code>
{" is optional and may appear any number of times."}
</p>
<label className="form-field">
<span>
{"Login-code message template"}
{" "}
{codeIsOverridden ? <span className="badge good">{"custom"}</span> : <span className="badge">{"default"}</span>}
</span>
<textarea
rows={5}
value={codeTemplate}
onChange={(event) => setCodeTemplate(event.target.value)}
placeholder={identity.default_login_code_message_template}
/>
{codeTemplateInvalid && (
<span style={{ color: "var(--danger-text)", fontSize: "0.85em" }}>
{codeOccurrences === 0
? "Must contain {{code}} exactly once -- it is currently missing."
: `Must contain {{code}} exactly once -- it currently appears ${codeOccurrences} times.`}
</span>
)}
</label>
<div className="gift-table-actions">
<ActionButton
tone="neutral"
compact
label={"Reset to default"}
path="/api/actions/set-login-code-message-template"
payload={() => ({ template: "" })}
disabled={!codeIsOverridden}
onDone={() => { setCodeTemplate(""); void load(); }}
/>
</div>
<div className="gift-table-actions identity-save-row">
<ActionButton
tone="neutral"
label={"Save login-code message template"}
path="/api/actions/set-login-code-message-template"
payload={() => ({ template: codeTemplate })}
disabled={codeTemplateInvalid}
onDone={() => void load()}
/>
</div>
</div>
)}
</section>
);
}
function ServerIconModal({ hasIcon, onClose, onDone }: { hasIcon: boolean; onClose: () => void; onDone: () => void }) {
const [file, setFile] = useState<File | null>(null);
const [previewURL, setPreviewURL] = useState("");

View file

@ -851,6 +851,26 @@ export type ServerIdentity = {
name: string;
description: string;
icon_ext?: string;
// welcome_message_*_template: raw admin-panel override for the 777000
// login-notification message, empty when unset (falls back to the
// TELESRV_WELCOME_MESSAGE_*_TEMPLATE env var, then a built-in default).
welcome_message_phone_template?: string;
welcome_message_email_template?: string;
// default_welcome_message_*_template: the effective fallback text this
// admin process currently reads (env var if set, else the compiled-in
// copy) -- shown when the override above is empty.
default_welcome_message_phone_template: string;
default_welcome_message_email_template: string;
// login_code_message_template: raw admin-panel override for the 777000
// login-code delivery message, empty when unset (falls back to the
// TELESRV_LOGIN_CODE_MESSAGE_TEMPLATE env var, then a built-in default).
// Unlike the welcome_message_* templates there is only one -- the
// message never varies by delivery channel. Must contain the {{code}}
// placeholder exactly once (enforced server-side on save).
login_code_message_template?: string;
// default_login_code_message_template: the effective fallback text this
// admin process currently reads -- shown when the override above is empty.
default_login_code_message_template: string;
};
export type EnvField = {

View file

@ -1401,6 +1401,8 @@ func run(logger *zap.Logger) error {
)
authService := auth.NewService(userStore, authzStore, codeStore, authKeyGetBatchStore, tempAuthKeyStore, cfg.DevAuthCode,
auth.WithLoginMessages(messageStore, dialogStore),
auth.WithLoginWelcomeMessages(identityStore, cfg.WelcomeMessagePhoneTemplate, cfg.WelcomeMessageEmailTemplate),
auth.WithLoginCodeMessageTemplate(identityStore, cfg.LoginCodeMessageTemplate),
auth.WithLoginCodeDelivery(messageStore),
auth.WithPasswords(passwordStore),
auth.WithBotLogin(botStore),

View file

@ -411,6 +411,9 @@ The language-pack file manifest is authoritative. To add a language, place `data
| `TELESRV_AUTH_CODE_PHONE_RATE_LIMIT` | int / `5` | Code issuance limit per normalized phone digest per rate window; `<=0` disables this dimension. |
| `TELESRV_AUTH_CODE_AUTH_KEY_RATE_LIMIT` | int / `20` | Code issuance limit per raw auth key per rate window; `<=0` disables this dimension. |
| `TELESRV_AUTH_CODE_RATE_WINDOW` | duration / `10m` | Shared window for phone and auth-key issuance limits. |
| `TELESRV_WELCOME_MESSAGE_PHONE_TEMPLATE` | string / built-in English copy | Fallback template for the 777000 login-notification message sent on every completed phone sign-in. Supports the `{{server_name}}` placeholder. Overridden live (no restart) by the admin panel's Server Settings page when set there; this is only the fallback. |
| `TELESRV_WELCOME_MESSAGE_EMAIL_TEMPLATE` | string / built-in English copy | Same as above, for email sign-ins. |
| `TELESRV_LOGIN_CODE_MESSAGE_TEMPLATE` | string / built-in English copy | Fallback template for the 777000 login-code delivery message -- one template for every delivery channel (SMS or email). Must contain the `{{code}}` placeholder exactly once; also supports `{{server_name}}`. Overridden live (no restart) by the admin panel's Server Settings page when set there, which rejects a save missing `{{code}}`; this env var is only the fallback. |
| `TELESRV_PHONE_CODE_DELIVERY_PROVIDER` | enum / `development` | `development` uses fixed codes; `webhook` generates random SMS codes for login, registration, and phone changes. Both modes first commit the same code to the durable 777000 dialog for existing accounts; Webhook is additive. |
| `TELESRV_EMAIL_CODE_DELIVERY_PROVIDER` | enum / `smtp` | Delivery implementation for login-email and email setup/change codes: `smtp` or `webhook`. Existing-account login-email codes are first mirrored to 777000; setup/change remains provider-only. |
| `TELESRV_OTP_WEBHOOK_URL` | absolute URL / empty | Required when any provider selects `webhook`; see [otp-delivery.md](otp-delivery.md) for the fixed v1 contract. Any valid `http://` or `https://` host/IP and port is accepted; userinfo is rejected. |

View file

@ -392,6 +392,9 @@ active key。不要手工编辑 manifest 或 PEM不要在各实例上分别
| `TELESRV_AUTH_CODE_PHONE_RATE_LIMIT` | int / `5` | 每个规范化手机号摘要在窗口内的发码上限;`<=0` 关闭该维度。 |
| `TELESRV_AUTH_CODE_AUTH_KEY_RATE_LIMIT` | int / `20` | 每个 raw auth key 在窗口内的发码上限;`<=0` 关闭该维度。 |
| `TELESRV_AUTH_CODE_RATE_WINDOW` | duration / `10m` | 手机号与 auth-key 发码限流共用窗口。 |
| `TELESRV_WELCOME_MESSAGE_PHONE_TEMPLATE` | string / 内置英文文案 | 每次手机号登录成功后777000 账号发送的登录通知消息的兜底模板。支持 `{{server_name}}` 占位符。管理面板 Server Settings 页面若设置了覆盖值会立即生效(无需重启);本变量只是未设置时的兜底。 |
| `TELESRV_WELCOME_MESSAGE_EMAIL_TEMPLATE` | string / 内置英文文案 | 同上,用于邮箱登录。 |
| `TELESRV_LOGIN_CODE_MESSAGE_TEMPLATE` | string / 内置英文文案 | 777000 账号发送的登录验证码消息的兜底模板——所有投递渠道(短信、邮箱)共用同一份模板。必须恰好包含一次 `{{code}}` 占位符;也支持 `{{server_name}}`。管理面板 Server Settings 页面若设置了覆盖值会立即生效(无需重启),且会拒绝保存缺少 `{{code}}` 的模板;本变量只是未设置时的兜底。 |
| `TELESRV_PHONE_CODE_DELIVERY_PROVIDER` | enum / `development` | `development` 使用固定码;`webhook` 为登录、注册、改号生成随机 SMS code 并调用 OTP Webhook。已有账号在两种模式下都先 durable 写入同码 777000 消息Webhook 只是附加渠道。 |
| `TELESRV_EMAIL_CODE_DELIVERY_PROVIDER` | enum / `smtp` | 登录邮箱、邮箱 setup/change 的投递实现:`smtp``webhook`。已有账号的登录邮箱码会先同码镜像到 777000邮箱 setup/change 仍只走 provider。 |
| `TELESRV_OTP_WEBHOOK_URL` | absolute URL / 空 | 任一 provider 选择 `webhook` 时必填;固定 v1 协议见 [otp-delivery.md](otp-delivery.md)。允许任意合法 `http://``https://` 主机/IP 与端口,不得含 userinfo。 |

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,