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