feat: sync account freeze lifecycle

This commit is contained in:
A 2026-07-15 20:24:37 +08:00
parent 76bfc5100f
commit 47fcf0ea41
40 changed files with 1363 additions and 196 deletions

View file

@ -63,6 +63,9 @@ type AccountDetail struct {
type RestrictionRow struct {
Frozen bool
Since *time.Time
Until *time.Time
AppealURL string
Reason string
Actor string
CommandID string
@ -152,7 +155,7 @@ SELECT u.id, u.phone, u.username, u.first_name, u.last_name, u.created_at, u.upd
COALESCE(a.last_active_at, '0001-01-01 00:00:00+00'::timestamptz), COALESCE(a.device_count, 0)::int,
COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS display_username
FROM users u
LEFT JOIN account_send_restrictions r ON r.user_id = u.id
LEFT JOIN account_restrictions r ON r.user_id = u.id
LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id
LEFT JOIN auth a ON a.user_id = u.id
WHERE u.id = $1 OR u.phone = $2 OR u.phone = $3 OR lower(u.username) = $4 OR p.username_lower = $4
@ -326,7 +329,7 @@ SELECT u.id, u.phone, u.username, u.first_name, u.last_name, u.created_at, u.upd
COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS display_username
FROM users u
JOIN auth ON auth.user_id = u.id
LEFT JOIN account_send_restrictions r ON r.user_id = u.id
LEFT JOIN account_restrictions r ON r.user_id = u.id
LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id
WHERE NOT u.is_bot
AND ($1::bigint = 0 OR (auth.last_active_at, u.id) < (to_timestamp(($1::double precision) / 1000000.0), $2::bigint))
@ -364,7 +367,7 @@ SELECT u.id, u.phone, u.username, u.first_name, u.last_name, u.created_at, u.upd
COALESCE(sb.balance, 0)::bigint, COALESCE(sb.granted, false),
COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS display_username
FROM users u
LEFT JOIN account_send_restrictions r ON r.user_id = u.id
LEFT JOIN account_restrictions r ON r.user_id = u.id
LEFT JOIN stars_balances sb ON sb.user_id = u.id
LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id
WHERE u.id = $1`, userID).Scan(
@ -393,9 +396,12 @@ WHERE u.id = $1`, userID).Scan(
func (s *readStore) restriction(ctx context.Context, userID int64) (RestrictionRow, bool, error) {
var r RestrictionRow
err := s.pool.QueryRow(ctx, `
SELECT frozen, reason, actor, command_id, updated_at
FROM account_send_restrictions
WHERE user_id = $1`, userID).Scan(&r.Frozen, &r.Reason, &r.Actor, &r.CommandID, &r.UpdatedAt)
SELECT frozen, frozen_since, frozen_until, appeal_url, reason, actor, command_id, updated_at
FROM account_restrictions
WHERE user_id = $1`, userID).Scan(
&r.Frozen, &r.Since, &r.Until, &r.AppealURL,
&r.Reason, &r.Actor, &r.CommandID, &r.UpdatedAt,
)
if err != nil {
if err == pgx.ErrNoRows {
return RestrictionRow{}, false, nil

View file

@ -56,7 +56,7 @@ func (s *server) routes() http.Handler {
mux.Handle("GET /api/messages/detail", s.requireAuthAPI(http.HandlerFunc(s.handleMessageDetailAPI)))
mux.Handle("GET /api/messages/groups", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessagesAPI)))
mux.Handle("GET /api/messages/groups/detail", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessageDetailAPI)))
mux.Handle("POST /api/actions/freeze-send", s.requireAuthAPI(http.HandlerFunc(s.handleFreezeSendAPI)))
mux.Handle("POST /api/actions/set-frozen", s.requireAuthAPI(http.HandlerFunc(s.handleSetAccountFrozenAPI)))
mux.Handle("POST /api/actions/grant-premium", s.requireAuthAPI(http.HandlerFunc(s.handleGrantPremiumAPI)))
mux.Handle("POST /api/actions/grant-stars", s.requireAuthAPI(http.HandlerFunc(s.handleGrantStarsAPI)))
mux.Handle("POST /api/actions/set-verified", s.requireAuthAPI(http.HandlerFunc(s.handleSetVerifiedAPI)))
@ -388,25 +388,29 @@ func (s *server) handleGroupMessageDetailAPI(w http.ResponseWriter, r *http.Requ
writeJSON(w, http.StatusOK, detail)
}
type freezeSendAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UserID int64 `json:"user_id"`
Frozen bool `json:"frozen"`
type setAccountFrozenAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UserID int64 `json:"user_id"`
Frozen bool `json:"frozen"`
Until time.Time `json:"freeze_until"`
AppealURL string `json:"freeze_appeal_url"`
}
func (s *server) handleFreezeSendAPI(w http.ResponseWriter, r *http.Request) {
var body freezeSendAPIRequest
func (s *server) handleSetAccountFrozenAPI(w http.ResponseWriter, r *http.Request) {
var body setAccountFrozenAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetSendFrozenRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "freeze-send"),
req := admin.SetAccountFrozenRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-frozen"),
UserID: body.UserID,
Frozen: body.Frozen,
Until: body.Until,
AppealURL: body.AppealURL,
}
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/freeze-send", req)
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/set-frozen", req)
writeCommandResultAPI(w, result, err)
}

View file

@ -1,11 +1,15 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"telesrv/internal/admin"
)
func TestSignedSessionRoundTripAndTamper(t *testing.T) {
@ -48,3 +52,33 @@ func TestAdminAPIURLDefaultUsesAdminAPIPort(t *testing.T) {
t.Fatalf("adminAPIURL(empty) = %q, want %q", got, want)
}
}
func TestSetAccountFrozenBFFForwardsClientVisibleState(t *testing.T) {
var got admin.SetAccountFrozenRequest
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/accounts/set-frozen" || r.Header.Get("Authorization") != "Bearer secret" {
t.Fatalf("upstream request path=%q authorization=%q", r.URL.Path, r.Header.Get("Authorization"))
}
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Fatal(err)
}
_ = json.NewEncoder(w).Encode(admin.CommandResult{CommandID: got.CommandID, Status: "completed", DryRun: got.DryRun})
}))
defer upstream.Close()
srv := &server{cfg: uiConfig{AdminAPIURL: upstream.URL, AdminAPIToken: "secret"}}
req := httptest.NewRequest(http.MethodPost, "/api/actions/set-frozen", strings.NewReader(`{
"reason":"review","confirm":false,"user_id":1001,"frozen":true,
"freeze_until":"2030-01-02T00:00:00Z","freeze_appeal_url":"https://appeals.example.test/1001"
}`))
req = req.WithContext(context.WithValue(req.Context(), actorKey{}, "operator"))
rec := httptest.NewRecorder()
srv.handleSetAccountFrozenAPI(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if got.Actor != "operator" || got.UserID != 1001 || !got.Frozen || !got.DryRun ||
got.Until.IsZero() || got.AppealURL != "https://appeals.example.test/1001" {
t.Fatalf("forwarded freeze request = %+v", got)
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>telesrv admin</title>
<script type="module" crossorigin src="/assets/index-BHnkZ_za.js"></script>
<script type="module" crossorigin src="/assets/index-DRWO_DgE.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-W1-UPxwc.css">
</head>
<body>

View file

@ -127,15 +127,21 @@ const translations: Record<Language, Record<string, string>> = {
"account.waitingData": "Waiting for data",
"account.noUsername": "No username",
"account.noPhone": "No phone",
"account.sendFrozen": "Sending frozen",
"account.sendNormal": "Sending allowed",
"account.accountFrozen": "Account frozen",
"account.accountActive": "Account active",
"account.authorizationsTitle": "Authorized Devices",
"account.authorizationsCount": "{count} authorizations",
"account.recentAdminOps": "Recent Admin Actions",
"account.recent30Audit": "Last 30 audit rows",
"account.actionDock": "Account Actions",
"account.freezeSend": "Freeze sending",
"account.unfreezeSend": "Unfreeze sending",
"account.freezeAccount": "Freeze account",
"account.updateFreeze": "Update freeze",
"account.unfreezeAccount": "Unfreeze account",
"account.freezeSince": "Frozen since",
"account.freezeUntil": "Appeal deadline",
"account.freezeUntilAria": "Freeze appeal deadline",
"account.freezeAppealURL": "Appeal URL",
"account.freezeAppealURLAria": "Freeze appeal URL",
"account.premiumMonths": "Premium duration (months)",
"account.premiumMonthsAria": "Set premium duration in months",
"account.setPremium": "Set premium",
@ -392,15 +398,21 @@ const translations: Record<Language, Record<string, string>> = {
"account.waitingData": "等待数据",
"account.noUsername": "无用户名",
"account.noPhone": "无手机号",
"account.sendFrozen": "发消息冻结",
"account.sendNormal": "发送正常",
"account.accountFrozen": "账号已冻结",
"account.accountActive": "账号正常",
"account.authorizationsTitle": "授权设备",
"account.authorizationsCount": "共 {count} 个授权",
"account.recentAdminOps": "最近后台操作",
"account.recent30Audit": "最近 30 条审计",
"account.actionDock": "账号操作",
"account.freezeSend": "冻结发消息",
"account.unfreezeSend": "解冻发消息",
"account.freezeAccount": "冻结账号",
"account.updateFreeze": "更新冻结信息",
"account.unfreezeAccount": "解冻账号",
"account.freezeSince": "冻结开始时间",
"account.freezeUntil": "申诉截止时间",
"account.freezeUntilAria": "账号冻结申诉截止时间",
"account.freezeAppealURL": "申诉链接",
"account.freezeAppealURLAria": "账号冻结申诉链接",
"account.premiumMonths": "会员时长(月)",
"account.premiumMonthsAria": "设置会员时长,单位月",
"account.setPremium": "设置会员",

View file

@ -16,12 +16,21 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
const [busy, setBusy] = useState(false);
const [months, setMonths] = useState("1");
const [starsAmount, setStarsAmount] = useState("1000");
const [freezeUntil, setFreezeUntil] = useState(() => toDateTimeLocal(new Date(Date.now() + 7 * 86400_000)));
const [freezeAppealURL, setFreezeAppealURL] = useState("");
async function load() {
setBusy(true);
setError("");
try {
setDetail(await api.account(id));
const next = await api.account(id);
setDetail(next);
if (next.Restriction.Frozen) {
if (next.Restriction.Until) {
setFreezeUntil(toDateTimeLocal(new Date(next.Restriction.Until)));
}
setFreezeAppealURL(next.Restriction.AppealURL || "");
}
} catch (err) {
setError(errorMessage(err));
} finally {
@ -58,7 +67,7 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
<div className="entity-badges">
{account.PremiumUntil > 0 ? <Badge tone="good">{t("account.premium")}</Badge> : <Badge>{t("account.notPremium")}</Badge>}
{detail.Verified ? <Badge tone="good">{t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>}
{account.Frozen ? <Badge tone="danger">{t("account.sendFrozen")}</Badge> : <Badge>{t("account.sendNormal")}</Badge>}
{account.Frozen ? <Badge tone="danger">{t("account.accountFrozen")}</Badge> : <Badge>{t("account.accountActive")}</Badge>}
</div>
</section>
<div className="summary-grid">
@ -70,6 +79,9 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
<Summary label={t("account.activeSessions")} value={String(detail.Authorizations.length)} />
<Summary label={t("account.accountFlags")} value={`support=${detail.Support} bot=${detail.Bot}`} />
<Summary label={t("account.restriction")} value={detail.HasRestriction ? detail.Restriction.Reason || t("account.restricted") : t("common.none")} />
<Summary label={t("account.freezeSince")} value={detail.Restriction.Since ? formatDate(detail.Restriction.Since) : t("common.none")} />
<Summary label={t("account.freezeUntil")} value={detail.Restriction.Until ? formatDate(detail.Restriction.Until) : t("common.none")} />
<Summary label={t("account.freezeAppealURL")} value={detail.Restriction.AppealURL || t("common.none")} />
<Summary label={t("account.createdAt")} value={formatDate(account.CreatedAt) || "-"} />
</div>
{detail.About && <p className="about-text">{detail.About}</p>}
@ -86,13 +98,46 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
side={
<section className="action-dock">
<div className="dock-title">{t("account.actionDock")}</div>
<label className="duration-field">
<span>{t("account.freezeUntil")}</span>
<input
aria-label={t("account.freezeUntilAria")}
value={freezeUntil}
onChange={(event) => setFreezeUntil(event.target.value)}
type="datetime-local"
/>
</label>
<label className="duration-field">
<span>{t("account.freezeAppealURL")}</span>
<input
aria-label={t("account.freezeAppealURLAria")}
value={freezeAppealURL}
onChange={(event) => setFreezeAppealURL(event.target.value)}
type="url"
placeholder="https://..."
/>
</label>
<ActionButton
label={account.Frozen ? t("account.unfreezeSend") : t("account.freezeSend")}
label={account.Frozen ? t("account.updateFreeze") : t("account.freezeAccount")}
icon={<CircleAlert size={15} />}
path="/api/actions/freeze-send"
payload={() => ({ user_id: account.ID, frozen: !account.Frozen })}
path="/api/actions/set-frozen"
payload={() => ({
user_id: account.ID,
frozen: true,
freeze_until: new Date(freezeUntil).toISOString(),
freeze_appeal_url: freezeAppealURL.trim()
})}
onDone={load}
/>
{account.Frozen && (
<ActionButton
label={t("account.unfreezeAccount")}
icon={<CircleAlert size={15} />}
path="/api/actions/set-frozen"
payload={() => ({ user_id: account.ID, frozen: false })}
onDone={load}
/>
)}
<label className="duration-field">
<span>{t("account.premiumMonths")}</span>
<input
@ -155,3 +200,8 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
</PageFrame>
);
}
function toDateTimeLocal(date: Date): string {
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}

View file

@ -16,6 +16,9 @@ export type AccountRow = {
export type RestrictionRow = {
Frozen: boolean;
Since: string | null;
Until: string | null;
AppealURL: string;
Reason: string;
Actor: string;
CommandID: string;

View file

@ -726,7 +726,8 @@ func run(logger *zap.Logger) error {
AuthKeySessionLayers: authKeyStore,
Account: accountService,
Privacy: privacyService,
Help: help.NewService(helpStore, helpStore, help.WithMapboxToken(cfg.MapboxToken)),
Help: help.NewService(helpStore, helpStore, help.WithMapboxToken(cfg.MapboxToken), help.WithAccountFreezeProvider(adminService)),
AccountFreeze: adminService,
AICompose: aiComposeService,
Users: usersService,
Updates: updatesService,

View file

@ -0,0 +1,10 @@
ALTER TABLE account_restrictions
DROP CONSTRAINT IF EXISTS account_restrictions_freeze_shape_check,
DROP COLUMN IF EXISTS frozen_since,
DROP COLUMN IF EXISTS frozen_until,
DROP COLUMN IF EXISTS appeal_url;
ALTER INDEX account_restrictions_frozen_idx
RENAME TO account_send_restrictions_frozen_idx;
ALTER TABLE account_restrictions RENAME TO account_send_restrictions;

View file

@ -0,0 +1,34 @@
ALTER TABLE account_send_restrictions RENAME TO account_restrictions;
ALTER INDEX account_send_restrictions_frozen_idx
RENAME TO account_restrictions_frozen_idx;
ALTER TABLE account_restrictions
ADD COLUMN frozen_since timestamptz,
ADD COLUMN frozen_until timestamptz,
ADD COLUMN appeal_url text NOT NULL DEFAULT '';
-- Existing send-frozen rows become valid account freezes explicitly during
-- migration; read paths never synthesize the missing state.
UPDATE account_restrictions
SET frozen_since = updated_at,
frozen_until = updated_at + interval '7 days',
appeal_url = 'https://t.me/SpamBot'
WHERE frozen;
UPDATE account_restrictions
SET frozen_since = NULL,
frozen_until = NULL,
appeal_url = ''
WHERE NOT frozen;
ALTER TABLE account_restrictions
ADD CONSTRAINT account_restrictions_freeze_shape_check CHECK (
(frozen AND frozen_since IS NOT NULL AND frozen_until IS NOT NULL
AND frozen_until > frozen_since
AND frozen_until <= to_timestamp(2147483647)
AND appeal_url <> '' AND octet_length(appeal_url) <= 2048)
OR
(NOT frozen AND frozen_since IS NULL AND frozen_until IS NULL
AND appeal_url = '')
);

View file

@ -4,6 +4,8 @@ import (
"context"
"encoding/json"
"fmt"
"math"
"net/url"
"sort"
"strings"
"time"
@ -12,7 +14,7 @@ import (
)
const (
ActionSetSendFrozen = "account.set_send_frozen"
ActionSetAccountFrozen = "account.set_frozen"
ActionGrantPremium = "account.grant_premium"
ActionGrantStars = "account.grant_stars"
ActionSetVerified = "account.set_verified"
@ -21,12 +23,13 @@ const (
ActionDeletePrivateMessages = "messages.delete_private_messages"
ActionDeletePrivateHistory = "messages.delete_private_history"
maxCommandIDLength = 128
maxActorLength = 128
maxReasonLength = 1000
maxHistoryBatches = 100
maxPremiumMonths = 120
maxStarsGrant = 1_000_000_000
maxCommandIDLength = 128
maxActorLength = 128
maxReasonLength = 1000
maxHistoryBatches = 100
maxPremiumMonths = 120
maxStarsGrant = 1_000_000_000
maxFreezeAppealURLLength = 2048
)
type CommandRepository interface {
@ -35,9 +38,8 @@ type CommandRepository interface {
}
type RestrictionStore interface {
GetSendRestriction(ctx context.Context, userID int64) (domain.AccountSendRestriction, bool, error)
SetSendRestriction(ctx context.Context, restriction domain.AccountSendRestriction) (domain.AccountSendRestriction, error)
IsSendFrozen(ctx context.Context, userID int64) (bool, error)
GetAccountFreeze(ctx context.Context, userID int64) (domain.AccountFreeze, bool, error)
SetAccountFreeze(ctx context.Context, freeze domain.AccountFreeze) (domain.AccountFreeze, error)
}
type AuthService interface {
@ -182,10 +184,12 @@ type CommandResult struct {
Error string `json:"error,omitempty"`
}
type SetSendFrozenRequest struct {
type SetAccountFrozenRequest struct {
CommandMeta
UserID int64 `json:"user_id"`
Frozen bool `json:"frozen"`
UserID int64 `json:"user_id"`
Frozen bool `json:"frozen"`
Until time.Time `json:"freeze_until,omitempty"`
AppealURL string `json:"freeze_appeal_url,omitempty"`
}
type GrantPremiumRequest struct {
@ -240,52 +244,127 @@ type DeletePrivateHistoryRequest struct {
MaxBatches int `json:"max_batches,omitempty"`
}
func (s *Service) CanSendMessages(ctx context.Context, userID int64) error {
// AccountFreeze returns the durable account-level freeze state. A missing row
// is the only non-frozen default; invalid active rows are rejected by the
// store/schema instead of normalized on read.
func (s *Service) AccountFreeze(ctx context.Context, userID int64) (domain.AccountFreeze, bool, error) {
if s == nil || s.restrictions == nil || userID == 0 {
return domain.AccountFreeze{}, false, nil
}
freeze, found, err := s.restrictions.GetAccountFreeze(ctx, userID)
if err != nil || !found {
return freeze, found, err
}
if err := validateAccountFreeze(freeze); err != nil {
return domain.AccountFreeze{}, false, fmt.Errorf("invalid durable account freeze for user %d: %w", userID, err)
}
return freeze, true, nil
}
func validateAccountFreeze(freeze domain.AccountFreeze) error {
if !freeze.Frozen {
if !freeze.Since.IsZero() || !freeze.Until.IsZero() || freeze.AppealURL != "" {
return fmt.Errorf("inactive freeze retains client-visible state")
}
return nil
}
frozen, err := s.restrictions.IsSendFrozen(ctx, userID)
if err != nil {
return err
if freeze.Since.IsZero() || freeze.Until.IsZero() || !freeze.Until.After(freeze.Since) ||
freeze.Since.Unix() <= 0 || freeze.Until.Unix() > math.MaxInt32 {
return fmt.Errorf("active freeze has invalid since/until")
}
if frozen {
return domain.ErrUserSendRestricted
if len(freeze.AppealURL) > maxFreezeAppealURLLength {
return fmt.Errorf("active freeze appeal URL is too long")
}
parsed, err := url.ParseRequestURI(freeze.AppealURL)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return fmt.Errorf("active freeze has invalid appeal URL")
}
return nil
}
func (s *Service) SetSendFrozen(ctx context.Context, req SetSendFrozenRequest) (CommandResult, error) {
func (s *Service) CanSendMessages(ctx context.Context, userID int64) error {
freeze, found, err := s.AccountFreeze(ctx, userID)
if err != nil {
return err
}
if found && freeze.Frozen {
return domain.ErrUserFrozen
}
return nil
}
func (s *Service) SetAccountFrozen(ctx context.Context, req SetAccountFrozenRequest) (CommandResult, error) {
if req.UserID <= 0 {
return CommandResult{}, fmt.Errorf("user_id is required")
}
if s == nil || s.restrictions == nil {
return CommandResult{}, fmt.Errorf("admin restriction store is not configured")
}
return s.runCommand(ctx, req.CommandMeta, ActionSetSendFrozen, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
prev, found, err := s.restrictions.GetSendRestriction(ctx, req.UserID)
now := s.now().UTC()
appealURL := strings.TrimSpace(req.AppealURL)
if req.Frozen {
if req.Until.IsZero() || req.Until.Unix() > math.MaxInt32 {
return CommandResult{}, fmt.Errorf("freeze_until must be a non-zero int32 Unix timestamp")
}
if len(appealURL) > maxFreezeAppealURLLength {
return CommandResult{}, fmt.Errorf("freeze_appeal_url must be <= %d bytes", maxFreezeAppealURLLength)
}
parsed, err := url.ParseRequestURI(appealURL)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return CommandResult{}, fmt.Errorf("freeze_appeal_url must be an absolute HTTP(S) URL")
}
}
return s.runCommand(ctx, req.CommandMeta, ActionSetAccountFrozen, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
// Keep this time-relative check inside runCommand: a completed command ID
// must remain replayable after its deadline, while a new stale request is
// recorded as failed and cannot mutate the restriction row.
if req.Frozen && !req.Until.After(now) {
return CommandResult{}, fmt.Errorf("freeze_until must be in the future")
}
prev, found, err := s.restrictions.GetAccountFreeze(ctx, req.UserID)
if err != nil {
return CommandResult{}, err
}
details := map[string]any{
"previous_frozen": found && prev.Frozen,
"new_frozen": req.Frozen,
"would_change": !found || prev.Frozen != req.Frozen,
}
if req.DryRun {
return CommandResult{Message: "dry-run completed", Details: details}, nil
}
updated, err := s.restrictions.SetSendRestriction(ctx, domain.AccountSendRestriction{
next := domain.AccountFreeze{
UserID: req.UserID,
Frozen: req.Frozen,
Reason: req.Reason,
Actor: req.Actor,
CommandID: req.CommandID,
})
}
if req.Frozen {
next.Since = now
if found && prev.Frozen {
next.Since = prev.Since
}
next.Until = req.Until.UTC()
next.AppealURL = appealURL
if !next.Until.After(next.Since) {
return CommandResult{}, fmt.Errorf("freeze_until must be after freeze_since")
}
}
wouldChange := !found || prev.Frozen != next.Frozen ||
!prev.Since.Equal(next.Since) || !prev.Until.Equal(next.Until) ||
prev.AppealURL != next.AppealURL
details := map[string]any{
"previous_frozen": found && prev.Frozen,
"new_frozen": req.Frozen,
"would_change": wouldChange,
}
if req.Frozen {
details["freeze_since"] = next.Since.Format(time.RFC3339)
details["freeze_until"] = next.Until.Format(time.RFC3339)
details["freeze_appeal_url"] = next.AppealURL
}
if req.DryRun {
return CommandResult{Message: "dry-run completed", Details: details}, nil
}
updated, err := s.restrictions.SetAccountFreeze(ctx, next)
if err != nil {
return CommandResult{}, err
}
details["updated_at"] = updated.UpdatedAt.UTC().Format(time.RFC3339)
return CommandResult{Message: "send restriction updated", Details: details}, nil
return CommandResult{Message: "account freeze updated", Details: details}, nil
})
}

View file

@ -4,13 +4,14 @@ import (
"context"
"errors"
"reflect"
"strings"
"testing"
"time"
"telesrv/internal/domain"
)
func TestSetSendFrozenDryRunExecuteAndIdempotency(t *testing.T) {
func TestSetAccountFrozenDryRunExecuteAndIdempotency(t *testing.T) {
ctx := context.Background()
repo := newMemoryCommandRepo()
restrictions := &fakeRestrictionStore{}
@ -20,10 +21,12 @@ func TestSetSendFrozenDryRunExecuteAndIdempotency(t *testing.T) {
Now: fixedNow,
})
dry, err := svc.SetSendFrozen(ctx, SetSendFrozenRequest{
dry, err := svc.SetAccountFrozen(ctx, SetAccountFrozenRequest{
CommandMeta: CommandMeta{CommandID: "dry-freeze", Actor: "ops", Reason: "test", DryRun: true},
UserID: 1001,
Frozen: true,
Until: fixedNow().Add(7 * 24 * time.Hour),
AppealURL: "https://appeals.example.test/account/1001",
})
if err != nil {
t.Fatalf("dry-run freeze: %v", err)
@ -32,23 +35,29 @@ func TestSetSendFrozenDryRunExecuteAndIdempotency(t *testing.T) {
t.Fatalf("dry-run result=%+v setCalls=%d, want completed dry-run without mutation", dry, restrictions.setCalls)
}
execReq := SetSendFrozenRequest{
execReq := SetAccountFrozenRequest{
CommandMeta: CommandMeta{CommandID: "exec-freeze", Actor: "ops", Reason: "incident", DryRun: false},
UserID: 1001,
Frozen: true,
Until: fixedNow().Add(7 * 24 * time.Hour),
AppealURL: "https://appeals.example.test/account/1001",
}
exec, err := svc.SetSendFrozen(ctx, execReq)
exec, err := svc.SetAccountFrozen(ctx, execReq)
if err != nil {
t.Fatalf("execute freeze: %v", err)
}
if exec.Status != string(domain.AdminCommandCompleted) || restrictions.setCalls != 1 {
t.Fatalf("execute result=%+v setCalls=%d", exec, restrictions.setCalls)
}
if err := svc.CanSendMessages(ctx, 1001); !errors.Is(err, domain.ErrUserSendRestricted) {
t.Fatalf("CanSendMessages err=%v, want ErrUserSendRestricted", err)
if err := svc.CanSendMessages(ctx, 1001); !errors.Is(err, domain.ErrUserFrozen) {
t.Fatalf("CanSendMessages err=%v, want ErrUserFrozen", err)
}
freeze, found, err := svc.AccountFreeze(ctx, 1001)
if err != nil || !found || !freeze.Frozen || !freeze.Since.Equal(fixedNow()) || freeze.AppealURL != execReq.AppealURL {
t.Fatalf("AccountFreeze = %+v found=%v err=%v", freeze, found, err)
}
again, err := svc.SetSendFrozen(ctx, execReq)
again, err := svc.SetAccountFrozen(ctx, execReq)
if err != nil {
t.Fatalf("duplicate freeze: %v", err)
}
@ -57,6 +66,101 @@ func TestSetSendFrozenDryRunExecuteAndIdempotency(t *testing.T) {
}
}
func TestSetAccountFrozenRejectsIncompleteStateAndUnfreezeClearsOverlay(t *testing.T) {
ctx := context.Background()
restrictions := &fakeRestrictionStore{}
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Restrictions: restrictions, Now: fixedNow})
for _, req := range []SetAccountFrozenRequest{
{CommandMeta: CommandMeta{CommandID: "bad-until", Actor: "ops", Reason: "test"}, UserID: 1001, Frozen: true, Until: fixedNow(), AppealURL: "https://appeals.example.test"},
{CommandMeta: CommandMeta{CommandID: "too-far", Actor: "ops", Reason: "test"}, UserID: 1001, Frozen: true, Until: time.Unix(1<<31, 0), AppealURL: "https://appeals.example.test"},
{CommandMeta: CommandMeta{CommandID: "bad-url", Actor: "ops", Reason: "test"}, UserID: 1001, Frozen: true, Until: fixedNow().Add(time.Hour), AppealURL: "javascript:bad"},
{CommandMeta: CommandMeta{CommandID: "long-url", Actor: "ops", Reason: "test"}, UserID: 1001, Frozen: true, Until: fixedNow().Add(time.Hour), AppealURL: "https://appeals.example.test/" + strings.Repeat("x", maxFreezeAppealURLLength)},
} {
if _, err := svc.SetAccountFrozen(ctx, req); err == nil {
t.Fatalf("SetAccountFrozen(%+v) succeeded", req)
}
}
freezeReq := SetAccountFrozenRequest{CommandMeta: CommandMeta{CommandID: "freeze", Actor: "ops", Reason: "test"}, UserID: 1001, Frozen: true, Until: fixedNow().Add(24 * time.Hour), AppealURL: "https://appeals.example.test"}
if _, err := svc.SetAccountFrozen(ctx, freezeReq); err != nil {
t.Fatal(err)
}
if _, err := svc.SetAccountFrozen(ctx, SetAccountFrozenRequest{CommandMeta: CommandMeta{CommandID: "unfreeze", Actor: "ops", Reason: "accepted"}, UserID: 1001}); err != nil {
t.Fatal(err)
}
freeze, found, err := svc.AccountFreeze(ctx, 1001)
if err != nil || !found || freeze.Frozen || !freeze.Since.IsZero() || !freeze.Until.IsZero() || freeze.AppealURL != "" {
t.Fatalf("unfrozen state = %+v found=%v err=%v", freeze, found, err)
}
}
func TestSetAccountFrozenUpdatePreservesOriginalSince(t *testing.T) {
ctx := context.Background()
now := fixedNow()
restrictions := &fakeRestrictionStore{}
svc := NewService(Dependencies{
Commands: newMemoryCommandRepo(),
Restrictions: restrictions,
Now: func() time.Time { return now },
})
if _, err := svc.SetAccountFrozen(ctx, SetAccountFrozenRequest{
CommandMeta: CommandMeta{CommandID: "freeze-initial", Actor: "ops", Reason: "review"},
UserID: 1001,
Frozen: true,
Until: now.Add(24 * time.Hour),
AppealURL: "https://appeals.example.test/initial",
}); err != nil {
t.Fatal(err)
}
originalSince := now
now = now.Add(2 * time.Hour)
updatedUntil := now.Add(72 * time.Hour)
if _, err := svc.SetAccountFrozen(ctx, SetAccountFrozenRequest{
CommandMeta: CommandMeta{CommandID: "freeze-update", Actor: "ops", Reason: "extend review"},
UserID: 1001,
Frozen: true,
Until: updatedUntil,
AppealURL: "https://appeals.example.test/updated",
}); err != nil {
t.Fatal(err)
}
freeze, found, err := svc.AccountFreeze(ctx, 1001)
if err != nil || !found || !freeze.Since.Equal(originalSince) || !freeze.Until.Equal(updatedUntil) ||
freeze.AppealURL != "https://appeals.example.test/updated" {
t.Fatalf("updated freeze = %+v found=%v err=%v", freeze, found, err)
}
}
func TestSetAccountFrozenReplayRemainsIdempotentAfterDeadline(t *testing.T) {
ctx := context.Background()
now := fixedNow()
restrictions := &fakeRestrictionStore{}
svc := NewService(Dependencies{
Commands: newMemoryCommandRepo(),
Restrictions: restrictions,
Now: func() time.Time { return now },
})
req := SetAccountFrozenRequest{
CommandMeta: CommandMeta{CommandID: "freeze-expiring", Actor: "ops", Reason: "review"},
UserID: 1001,
Frozen: true,
Until: now.Add(time.Hour),
AppealURL: "https://appeals.example.test/expiring",
}
if _, err := svc.SetAccountFrozen(ctx, req); err != nil {
t.Fatal(err)
}
now = now.Add(2 * time.Hour)
replayed, err := svc.SetAccountFrozen(ctx, req)
if err != nil || !replayed.AlreadyExecuted || restrictions.setCalls != 1 {
t.Fatalf("expired replay = %+v err=%v setCalls=%d", replayed, err, restrictions.setCalls)
}
stale := req
stale.CommandID = "new-stale-freeze"
if _, err := svc.SetAccountFrozen(ctx, stale); err == nil || restrictions.setCalls != 1 {
t.Fatalf("new stale request err=%v setCalls=%d, want rejection without state mutation", err, restrictions.setCalls)
}
}
func TestGrantPremiumDryRunExecuteAndIdempotency(t *testing.T) {
ctx := context.Background()
users := &fakeUsersService{users: map[int64]domain.User{
@ -365,21 +469,21 @@ func (m *memoryCommandRepo) FinishCommand(_ context.Context, commandID string, s
}
type fakeRestrictionStore struct {
items map[int64]domain.AccountSendRestriction
items map[int64]domain.AccountFreeze
setCalls int
}
func (f *fakeRestrictionStore) GetSendRestriction(_ context.Context, userID int64) (domain.AccountSendRestriction, bool, error) {
func (f *fakeRestrictionStore) GetAccountFreeze(_ context.Context, userID int64) (domain.AccountFreeze, bool, error) {
if f.items == nil {
return domain.AccountSendRestriction{}, false, nil
return domain.AccountFreeze{}, false, nil
}
r, ok := f.items[userID]
return r, ok, nil
}
func (f *fakeRestrictionStore) SetSendRestriction(_ context.Context, r domain.AccountSendRestriction) (domain.AccountSendRestriction, error) {
func (f *fakeRestrictionStore) SetAccountFreeze(_ context.Context, r domain.AccountFreeze) (domain.AccountFreeze, error) {
if f.items == nil {
f.items = map[int64]domain.AccountSendRestriction{}
f.items = map[int64]domain.AccountFreeze{}
}
f.setCalls++
r.UpdatedAt = fixedNow()
@ -387,13 +491,6 @@ func (f *fakeRestrictionStore) SetSendRestriction(_ context.Context, r domain.Ac
return r, nil
}
func (f *fakeRestrictionStore) IsSendFrozen(_ context.Context, userID int64) (bool, error) {
if f.items == nil {
return false, nil
}
return f.items[userID].Frozen, nil
}
type fakeMessagesService struct {
byID []domain.Message
deleteCalls int

View file

@ -20,7 +20,7 @@ type Config struct {
}
type Service interface {
SetSendFrozen(ctx context.Context, req admin.SetSendFrozenRequest) (admin.CommandResult, error)
SetAccountFrozen(ctx context.Context, req admin.SetAccountFrozenRequest) (admin.CommandResult, error)
GrantPremium(ctx context.Context, req admin.GrantPremiumRequest) (admin.CommandResult, error)
GrantStars(ctx context.Context, req admin.GrantStarsRequest) (admin.CommandResult, error)
SetVerified(ctx context.Context, req admin.SetVerifiedRequest) (admin.CommandResult, error)
@ -76,7 +76,7 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
mux.HandleFunc("POST /v1/accounts/freeze-send", s.authenticated(s.handleFreezeSend))
mux.HandleFunc("POST /v1/accounts/set-frozen", s.authenticated(s.handleSetAccountFrozen))
mux.HandleFunc("POST /v1/accounts/grant-premium", s.authenticated(s.handleGrantPremium))
mux.HandleFunc("POST /v1/accounts/grant-stars", s.authenticated(s.handleGrantStars))
mux.HandleFunc("POST /v1/accounts/set-verified", s.authenticated(s.handleSetVerified))
@ -98,12 +98,12 @@ func (s *Server) authenticated(next http.HandlerFunc) http.HandlerFunc {
}
}
func (s *Server) handleFreezeSend(w http.ResponseWriter, r *http.Request) {
var req admin.SetSendFrozenRequest
func (s *Server) handleSetAccountFrozen(w http.ResponseWriter, r *http.Request) {
var req admin.SetAccountFrozenRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.SetSendFrozen(r.Context(), req)
result, err := s.svc.SetAccountFrozen(r.Context(), req)
writeCommandResult(w, result, err)
}

View file

@ -12,7 +12,7 @@ import (
func TestAdminAPIRequiresBearerToken(t *testing.T) {
srv := &Server{token: "secret", svc: fakeService{}}
req := httptest.NewRequest(http.MethodPost, "/v1/accounts/freeze-send", strings.NewReader(`{}`))
req := httptest.NewRequest(http.MethodPost, "/v1/accounts/set-frozen", strings.NewReader(`{}`))
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
@ -20,9 +20,10 @@ func TestAdminAPIRequiresBearerToken(t *testing.T) {
}
}
func TestAdminAPIFreezeSend(t *testing.T) {
srv := &Server{token: "secret", svc: fakeService{}}
req := httptest.NewRequest(http.MethodPost, "/v1/accounts/freeze-send", strings.NewReader(`{"command_id":"c1","actor":"ops","reason":"test","dry_run":true,"user_id":1001,"frozen":true}`))
func TestAdminAPISetAccountFrozen(t *testing.T) {
svc := &captureFreezeService{}
srv := &Server{token: "secret", svc: svc}
req := httptest.NewRequest(http.MethodPost, "/v1/accounts/set-frozen", strings.NewReader(`{"command_id":"c1","actor":"ops","reason":"test","dry_run":true,"user_id":1001,"frozen":true,"freeze_until":"2030-01-02T00:00:00Z","freeze_appeal_url":"https://appeals.example.test"}`))
req.Header.Set("Authorization", "Bearer secret")
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
@ -32,6 +33,9 @@ func TestAdminAPIFreezeSend(t *testing.T) {
if !strings.Contains(rec.Body.String(), `"command_id":"c1"`) {
t.Fatalf("body=%s", rec.Body.String())
}
if svc.req.UserID != 1001 || !svc.req.Frozen || svc.req.Until.IsZero() || svc.req.AppealURL != "https://appeals.example.test" {
t.Fatalf("decoded freeze request = %+v", svc.req)
}
}
func TestAdminAPISetVerified(t *testing.T) {
@ -78,7 +82,17 @@ func TestAdminAPISetChannelVerified(t *testing.T) {
type fakeService struct{}
func (fakeService) SetSendFrozen(_ context.Context, req admin.SetSendFrozenRequest) (admin.CommandResult, error) {
type captureFreezeService struct {
fakeService
req admin.SetAccountFrozenRequest
}
func (s *captureFreezeService) SetAccountFrozen(_ context.Context, req admin.SetAccountFrozenRequest) (admin.CommandResult, error) {
s.req = req
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) SetAccountFrozen(_ context.Context, req admin.SetAccountFrozenRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}

View file

@ -25,8 +25,8 @@ func TestServiceSendMessageHonorsSendPermissionGate(t *testing.T) {
ChannelID: 2001,
RandomID: 1,
Message: "blocked",
}); !errors.Is(err, domain.ErrUserSendRestricted) {
t.Fatalf("SendMessage err=%v, want ErrUserSendRestricted", err)
}); !errors.Is(err, domain.ErrUserFrozen) {
t.Fatalf("SendMessage err=%v, want ErrUserFrozen", err)
}
}
@ -79,8 +79,8 @@ func TestServiceSendMonoforumMessageHonorsSendPermissionGate(t *testing.T) {
SavedPeer: domain.Peer{Type: domain.PeerTypeUser, ID: 1002},
RandomID: 1,
Message: "blocked",
}); !errors.Is(err, domain.ErrUserSendRestricted) {
t.Fatalf("SendMonoforumMessage err=%v, want ErrUserSendRestricted", err)
}); !errors.Is(err, domain.ErrUserFrozen) {
t.Fatalf("SendMonoforumMessage err=%v, want ErrUserFrozen", err)
}
}
@ -133,7 +133,7 @@ func TestServiceMonoforumReplayPrecedesCurrentSendPermissionGate(t *testing.T) {
type channelDenySendChecker struct{}
func (channelDenySendChecker) CanSendMessages(context.Context, int64) error {
return domain.ErrUserSendRestricted
return domain.ErrUserFrozen
}
func (p testBotProfiles) BotInfo(_ context.Context, botUserID int64) (domain.BotProfile, bool, error) {

View file

@ -3,7 +3,9 @@ package help
import (
"context"
"encoding/json"
"fmt"
"hash/crc32"
"strconv"
"sync"
"telesrv/internal/domain"
@ -64,9 +66,10 @@ const defaultAppConfigHash = 23 // 默认 app config 内容变更时必须递增
// (登录页/启动配置是高频握手路径)。运维改库需重启生效。timezones/emoji 等其余目录走
// internal/seed/catalog(go:embed 一次解析),本就在内存。
type Service struct {
appConfigs store.AppConfigStore
countries store.CountryStore
mapboxToken string
appConfigs store.AppConfigStore
countries store.CountryStore
accountFreeze AccountFreezeProvider
mapboxToken string
appConfigOnce sync.Once
appConfigCache domain.AppConfig
@ -77,6 +80,18 @@ type Service struct {
// Option 配置 help 服务运行期默认目录。
type Option func(*Service)
// AccountFreezeProvider supplies account-specific read-only state without
// exposing protocol types to the help application service.
type AccountFreezeProvider interface {
AccountFreeze(ctx context.Context, userID int64) (domain.AccountFreeze, bool, error)
}
func WithAccountFreezeProvider(provider AccountFreezeProvider) Option {
return func(s *Service) {
s.accountFreeze = provider
}
}
// WithMapboxToken 设置 TDesktop appConfig 与地图缩略图代理共用的 Mapbox token。
func WithMapboxToken(token string) Option {
return func(s *Service) {
@ -119,12 +134,69 @@ func defaultAppConfigHashFor(mapboxToken string) int {
return defaultAppConfigHash + 1 + int(crc32.ChecksumIEEE([]byte(mapboxToken))&0x3fffffff)
}
// GetAppConfig 返回 TDesktop app confighash 命中时返回 notModified。首次调用加载一次后缓存。
func (s *Service) GetAppConfig(ctx context.Context, hash int) (domain.AppConfig, bool, error) {
// GetAppConfig returns the cached global app config plus an authenticated,
// per-account freeze overlay. The overlay owns its own deterministic hash so a
// FROZEN_METHOD_INVALID-triggered refresh can never be answered notModified.
func (s *Service) GetAppConfig(ctx context.Context, userID int64, hash int) (domain.AppConfig, bool, error) {
cfg := s.loadAppConfig(ctx)
var err error
cfg, err = s.accountAppConfig(ctx, userID, cfg)
if err != nil {
return domain.AppConfig{}, false, err
}
return cfg, hash != 0 && hash == cfg.Hash, nil
}
func (s *Service) accountAppConfig(ctx context.Context, userID int64, base domain.AppConfig) (domain.AppConfig, error) {
values := make(map[string]json.RawMessage)
if err := json.Unmarshal(base.JSON, &values); err != nil {
return domain.AppConfig{}, fmt.Errorf("decode base app config: %w", err)
}
changed := false
for _, key := range []string{"freeze_since_date", "freeze_until_date", "freeze_appeal_url"} {
if _, exists := values[key]; exists {
delete(values, key)
changed = true
}
}
if userID > 0 {
// DrKLO applies only keys present in the new JSON object and retains old
// SharedPreferences values for missing keys. Authenticated non-frozen
// accounts therefore need an explicit zero/empty triplet to converge after
// an unfreeze; merely omitting the overlay works in TDesktop but leaves
// Android frozen indefinitely. Unauthenticated config remains unscoped.
values["freeze_since_date"] = json.RawMessage("0")
values["freeze_until_date"] = json.RawMessage("0")
values["freeze_appeal_url"] = json.RawMessage(`""`)
changed = true
if s != nil && s.accountFreeze != nil {
freeze, found, err := s.accountFreeze.AccountFreeze(ctx, userID)
if err != nil {
return domain.AppConfig{}, fmt.Errorf("load account freeze: %w", err)
}
if found && freeze.Frozen {
values["freeze_since_date"] = json.RawMessage(strconv.FormatInt(freeze.Since.Unix(), 10))
values["freeze_until_date"] = json.RawMessage(strconv.FormatInt(freeze.Until.Unix(), 10))
appeal, _ := json.Marshal(freeze.AppealURL)
values["freeze_appeal_url"] = appeal
}
}
}
if !changed {
return base, nil
}
body, err := json.Marshal(values)
if err != nil {
return domain.AppConfig{}, fmt.Errorf("encode account app config: %w", err)
}
hashInput := append([]byte(strconv.Itoa(base.Hash)+"\x00"), body...)
overlayHash := int(crc32.ChecksumIEEE(hashInput) & 0x7fffffff)
if overlayHash == 0 || overlayHash == base.Hash {
overlayHash = base.Hash + 1
}
return domain.AppConfig{Client: base.Client, Hash: overlayHash, JSON: body}, nil
}
func (s *Service) loadAppConfig(ctx context.Context) domain.AppConfig {
if s == nil {
return defaultAppConfig("")

View file

@ -0,0 +1,133 @@
package help
import (
"context"
"encoding/json"
"testing"
"time"
"telesrv/internal/domain"
)
func TestAccountAppConfigFreezeOverlayIsUserScopedAndHashAware(t *testing.T) {
since := time.Date(2026, 7, 15, 1, 2, 3, 0, time.UTC)
until := since.Add(7 * 24 * time.Hour)
provider := &fakeAccountFreezeProvider{items: map[int64]domain.AccountFreeze{
1001: {UserID: 1001, Frozen: true, Since: since, Until: until, AppealURL: "https://appeals.example.test/1001"},
}}
svc := NewService(nil, nil, WithAccountFreezeProvider(provider))
normal, notModified, err := svc.GetAppConfig(context.Background(), 1002, 0)
if err != nil || notModified {
t.Fatalf("normal GetAppConfig = %+v notModified=%v err=%v", normal, notModified, err)
}
frozen, notModified, err := svc.GetAppConfig(context.Background(), 1001, normal.Hash)
if err != nil || notModified || frozen.Hash == normal.Hash {
t.Fatalf("frozen GetAppConfig = hash:%d normal:%d notModified=%v err=%v", frozen.Hash, normal.Hash, notModified, err)
}
assertFreezeConfig(t, frozen.JSON, since.Unix(), until.Unix(), "https://appeals.example.test/1001")
if _, notModified, err := svc.GetAppConfig(context.Background(), 1001, frozen.Hash); err != nil || !notModified {
t.Fatalf("frozen hash replay = notModified:%v err:%v", notModified, err)
}
provider.items[1001] = domain.AccountFreeze{
UserID: 1001,
Frozen: true,
Since: since,
Until: until.Add(24 * time.Hour),
AppealURL: "https://appeals.example.test/1001/review",
}
updated, notModified, err := svc.GetAppConfig(context.Background(), 1001, frozen.Hash)
if err != nil || notModified || updated.Hash == frozen.Hash {
t.Fatalf("updated freeze config = hash:%d old:%d notModified=%v err=%v", updated.Hash, frozen.Hash, notModified, err)
}
assertFreezeConfig(t, updated.JSON, since.Unix(), until.Add(24*time.Hour).Unix(), "https://appeals.example.test/1001/review")
other, notModified, err := svc.GetAppConfig(context.Background(), 1002, frozen.Hash)
if err != nil || notModified || other.Hash != normal.Hash {
t.Fatalf("other user = hash:%d notModified:%v err:%v", other.Hash, notModified, err)
}
assertClearedFreezeConfig(t, other.JSON)
unauthorized, _, err := svc.GetAppConfig(context.Background(), 0, 0)
if err != nil {
t.Fatal(err)
}
assertNoFreezeConfig(t, unauthorized.JSON)
provider.items[1001] = domain.AccountFreeze{UserID: 1001}
unfrozen, notModified, err := svc.GetAppConfig(context.Background(), 1001, updated.Hash)
if err != nil || notModified || unfrozen.Hash != normal.Hash {
t.Fatalf("unfreeze refresh = hash:%d notModified:%v err:%v", unfrozen.Hash, notModified, err)
}
assertClearedFreezeConfig(t, unfrozen.JSON)
}
func TestAuthenticatedAppConfigClearsPersistedFreezeWithoutProvider(t *testing.T) {
svc := NewService(nil, nil)
unauthorized, _, err := svc.GetAppConfig(context.Background(), 0, 0)
if err != nil {
t.Fatal(err)
}
assertNoFreezeConfig(t, unauthorized.JSON)
authenticated, notModified, err := svc.GetAppConfig(context.Background(), 1001, unauthorized.Hash)
if err != nil || notModified || authenticated.Hash == unauthorized.Hash {
t.Fatalf("authenticated clear config = hash:%d base:%d notModified:%v err:%v", authenticated.Hash, unauthorized.Hash, notModified, err)
}
assertClearedFreezeConfig(t, authenticated.JSON)
}
func TestAccountAppConfigStripsGlobalFreezeFields(t *testing.T) {
svc := NewService(nil, nil)
base := domain.AppConfig{Client: "tdesktop", Hash: 9, JSON: []byte(`{"quote_length_max":1024,"freeze_since_date":1,"freeze_until_date":2,"freeze_appeal_url":"https://wrong.example"}`)}
cfg, err := svc.accountAppConfig(context.Background(), 0, base)
if err != nil {
t.Fatal(err)
}
if cfg.Hash == base.Hash {
t.Fatal("stripped config reused base hash")
}
assertNoFreezeConfig(t, cfg.JSON)
}
func assertFreezeConfig(t *testing.T, body []byte, since, until int64, appealURL string) {
t.Helper()
var values map[string]any
if err := json.Unmarshal(body, &values); err != nil {
t.Fatal(err)
}
if values["freeze_since_date"] != float64(since) || values["freeze_until_date"] != float64(until) || values["freeze_appeal_url"] != appealURL {
t.Fatalf("freeze config = %#v", values)
}
}
func assertNoFreezeConfig(t *testing.T, body []byte) {
t.Helper()
var values map[string]any
if err := json.Unmarshal(body, &values); err != nil {
t.Fatal(err)
}
for _, key := range []string{"freeze_since_date", "freeze_until_date", "freeze_appeal_url"} {
if _, exists := values[key]; exists {
t.Fatalf("unexpected %s in config", key)
}
}
}
func assertClearedFreezeConfig(t *testing.T, body []byte) {
t.Helper()
var values map[string]any
if err := json.Unmarshal(body, &values); err != nil {
t.Fatal(err)
}
if values["freeze_since_date"] != float64(0) || values["freeze_until_date"] != float64(0) || values["freeze_appeal_url"] != "" {
t.Fatalf("freeze clear config = %#v", values)
}
}
type fakeAccountFreezeProvider struct {
items map[int64]domain.AccountFreeze
}
func (f *fakeAccountFreezeProvider) AccountFreeze(_ context.Context, userID int64) (domain.AccountFreeze, bool, error) {
freeze, found := f.items[userID]
return freeze, found, nil
}

View file

@ -11,14 +11,14 @@ import (
// premiumCanBuy()=!premium_purchase_blocked 耦合,置 true 会同时隐藏送礼入口;
// reactions_user_max_premium 必须与服务端 enforcement 档位一致。
func TestAppConfigPremiumKeys(t *testing.T) {
cfg, notModified, err := (*Service)(nil).GetAppConfig(context.Background(), 0)
cfg, notModified, err := (*Service)(nil).GetAppConfig(context.Background(), 0, 0)
if err != nil || notModified {
t.Fatalf("GetAppConfig = notModified %v err %v", notModified, err)
}
if cfg.Hash != defaultAppConfigHash || cfg.Hash < 10 {
t.Fatalf("hash = %d, want defaultAppConfigHash(≥10)", cfg.Hash)
}
oldCfg, oldNotModified, err := (*Service)(nil).GetAppConfig(context.Background(), defaultAppConfigHash-1)
oldCfg, oldNotModified, err := (*Service)(nil).GetAppConfig(context.Background(), 0, defaultAppConfigHash-1)
if err != nil || oldNotModified || oldCfg.Hash != defaultAppConfigHash {
t.Fatalf("GetAppConfig(old hash) = hash %d notModified %v err %v, want refreshed config", oldCfg.Hash, oldNotModified, err)
}
@ -93,7 +93,7 @@ func TestAppConfigPremiumKeys(t *testing.T) {
}
func TestAppConfigOmitsMapboxTokenByDefault(t *testing.T) {
cfg, notModified, err := (*Service)(nil).GetAppConfig(context.Background(), 0)
cfg, notModified, err := (*Service)(nil).GetAppConfig(context.Background(), 0, 0)
if err != nil || notModified {
t.Fatalf("GetAppConfig = notModified %v err %v", notModified, err)
}
@ -108,14 +108,14 @@ func TestAppConfigOmitsMapboxTokenByDefault(t *testing.T) {
func TestAppConfigUsesConfiguredMapboxTokenAndHash(t *testing.T) {
svc := NewService(nil, nil, WithMapboxToken("pk.test-token"))
cfg, notModified, err := svc.GetAppConfig(context.Background(), 0)
cfg, notModified, err := svc.GetAppConfig(context.Background(), 0, 0)
if err != nil || notModified {
t.Fatalf("GetAppConfig = notModified %v err %v", notModified, err)
}
if cfg.Hash == defaultAppConfigHash {
t.Fatalf("hash = %d, want token-specific hash", cfg.Hash)
}
if _, notModified, err := svc.GetAppConfig(context.Background(), cfg.Hash); err != nil || !notModified {
if _, notModified, err := svc.GetAppConfig(context.Background(), 0, cfg.Hash); err != nil || !notModified {
t.Fatalf("GetAppConfig(hash) = notModified %v err %v, want notModified", notModified, err)
}
var decoded map[string]any

View file

@ -98,7 +98,7 @@ func (s *Service) SendPrivateText(ctx context.Context, userID int64, req domain.
req.SenderUserID = userID
}
if req.SenderUserID != userID {
return domain.SendPrivateTextResult{}, domain.ErrUserSendRestricted
return domain.SendPrivateTextResult{}, domain.ErrAuthenticatedScopeInvalid
}
if req.RandomID != 0 && !req.IdempotencyPreflighted {
fingerprint, err := store.PrivateSendFingerprint(req)

View file

@ -21,8 +21,8 @@ func TestServiceSendPrivateTextHonorsSendPermissionGate(t *testing.T) {
RecipientUserID: 1002,
RandomID: 1,
Message: "blocked",
}); !errors.Is(err, domain.ErrUserSendRestricted) {
t.Fatalf("SendPrivateText err=%v, want ErrUserSendRestricted", err)
}); !errors.Is(err, domain.ErrUserFrozen) {
t.Fatalf("SendPrivateText err=%v, want ErrUserFrozen", err)
}
if store.sends != 0 {
t.Fatalf("store sends=%d, want 0", store.sends)
@ -71,8 +71,8 @@ func TestServiceForwardPrivateMessagesHonorsSendPermissionGate(t *testing.T) {
ToUserID: 1003,
MessageIDs: []int{1},
RandomIDs: []int64{2},
}); !errors.Is(err, domain.ErrUserSendRestricted) {
t.Fatalf("ForwardPrivateMessages err=%v, want ErrUserSendRestricted", err)
}); !errors.Is(err, domain.ErrUserFrozen) {
t.Fatalf("ForwardPrivateMessages err=%v, want ErrUserFrozen", err)
}
if store.forwards != 0 {
t.Fatalf("store forwards=%d, want 0", store.forwards)
@ -502,7 +502,7 @@ type projectionMessageStore struct {
type denySendChecker struct{}
func (denySendChecker) CanSendMessages(context.Context, int64) error {
return domain.ErrUserSendRestricted
return domain.ErrUserFrozen
}
type gateMessageStore struct {

View file

@ -26,9 +26,15 @@ type AdminCommand struct {
CompletedAt *time.Time
}
type AccountSendRestriction struct {
// AccountFreeze is the durable account-level read-only state advertised to
// Telegram clients through help.getAppConfig. Until is the appeal/deletion
// deadline; reaching it does not silently unfreeze the account.
type AccountFreeze struct {
UserID int64
Frozen bool
Since time.Time
Until time.Time
AppealURL string
Reason string
Actor string
CommandID string

View file

@ -3,14 +3,15 @@ package domain
import "errors"
var (
ErrUsernameInvalid = errors.New("username invalid")
ErrUsernameOccupied = errors.New("username occupied")
ErrUsernameNotOccupied = errors.New("username not occupied")
ErrPhoneNotOccupied = errors.New("phone not occupied")
ErrFirstNameInvalid = errors.New("first name invalid")
ErrAboutTooLong = errors.New("about too long")
ErrUserNotFound = errors.New("user not found")
ErrUserSendRestricted = errors.New("user send restricted")
ErrUsernameInvalid = errors.New("username invalid")
ErrUsernameOccupied = errors.New("username occupied")
ErrUsernameNotOccupied = errors.New("username not occupied")
ErrPhoneNotOccupied = errors.New("phone not occupied")
ErrFirstNameInvalid = errors.New("first name invalid")
ErrAboutTooLong = errors.New("about too long")
ErrUserNotFound = errors.New("user not found")
ErrUserFrozen = errors.New("user account frozen")
ErrAuthenticatedScopeInvalid = errors.New("authenticated user scope invalid")
// ErrPremiumRequired 表示该操作仅限有效会员PREMIUM_ACCOUNT_REQUIRED
ErrPremiumRequired = errors.New("premium account required")
// ErrPremiumBotUnsupported 表示 bot 账号不可被授予会员(官方语义)。

View file

@ -187,8 +187,29 @@ func TestLoginRegisterFlow(t *testing.T) {
}
// 注意client.Run 回调里 t.Fatalf 只会杀当前 goroutine、测试主协程
// 会等到 ctx 超时——断言失败用 return fmt.Errorf 让 Run 立即返回。
if cfg, ok := appConfig.(*tg.HelpAppConfig); !ok || cfg.Hash != seedAppConfigHash {
return fmt.Errorf("help.getAppConfig = %T %+v, want seeded hash=%d config", appConfig, appConfig, seedAppConfigHash)
cfg, ok := appConfig.(*tg.HelpAppConfig)
if !ok || cfg.Hash == 0 || cfg.Hash == seedAppConfigHash {
return fmt.Errorf("help.getAppConfig = %T %+v, want authenticated overlay hash distinct from seed=%d", appConfig, appConfig, seedAppConfigHash)
}
object, ok := cfg.Config.(*tg.JSONObject)
if !ok {
return fmt.Errorf("help.getAppConfig config = %T, want *tg.JSONObject", cfg.Config)
}
values := make(map[string]tg.JSONValueClass, len(object.Value))
for _, item := range object.Value {
values[item.Key] = item.Value
}
for _, key := range []string{"freeze_since_date", "freeze_until_date"} {
value, ok := values[key].(*tg.JSONNumber)
if !ok || value.Value != 0 {
return fmt.Errorf("help.getAppConfig %s = %T %+v, want zero clear value", key, values[key], values[key])
}
}
if value, ok := values["freeze_appeal_url"].(*tg.JSONString); !ok || value.Value != "" {
return fmt.Errorf("help.getAppConfig freeze_appeal_url = %T %+v, want empty clear value", values["freeze_appeal_url"], values["freeze_appeal_url"])
}
if value, ok := values["quote_length_max"].(*tg.JSONNumber); !ok || value.Value != 1024 {
return fmt.Errorf("help.getAppConfig lost seeded base config: quote_length_max=%T %+v", values["quote_length_max"], values["quote_length_max"])
}
countriesRes, err := raw.HelpGetCountriesList(ctx, &tg.HelpGetCountriesListRequest{LangCode: "en"})
if err != nil {

View file

@ -262,6 +262,9 @@ func (r *Router) onChannelsGetMessages(ctx context.Context, req *tg.ChannelsGetM
if err != nil {
return nil, err
}
if err := r.checkFrozenChannelParticipants(ctx, userID, channelID); err != nil {
return nil, err
}
ids := make([]int, 0, len(req.ID))
for _, input := range req.ID {
id, ok := inputMessageBoxID(input)

View file

@ -655,7 +655,7 @@ func (r *Router) channelIDFromLegacyInputPeerChecked(ctx context.Context, userID
func channelInvalidErr(err error) error {
switch {
case errors.Is(err, domain.ErrUserSendRestricted):
case errors.Is(err, domain.ErrUserFrozen):
return frozenMethodInvalidErr()
case errors.Is(err, domain.ErrChannelTitleInvalid):
return tgerr400("CHAT_TITLE_EMPTY")

View file

@ -18,15 +18,19 @@ func (r *Router) onUpdatesGetChannelDifference(ctx context.Context, req *tg.Upda
if err != nil {
return nil, internalErr()
}
// difference 类 catch-up FLOOD_WAIT设计 Phase 2 / §10.3nudge 被消费后客户端会触发
// getChannelDifference大群 nudge 全速前需限速防风暴。未配置阈值时不限速。
if err := r.checkCatchupRateLimit(ctx, userID, channelDifferenceRateLimitKeyPrefix); err != nil {
return nil, err
}
channelID, err := r.channelIDFromInput(ctx, userID, req.Channel)
if err != nil {
return nil, err
}
if err := r.checkFrozenChannelParticipants(ctx, userID, channelID); err != nil {
return nil, err
}
// difference 类 catch-up FLOOD_WAIT设计 Phase 2 / §10.3nudge 被消费后客户端会触发
// getChannelDifference大群 nudge 全速前需限速防风暴。未配置阈值时不限速。
// participant gate 必须先于限流写入,保证冻结拒绝没有副作用。
if err := r.checkCatchupRateLimit(ctx, userID, channelDifferenceRateLimitKeyPrefix); err != nil {
return nil, err
}
r.trackChannelInterest(ctx, userID, channelID)
diff, err := r.deps.Channels.GetDifference(ctx, userID, domain.ChannelDifferenceRequest{
UserID: userID,

View file

@ -382,10 +382,16 @@ type PrivacyService interface {
// HelpService 抽象启动配置与国家区号目录。
type HelpService interface {
GetAppConfig(ctx context.Context, hash int) (domain.AppConfig, bool, error)
GetAppConfig(ctx context.Context, userID int64, hash int) (domain.AppConfig, bool, error)
GetCountries(ctx context.Context, langCode string, hash int) (domain.CountriesList, bool, error)
}
// AccountFreezeService exposes the account-level read-only fact used by the
// central RPC mutation gate. It is domain-only and shared with app/help.
type AccountFreezeService interface {
AccountFreeze(ctx context.Context, userID int64) (domain.AccountFreeze, bool, error)
}
// UpdatesService 抽象 update 状态查询。
type UpdatesService interface {
GetState(ctx context.Context, authKeyID [8]byte, userID int64) (domain.UpdateState, error)
@ -794,6 +800,7 @@ type Deps struct {
Account AccountService
Privacy PrivacyService
Help HelpService
AccountFreeze AccountFreezeService
AICompose AIComposeService
Users UsersService
Updates UpdatesService

View file

@ -91,7 +91,8 @@ func documentInvalidErr() error { return tgerr.New(400, "DOCUMENT_INVALID")
func mediaEmptyErr() error { return tgerr.New(400, "MEDIA_EMPTY") }
func frozenMethodInvalidErr() error { return tgerr.New(400, "FROZEN_METHOD_INVALID") }
func frozenMethodInvalidErr() error { return tgerr.New(420, "FROZEN_METHOD_INVALID") }
func frozenParticipantMissingErr() error { return tgerr.New(400, "FROZEN_PARTICIPANT_MISSING") }
func photoInvalidErr() error { return tgerr.New(400, "PHOTO_INVALID") }

123
internal/rpc/frozen_gate.go Normal file
View file

@ -0,0 +1,123 @@
package rpc
import (
"context"
"strings"
"telesrv/internal/domain"
)
// Frozen accounts are read-only. Classifying the finite read vocabulary and
// failing closed for every other semantic method also covers future handlers:
// an unfamiliar mutation cannot silently bypass the account-level gate.
var frozenReadOnlyOperationPrefixes = []string{
"can", "check", "find", "get", "load", "lookup", "query", "read", "resolve", "search", "translate",
}
var frozenAlwaysBlockedMethods = map[string]struct{}{
"channels.deleteMessages": {},
"channels.joinChannel": {},
"channels.searchPosts": {},
}
// These methods are security/session housekeeping or read acknowledgements
// that must remain available in read-only mode. In particular, a frozen user
// must be able to log out/delete the account and clients must not enter retry
// loops for presence, push registration, or delivery/read acknowledgements.
var frozenAllowedMutationNamedMethods = map[string]struct{}{
"account.changeAuthorizationSettings": {},
"account.deleteAccount": {},
"account.registerDevice": {},
"account.resetAuthorization": {},
"account.resetAuthorizations": {},
"account.unregisterDevice": {},
"account.updateDeviceLocked": {},
"account.updateStatus": {},
"messages.readHistory": {},
"messages.readMentions": {},
"messages.readMessageContents": {},
"messages.readReactions": {},
"messages.receivedMessages": {},
"messages.receivedQueue": {},
"messages.reportMessagesDelivery": {},
"messages.viewSponsoredMessage": {},
"channels.readHistory": {},
"channels.readMessageContents": {},
"phone.receivedCall": {},
"stories.incrementStoryViews": {},
}
func frozenMethodRequiresWriteGate(method string) bool {
if _, blocked := frozenAlwaysBlockedMethods[method]; blocked {
return true
}
if _, allowed := frozenAllowedMutationNamedMethods[method]; allowed {
return false
}
if strings.HasPrefix(method, "auth.") {
return false
}
dot := strings.IndexByte(method, '.')
if dot < 0 || dot == len(method)-1 {
return false
}
operation := method[dot+1:]
for _, prefix := range frozenReadOnlyOperationPrefixes {
if strings.HasPrefix(operation, prefix) {
return false
}
}
return true
}
func (r *Router) checkFrozenRPC(ctx context.Context, method string) error {
if r == nil || r.deps.AccountFreeze == nil || !frozenMethodRequiresWriteGate(method) {
return nil
}
userID, authorized := UserIDFrom(ctx)
if !authorized || userID == 0 {
return nil
}
freeze, found, err := r.deps.AccountFreeze.AccountFreeze(ctx, userID)
if err != nil {
return internalErr()
}
if found && freeze.Frozen {
return frozenMethodInvalidErr()
}
return nil
}
// checkFrozenChannelParticipants implements Telegram's narrower read rule for
// the methods documented with FROZEN_PARTICIPANT_MISSING: an account freeze
// does not hide joined channels, but it removes public/linked guest preview.
func (r *Router) checkFrozenChannelParticipants(ctx context.Context, userID int64, channelIDs ...int64) error {
if r == nil || r.deps.AccountFreeze == nil || r.deps.Channels == nil || userID == 0 || len(channelIDs) == 0 {
return nil
}
freeze, found, err := r.deps.AccountFreeze.AccountFreeze(ctx, userID)
if err != nil {
return internalErr()
}
if !found || !freeze.Frozen {
return nil
}
seen := make(map[int64]struct{}, len(channelIDs))
for _, channelID := range channelIDs {
if channelID <= 0 {
continue
}
if _, duplicate := seen[channelID]; duplicate {
continue
}
seen[channelID] = struct{}{}
view, err := r.deps.Channels.ResolveChannel(ctx, userID, channelID)
if err != nil {
return channelInvalidErr(err)
}
if view.Self.UserID != userID || view.Self.Status != domain.ChannelMemberActive || view.Self.Guest {
return frozenParticipantMissingErr()
}
}
return nil
}

View file

@ -0,0 +1,265 @@
package rpc
import (
"context"
"errors"
"fmt"
"testing"
"time"
"github.com/iamxvbaba/td/bin"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"go.uber.org/zap/zaptest"
"telesrv/internal/domain"
)
type frozenGateFreezeProvider struct {
freeze domain.AccountFreeze
found bool
err error
calls int
items map[int64]domain.AccountFreeze
}
func (p *frozenGateFreezeProvider) AccountFreeze(_ context.Context, userID int64) (domain.AccountFreeze, bool, error) {
p.calls++
if p.items != nil {
freeze, found := p.items[userID]
return freeze, found, p.err
}
return p.freeze, p.found, p.err
}
type frozenGateChannels struct {
ChannelsService
views map[int64]domain.ChannelView
err error
}
func (s frozenGateChannels) ResolveChannel(_ context.Context, _ int64, channelID int64) (domain.ChannelView, error) {
if s.err != nil {
return domain.ChannelView{}, s.err
}
view, ok := s.views[channelID]
if !ok {
return domain.ChannelView{}, domain.ErrChannelInvalid
}
return view, nil
}
func frozenGateActiveState(userID int64) domain.AccountFreeze {
since := time.Unix(1_700_000_000, 0).UTC()
return domain.AccountFreeze{
UserID: userID,
Frozen: true,
Since: since,
Until: since.Add(7 * 24 * time.Hour),
AppealURL: "https://example.test/appeal",
}
}
func TestFrozenMethodGateIsReadOnlyAndFailsClosed(t *testing.T) {
tests := map[string]bool{
"help.getAppConfig": false,
"messages.getHistory": false,
"messages.searchGlobal": false,
"contacts.resolveUsername": false,
"payments.checkCanSendGift": false,
"messages.readHistory": false,
"messages.readDiscussion": false,
"stats.loadAsyncGraph": false,
"stories.incrementStoryViews": false,
"account.updateDeviceLocked": false,
"account.updateStatus": false,
"account.deleteAccount": false,
"auth.logOut": false,
"messages.sendMessage": true,
"messages.editMessage": true,
"messages.deleteHistory": true,
"messages.forwardMessages": true,
"messages.sendReaction": true,
"channels.joinChannel": true,
"channels.searchPosts": true,
"contacts.importContacts": true,
"account.saveAutoDownloadSettings": true,
"future.performNewMutation": true,
}
for method, want := range tests {
t.Run(method, func(t *testing.T) {
if got := frozenMethodRequiresWriteGate(method); got != want {
t.Fatalf("frozenMethodRequiresWriteGate(%q) = %v, want %v", method, got, want)
}
})
}
}
func TestFrozenMethodGateIsUserScopedAcrossSessionsAndUnfreezesImmediately(t *testing.T) {
const (
frozenUser = int64(1001)
otherUser = int64(1002)
)
provider := &frozenGateFreezeProvider{items: map[int64]domain.AccountFreeze{
frozenUser: frozenGateActiveState(frozenUser),
}}
router := &Router{deps: Deps{AccountFreeze: provider}}
// Separate MTProto sessions for one user share the same durable account fact.
for _, sessionID := range []int64{11, 22} {
ctx := WithSessionID(WithUserID(context.Background(), frozenUser), sessionID)
if err := router.checkFrozenRPC(ctx, "messages.sendMessage"); !tgerr.Is(err, "FROZEN_METHOD_INVALID") {
t.Fatalf("session %d err = %v, want FROZEN_METHOD_INVALID", sessionID, err)
}
}
if err := router.checkFrozenRPC(WithUserID(context.Background(), otherUser), "messages.sendMessage"); err != nil {
t.Fatalf("other user was gated: %v", err)
}
// Unfreeze is a durable state transition; existing sessions are admitted on
// their very next RPC without reconnecting or retaining a per-session flag.
delete(provider.items, frozenUser)
for _, sessionID := range []int64{11, 22} {
ctx := WithSessionID(WithUserID(context.Background(), frozenUser), sessionID)
if err := router.checkFrozenRPC(ctx, "messages.sendMessage"); err != nil {
t.Fatalf("session %d remained gated after unfreeze: %v", sessionID, err)
}
}
}
func TestFrozenMethodGateReturns420BeforeLayerHandler(t *testing.T) {
const userID = int64(1001)
for _, profile := range []tg.LayerProfile{
tg.LayerProfile225,
tg.LayerProfile226,
tg.LayerProfile227,
tg.LayerProfile228,
} {
t.Run(fmt.Sprintf("layer_%d", profile), func(t *testing.T) {
provider := &frozenGateFreezeProvider{freeze: frozenGateActiveState(userID), found: true}
router := New(
Config{DC: 2, IP: "127.0.0.1", Port: 2398},
Deps{AccountFreeze: provider},
zaptest.NewLogger(t),
clock.System,
)
body := encodeExactLayerRPC(t, profile, &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerSelf{},
Message: "must not reach handler",
RandomID: 1,
})
admitted, err := router.AdmitLayer(profile, &body, tg.LayerDecodeLimits{})
if err != nil {
t.Fatal(err)
}
_, method, err := router.DispatchAdmitted(WithUserID(context.Background(), userID), [8]byte{1}, 10, 0, 1, admitted)
if method != "messages.sendMessage" || !tgerr.Is(err, "FROZEN_METHOD_INVALID") {
t.Fatalf("DispatchAdmitted = method:%q err:%v", method, err)
}
rpcErr, ok := tgerr.As(err)
if !ok || rpcErr.Code != 420 {
t.Fatalf("RPC error = %#v, want code 420", rpcErr)
}
if provider.calls != 1 {
t.Fatalf("freeze provider calls = %d, want 1", provider.calls)
}
})
}
}
func TestFrozenMethodGateReturns420BeforeLegacyHandler(t *testing.T) {
const userID = int64(1001)
provider := &frozenGateFreezeProvider{freeze: frozenGateActiveState(userID), found: true}
router := New(
Config{DC: 2, IP: "127.0.0.1", Port: 2398},
Deps{AccountFreeze: provider},
zaptest.NewLogger(t),
clock.System,
)
request := &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerSelf{},
Message: "must not reach legacy handler",
RandomID: 2,
}
var body bin.Buffer
if err := request.Encode(&body); err != nil {
t.Fatal(err)
}
if _, err := router.Dispatch(WithUserID(context.Background(), userID), [8]byte{1}, 10, &body); !tgerr.Is(err, "FROZEN_METHOD_INVALID") {
t.Fatalf("legacy Dispatch err = %v, want FROZEN_METHOD_INVALID", err)
} else if rpcErr, ok := tgerr.As(err); !ok || rpcErr.Code != 420 {
t.Fatalf("legacy RPC error = %#v, want code 420", rpcErr)
}
if provider.calls != 1 {
t.Fatalf("freeze provider calls = %d, want 1", provider.calls)
}
}
func TestFrozenParticipantGateAllowsOnlyJoinedChannels(t *testing.T) {
const userID = int64(1001)
provider := &frozenGateFreezeProvider{freeze: frozenGateActiveState(userID), found: true}
router := &Router{deps: Deps{
AccountFreeze: provider,
Channels: frozenGateChannels{views: map[int64]domain.ChannelView{
10: {Self: domain.ChannelMember{UserID: userID, Status: domain.ChannelMemberActive}},
20: {Self: domain.ChannelMember{UserID: userID, Status: domain.ChannelMemberLeft}},
30: {Self: domain.ChannelMember{UserID: userID, Status: domain.ChannelMemberActive, Guest: true}},
}},
}}
ctx := WithUserID(context.Background(), userID)
if err := router.checkFrozenChannelParticipants(ctx, userID, 10, 10); err != nil {
t.Fatalf("joined channel: %v", err)
}
for _, channelID := range []int64{20, 30} {
if err := router.checkFrozenChannelParticipants(ctx, userID, channelID); !tgerr.Is(err, "FROZEN_PARTICIPANT_MISSING") {
t.Fatalf("channel %d err = %v, want FROZEN_PARTICIPANT_MISSING", channelID, err)
} else if rpcErr, ok := tgerr.As(err); !ok || rpcErr.Code != 400 {
t.Fatalf("channel %d RPC error = %#v, want code 400", channelID, rpcErr)
}
}
}
func TestFrozenParticipantGateRejectsBeforeCatchupRateLimitWrite(t *testing.T) {
const (
userID = int64(1001)
channelID = int64(20)
accessHash = int64(20020)
)
provider := &frozenGateFreezeProvider{freeze: frozenGateActiveState(userID), found: true}
limiter := &captureRateLimiter{}
router := &Router{
cfg: Config{CatchupRateLimit: 1, CatchupRateWindow: time.Minute},
deps: Deps{
AccountFreeze: provider,
Limiter: limiter,
Channels: frozenGateChannels{views: map[int64]domain.ChannelView{
channelID: {
Channel: domain.Channel{ID: channelID, AccessHash: accessHash},
Self: domain.ChannelMember{UserID: userID, Status: domain.ChannelMemberLeft},
},
}},
},
}
_, err := router.onUpdatesGetChannelDifference(
WithUserID(context.Background(), userID),
&tg.UpdatesGetChannelDifferenceRequest{
Channel: &tg.InputChannel{ChannelID: channelID, AccessHash: accessHash},
Limit: 100,
},
)
if !tgerr.Is(err, "FROZEN_PARTICIPANT_MISSING") {
t.Fatalf("getChannelDifference err = %v, want FROZEN_PARTICIPANT_MISSING", err)
}
if len(limiter.calls) != 0 {
t.Fatalf("rejected frozen participant consumed rate-limit state: %+v", limiter.calls)
}
}
func TestFrozenGatesFailClosedOnFreezeLookupError(t *testing.T) {
provider := &frozenGateFreezeProvider{err: errors.New("database unavailable")}
router := &Router{deps: Deps{AccountFreeze: provider}}
if err := router.checkFrozenRPC(WithUserID(context.Background(), 1001), "messages.sendMessage"); !tgerr.Is(err, "INTERNAL_SERVER_ERROR") {
t.Fatalf("checkFrozenRPC error = %v, want INTERNAL_SERVER_ERROR", err)
}
}

View file

@ -30,7 +30,8 @@ func (r *Router) registerHelp(d *tg.ServerDispatcher) {
if r.deps.Help == nil {
return tdesktop.AppConfig(hash), nil
}
cfg, notModified, err := r.deps.Help.GetAppConfig(ctx, hash)
userID, _ := UserIDFrom(ctx)
cfg, notModified, err := r.deps.Help.GetAppConfig(ctx, userID, hash)
if err != nil {
return nil, internalErr()
}

View file

@ -254,6 +254,9 @@ func (r *Router) DispatchAdmitted(
return nil, method, authKeyUnregisteredErr()
}
}
if err := r.checkFrozenRPC(ctx, method); err != nil {
return nil, method, err
}
if profileKnown && profileEvidenceFresh {
r.maybeMarkSessionReceivesUpdates(ctx)
}

View file

@ -201,15 +201,26 @@ func (r *Router) registerMessages(d *tg.ServerDispatcher) {
if err != nil {
return nil, internalErr()
}
// difference 类 catch-up FLOOD_WAIT设计 Phase 2 / §10.3DrKLO 收 nudge 对未加载频道
// 走 loadUnknownChannel→getPeerDialogs限速须同时覆盖它不止 getChannelDifference
if err := r.checkCatchupRateLimit(ctx, userID, peerDialogsRateLimitKeyPrefix); err != nil {
return nil, err
}
domainPeers, err := r.dialogPeersFromInput(ctx, userID, peers)
if err != nil {
return nil, err
}
channelIDs := make([]int64, 0, len(domainPeers))
for _, peer := range domainPeers {
if peer.Type == domain.PeerTypeChannel {
channelIDs = append(channelIDs, peer.ID)
}
}
if err := r.checkFrozenChannelParticipants(ctx, userID, channelIDs...); err != nil {
return nil, err
}
// difference 类 catch-up FLOOD_WAIT设计 Phase 2 / §10.3DrKLO 收 nudge 对未加载频道
// 走 loadUnknownChannel→getPeerDialogs限速须同时覆盖它不止 getChannelDifference
// 冻结账号的 guest/non-member 必须先返回 FROZEN_PARTICIPANT_MISSING拒绝路径不能
// 消耗限流额度或产生其它可变状态。
if err := r.checkCatchupRateLimit(ctx, userID, peerDialogsRateLimitKeyPrefix); err != nil {
return nil, err
}
var list domain.DialogList
if len(domainPeers) > 0 && r.deps.Dialogs != nil {
var err error
@ -259,6 +270,9 @@ func (r *Router) registerMessages(d *tg.ServerDispatcher) {
if err := r.validateInputPeerChannelAccess(ctx, userID, req.Peer, filter.Peer.ID); err != nil {
return nil, err
}
if err := r.checkFrozenChannelParticipants(ctx, userID, filter.Peer.ID); err != nil {
return nil, err
}
if isLegacyInputPeerChat(req.Peer) {
return &tg.MessagesMessages{}, nil
}

View file

@ -202,7 +202,7 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
func messageSendErr(err error) error {
switch {
case errors.Is(err, domain.ErrUserSendRestricted):
case errors.Is(err, domain.ErrUserFrozen):
return frozenMethodInvalidErr()
case errors.Is(err, domain.ErrReplyMessageIDInvalid):
return replyMessageIDInvalidErr()

View file

@ -670,6 +670,9 @@ func (r *Router) dispatch(ctx context.Context, b *bin.Buffer, depth int, meta *r
if err := preflightRPCRequest(id, b); err != nil {
return nil, err
}
if err := r.checkFrozenRPC(ctx, tlTypeName(id)); err != nil {
return nil, err
}
// 任何未包 invokeWithoutUpdates 的已登录 RPC 都把当前 session 视为 updates
// 接收者。仅靠 updates.getState/getDifference 置位会漏掉 DrKLO 热恢复:
// 它重连后不重建同步基线pts 在进程内存里),只发普通业务请求,置位

View file

@ -166,57 +166,66 @@ func scanAdminCommand(row pgx.Row) (domain.AdminCommand, error) {
return cmd, nil
}
func (s *AdminStore) GetSendRestriction(ctx context.Context, userID int64) (domain.AccountSendRestriction, bool, error) {
func (s *AdminStore) GetAccountFreeze(ctx context.Context, userID int64) (domain.AccountFreeze, bool, error) {
row := s.db.QueryRow(ctx, `
SELECT user_id, frozen, reason, actor, command_id, updated_at
FROM account_send_restrictions
SELECT user_id, frozen, frozen_since, frozen_until, appeal_url, reason, actor, command_id, updated_at
FROM account_restrictions
WHERE user_id = $1`, userID)
r, err := scanSendRestriction(row)
r, err := scanAccountFreeze(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.AccountSendRestriction{}, false, nil
return domain.AccountFreeze{}, false, nil
}
return domain.AccountSendRestriction{}, false, fmt.Errorf("get send restriction: %w", err)
return domain.AccountFreeze{}, false, fmt.Errorf("get account freeze: %w", err)
}
return r, true, nil
}
func (s *AdminStore) SetSendRestriction(ctx context.Context, restriction domain.AccountSendRestriction) (domain.AccountSendRestriction, error) {
func (s *AdminStore) SetAccountFreeze(ctx context.Context, freeze domain.AccountFreeze) (domain.AccountFreeze, error) {
var since, until any
if freeze.Frozen {
since = freeze.Since
until = freeze.Until
}
row := s.db.QueryRow(ctx, `
INSERT INTO account_send_restrictions (user_id, frozen, reason, actor, command_id, updated_at)
VALUES ($1,$2,$3,$4,$5,now())
INSERT INTO account_restrictions (
user_id, frozen, frozen_since, frozen_until, appeal_url, reason, actor, command_id, updated_at
)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,now())
ON CONFLICT (user_id) DO UPDATE SET
frozen = EXCLUDED.frozen,
frozen_since = EXCLUDED.frozen_since,
frozen_until = EXCLUDED.frozen_until,
appeal_url = EXCLUDED.appeal_url,
reason = EXCLUDED.reason,
actor = EXCLUDED.actor,
command_id = EXCLUDED.command_id,
updated_at = now()
RETURNING user_id, frozen, reason, actor, command_id, updated_at`,
restriction.UserID, restriction.Frozen, restriction.Reason, restriction.Actor, restriction.CommandID,
RETURNING user_id, frozen, frozen_since, frozen_until, appeal_url, reason, actor, command_id, updated_at`,
freeze.UserID, freeze.Frozen, since, until, freeze.AppealURL, freeze.Reason, freeze.Actor, freeze.CommandID,
)
out, err := scanSendRestriction(row)
out, err := scanAccountFreeze(row)
if err != nil {
return domain.AccountSendRestriction{}, fmt.Errorf("set send restriction: %w", err)
return domain.AccountFreeze{}, fmt.Errorf("set account freeze: %w", err)
}
return out, nil
}
func (s *AdminStore) IsSendFrozen(ctx context.Context, userID int64) (bool, error) {
var frozen bool
if err := s.db.QueryRow(ctx, `SELECT frozen FROM account_send_restrictions WHERE user_id = $1`, userID).Scan(&frozen); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
return false, fmt.Errorf("check send restriction: %w", err)
}
return frozen, nil
}
func scanSendRestriction(row pgx.Row) (domain.AccountSendRestriction, error) {
var r domain.AccountSendRestriction
func scanAccountFreeze(row pgx.Row) (domain.AccountFreeze, error) {
var r domain.AccountFreeze
var since, until pgtype.Timestamptz
var updated time.Time
if err := row.Scan(&r.UserID, &r.Frozen, &r.Reason, &r.Actor, &r.CommandID, &updated); err != nil {
return domain.AccountSendRestriction{}, err
if err := row.Scan(
&r.UserID, &r.Frozen, &since, &until, &r.AppealURL,
&r.Reason, &r.Actor, &r.CommandID, &updated,
); err != nil {
return domain.AccountFreeze{}, err
}
if since.Valid {
r.Since = since.Time
}
if until.Valid {
r.Until = until.Time
}
r.UpdatedAt = updated
return r, nil

View file

@ -0,0 +1,109 @@
package postgres
import (
"context"
"testing"
"time"
"telesrv/deploy"
"telesrv/internal/domain"
)
func TestAccountFreezeMigrationAndStoreRoundTrip(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatal(err)
}
defer func() { _ = tx.Rollback(ctx) }()
downSQL, err := deploy.Migrations.ReadFile("migrations/0088_account_freeze_state.down.sql")
if err != nil {
t.Fatal(err)
}
upSQL, err := deploy.Migrations.ReadFile("migrations/0088_account_freeze_state.up.sql")
if err != nil {
t.Fatal(err)
}
if _, err := tx.Exec(ctx, string(downSQL)); err != nil {
t.Fatalf("roll schema back to legacy restriction shape: %v", err)
}
const (
frozenUserID = int64(1999999881)
activeUserID = int64(1999999882)
)
for _, user := range []struct {
id int64
phone string
}{{frozenUserID, "1999999881"}, {activeUserID, "1999999882"}} {
if _, err := tx.Exec(ctx, `
INSERT INTO users (id, access_hash, phone, first_name)
VALUES ($1, $1, $2, 'Freeze migration test')`, user.id, user.phone); err != nil {
t.Fatalf("insert migration user %d: %v", user.id, err)
}
}
legacyUpdatedAt := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
if _, err := tx.Exec(ctx, `
INSERT INTO account_send_restrictions (user_id, frozen, reason, actor, command_id, updated_at)
VALUES ($1, true, 'legacy freeze', 'ops', 'legacy-freeze', $2)`, frozenUserID, legacyUpdatedAt); err != nil {
t.Fatalf("insert legacy restriction: %v", err)
}
if _, err := tx.Exec(ctx, string(upSQL)); err != nil {
t.Fatalf("apply account freeze migration: %v", err)
}
store := NewAdminStore(tx)
migrated, found, err := store.GetAccountFreeze(ctx, frozenUserID)
if err != nil || !found {
t.Fatalf("GetAccountFreeze migrated = %+v found=%v err=%v", migrated, found, err)
}
if !migrated.Frozen || !migrated.Since.Equal(legacyUpdatedAt) ||
!migrated.Until.Equal(legacyUpdatedAt.Add(7*24*time.Hour)) || migrated.AppealURL != "https://t.me/SpamBot" {
t.Fatalf("migrated freeze = %+v", migrated)
}
since := time.Date(2026, 7, 15, 2, 0, 0, 0, time.UTC)
want := domain.AccountFreeze{
UserID: activeUserID,
Frozen: true,
Since: since,
Until: since.Add(48 * time.Hour),
AppealURL: "https://appeals.example.test/users/1999999882",
Reason: "abuse review",
Actor: "ops",
CommandID: "freeze-round-trip",
}
if _, err := store.SetAccountFreeze(ctx, want); err != nil {
t.Fatalf("SetAccountFreeze active: %v", err)
}
got, found, err := store.GetAccountFreeze(ctx, activeUserID)
if err != nil || !found || !got.Frozen || !got.Since.Equal(want.Since) ||
!got.Until.Equal(want.Until) || got.AppealURL != want.AppealURL {
t.Fatalf("active round trip = %+v found=%v err=%v", got, found, err)
}
if _, err := store.SetAccountFreeze(ctx, domain.AccountFreeze{
UserID: activeUserID, Reason: "appeal accepted", Actor: "ops", CommandID: "unfreeze-round-trip",
}); err != nil {
t.Fatalf("SetAccountFreeze inactive: %v", err)
}
got, found, err = store.GetAccountFreeze(ctx, activeUserID)
if err != nil || !found || got.Frozen || !got.Since.IsZero() || !got.Until.IsZero() || got.AppealURL != "" {
t.Fatalf("inactive round trip = %+v found=%v err=%v", got, found, err)
}
if _, err := tx.Exec(ctx, "SAVEPOINT invalid_freeze"); err != nil {
t.Fatal(err)
}
_, invalidErr := tx.Exec(ctx, `
UPDATE account_restrictions
SET frozen = true, frozen_since = NULL, frozen_until = NULL, appeal_url = ''
WHERE user_id = $1`, activeUserID)
if invalidErr == nil {
t.Fatal("database accepted an active freeze without client-visible state")
}
if _, err := tx.Exec(ctx, "ROLLBACK TO SAVEPOINT invalid_freeze"); err != nil {
t.Fatalf("rollback invalid freeze savepoint: %v", err)
}
}

View file

@ -53,13 +53,16 @@ type AccountReactionSetting struct {
UpdatedAt pgtype.Timestamptz
}
type AccountSendRestriction struct {
UserID int64
Frozen bool
Reason string
Actor string
CommandID string
UpdatedAt pgtype.Timestamptz
type AccountRestriction struct {
UserID int64
Frozen bool
Reason string
Actor string
CommandID string
UpdatedAt pgtype.Timestamptz
FrozenSince pgtype.Timestamptz
FrozenUntil pgtype.Timestamptz
AppealUrl string
}
type AccountSetting struct {
@ -131,6 +134,17 @@ type AiComposeToneSafe struct {
SavedAt pgtype.Timestamptz
}
// Durable pre-send binding from album item random_id to grouped_id; never reconstructed from a retry subset.
type AlbumGroupReservation struct {
SenderUserID int64
PeerType string
PeerID int64
RandomID int64
IntentHash []byte
GroupedID int64
CreatedAt pgtype.Timestamptz
}
type AppConfig struct {
Client string
Hash int32
@ -164,18 +178,28 @@ type AttachMenuUserState struct {
}
type AuthKey struct {
AuthKeyID int64
Body []byte
ServerSalt int64
CreatedAt pgtype.Timestamptz
AuthKeyID int64
Body []byte
ServerSalt int64
CreatedAt pgtype.Timestamptz
Layer int32
DeviceModel string
Platform string
SystemVersion string
ApiID int32
AppVersion string
LastUsedAt pgtype.Timestamptz
ExpiresAt int32
LayerObservationID int64
}
type AuthKeySessionLayer struct {
RawAuthKeyID int64
SessionID int64
Layer int32
DeviceModel string
Platform string
SystemVersion string
ApiID int32
AppVersion string
LastUsedAt pgtype.Timestamptz
ExpiresAt int32
MsgID int64
ObservationID int64
ExpiresAt pgtype.Timestamptz
}
type Authorization struct {
@ -628,6 +652,7 @@ type ChannelMessage struct {
DeletePtsCount int32
DeleteDate int32
DeleteMessageIds []byte
RequestFingerprint []byte
}
type ChannelMessageMedium struct {
@ -1033,6 +1058,20 @@ type LangPackString struct {
UpdatedAt pgtype.Timestamptz
}
type LoginCodeMessageDelivery struct {
// SHA-256(phone_code_hash); raw phone_code_hash is never persisted
DeliveryKey []byte
// HMAC-SHA-256(code), keyed by the non-persisted raw phone_code_hash
CodeFingerprint []byte
UserID int64
PrivateMessageID int64
MessageBoxID int32
Pts int32
MessageDate int32
CreatedAt pgtype.Timestamptz
ExpiresAt pgtype.Timestamptz
}
type MessageBox struct {
OwnerUserID int64
BoxID int32
@ -1132,6 +1171,14 @@ type PeerStarGift struct {
SavedID int64
}
type PeerTranslationSetting struct {
UserID int64
PeerType string
PeerID int64
Disabled bool
UpdatedAt pgtype.Timestamptz
}
type PeerUsername struct {
UsernameLower string
PeerType string
@ -1631,6 +1678,7 @@ type UserStickerCollection struct {
Kind string
DocumentID int64
UsedAt int32
OrderKey int64
}
type UserStickerSet struct {