feat: sync account freeze lifecycle
This commit is contained in:
parent
76bfc5100f
commit
47fcf0ea41
40 changed files with 1363 additions and 196 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
8
cmd/telesrv-admin/web/dist/assets/index-DRWO_DgE.js
vendored
Normal file
8
cmd/telesrv-admin/web/dist/assets/index-DRWO_DgE.js
vendored
Normal file
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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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": "设置会员",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
10
deploy/migrations/0088_account_freeze_state.down.sql
Normal file
10
deploy/migrations/0088_account_freeze_state.down.sql
Normal 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;
|
||||
34
deploy/migrations/0088_account_freeze_state.up.sql
Normal file
34
deploy/migrations/0088_account_freeze_state.up.sql
Normal 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 = '')
|
||||
);
|
||||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 config,hash 命中时返回 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("")
|
||||
|
|
|
|||
133
internal/app/help/service_freeze_test.go
Normal file
133
internal/app/help/service_freeze_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 账号不可被授予会员(官方语义)。
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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.3):nudge 被消费后客户端会触发
|
||||
// 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.3):nudge 被消费后客户端会触发
|
||||
// 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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
123
internal/rpc/frozen_gate.go
Normal 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
|
||||
}
|
||||
265
internal/rpc/frozen_gate_test.go
Normal file
265
internal/rpc/frozen_gate_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.3):DrKLO 收 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.3):DrKLO 收 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 在进程内存里),只发普通业务请求,置位
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
109
internal/store/postgres/admin_freeze_integration_test.go
Normal file
109
internal/store/postgres/admin_freeze_integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue