feat: sync account deletion lifecycle
Sync telesrv 73f5c91 (feat(account): implement unified account deletion lifecycle). Skipped telesrv docs changes per public sync rules.
This commit is contained in:
parent
96a419b565
commit
edb7057757
32 changed files with 3236 additions and 88 deletions
|
|
@ -436,7 +436,7 @@ func run(logger *zap.Logger) error {
|
||||||
return fmt.Errorf("seed appearance: %w", err)
|
return fmt.Errorf("seed appearance: %w", err)
|
||||||
} else if !stats.Skipped {
|
} else if !stats.Skipped {
|
||||||
logger.Info("外观种子导入完成",
|
logger.Info("外观种子导入完成",
|
||||||
zap.String("source", "default-seed"),
|
zap.String("source", "orange-live"),
|
||||||
zap.Int("wallpapers", stats.Wallpapers),
|
zap.Int("wallpapers", stats.Wallpapers),
|
||||||
zap.Int("documents", stats.Documents),
|
zap.Int("documents", stats.Documents),
|
||||||
zap.Int("blobs", stats.Blobs),
|
zap.Int("blobs", stats.Blobs),
|
||||||
|
|
@ -526,6 +526,7 @@ func run(logger *zap.Logger) error {
|
||||||
account.WithBusinessAutomation(passwordStore),
|
account.WithBusinessAutomation(passwordStore),
|
||||||
account.WithUsers(userStore),
|
account.WithUsers(userStore),
|
||||||
account.WithPhoneChange(phoneChangeStore, authzStore, codeStore, userCache, cfg.DevAuthCode, cfg.AuthCodeTTL, cfg.AuthCodeMaxAttempts),
|
account.WithPhoneChange(phoneChangeStore, authzStore, codeStore, userCache, cfg.DevAuthCode, cfg.AuthCodeTTL, cfg.AuthCodeMaxAttempts),
|
||||||
|
account.WithAccountLifecycle(postgres.NewAccountLifecycleStore(pool)),
|
||||||
account.WithPublicBaseURL(cfg.PublicBaseURL),
|
account.WithPublicBaseURL(cfg.PublicBaseURL),
|
||||||
}
|
}
|
||||||
var webhookSender otpdelivery.Sender
|
var webhookSender otpdelivery.Sender
|
||||||
|
|
@ -871,6 +872,7 @@ func run(logger *zap.Logger) error {
|
||||||
go router.RunPresenceSweeper(ctx, time.Minute)
|
go router.RunPresenceSweeper(ctx, time.Minute)
|
||||||
go activeSessions.RunPendingSweeper(ctx, time.Minute)
|
go activeSessions.RunPendingSweeper(ctx, time.Minute)
|
||||||
go router.RunPremiumSweeper(ctx, cfg.PremiumSweepInterval, cfg.PremiumSweepBatch)
|
go router.RunPremiumSweeper(ctx, cfg.PremiumSweepInterval, cfg.PremiumSweepBatch)
|
||||||
|
go router.RunAccountLifecycle(ctx, time.Minute, 500)
|
||||||
go func() {
|
go func() {
|
||||||
interval := cfg.StarGiftSweepInterval
|
interval := cfg.StarGiftSweepInterval
|
||||||
if interval <= 0 {
|
if interval <= 0 {
|
||||||
|
|
|
||||||
25
deploy/migrations/0107_account_lifecycle.down.sql
Normal file
25
deploy/migrations/0107_account_lifecycle.down.sql
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
DROP TRIGGER IF EXISTS account_settings_ttl_trigger ON public.account_settings;
|
||||||
|
DROP FUNCTION IF EXISTS public.telesrv_account_settings_ttl_trigger();
|
||||||
|
DROP TRIGGER IF EXISTS users_account_delete_at_trigger ON public.users;
|
||||||
|
DROP FUNCTION IF EXISTS public.telesrv_users_account_delete_at_trigger();
|
||||||
|
DROP FUNCTION IF EXISTS public.telesrv_account_delete_at(timestamp with time zone, bigint, integer);
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS account_passwords_changed_at_trigger ON public.account_passwords;
|
||||||
|
DROP FUNCTION IF EXISTS public.telesrv_password_changed_at_trigger();
|
||||||
|
ALTER TABLE public.account_passwords DROP COLUMN IF EXISTS password_changed_at;
|
||||||
|
|
||||||
|
ALTER TABLE public.account_settings
|
||||||
|
DROP CONSTRAINT account_settings_account_ttl_days_check,
|
||||||
|
ADD CONSTRAINT account_settings_account_ttl_days_check CHECK (account_ttl_days > 0);
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS public.account_deletion_notifications;
|
||||||
|
DROP TABLE IF EXISTS public.account_deletion_requests;
|
||||||
|
DROP INDEX IF EXISTS public.dialogs_user_peer_reverse_idx;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS public.users_account_delete_due_idx;
|
||||||
|
ALTER TABLE public.users DROP CONSTRAINT IF EXISTS users_deletion_state_check;
|
||||||
|
ALTER TABLE public.users
|
||||||
|
DROP COLUMN IF EXISTS account_delete_at,
|
||||||
|
DROP COLUMN IF EXISTS deletion_reason,
|
||||||
|
DROP COLUMN IF EXISTS deletion_source,
|
||||||
|
DROP COLUMN IF EXISTS deleted_at;
|
||||||
216
deploy/migrations/0107_account_lifecycle.up.sql
Normal file
216
deploy/migrations/0107_account_lifecycle.up.sql
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
-- Unified account deletion lifecycle. A deleted account remains as a minimal
|
||||||
|
-- user tombstone so historical messages keep a stable sender id, while all
|
||||||
|
-- reusable identity and profile fields are released atomically.
|
||||||
|
ALTER TABLE public.users
|
||||||
|
ADD COLUMN deleted_at timestamp with time zone,
|
||||||
|
ADD COLUMN deletion_source text DEFAULT '' NOT NULL,
|
||||||
|
ADD COLUMN deletion_reason text DEFAULT '' NOT NULL,
|
||||||
|
ADD COLUMN account_delete_at timestamp with time zone;
|
||||||
|
|
||||||
|
ALTER TABLE public.account_passwords
|
||||||
|
ADD COLUMN password_changed_at timestamp with time zone;
|
||||||
|
|
||||||
|
ALTER TABLE public.account_settings
|
||||||
|
DROP CONSTRAINT account_settings_account_ttl_days_check,
|
||||||
|
ADD CONSTRAINT account_settings_account_ttl_days_check
|
||||||
|
CHECK (account_ttl_days BETWEEN 1 AND 3650);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.telesrv_password_changed_at_trigger()
|
||||||
|
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP = 'INSERT' THEN
|
||||||
|
NEW.password_changed_at := CASE WHEN NEW.has_password THEN now() ELSE NULL END;
|
||||||
|
ELSIF NEW.has_password IS DISTINCT FROM OLD.has_password
|
||||||
|
OR NEW.srp_verifier IS DISTINCT FROM OLD.srp_verifier
|
||||||
|
OR NEW.current_algo_salt1 IS DISTINCT FROM OLD.current_algo_salt1
|
||||||
|
OR NEW.current_algo_salt2 IS DISTINCT FROM OLD.current_algo_salt2
|
||||||
|
OR NEW.current_algo_g IS DISTINCT FROM OLD.current_algo_g
|
||||||
|
OR NEW.current_algo_p IS DISTINCT FROM OLD.current_algo_p THEN
|
||||||
|
NEW.password_changed_at := CASE WHEN NEW.has_password THEN now() ELSE NULL END;
|
||||||
|
ELSE
|
||||||
|
NEW.password_changed_at := OLD.password_changed_at;
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
UPDATE public.account_passwords
|
||||||
|
SET password_changed_at = updated_at
|
||||||
|
WHERE has_password = true AND password_changed_at IS NULL;
|
||||||
|
|
||||||
|
CREATE TRIGGER account_passwords_changed_at_trigger
|
||||||
|
BEFORE INSERT OR UPDATE ON public.account_passwords
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.telesrv_password_changed_at_trigger();
|
||||||
|
|
||||||
|
ALTER TABLE public.users
|
||||||
|
ADD CONSTRAINT users_deletion_state_check CHECK (
|
||||||
|
(deleted_at IS NULL AND deletion_source = '' AND deletion_reason = '')
|
||||||
|
OR
|
||||||
|
(deleted_at IS NOT NULL
|
||||||
|
AND deletion_source IN (
|
||||||
|
'manual',
|
||||||
|
'forgot_password',
|
||||||
|
'tos_decline',
|
||||||
|
'password_reset_expiry',
|
||||||
|
'account_ttl',
|
||||||
|
'freeze_expiry'
|
||||||
|
)
|
||||||
|
AND account_delete_at IS NULL
|
||||||
|
AND phone = ''
|
||||||
|
AND first_name = ''
|
||||||
|
AND last_name = ''
|
||||||
|
AND username = ''
|
||||||
|
AND country_code = ''
|
||||||
|
AND about = ''
|
||||||
|
AND verified = false
|
||||||
|
AND support = false
|
||||||
|
AND premium_expires_at IS NULL
|
||||||
|
AND emoji_status_document_id = 0
|
||||||
|
AND emoji_status_until = 0
|
||||||
|
AND color_set = false
|
||||||
|
AND color = 0
|
||||||
|
AND color_background_emoji_id = 0
|
||||||
|
AND profile_color_set = false
|
||||||
|
AND profile_color = 0
|
||||||
|
AND profile_color_background_emoji_id = 0
|
||||||
|
AND birthday_day = 0
|
||||||
|
AND birthday_month = 0
|
||||||
|
AND birthday_year = 0
|
||||||
|
AND personal_channel_id = 0
|
||||||
|
AND last_seen_at = 0
|
||||||
|
AND octet_length(deletion_reason) <= 1024)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX users_account_delete_due_idx
|
||||||
|
ON public.users (account_delete_at, id)
|
||||||
|
WHERE deleted_at IS NULL AND is_bot = false AND account_delete_at IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE TABLE public.account_deletion_requests (
|
||||||
|
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
user_id bigint NOT NULL REFERENCES public.users(id),
|
||||||
|
requester_auth_key_id bigint NOT NULL,
|
||||||
|
state text DEFAULT 'pending' NOT NULL,
|
||||||
|
reason text DEFAULT '' NOT NULL,
|
||||||
|
confirm_hash_digest bytea NOT NULL,
|
||||||
|
requested_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
execute_at timestamp with time zone NOT NULL,
|
||||||
|
completed_at timestamp with time zone,
|
||||||
|
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT account_deletion_requests_state_check CHECK (
|
||||||
|
(state = 'pending' AND completed_at IS NULL)
|
||||||
|
OR (state IN ('cancelled', 'executed') AND completed_at IS NOT NULL)
|
||||||
|
),
|
||||||
|
CONSTRAINT account_deletion_requests_reason_check CHECK (octet_length(reason) <= 1024),
|
||||||
|
CONSTRAINT account_deletion_requests_hash_check CHECK (octet_length(confirm_hash_digest) = 32)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX account_deletion_requests_one_pending_user_idx
|
||||||
|
ON public.account_deletion_requests(user_id) WHERE state = 'pending';
|
||||||
|
CREATE UNIQUE INDEX account_deletion_requests_pending_hash_idx
|
||||||
|
ON public.account_deletion_requests(confirm_hash_digest) WHERE state = 'pending';
|
||||||
|
CREATE INDEX account_deletion_requests_due_idx
|
||||||
|
ON public.account_deletion_requests(execute_at, id) WHERE state = 'pending';
|
||||||
|
|
||||||
|
-- updateUser is not a pts-bearing update. Keep a dedicated durable online-nudge
|
||||||
|
-- queue so a crash between tombstone commit and best-effort fan-out is recovered.
|
||||||
|
-- Offline clients converge from authoritative dialog/history hydration instead
|
||||||
|
-- of an immortal retry queue or invented user pts events.
|
||||||
|
CREATE TABLE public.account_deletion_notifications (
|
||||||
|
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
target_user_id bigint NOT NULL REFERENCES public.users(id),
|
||||||
|
deleted_user_id bigint NOT NULL REFERENCES public.users(id),
|
||||||
|
status text DEFAULT 'pending' NOT NULL,
|
||||||
|
attempts integer DEFAULT 0 NOT NULL,
|
||||||
|
next_attempt_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
lease_until timestamp with time zone,
|
||||||
|
last_error text DEFAULT '' NOT NULL,
|
||||||
|
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT account_deletion_notifications_status_check
|
||||||
|
CHECK (status IN ('pending', 'dispatching', 'delivered')),
|
||||||
|
CONSTRAINT account_deletion_notifications_attempts_check CHECK (attempts >= 0),
|
||||||
|
CONSTRAINT account_deletion_notifications_not_self_check CHECK (target_user_id <> deleted_user_id),
|
||||||
|
UNIQUE (target_user_id, deleted_user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX account_deletion_notifications_ready_idx
|
||||||
|
ON public.account_deletion_notifications(next_attempt_at, id)
|
||||||
|
WHERE status = 'pending';
|
||||||
|
|
||||||
|
-- Account deletion discovers peers that have an inbound dialog row pointing at
|
||||||
|
-- the deleted user. The ordinary dialog PK only covers the owner direction;
|
||||||
|
-- keep the reverse lookup indexed so tombstoning one account never scans every
|
||||||
|
-- user's dialog table.
|
||||||
|
CREATE INDEX dialogs_user_peer_reverse_idx
|
||||||
|
ON public.dialogs(peer_id, user_id)
|
||||||
|
WHERE peer_type = 'user';
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.telesrv_account_delete_at(
|
||||||
|
p_created_at timestamp with time zone,
|
||||||
|
p_last_seen_at bigint,
|
||||||
|
p_ttl_days integer
|
||||||
|
) RETURNS timestamp with time zone
|
||||||
|
LANGUAGE sql IMMUTABLE AS $$
|
||||||
|
SELECT GREATEST(
|
||||||
|
p_created_at,
|
||||||
|
CASE WHEN p_last_seen_at > 0 THEN to_timestamp(p_last_seen_at) ELSE p_created_at END
|
||||||
|
) + make_interval(days => p_ttl_days)
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.telesrv_users_account_delete_at_trigger()
|
||||||
|
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
DECLARE
|
||||||
|
v_ttl_days integer;
|
||||||
|
BEGIN
|
||||||
|
IF NEW.deleted_at IS NOT NULL OR NEW.is_bot THEN
|
||||||
|
NEW.account_delete_at := NULL;
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
SELECT account_ttl_days INTO v_ttl_days
|
||||||
|
FROM public.account_settings WHERE user_id = NEW.id;
|
||||||
|
NEW.account_delete_at := public.telesrv_account_delete_at(
|
||||||
|
NEW.created_at,
|
||||||
|
NEW.last_seen_at,
|
||||||
|
COALESCE(v_ttl_days, 365)
|
||||||
|
);
|
||||||
|
RETURN NEW;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE TRIGGER users_account_delete_at_trigger
|
||||||
|
BEFORE INSERT OR UPDATE OF last_seen_at, deleted_at, is_bot
|
||||||
|
ON public.users
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.telesrv_users_account_delete_at_trigger();
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.telesrv_account_settings_ttl_trigger()
|
||||||
|
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
DECLARE
|
||||||
|
v_user_id bigint;
|
||||||
|
v_ttl_days integer;
|
||||||
|
BEGIN
|
||||||
|
v_user_id := COALESCE(NEW.user_id, OLD.user_id);
|
||||||
|
v_ttl_days := CASE WHEN TG_OP = 'DELETE' THEN 365 ELSE NEW.account_ttl_days END;
|
||||||
|
UPDATE public.users
|
||||||
|
SET account_delete_at = public.telesrv_account_delete_at(created_at, last_seen_at, v_ttl_days),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = v_user_id AND deleted_at IS NULL AND is_bot = false;
|
||||||
|
RETURN COALESCE(NEW, OLD);
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE TRIGGER account_settings_ttl_trigger
|
||||||
|
AFTER INSERT OR UPDATE OF account_ttl_days OR DELETE
|
||||||
|
ON public.account_settings
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.telesrv_account_settings_ttl_trigger();
|
||||||
|
|
||||||
|
UPDATE public.users u
|
||||||
|
SET account_delete_at = public.telesrv_account_delete_at(
|
||||||
|
u.created_at,
|
||||||
|
u.last_seen_at,
|
||||||
|
COALESCE((
|
||||||
|
SELECT s.account_ttl_days
|
||||||
|
FROM public.account_settings s
|
||||||
|
WHERE s.user_id = u.id
|
||||||
|
), 365)
|
||||||
|
)
|
||||||
|
WHERE u.deleted_at IS NULL AND u.is_bot = false;
|
||||||
360
internal/app/account/lifecycle.go
Normal file
360
internal/app/account/lifecycle.go
Normal file
|
|
@ -0,0 +1,360 @@
|
||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
"telesrv/internal/otpdelivery"
|
||||||
|
"telesrv/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const accountDeletionDelay = 7 * 24 * time.Hour
|
||||||
|
|
||||||
|
// DeleteAccount implements the official 2FA deletion decision. A supplied and
|
||||||
|
// valid SRP proof always deletes immediately. Without a proof, an account whose
|
||||||
|
// password is older than seven days and which was active during the last seven
|
||||||
|
// days gets a cancellable seven-day window; all other cases delete immediately.
|
||||||
|
func (s *Service) DeleteAccount(ctx context.Context, userID int64, authKeyID [8]byte, reason string, password *domain.PasswordCheck, now time.Time) (domain.AccountDeleteOutcome, error) {
|
||||||
|
if s == nil || s.lifecycle == nil || userID == 0 || authKeyID == ([8]byte{}) {
|
||||||
|
return domain.AccountDeleteOutcome{}, domain.ErrAccountDeletionForbidden
|
||||||
|
}
|
||||||
|
if now.IsZero() {
|
||||||
|
now = time.Now().UTC()
|
||||||
|
}
|
||||||
|
reason = strings.TrimSpace(reason)
|
||||||
|
if len(reason) > 1024 {
|
||||||
|
return domain.AccountDeleteOutcome{}, domain.ErrAccountDeletionForbidden
|
||||||
|
}
|
||||||
|
snapshot, found, err := s.lifecycle.AccountDeletionSnapshot(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.AccountDeleteOutcome{}, err
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return domain.AccountDeleteOutcome{}, domain.ErrUserNotFound
|
||||||
|
}
|
||||||
|
if snapshot.User.Deleted {
|
||||||
|
return domain.AccountDeleteOutcome{Kind: domain.AccountDeleteImmediate, Deletion: domain.AccountDeletionResult{User: snapshot.User}}, nil
|
||||||
|
}
|
||||||
|
if snapshot.User.Bot || domain.IsSystemUserID(snapshot.User.ID) {
|
||||||
|
return domain.AccountDeleteOutcome{}, domain.ErrAccountDeletionForbidden
|
||||||
|
}
|
||||||
|
if password != nil && !password.Empty {
|
||||||
|
if !snapshot.HasPassword {
|
||||||
|
return domain.AccountDeleteOutcome{}, domain.ErrPasswordHashInvalid
|
||||||
|
}
|
||||||
|
if err := s.CheckPassword(ctx, userID, *password); err != nil {
|
||||||
|
return domain.AccountDeleteOutcome{}, err
|
||||||
|
}
|
||||||
|
return s.executeAccountDeletion(ctx, userID, deletionSourceForReason(reason), reason, now)
|
||||||
|
}
|
||||||
|
if !snapshot.HasPassword {
|
||||||
|
return s.executeAccountDeletion(ctx, userID, deletionSourceForReason(reason), reason, now)
|
||||||
|
}
|
||||||
|
lastActive := snapshot.User.CreatedAt
|
||||||
|
if snapshot.User.LastSeenAt > 0 {
|
||||||
|
seen := time.Unix(int64(snapshot.User.LastSeenAt), 0).UTC()
|
||||||
|
if seen.After(lastActive) {
|
||||||
|
lastActive = seen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
passwordOldEnough := !snapshot.PasswordUpdatedAt.IsZero() && !snapshot.PasswordUpdatedAt.After(now.Add(-accountDeletionDelay))
|
||||||
|
recentlyActive := !lastActive.IsZero() && !lastActive.Before(now.Add(-accountDeletionDelay))
|
||||||
|
if !passwordOldEnough || !recentlyActive {
|
||||||
|
return s.executeAccountDeletion(ctx, userID, deletionSourceForReason(reason), reason, now)
|
||||||
|
}
|
||||||
|
if snapshot.Pending != nil {
|
||||||
|
return delayedDeleteOutcome(*snapshot.Pending, now), nil
|
||||||
|
}
|
||||||
|
rawToken, digest, err := newAccountDeletionToken()
|
||||||
|
if err != nil {
|
||||||
|
return domain.AccountDeleteOutcome{}, err
|
||||||
|
}
|
||||||
|
executeAt := now.Add(accountDeletionDelay)
|
||||||
|
message := fmt.Sprintf(
|
||||||
|
"A request was made to delete your Telegram account. If this wasn't you, cancel the request: tg://confirmphone?phone=%s&hash=%s",
|
||||||
|
url.QueryEscape(snapshot.User.Phone), url.QueryEscape(rawToken),
|
||||||
|
)
|
||||||
|
pending, _, err := s.lifecycle.ScheduleAccountDeletion(ctx, domain.ScheduleAccountDeletion{
|
||||||
|
UserID: userID,
|
||||||
|
RequesterAuthKeyID: authKeyID,
|
||||||
|
Reason: reason,
|
||||||
|
ConfirmHashDigest: digest,
|
||||||
|
ServiceMessage: message,
|
||||||
|
RequestedAt: now,
|
||||||
|
ExecuteAt: executeAt,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return domain.AccountDeleteOutcome{}, err
|
||||||
|
}
|
||||||
|
return delayedDeleteOutcome(pending, now), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) executeAccountDeletion(ctx context.Context, userID int64, source domain.AccountDeletionSource, reason string, now time.Time) (domain.AccountDeleteOutcome, error) {
|
||||||
|
result, err := s.lifecycle.ExecuteAccountDeletion(ctx, userID, source, reason, now)
|
||||||
|
if err != nil {
|
||||||
|
return domain.AccountDeleteOutcome{}, err
|
||||||
|
}
|
||||||
|
if s.userCache != nil {
|
||||||
|
_ = s.userCache.Delete(ctx, []int64{userID})
|
||||||
|
}
|
||||||
|
return domain.AccountDeleteOutcome{Kind: domain.AccountDeleteImmediate, Deletion: result}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func delayedDeleteOutcome(pending domain.AccountDeletionRequest, now time.Time) domain.AccountDeleteOutcome {
|
||||||
|
wait := int(time.Until(pending.ExecuteAt).Seconds())
|
||||||
|
if !now.IsZero() {
|
||||||
|
wait = int(pending.ExecuteAt.Sub(now).Seconds())
|
||||||
|
}
|
||||||
|
if wait < 0 {
|
||||||
|
wait = 0
|
||||||
|
}
|
||||||
|
return domain.AccountDeleteOutcome{Kind: domain.AccountDeleteDelayed, WaitSeconds: wait, ExecuteAt: pending.ExecuteAt}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deletionSourceForReason(reason string) domain.AccountDeletionSource {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(reason)) {
|
||||||
|
case "forgot password":
|
||||||
|
return domain.AccountDeletionForgotPassword
|
||||||
|
case "decline tos update":
|
||||||
|
return domain.AccountDeletionTOSDecline
|
||||||
|
default:
|
||||||
|
return domain.AccountDeletionManual
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAccountDeletionToken() (string, [32]byte, error) {
|
||||||
|
var raw [32]byte
|
||||||
|
if _, err := rand.Read(raw[:]); err != nil {
|
||||||
|
return "", [32]byte{}, fmt.Errorf("generate account deletion token: %w", err)
|
||||||
|
}
|
||||||
|
token := hex.EncodeToString(raw[:])
|
||||||
|
return token, sha256.Sum256([]byte(token)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func accountDeletionDigest(raw string) ([32]byte, error) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
decoded, err := hex.DecodeString(raw)
|
||||||
|
if err != nil || len(decoded) != 32 {
|
||||||
|
return [32]byte{}, domain.ErrAccountDeletionHashInvalid
|
||||||
|
}
|
||||||
|
return sha256.Sum256([]byte(raw)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendConfirmPhoneCode validates the secret confirmphone link and issues an
|
||||||
|
// auth-key-scoped SMS code to the account's current phone.
|
||||||
|
func (s *Service) SendConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, rawHash string) (string, domain.AuthCodeDelivery, error) {
|
||||||
|
digest, err := accountDeletionDigest(rawHash)
|
||||||
|
if err != nil {
|
||||||
|
return "", domain.AuthCodeDelivery{}, err
|
||||||
|
}
|
||||||
|
if s == nil || s.lifecycle == nil || s.users == nil || s.codes == nil || userID == 0 || authKeyID == ([8]byte{}) {
|
||||||
|
return "", domain.AuthCodeDelivery{}, domain.ErrAccountDeletionHashInvalid
|
||||||
|
}
|
||||||
|
if _, found, err := s.lifecycle.PendingAccountDeletionByHash(ctx, userID, digest); err != nil {
|
||||||
|
return "", domain.AuthCodeDelivery{}, err
|
||||||
|
} else if !found {
|
||||||
|
return "", domain.AuthCodeDelivery{}, domain.ErrAccountDeletionHashInvalid
|
||||||
|
}
|
||||||
|
u, found, err := s.users.ByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return "", domain.AuthCodeDelivery{}, err
|
||||||
|
}
|
||||||
|
if !found || u.Deleted || u.Phone == "" {
|
||||||
|
return "", domain.AuthCodeDelivery{}, domain.ErrAccountDeletionHashInvalid
|
||||||
|
}
|
||||||
|
return s.issueConfirmPhoneCode(ctx, userID, authKeyID, sessionID, u.Phone, digest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) issueConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, phone string, digest [32]byte) (string, domain.AuthCodeDelivery, error) {
|
||||||
|
hash, err := phoneChangeHash()
|
||||||
|
if err != nil {
|
||||||
|
return "", domain.AuthCodeDelivery{}, err
|
||||||
|
}
|
||||||
|
code := s.phoneChangeCode
|
||||||
|
channel := store.PhoneCodeChannelPhone
|
||||||
|
deliveryID := ""
|
||||||
|
if s.phoneCodeSender != nil {
|
||||||
|
code, err = randomDigits(s.phoneCodeLength)
|
||||||
|
if err != nil {
|
||||||
|
return "", domain.AuthCodeDelivery{}, err
|
||||||
|
}
|
||||||
|
deliveryID, err = otpdelivery.NewDeliveryID()
|
||||||
|
if err != nil {
|
||||||
|
return "", domain.AuthCodeDelivery{}, err
|
||||||
|
}
|
||||||
|
channel = store.PhoneCodeChannelSMS
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(code) == "" {
|
||||||
|
return "", domain.AuthCodeDelivery{}, fmt.Errorf("confirm phone code service is not configured")
|
||||||
|
}
|
||||||
|
rec := store.PhoneCode{
|
||||||
|
Version: store.PhoneCodeVersionCurrent,
|
||||||
|
Phone: phone,
|
||||||
|
Code: code,
|
||||||
|
DeliveryID: deliveryID,
|
||||||
|
Channel: channel,
|
||||||
|
Purpose: store.PhoneCodePurposeConfirmPhone,
|
||||||
|
UserID: userID,
|
||||||
|
AuthKeyID: authKeyID,
|
||||||
|
SessionID: sessionID,
|
||||||
|
MaxAttempts: s.phoneChangeMaxAttempts,
|
||||||
|
AccountDeletionHash: hex.EncodeToString(digest[:]),
|
||||||
|
}
|
||||||
|
expiresAt := time.Now().Add(s.phoneChangeCodeTTL)
|
||||||
|
if err := s.codes.Set(ctx, hash, rec, s.phoneChangeCodeTTL); err != nil {
|
||||||
|
return "", domain.AuthCodeDelivery{}, fmt.Errorf("store confirm phone code: %w", err)
|
||||||
|
}
|
||||||
|
if s.phoneCodeSender != nil {
|
||||||
|
if err := deliverOTP(ctx, s.phoneCodeSender, otpdelivery.Request{
|
||||||
|
DeliveryID: deliveryID,
|
||||||
|
Purpose: otpdelivery.PurposeConfirmPhone,
|
||||||
|
Channel: otpdelivery.ChannelSMS,
|
||||||
|
Recipient: phone,
|
||||||
|
Code: code,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
}); err != nil {
|
||||||
|
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if _, _, cleanupErr := s.codes.ConsumeScoped(cleanupCtx, hash, rec.Scope()); cleanupErr != nil {
|
||||||
|
return "", domain.AuthCodeDelivery{}, errors.Join(err, cleanupErr)
|
||||||
|
}
|
||||||
|
return "", domain.AuthCodeDelivery{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hash, domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliverySMS, Length: len(code)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfirmPhone consumes the scoped OTP, cancels the pending deletion and
|
||||||
|
// revokes the auth key that initiated the deletion attempt.
|
||||||
|
func (s *Service) ConfirmPhone(ctx context.Context, userID int64, authKeyID [8]byte, phoneCodeHash, code string, now time.Time) ([]domain.Authorization, error) {
|
||||||
|
if strings.TrimSpace(phoneCodeHash) == "" || strings.TrimSpace(code) == "" {
|
||||||
|
return nil, domain.ErrPhoneCodeEmpty
|
||||||
|
}
|
||||||
|
if s == nil || s.codes == nil || s.lifecycle == nil || s.users == nil {
|
||||||
|
return nil, domain.ErrPhoneCodeInvalid
|
||||||
|
}
|
||||||
|
u, found, err := s.users.ByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !found || u.Deleted || u.Phone == "" {
|
||||||
|
return nil, domain.ErrPhoneCodeInvalid
|
||||||
|
}
|
||||||
|
scope := store.PhoneCodeScope{Purpose: store.PhoneCodePurposeConfirmPhone, UserID: userID, AuthKeyID: authKeyID, Phone: u.Phone}
|
||||||
|
verified, err := s.codes.VerifyScoped(ctx, phoneCodeHash, scope, strings.TrimSpace(code), s.phoneChangeMaxAttempts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
switch verified.Status {
|
||||||
|
case store.LoginCodeVerifyMissing:
|
||||||
|
return nil, domain.ErrPhoneCodeExpired
|
||||||
|
case store.LoginCodeVerifyInvalid:
|
||||||
|
return nil, domain.ErrPhoneCodeInvalid
|
||||||
|
case store.LoginCodeVerifyAccepted:
|
||||||
|
default:
|
||||||
|
return nil, domain.ErrPhoneCodeInvalid
|
||||||
|
}
|
||||||
|
digestBytes, err := hex.DecodeString(verified.Record.AccountDeletionHash)
|
||||||
|
if err != nil || len(digestBytes) != 32 {
|
||||||
|
return nil, domain.ErrPhoneCodeInvalid
|
||||||
|
}
|
||||||
|
var digest [32]byte
|
||||||
|
copy(digest[:], digestBytes)
|
||||||
|
if now.IsZero() {
|
||||||
|
now = time.Now().UTC()
|
||||||
|
}
|
||||||
|
return s.lifecycle.CancelAccountDeletion(ctx, userID, digest, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResendConfirmPhoneCode handles auth.resendCode only when the supplied hash is
|
||||||
|
// an active confirm-phone code for this authorized user/auth key.
|
||||||
|
func (s *Service) ResendConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, phone, oldHash string) (string, domain.AuthCodeDelivery, bool, error) {
|
||||||
|
if s == nil || s.codes == nil || s.users == nil || userID == 0 {
|
||||||
|
return "", domain.AuthCodeDelivery{}, false, nil
|
||||||
|
}
|
||||||
|
rec, found, err := s.codes.Get(ctx, oldHash)
|
||||||
|
if err != nil || !found || rec.Purpose != store.PhoneCodePurposeConfirmPhone || rec.UserID != userID || rec.AuthKeyID != authKeyID {
|
||||||
|
return "", domain.AuthCodeDelivery{}, false, err
|
||||||
|
}
|
||||||
|
u, found, err := s.users.ByID(ctx, userID)
|
||||||
|
if err != nil || !found || u.Deleted || domain.NormalizePhone(phone) != domain.NormalizePhone(u.Phone) {
|
||||||
|
if err == nil {
|
||||||
|
err = domain.ErrPhoneCodeInvalid
|
||||||
|
}
|
||||||
|
return "", domain.AuthCodeDelivery{}, true, err
|
||||||
|
}
|
||||||
|
consumed, found, err := s.codes.ConsumeScoped(ctx, oldHash, rec.Scope())
|
||||||
|
if err != nil || !found {
|
||||||
|
if err == nil {
|
||||||
|
err = domain.ErrPhoneCodeExpired
|
||||||
|
}
|
||||||
|
return "", domain.AuthCodeDelivery{}, true, err
|
||||||
|
}
|
||||||
|
digestBytes, err := hex.DecodeString(consumed.AccountDeletionHash)
|
||||||
|
if err != nil || len(digestBytes) != 32 {
|
||||||
|
return "", domain.AuthCodeDelivery{}, true, domain.ErrPhoneCodeInvalid
|
||||||
|
}
|
||||||
|
var digest [32]byte
|
||||||
|
copy(digest[:], digestBytes)
|
||||||
|
hash, delivery, err := s.issueConfirmPhoneCode(ctx, userID, authKeyID, sessionID, u.Phone, digest)
|
||||||
|
return hash, delivery, true, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) CancelConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, phone, hash string) (bool, error) {
|
||||||
|
if s == nil || s.codes == nil || userID == 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
rec, found, err := s.codes.Get(ctx, hash)
|
||||||
|
if err != nil || !found || rec.Purpose != store.PhoneCodePurposeConfirmPhone || rec.UserID != userID || rec.AuthKeyID != authKeyID {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if domain.NormalizePhone(phone) != domain.NormalizePhone(rec.Phone) {
|
||||||
|
return true, domain.ErrPhoneCodeInvalid
|
||||||
|
}
|
||||||
|
_, _, err = s.codes.ConsumeScoped(ctx, hash, rec.Scope())
|
||||||
|
return true, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SweepDueAccountDeletions(ctx context.Context, now time.Time, limit int) ([]domain.AccountDeletionResult, error) {
|
||||||
|
if s == nil || s.lifecycle == nil || limit <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
candidates, err := s.lifecycle.DueAccountDeletions(ctx, now, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]domain.AccountDeletionResult, 0, len(candidates))
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
result, err := s.lifecycle.ExecuteAccountDeletion(ctx, candidate.UserID, candidate.Source, "", now)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
if s.userCache != nil {
|
||||||
|
_ = s.userCache.Delete(ctx, []int64{candidate.UserID})
|
||||||
|
}
|
||||||
|
out = append(out, result)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ClaimAccountDeletionNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountDeletionNotification, error) {
|
||||||
|
if s == nil || s.lifecycle == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return s.lifecycle.ClaimAccountDeletionNotifications(ctx, now, limit, lease)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) CompleteAccountDeletionNotification(ctx context.Context, id int64, now time.Time) error {
|
||||||
|
if s == nil || s.lifecycle == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.lifecycle.CompleteAccountDeletionNotification(ctx, id, now)
|
||||||
|
}
|
||||||
149
internal/app/account/lifecycle_test.go
Normal file
149
internal/app/account/lifecycle_test.go
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
"telesrv/internal/store/memory"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDeleteAccountTwoFADelayDecisionMatrix(t *testing.T) {
|
||||||
|
now := time.Unix(1_800_000_000, 0).UTC()
|
||||||
|
authKey := [8]byte{1}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
hasPassword bool
|
||||||
|
passwordUpdated time.Time
|
||||||
|
createdAt time.Time
|
||||||
|
lastSeen int
|
||||||
|
wantKind domain.AccountDeleteKind
|
||||||
|
}{
|
||||||
|
{name: "no password deletes immediately", createdAt: now.Add(-time.Hour), lastSeen: int(now.Unix()), wantKind: domain.AccountDeleteImmediate},
|
||||||
|
{name: "old password and recent activity delays", hasPassword: true, passwordUpdated: now.Add(-8 * 24 * time.Hour), createdAt: now.Add(-30 * 24 * time.Hour), lastSeen: int(now.Add(-time.Hour).Unix()), wantKind: domain.AccountDeleteDelayed},
|
||||||
|
{name: "recent password change deletes immediately", hasPassword: true, passwordUpdated: now.Add(-2 * 24 * time.Hour), createdAt: now.Add(-30 * 24 * time.Hour), lastSeen: int(now.Add(-time.Hour).Unix()), wantKind: domain.AccountDeleteImmediate},
|
||||||
|
{name: "inactive account deletes immediately", hasPassword: true, passwordUpdated: now.Add(-30 * 24 * time.Hour), createdAt: now.Add(-30 * 24 * time.Hour), lastSeen: int(now.Add(-8 * 24 * time.Hour).Unix()), wantKind: domain.AccountDeleteImmediate},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
lifecycle := &fakeAccountLifecycleStore{snapshot: domain.AccountDeletionSnapshot{
|
||||||
|
User: domain.User{ID: 42, Phone: "15550010000", CreatedAt: test.createdAt, LastSeenAt: test.lastSeen},
|
||||||
|
HasPassword: test.hasPassword, PasswordUpdatedAt: test.passwordUpdated,
|
||||||
|
}}
|
||||||
|
svc := NewService(memory.NewPasswordStore(), WithAccountLifecycle(lifecycle))
|
||||||
|
outcome, err := svc.DeleteAccount(context.Background(), 42, authKey, "manual", nil, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DeleteAccount: %v", err)
|
||||||
|
}
|
||||||
|
if outcome.Kind != test.wantKind {
|
||||||
|
t.Fatalf("kind = %q, want %q", outcome.Kind, test.wantKind)
|
||||||
|
}
|
||||||
|
if test.wantKind == domain.AccountDeleteDelayed {
|
||||||
|
if lifecycle.scheduled == nil || !strings.Contains(lifecycle.scheduled.ServiceMessage, "tg://confirmphone?") || outcome.WaitSeconds != int(accountDeletionDelay.Seconds()) {
|
||||||
|
t.Fatalf("delayed outcome=%+v scheduled=%+v", outcome, lifecycle.scheduled)
|
||||||
|
}
|
||||||
|
} else if lifecycle.executedSource == "" {
|
||||||
|
t.Fatal("immediate path did not execute the tombstone boundary")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfirmPhoneCancelsPendingDeletionAndRevokesRequester(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
now := time.Unix(1_800_000_000, 0).UTC()
|
||||||
|
users := memory.NewUserStore()
|
||||||
|
u, err := users.Create(ctx, domain.User{Phone: "15550010001", FirstName: "Alice"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
requester := [8]byte{9}
|
||||||
|
confirming := [8]byte{8}
|
||||||
|
lifecycle := &fakeAccountLifecycleStore{snapshot: domain.AccountDeletionSnapshot{User: u}}
|
||||||
|
svc := NewService(memory.NewPasswordStore(),
|
||||||
|
WithUsers(users),
|
||||||
|
WithPhoneChange(nil, nil, memory.NewCodeStore(), nil, "12345", 5*time.Minute, 5),
|
||||||
|
WithAccountLifecycle(lifecycle),
|
||||||
|
)
|
||||||
|
rawToken, digest, err := newAccountDeletionToken()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
lifecycle.pending = &domain.AccountDeletionRequest{
|
||||||
|
ID: 1, UserID: u.ID, RequesterAuthKeyID: requester, State: domain.AccountDeletionPending,
|
||||||
|
ConfirmHashDigest: digest, RequestedAt: now, ExecuteAt: now.Add(accountDeletionDelay),
|
||||||
|
}
|
||||||
|
hash, delivery, err := svc.SendConfirmPhoneCode(ctx, u.ID, confirming, 77, rawToken)
|
||||||
|
if err != nil || hash == "" || delivery.Length != 5 {
|
||||||
|
t.Fatalf("SendConfirmPhoneCode hash=%q delivery=%+v err=%v", hash, delivery, err)
|
||||||
|
}
|
||||||
|
revoked, err := svc.ConfirmPhone(ctx, u.ID, confirming, hash, "12345", now.Add(time.Minute))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ConfirmPhone: %v", err)
|
||||||
|
}
|
||||||
|
if len(revoked) != 1 || revoked[0].AuthKeyID != requester || lifecycle.pending != nil {
|
||||||
|
t.Fatalf("revoked=%+v pending=%+v", revoked, lifecycle.pending)
|
||||||
|
}
|
||||||
|
if _, err := svc.ConfirmPhone(ctx, u.ID, confirming, hash, "12345", now.Add(2*time.Minute)); !errors.Is(err, domain.ErrPhoneCodeExpired) {
|
||||||
|
t.Fatalf("replay error = %v, want expired", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeAccountLifecycleStore struct {
|
||||||
|
snapshot domain.AccountDeletionSnapshot
|
||||||
|
pending *domain.AccountDeletionRequest
|
||||||
|
scheduled *domain.ScheduleAccountDeletion
|
||||||
|
executedSource domain.AccountDeletionSource
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeAccountLifecycleStore) AccountDeletionSnapshot(context.Context, int64) (domain.AccountDeletionSnapshot, bool, error) {
|
||||||
|
f.snapshot.Pending = f.pending
|
||||||
|
return f.snapshot, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeAccountLifecycleStore) ScheduleAccountDeletion(_ context.Context, req domain.ScheduleAccountDeletion) (domain.AccountDeletionRequest, bool, error) {
|
||||||
|
f.scheduled = &req
|
||||||
|
pending := domain.AccountDeletionRequest{ID: 1, UserID: req.UserID, RequesterAuthKeyID: req.RequesterAuthKeyID, State: domain.AccountDeletionPending, Reason: req.Reason, ConfirmHashDigest: req.ConfirmHashDigest, RequestedAt: req.RequestedAt, ExecuteAt: req.ExecuteAt}
|
||||||
|
f.pending = &pending
|
||||||
|
return pending, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeAccountLifecycleStore) PendingAccountDeletionByHash(_ context.Context, userID int64, digest [32]byte) (domain.AccountDeletionRequest, bool, error) {
|
||||||
|
if f.pending == nil || f.pending.UserID != userID || f.pending.ConfirmHashDigest != digest {
|
||||||
|
return domain.AccountDeletionRequest{}, false, nil
|
||||||
|
}
|
||||||
|
return *f.pending, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeAccountLifecycleStore) ExecuteAccountDeletion(_ context.Context, userID int64, source domain.AccountDeletionSource, reason string, now time.Time) (domain.AccountDeletionResult, error) {
|
||||||
|
f.executedSource = source
|
||||||
|
u := f.snapshot.User
|
||||||
|
u.Deleted = true
|
||||||
|
u.DeletedAt = now.Unix()
|
||||||
|
u.DeletionSource = source
|
||||||
|
u.DeletionReason = reason
|
||||||
|
u = u.DeletedTombstone()
|
||||||
|
return domain.AccountDeletionResult{User: u, Changed: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeAccountLifecycleStore) CancelAccountDeletion(_ context.Context, userID int64, digest [32]byte, _ time.Time) ([]domain.Authorization, error) {
|
||||||
|
if f.pending == nil || f.pending.UserID != userID || f.pending.ConfirmHashDigest != digest {
|
||||||
|
return nil, domain.ErrAccountDeletionHashInvalid
|
||||||
|
}
|
||||||
|
revoked := []domain.Authorization{{AuthKeyID: f.pending.RequesterAuthKeyID, UserID: userID}}
|
||||||
|
f.pending = nil
|
||||||
|
return revoked, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*fakeAccountLifecycleStore) DueAccountDeletions(context.Context, time.Time, int) ([]domain.AccountDeletionCandidate, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (*fakeAccountLifecycleStore) ClaimAccountDeletionNotifications(context.Context, time.Time, int, time.Duration) ([]domain.AccountDeletionNotification, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (*fakeAccountLifecycleStore) CompleteAccountDeletionNotification(context.Context, int64, time.Time) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -43,6 +43,7 @@ type Service struct {
|
||||||
userCache store.UserCache
|
userCache store.UserCache
|
||||||
authorizations store.AuthorizationStore
|
authorizations store.AuthorizationStore
|
||||||
phoneChanges store.PhoneChangeStore
|
phoneChanges store.PhoneChangeStore
|
||||||
|
lifecycle store.AccountLifecycleStore
|
||||||
publicBaseURL string
|
publicBaseURL string
|
||||||
codes store.CodeStore
|
codes store.CodeStore
|
||||||
phoneChangeCode string
|
phoneChangeCode string
|
||||||
|
|
@ -166,6 +167,14 @@ func WithPhoneCodeDelivery(sender otpdelivery.Sender, length int) ServiceOption
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithAccountLifecycle installs the single durable account deletion boundary.
|
||||||
|
// It shares the already configured phone-code delivery and user cache.
|
||||||
|
func WithAccountLifecycle(lifecycle store.AccountLifecycleStore) ServiceOption {
|
||||||
|
return func(s *Service) {
|
||||||
|
s.lifecycle = lifecycle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// NewService 创建 account 服务。
|
// NewService 创建 account 服务。
|
||||||
func NewService(passwords store.PasswordStore, opts ...ServiceOption) *Service {
|
func NewService(passwords store.PasswordStore, opts ...ServiceOption) *Service {
|
||||||
s := &Service{
|
s := &Service{
|
||||||
|
|
|
||||||
19
internal/app/userprojection/deleted_test.go
Normal file
19
internal/app/userprojection/deleted_test.go
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
package userprojection
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDeletedUserProjectionCannotReintroducePII(t *testing.T) {
|
||||||
|
in := domain.User{ID: 42, AccessHash: 99, Deleted: true, Phone: "stale", FirstName: "Stale", PhotoID: 123, Contact: true}
|
||||||
|
got, err := New().One(context.Background(), 7, in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !got.Deleted || got.ID != 42 || got.Phone != "" || got.FirstName != "" || got.PhotoID != 0 || got.Contact {
|
||||||
|
t.Fatalf("deleted projection leaked PII: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -83,6 +83,7 @@ func New(opts ...Option) *Projector {
|
||||||
|
|
||||||
// ForViewer applies both current profile photos and owner-specific contact view.
|
// ForViewer applies both current profile photos and owner-specific contact view.
|
||||||
func (p *Projector) ForViewer(ctx context.Context, viewerUserID int64, users []domain.User) ([]domain.User, error) {
|
func (p *Projector) ForViewer(ctx context.Context, viewerUserID int64, users []domain.User) ([]domain.User, error) {
|
||||||
|
users = sanitizeDeletedUsers(users)
|
||||||
if p == nil {
|
if p == nil {
|
||||||
return users, nil
|
return users, nil
|
||||||
}
|
}
|
||||||
|
|
@ -112,6 +113,7 @@ func (p *Projector) One(ctx context.Context, viewerUserID int64, user domain.Use
|
||||||
// (无 O(owner) 反查接口),客户端下次 getChannelDifference/getHistory 会走 projectBatch 完整投影自愈。
|
// (无 O(owner) 反查接口),客户端下次 getChannelDifference/getHistory 会走 projectBatch 完整投影自愈。
|
||||||
// 调用方传入的 users 不被修改(内部复制)。
|
// 调用方传入的 users 不被修改(内部复制)。
|
||||||
func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users []domain.User) (map[int64][]domain.User, error) {
|
func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users []domain.User) (map[int64][]domain.User, error) {
|
||||||
|
users = sanitizeDeletedUsers(users)
|
||||||
out := make(map[int64][]domain.User, len(viewerUserIDs))
|
out := make(map[int64][]domain.User, len(viewerUserIDs))
|
||||||
if p == nil || len(users) == 0 {
|
if p == nil || len(users) == 0 {
|
||||||
for _, v := range viewerUserIDs {
|
for _, v := range viewerUserIDs {
|
||||||
|
|
@ -170,6 +172,10 @@ func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users
|
||||||
if u.ID == 0 {
|
if u.ID == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if u.Deleted {
|
||||||
|
projected[i] = u.DeletedTombstone()
|
||||||
|
continue
|
||||||
|
}
|
||||||
if pj, ok := cache[u.ID]; ok {
|
if pj, ok := cache[u.ID]; ok {
|
||||||
projected[i] = pj
|
projected[i] = pj
|
||||||
continue
|
continue
|
||||||
|
|
@ -276,13 +282,14 @@ func dedupNonZeroInt64(ids []int64) []int64 {
|
||||||
// WithProfilePhotos enriches users with their current avatar from profile photo storage.
|
// WithProfilePhotos enriches users with their current avatar from profile photo storage.
|
||||||
// The lookup is best-effort: a storage error keeps the original user list.
|
// The lookup is best-effort: a storage error keeps the original user list.
|
||||||
func WithProfilePhotos(ctx context.Context, photos ProfilePhotoProvider, users []domain.User) []domain.User {
|
func WithProfilePhotos(ctx context.Context, photos ProfilePhotoProvider, users []domain.User) []domain.User {
|
||||||
|
users = sanitizeDeletedUsers(users)
|
||||||
if photos == nil || len(users) == 0 {
|
if photos == nil || len(users) == 0 {
|
||||||
return users
|
return users
|
||||||
}
|
}
|
||||||
ids := make([]int64, 0, len(users))
|
ids := make([]int64, 0, len(users))
|
||||||
seen := make(map[int64]struct{}, len(users))
|
seen := make(map[int64]struct{}, len(users))
|
||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
if u.ID == 0 {
|
if u.ID == 0 || u.Deleted {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, ok := seen[u.ID]; ok {
|
if _, ok := seen[u.ID]; ok {
|
||||||
|
|
@ -312,6 +319,7 @@ func WithProfilePhotos(ctx context.Context, photos ProfilePhotoProvider, users [
|
||||||
// In particular, phone is visible for self and contacts; non-contacts should not
|
// In particular, phone is visible for self and contacts; non-contacts should not
|
||||||
// receive a phone field because TDesktop will prefer it over the public name.
|
// receive a phone field because TDesktop will prefer it over the public name.
|
||||||
func ForViewer(ctx context.Context, contacts store.ContactStore, viewerUserID int64, users []domain.User) ([]domain.User, error) {
|
func ForViewer(ctx context.Context, contacts store.ContactStore, viewerUserID int64, users []domain.User) ([]domain.User, error) {
|
||||||
|
users = sanitizeDeletedUsers(users)
|
||||||
if contacts == nil || viewerUserID == 0 || len(users) == 0 {
|
if contacts == nil || viewerUserID == 0 || len(users) == 0 {
|
||||||
return users, nil
|
return users, nil
|
||||||
}
|
}
|
||||||
|
|
@ -320,7 +328,7 @@ func ForViewer(ctx context.Context, contacts store.ContactStore, viewerUserID in
|
||||||
cache := make(map[int64]domain.User, len(users))
|
cache := make(map[int64]domain.User, len(users))
|
||||||
for i := range out {
|
for i := range out {
|
||||||
u := out[i]
|
u := out[i]
|
||||||
if u.ID == 0 || u.ID == viewerUserID || u.ID == domain.OfficialSystemUserID || u.Bot {
|
if u.ID == 0 || u.Deleted || u.ID == viewerUserID || u.ID == domain.OfficialSystemUserID || u.Bot {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if projected, ok := cache[u.ID]; ok {
|
if projected, ok := cache[u.ID]; ok {
|
||||||
|
|
@ -352,6 +360,7 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi
|
||||||
}
|
}
|
||||||
out := make([]domain.User, len(users))
|
out := make([]domain.User, len(users))
|
||||||
copy(out, users)
|
copy(out, users)
|
||||||
|
out = sanitizeDeletedUsers(out)
|
||||||
ids := uniqueUserIDs(out)
|
ids := uniqueUserIDs(out)
|
||||||
var (
|
var (
|
||||||
profileRefs = map[int64]domain.ProfilePhotoRef{}
|
profileRefs = map[int64]domain.ProfilePhotoRef{}
|
||||||
|
|
@ -430,6 +439,10 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi
|
||||||
if u.ID == 0 {
|
if u.ID == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if u.Deleted {
|
||||||
|
out[i] = u.DeletedTombstone()
|
||||||
|
continue
|
||||||
|
}
|
||||||
if projected, ok := cache[u.ID]; ok {
|
if projected, ok := cache[u.ID]; ok {
|
||||||
out[i] = projected
|
out[i] = projected
|
||||||
continue
|
continue
|
||||||
|
|
@ -463,7 +476,7 @@ func prefetchPrivacyVisibility(ctx context.Context, privacy PrivacyEvaluator, vi
|
||||||
ids := make([]int64, 0, len(users))
|
ids := make([]int64, 0, len(users))
|
||||||
seen := make(map[int64]struct{}, len(users))
|
seen := make(map[int64]struct{}, len(users))
|
||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
if u.ID == 0 || u.ID == viewerUserID || u.ID == domain.OfficialSystemUserID || u.Bot {
|
if u.ID == 0 || u.Deleted || u.ID == viewerUserID || u.ID == domain.OfficialSystemUserID || u.Bot {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, ok := seen[u.ID]; ok {
|
if _, ok := seen[u.ID]; ok {
|
||||||
|
|
@ -479,6 +492,9 @@ func prefetchPrivacyVisibility(ctx context.Context, privacy PrivacyEvaluator, vi
|
||||||
}
|
}
|
||||||
|
|
||||||
func projectOne(ctx context.Context, contacts store.ContactStore, viewerUserID int64, user domain.User) (domain.User, error) {
|
func projectOne(ctx context.Context, contacts store.ContactStore, viewerUserID int64, user domain.User) (domain.User, error) {
|
||||||
|
if user.Deleted {
|
||||||
|
return user.DeletedTombstone(), nil
|
||||||
|
}
|
||||||
contact, found, err := contacts.Get(ctx, viewerUserID, user.ID)
|
contact, found, err := contacts.Get(ctx, viewerUserID, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.User{}, err
|
return domain.User{}, err
|
||||||
|
|
@ -513,7 +529,7 @@ func uniqueUserIDs(users []domain.User) []int64 {
|
||||||
seen := make(map[int64]struct{}, len(users))
|
seen := make(map[int64]struct{}, len(users))
|
||||||
ids := make([]int64, 0, len(users))
|
ids := make([]int64, 0, len(users))
|
||||||
for _, user := range users {
|
for _, user := range users {
|
||||||
if user.ID == 0 {
|
if user.ID == 0 || user.Deleted {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, ok := seen[user.ID]; ok {
|
if _, ok := seen[user.ID]; ok {
|
||||||
|
|
@ -526,6 +542,9 @@ func uniqueUserIDs(users []domain.User) []int64 {
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyBasePhotos(user domain.User, profileRefs, fallbackRefs, personalRefs map[int64]domain.ProfilePhotoRef, viewerUserID int64) domain.User {
|
func applyBasePhotos(user domain.User, profileRefs, fallbackRefs, personalRefs map[int64]domain.ProfilePhotoRef, viewerUserID int64) domain.User {
|
||||||
|
if user.Deleted {
|
||||||
|
return user.DeletedTombstone()
|
||||||
|
}
|
||||||
if !hasPhotoLookups(profileRefs, fallbackRefs, personalRefs) {
|
if !hasPhotoLookups(profileRefs, fallbackRefs, personalRefs) {
|
||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|
@ -548,6 +567,9 @@ func applyBasePhotos(user domain.User, profileRefs, fallbackRefs, personalRefs m
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyContactProjection(user domain.User, contact domain.Contact, found bool) domain.User {
|
func applyContactProjection(user domain.User, contact domain.Contact, found bool) domain.User {
|
||||||
|
if user.Deleted {
|
||||||
|
return user.DeletedTombstone()
|
||||||
|
}
|
||||||
if !found {
|
if !found {
|
||||||
user.Phone = ""
|
user.Phone = ""
|
||||||
user.Contact = false
|
user.Contact = false
|
||||||
|
|
@ -574,6 +596,9 @@ func applyContactProjection(user domain.User, contact domain.Contact, found bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyPrivacy(ctx context.Context, privacy PrivacyEvaluator, viewerUserID int64, user domain.User, isContact bool, vis map[domain.PrivacyKey]bool, profileRefs, fallbackRefs, personalRefs map[int64]domain.ProfilePhotoRef) (domain.User, error) {
|
func applyPrivacy(ctx context.Context, privacy PrivacyEvaluator, viewerUserID int64, user domain.User, isContact bool, vis map[domain.PrivacyKey]bool, profileRefs, fallbackRefs, personalRefs map[int64]domain.ProfilePhotoRef) (domain.User, error) {
|
||||||
|
if user.Deleted {
|
||||||
|
return user.DeletedTombstone(), nil
|
||||||
|
}
|
||||||
if privacy == nil {
|
if privacy == nil {
|
||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
@ -647,3 +672,21 @@ func clearPhoto(user *domain.User) {
|
||||||
user.PhotoPersonal = false
|
user.PhotoPersonal = false
|
||||||
user.PhotoHasVideo = false
|
user.PhotoHasVideo = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sanitizeDeletedUsers(users []domain.User) []domain.User {
|
||||||
|
var out []domain.User
|
||||||
|
for i, user := range users {
|
||||||
|
if !user.Deleted {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
out = make([]domain.User, len(users))
|
||||||
|
copy(out, users)
|
||||||
|
}
|
||||||
|
out[i] = user.DeletedTombstone()
|
||||||
|
}
|
||||||
|
if out != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
return users
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -174,7 +174,12 @@ func DefaultAccountReactionSettings() AccountReactionSettings {
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultAccountTTLDays 是账号自毁默认期限(无显式设置时)。与历史固定回显一致。
|
// DefaultAccountTTLDays 是账号自毁默认期限(无显式设置时)。与历史固定回显一致。
|
||||||
const DefaultAccountTTLDays = 365
|
const (
|
||||||
|
DefaultAccountTTLDays = 365
|
||||||
|
// MaxAccountTTLDays prevents an untrusted int32 TL value from producing an
|
||||||
|
// out-of-range PostgreSQL interval/timestamp during deadline maintenance.
|
||||||
|
MaxAccountTTLDays = 3650
|
||||||
|
)
|
||||||
|
|
||||||
// GlobalPrivacy 是 globalPrivacySettings 的业务层表达(账号级隐私开关)。
|
// GlobalPrivacy 是 globalPrivacySettings 的业务层表达(账号级隐私开关)。
|
||||||
// DisallowedGifts 依赖礼物资产模型(当前未实现),故不建模、保持默认。
|
// DisallowedGifts 依赖礼物资产模型(当前未实现),故不建模、保持默认。
|
||||||
|
|
@ -212,7 +217,7 @@ func DefaultAccountSettings() AccountSettings {
|
||||||
|
|
||||||
// NormalizedTTLDays 返回钳制后的账号自毁期限(0/越界回落默认)。
|
// NormalizedTTLDays 返回钳制后的账号自毁期限(0/越界回落默认)。
|
||||||
func (s AccountSettings) NormalizedTTLDays() int {
|
func (s AccountSettings) NormalizedTTLDays() int {
|
||||||
if s.AccountTTLDays <= 0 {
|
if s.AccountTTLDays <= 0 || s.AccountTTLDays > MaxAccountTTLDays {
|
||||||
return DefaultAccountTTLDays
|
return DefaultAccountTTLDays
|
||||||
}
|
}
|
||||||
return s.AccountTTLDays
|
return s.AccountTTLDays
|
||||||
|
|
|
||||||
99
internal/domain/account_deletion.go
Normal file
99
internal/domain/account_deletion.go
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrAccountDeleted = errors.New("account deleted")
|
||||||
|
ErrAccountDeletionForbidden = errors.New("account deletion forbidden")
|
||||||
|
ErrAccountDeletionHashInvalid = errors.New("account deletion hash invalid")
|
||||||
|
ErrAccountDeletionNotPending = errors.New("account deletion not pending")
|
||||||
|
)
|
||||||
|
|
||||||
|
// AccountDeletionSource is the single audited reason attached to a user
|
||||||
|
// tombstone. Different entry points share one execution and cleanup path.
|
||||||
|
type AccountDeletionSource string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AccountDeletionManual AccountDeletionSource = "manual"
|
||||||
|
AccountDeletionForgotPassword AccountDeletionSource = "forgot_password"
|
||||||
|
AccountDeletionTOSDecline AccountDeletionSource = "tos_decline"
|
||||||
|
AccountDeletionPasswordResetExpiry AccountDeletionSource = "password_reset_expiry"
|
||||||
|
AccountDeletionAccountTTL AccountDeletionSource = "account_ttl"
|
||||||
|
AccountDeletionFreezeExpiry AccountDeletionSource = "freeze_expiry"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AccountDeletionRequestState string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AccountDeletionPending AccountDeletionRequestState = "pending"
|
||||||
|
AccountDeletionCancelled AccountDeletionRequestState = "cancelled"
|
||||||
|
AccountDeletionExecuted AccountDeletionRequestState = "executed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AccountDeletionRequest represents the seven-day 2FA confirmation window.
|
||||||
|
// ConfirmHashDigest is SHA-256(raw link token); the raw token is only included
|
||||||
|
// in the durable service message and is never persisted as a credential.
|
||||||
|
type AccountDeletionRequest struct {
|
||||||
|
ID int64
|
||||||
|
UserID int64
|
||||||
|
RequesterAuthKeyID [8]byte
|
||||||
|
State AccountDeletionRequestState
|
||||||
|
Reason string
|
||||||
|
ConfirmHashDigest [32]byte
|
||||||
|
RequestedAt time.Time
|
||||||
|
ExecuteAt time.Time
|
||||||
|
CompletedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type AccountDeletionSnapshot struct {
|
||||||
|
User User
|
||||||
|
HasPassword bool
|
||||||
|
PasswordUpdatedAt time.Time
|
||||||
|
Pending *AccountDeletionRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScheduleAccountDeletion struct {
|
||||||
|
UserID int64
|
||||||
|
RequesterAuthKeyID [8]byte
|
||||||
|
Reason string
|
||||||
|
ConfirmHashDigest [32]byte
|
||||||
|
ServiceMessage string
|
||||||
|
RequestedAt time.Time
|
||||||
|
ExecuteAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type AccountDeletionResult struct {
|
||||||
|
User User
|
||||||
|
Changed bool
|
||||||
|
RevokedAuthorizations []Authorization
|
||||||
|
}
|
||||||
|
|
||||||
|
type AccountDeleteKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AccountDeleteImmediate AccountDeleteKind = "immediate"
|
||||||
|
AccountDeleteDelayed AccountDeleteKind = "delayed"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AccountDeleteOutcome struct {
|
||||||
|
Kind AccountDeleteKind
|
||||||
|
WaitSeconds int
|
||||||
|
ExecuteAt time.Time
|
||||||
|
Deletion AccountDeletionResult
|
||||||
|
}
|
||||||
|
|
||||||
|
type AccountDeletionCandidate struct {
|
||||||
|
UserID int64
|
||||||
|
Source AccountDeletionSource
|
||||||
|
DueAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type AccountDeletionNotification struct {
|
||||||
|
ID int64
|
||||||
|
TargetUserID int64
|
||||||
|
DeletedUserID int64
|
||||||
|
Attempts int
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
package domain
|
package domain
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
// UserIDSequenceBase 是普通用户 ID 的起始值。
|
// UserIDSequenceBase 是普通用户 ID 的起始值。
|
||||||
//
|
//
|
||||||
// 取 2026-06-01 00:00:00 Asia/Shanghai 的 Unix 秒级时间戳。
|
// 取 2026-06-01 00:00:00 Asia/Shanghai 的 Unix 秒级时间戳。
|
||||||
|
|
@ -63,6 +65,15 @@ type User struct {
|
||||||
PhotoHasVideo bool
|
PhotoHasVideo bool
|
||||||
LastSeenAt int
|
LastSeenAt int
|
||||||
Status UserStatus
|
Status UserStatus
|
||||||
|
// Deleted is the durable tombstone state. Deleted users remain addressable by
|
||||||
|
// ID so historical messages can render "Deleted Account", but all profile
|
||||||
|
// and reusable identity fields are cleared at the store boundary.
|
||||||
|
Deleted bool
|
||||||
|
DeletedAt int64
|
||||||
|
DeletionSource AccountDeletionSource
|
||||||
|
DeletionReason string
|
||||||
|
CreatedAt time.Time
|
||||||
|
AccountDeleteAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// PremiumActiveAt 报告用户在 now(Unix 秒)时刻是否为有效会员。
|
// PremiumActiveAt 报告用户在 now(Unix 秒)时刻是否为有效会员。
|
||||||
|
|
@ -81,6 +92,25 @@ func (u User) EmojiStatusActiveAt(now int64) bool {
|
||||||
return u.EmojiStatusUntil == 0 || int64(u.EmojiStatusUntil) > now
|
return u.EmojiStatusUntil == 0 || int64(u.EmojiStatusUntil) > now
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeletedTombstone strips every viewer-dependent or personally identifying
|
||||||
|
// field while preserving the immutable id and lifecycle audit facts.
|
||||||
|
func (u User) DeletedTombstone() User {
|
||||||
|
if !u.Deleted {
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
return User{
|
||||||
|
ID: u.ID,
|
||||||
|
AccessHash: u.AccessHash,
|
||||||
|
Deleted: true,
|
||||||
|
DeletedAt: u.DeletedAt,
|
||||||
|
DeletionSource: u.DeletionSource,
|
||||||
|
DeletionReason: u.DeletionReason,
|
||||||
|
CreatedAt: u.CreatedAt,
|
||||||
|
AccountDeleteAt: u.AccountDeleteAt,
|
||||||
|
Status: UserStatus{Kind: UserStatusEmpty},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// UserStatusKind is a protocol-neutral account presence state.
|
// UserStatusKind is a protocol-neutral account presence state.
|
||||||
type UserStatusKind int
|
type UserStatusKind int
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ const (
|
||||||
PurposeLoginEmailSetup Purpose = "login_email_setup"
|
PurposeLoginEmailSetup Purpose = "login_email_setup"
|
||||||
PurposeLoginEmailChange Purpose = "login_email_change"
|
PurposeLoginEmailChange Purpose = "login_email_change"
|
||||||
PurposeChangePhone Purpose = "change_phone"
|
PurposeChangePhone Purpose = "change_phone"
|
||||||
|
PurposeConfirmPhone Purpose = "confirm_phone"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Request struct {
|
type Request struct {
|
||||||
|
|
@ -46,7 +47,7 @@ func (r Request) Validate(now time.Time) error {
|
||||||
return fmt.Errorf("delivery id is empty or too long")
|
return fmt.Errorf("delivery id is empty or too long")
|
||||||
}
|
}
|
||||||
switch r.Purpose {
|
switch r.Purpose {
|
||||||
case PurposeLoginEmail, PurposeLoginSMS, PurposeLoginEmailSetup, PurposeLoginEmailChange, PurposeChangePhone:
|
case PurposeLoginEmail, PurposeLoginSMS, PurposeLoginEmailSetup, PurposeLoginEmailChange, PurposeChangePhone, PurposeConfirmPhone:
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported delivery purpose %q", r.Purpose)
|
return fmt.Errorf("unsupported delivery purpose %q", r.Purpose)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,15 @@ import (
|
||||||
|
|
||||||
// registerAccount 注册 account.* RPC handler。
|
// registerAccount 注册 account.* RPC handler。
|
||||||
func (r *Router) registerAccount(d *tlprofile.Dispatcher) {
|
func (r *Router) registerAccount(d *tlprofile.Dispatcher) {
|
||||||
|
registerRPC[*tg.AccountDeleteAccountRequest](d, tlprofile.SemanticMethodAccountDeleteAccount, func(ctx context.Context, req *tg.AccountDeleteAccountRequest) (any, error) {
|
||||||
|
return r.onAccountDeleteAccount(ctx, req)
|
||||||
|
})
|
||||||
|
registerRPC[*tg.AccountSendConfirmPhoneCodeRequest](d, tlprofile.SemanticMethodAccountSendConfirmPhoneCode, func(ctx context.Context, req *tg.AccountSendConfirmPhoneCodeRequest) (any, error) {
|
||||||
|
return r.onAccountSendConfirmPhoneCode(ctx, req)
|
||||||
|
})
|
||||||
|
registerRPC[*tg.AccountConfirmPhoneRequest](d, tlprofile.SemanticMethodAccountConfirmPhone, func(ctx context.Context, req *tg.AccountConfirmPhoneRequest) (any, error) {
|
||||||
|
return r.onAccountConfirmPhone(ctx, req)
|
||||||
|
})
|
||||||
registerRPC[*tg.AccountRegisterDeviceRequest](d, tlprofile.SemanticMethodAccountRegisterDevice, func(ctx context.Context, req *tg.AccountRegisterDeviceRequest) (any, error) {
|
registerRPC[*tg.AccountRegisterDeviceRequest](d, tlprofile.SemanticMethodAccountRegisterDevice, func(ctx context.Context, req *tg.AccountRegisterDeviceRequest) (any, error) {
|
||||||
return true, nil
|
return true, nil
|
||||||
})
|
})
|
||||||
|
|
@ -902,7 +911,7 @@ func (r *Router) onAccountSetAccountTTL(ctx context.Context, ttl tg.AccountDaysT
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, internalErr()
|
return false, internalErr()
|
||||||
}
|
}
|
||||||
if ttl.Days <= 0 {
|
if ttl.Days <= 0 || ttl.Days > domain.MaxAccountTTLDays {
|
||||||
return false, tgerr400("TTL_DAYS_INVALID")
|
return false, tgerr400("TTL_DAYS_INVALID")
|
||||||
}
|
}
|
||||||
if svc, ok := r.accountSettingsSvc(); ok {
|
if svc, ok := r.accountSettingsSvc(); ok {
|
||||||
|
|
|
||||||
152
internal/rpc/account_deletion.go
Normal file
152
internal/rpc/account_deletion.go
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
package rpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/iamxvbaba/td/tg"
|
||||||
|
"github.com/iamxvbaba/td/tgerr"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
"telesrv/internal/postresponse"
|
||||||
|
)
|
||||||
|
|
||||||
|
type accountDeletionService interface {
|
||||||
|
DeleteAccount(ctx context.Context, userID int64, authKeyID [8]byte, reason string, password *domain.PasswordCheck, now time.Time) (domain.AccountDeleteOutcome, error)
|
||||||
|
SendConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, hash string) (string, domain.AuthCodeDelivery, error)
|
||||||
|
ConfirmPhone(ctx context.Context, userID int64, authKeyID [8]byte, phoneCodeHash, code string, now time.Time) ([]domain.Authorization, error)
|
||||||
|
ResendConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, phone, oldHash string) (string, domain.AuthCodeDelivery, bool, error)
|
||||||
|
CancelConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, phone, hash string) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) accountDeletionSvc() (accountDeletionService, bool) {
|
||||||
|
svc, ok := r.deps.Account.(accountDeletionService)
|
||||||
|
return svc, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) onAccountDeleteAccount(ctx context.Context, req *tg.AccountDeleteAccountRequest) (bool, error) {
|
||||||
|
userID, authorized, passwordPending, err := r.currentOrPendingPasswordUserID(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, internalErr()
|
||||||
|
}
|
||||||
|
if userID == 0 || (!authorized && !passwordPending) {
|
||||||
|
return false, authKeyUnregisteredErr()
|
||||||
|
}
|
||||||
|
svc, ok := r.accountDeletionSvc()
|
||||||
|
if !ok {
|
||||||
|
return false, internalErr()
|
||||||
|
}
|
||||||
|
authKeyID, ok := AuthKeyIDFrom(ctx)
|
||||||
|
if !ok || authKeyID == ([8]byte{}) {
|
||||||
|
return false, authKeyUnregisteredErr()
|
||||||
|
}
|
||||||
|
var password *domain.PasswordCheck
|
||||||
|
if check, present := req.GetPassword(); present {
|
||||||
|
converted := domainPasswordCheck(check)
|
||||||
|
password = &converted
|
||||||
|
}
|
||||||
|
outcome, err := svc.DeleteAccount(ctx, userID, authKeyID, req.Reason, password, time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
return false, accountDeletionErr(err)
|
||||||
|
}
|
||||||
|
if outcome.Kind == domain.AccountDeleteDelayed {
|
||||||
|
wait := outcome.WaitSeconds
|
||||||
|
if wait < 1 {
|
||||||
|
wait = 1
|
||||||
|
}
|
||||||
|
return false, tgerr.New(420, fmt.Sprintf("2FA_CONFIRM_WAIT_%d", wait))
|
||||||
|
}
|
||||||
|
r.finishDeletedAccountAuthorizations(ctx, userID, outcome.Deletion.RevokedAuthorizations)
|
||||||
|
r.invalidateRPCProjectionForUser(userID)
|
||||||
|
dispatchNotifications := func() {
|
||||||
|
dispatchCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
r.runAccountLifecycleOnce(dispatchCtx, 500)
|
||||||
|
}
|
||||||
|
if !postresponse.Register(ctx, dispatchNotifications) {
|
||||||
|
go dispatchNotifications()
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) onAccountSendConfirmPhoneCode(ctx context.Context, req *tg.AccountSendConfirmPhoneCodeRequest) (tg.AuthSentCodeClass, error) {
|
||||||
|
userID, authorized, err := r.currentUserID(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, internalErr()
|
||||||
|
}
|
||||||
|
if !authorized || userID == 0 {
|
||||||
|
return nil, authKeyUnregisteredErr()
|
||||||
|
}
|
||||||
|
svc, ok := r.accountDeletionSvc()
|
||||||
|
if !ok {
|
||||||
|
return nil, internalErr()
|
||||||
|
}
|
||||||
|
authKeyID, _ := AuthKeyIDFrom(ctx)
|
||||||
|
sessionID, _ := SessionIDFrom(ctx)
|
||||||
|
hash, delivery, err := svc.SendConfirmPhoneCode(ctx, userID, authKeyID, sessionID, req.Hash)
|
||||||
|
if err != nil {
|
||||||
|
return nil, accountDeletionErr(err)
|
||||||
|
}
|
||||||
|
return tgSMSSentCode(hash, delivery.Length), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) onAccountConfirmPhone(ctx context.Context, req *tg.AccountConfirmPhoneRequest) (bool, error) {
|
||||||
|
userID, authorized, err := r.currentUserID(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, internalErr()
|
||||||
|
}
|
||||||
|
if !authorized || userID == 0 {
|
||||||
|
return false, authKeyUnregisteredErr()
|
||||||
|
}
|
||||||
|
svc, ok := r.accountDeletionSvc()
|
||||||
|
if !ok {
|
||||||
|
return false, internalErr()
|
||||||
|
}
|
||||||
|
authKeyID, _ := AuthKeyIDFrom(ctx)
|
||||||
|
revoked, err := svc.ConfirmPhone(ctx, userID, authKeyID, req.PhoneCodeHash, req.PhoneCode, time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
return false, accountDeletionErr(err)
|
||||||
|
}
|
||||||
|
r.finishDeletedAccountAuthorizations(ctx, userID, revoked)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) finishDeletedAccountAuthorizations(ctx context.Context, userID int64, revoked []domain.Authorization) {
|
||||||
|
current, _ := AuthKeyIDFrom(ctx)
|
||||||
|
for _, authorization := range revoked {
|
||||||
|
a := authorization
|
||||||
|
finish := func() {
|
||||||
|
r.discardSecretChatsForAuthKey(context.Background(), businessAuthKeyInt64(a.AuthKeyID), userID)
|
||||||
|
r.revokeAuthKeySessions(a.AuthKeyID)
|
||||||
|
}
|
||||||
|
if a.AuthKeyID == current {
|
||||||
|
if postresponse.Register(ctx, finish) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func accountDeletionErr(err error) error {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, domain.ErrPasswordHashInvalid), errors.Is(err, domain.ErrSRPIDInvalid), errors.Is(err, domain.ErrSRPPasswordChanged):
|
||||||
|
return passwordErr(err)
|
||||||
|
case errors.Is(err, domain.ErrAccountDeletionHashInvalid), errors.Is(err, domain.ErrAccountDeletionNotPending):
|
||||||
|
return tgerr.New(400, "HASH_INVALID")
|
||||||
|
case errors.Is(err, domain.ErrPhoneCodeEmpty):
|
||||||
|
return phoneCodeEmptyErr()
|
||||||
|
case errors.Is(err, domain.ErrPhoneCodeInvalid):
|
||||||
|
return phoneCodeInvalidErr()
|
||||||
|
case errors.Is(err, domain.ErrPhoneCodeExpired):
|
||||||
|
return phoneCodeExpiredErr()
|
||||||
|
case errors.Is(err, domain.ErrAccountDeletionForbidden):
|
||||||
|
return botMethodInvalidErr()
|
||||||
|
case errors.Is(err, domain.ErrAccountDeleted):
|
||||||
|
return authKeyUnregisteredErr()
|
||||||
|
default:
|
||||||
|
return internalErr()
|
||||||
|
}
|
||||||
|
}
|
||||||
177
internal/rpc/account_deletion_rpc_test.go
Normal file
177
internal/rpc/account_deletion_rpc_test.go
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
package rpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/iamxvbaba/td/clock"
|
||||||
|
"github.com/iamxvbaba/td/proto"
|
||||||
|
"github.com/iamxvbaba/td/tg"
|
||||||
|
"github.com/iamxvbaba/td/tgerr"
|
||||||
|
"go.uber.org/zap/zaptest"
|
||||||
|
|
||||||
|
appaccount "telesrv/internal/app/account"
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
"telesrv/internal/postresponse"
|
||||||
|
"telesrv/internal/store/memory"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAccountDeleteRPCDeliversResultBeforeClosingCurrentSession(t *testing.T) {
|
||||||
|
current := [8]byte{1}
|
||||||
|
other := [8]byte{2}
|
||||||
|
accountSvc := &rpcDeletionAccountService{
|
||||||
|
Service: appaccount.NewService(memory.NewPasswordStore()),
|
||||||
|
outcome: domain.AccountDeleteOutcome{
|
||||||
|
Kind: domain.AccountDeleteImmediate,
|
||||||
|
Deletion: domain.AccountDeletionResult{Changed: true, RevokedAuthorizations: []domain.Authorization{
|
||||||
|
{AuthKeyID: current, UserID: 42},
|
||||||
|
{AuthKeyID: other, UserID: 42},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
sessions := &deletionCaptureSessions{}
|
||||||
|
r := New(Config{}, Deps{Account: accountSvc, Sessions: sessions}, zaptest.NewLogger(t), clock.System)
|
||||||
|
ctx := postresponse.WithCallbacks(WithSessionID(WithAuthKeyID(WithUserID(context.Background(), 42), current), 77))
|
||||||
|
ok, err := r.onAccountDeleteAccount(ctx, &tg.AccountDeleteAccountRequest{Reason: "manual"})
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("delete account ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
if sessions.wasClosed(current) {
|
||||||
|
t.Fatal("current auth key closed before rpc_result delivery")
|
||||||
|
}
|
||||||
|
if !sessions.wasClosed(other) {
|
||||||
|
t.Fatal("other auth key was not revoked immediately")
|
||||||
|
}
|
||||||
|
postresponse.Run(ctx)
|
||||||
|
if !sessions.wasClosed(current) {
|
||||||
|
t.Fatal("current auth key not closed after rpc_result delivery")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountDeleteRPCMapsDelayedTwoFAWait(t *testing.T) {
|
||||||
|
accountSvc := &rpcDeletionAccountService{
|
||||||
|
Service: appaccount.NewService(memory.NewPasswordStore()),
|
||||||
|
outcome: domain.AccountDeleteOutcome{Kind: domain.AccountDeleteDelayed, WaitSeconds: 604800},
|
||||||
|
}
|
||||||
|
r := New(Config{}, Deps{Account: accountSvc}, zaptest.NewLogger(t), clock.System)
|
||||||
|
ctx := WithAuthKeyID(WithUserID(context.Background(), 42), [8]byte{1})
|
||||||
|
ok, err := r.onAccountDeleteAccount(ctx, &tg.AccountDeleteAccountRequest{Reason: "Forgot password"})
|
||||||
|
if ok || !tgerr.Is(err, "2FA_CONFIRM_WAIT") || !strings.Contains(err.Error(), "604800") {
|
||||||
|
t.Fatalf("delayed delete ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteAccountAllowedWithoutFullAuthorization(t *testing.T) {
|
||||||
|
if !rpcAllowedWithoutAuthorization(tg.AccountDeleteAccountRequestTypeID) {
|
||||||
|
t.Fatal("account.deleteAccount must reach the narrow password_pending identity resolver")
|
||||||
|
}
|
||||||
|
if rpcAllowedWithoutAuthorization(tg.AccountConfirmPhoneRequestTypeID) || rpcAllowedWithoutAuthorization(tg.AccountSendConfirmPhoneCodeRequestTypeID) {
|
||||||
|
t.Fatal("confirm-phone methods must remain fully authorized")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountDeletionNotificationCompletesForOfflineTarget(t *testing.T) {
|
||||||
|
sessions := &offlineDeletionSessions{}
|
||||||
|
svc := &deletionWorkerService{}
|
||||||
|
r := New(Config{}, Deps{Sessions: sessions}, zaptest.NewLogger(t), clock.System)
|
||||||
|
r.dispatchAccountDeletionNotification(context.Background(), svc, domain.AccountDeletionNotification{
|
||||||
|
ID: 9, TargetUserID: 42, DeletedUserID: 77, Attempts: 1,
|
||||||
|
})
|
||||||
|
if len(svc.completed) != 1 || svc.completed[0] != 9 {
|
||||||
|
t.Fatalf("completed notifications = %v, want [9]", svc.completed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountLifecyclePartialSweepFinishesCommittedDeletion(t *testing.T) {
|
||||||
|
revoked := [8]byte{3}
|
||||||
|
svc := &rpcDeletionAccountService{
|
||||||
|
Service: appaccount.NewService(memory.NewPasswordStore()),
|
||||||
|
sweepResults: []domain.AccountDeletionResult{{
|
||||||
|
Changed: true,
|
||||||
|
User: domain.User{ID: 42, Deleted: true},
|
||||||
|
RevokedAuthorizations: []domain.Authorization{{AuthKeyID: revoked, UserID: 42}},
|
||||||
|
}},
|
||||||
|
sweepErr: errors.New("later candidate failed"),
|
||||||
|
}
|
||||||
|
sessions := &deletionCaptureSessions{}
|
||||||
|
r := New(Config{}, Deps{Account: svc, Sessions: sessions}, zaptest.NewLogger(t), clock.System)
|
||||||
|
r.runAccountLifecycleOnce(context.Background(), 10)
|
||||||
|
if !sessions.wasClosed(revoked) {
|
||||||
|
t.Fatal("committed deletion authorization was not closed after partial sweep failure")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type rpcDeletionAccountService struct {
|
||||||
|
*appaccount.Service
|
||||||
|
outcome domain.AccountDeleteOutcome
|
||||||
|
err error
|
||||||
|
sweepResults []domain.AccountDeletionResult
|
||||||
|
sweepErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *rpcDeletionAccountService) DeleteAccount(context.Context, int64, [8]byte, string, *domain.PasswordCheck, time.Time) (domain.AccountDeleteOutcome, error) {
|
||||||
|
return s.outcome, s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*rpcDeletionAccountService) SendConfirmPhoneCode(context.Context, int64, [8]byte, int64, string) (string, domain.AuthCodeDelivery, error) {
|
||||||
|
return "hash", domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliverySMS, Length: 5}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*rpcDeletionAccountService) ConfirmPhone(context.Context, int64, [8]byte, string, string, time.Time) ([]domain.Authorization, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*rpcDeletionAccountService) ResendConfirmPhoneCode(context.Context, int64, [8]byte, int64, string, string) (string, domain.AuthCodeDelivery, bool, error) {
|
||||||
|
return "", domain.AuthCodeDelivery{}, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*rpcDeletionAccountService) CancelConfirmPhoneCode(context.Context, int64, [8]byte, string, string) (bool, error) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *rpcDeletionAccountService) SweepDueAccountDeletions(context.Context, time.Time, int) ([]domain.AccountDeletionResult, error) {
|
||||||
|
return s.sweepResults, s.sweepErr
|
||||||
|
}
|
||||||
|
|
||||||
|
type deletionCaptureSessions struct {
|
||||||
|
captureSessions
|
||||||
|
closed [][8]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type offlineDeletionSessions struct{ captureSessions }
|
||||||
|
|
||||||
|
func (*offlineDeletionSessions) PushToUserExceptAuthKeySession(context.Context, int64, [8]byte, int64, proto.MessageType, tg.UpdatesClass) (int, error) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type deletionWorkerService struct{ completed []int64 }
|
||||||
|
|
||||||
|
func (*deletionWorkerService) SweepDueAccountDeletions(context.Context, time.Time, int) ([]domain.AccountDeletionResult, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*deletionWorkerService) ClaimAccountDeletionNotifications(context.Context, time.Time, int, time.Duration) ([]domain.AccountDeletionNotification, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *deletionWorkerService) CompleteAccountDeletionNotification(_ context.Context, id int64, _ time.Time) error {
|
||||||
|
s.completed = append(s.completed, id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *deletionCaptureSessions) CloseSessionsForBusinessAuthKey(id [8]byte) int {
|
||||||
|
s.closed = append(s.closed, id)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *deletionCaptureSessions) wasClosed(id [8]byte) bool {
|
||||||
|
for _, closed := range s.closed {
|
||||||
|
if closed == id {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
99
internal/rpc/account_lifecycle_worker.go
Normal file
99
internal/rpc/account_lifecycle_worker.go
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
package rpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/iamxvbaba/td/tg"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type accountLifecycleWorkerService interface {
|
||||||
|
SweepDueAccountDeletions(ctx context.Context, now time.Time, limit int) ([]domain.AccountDeletionResult, error)
|
||||||
|
ClaimAccountDeletionNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountDeletionNotification, error)
|
||||||
|
CompleteAccountDeletionNotification(ctx context.Context, id int64, now time.Time) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunAccountLifecycle executes all due account deletion sources through one
|
||||||
|
// tombstone path and drains the durable non-pts updateUser queue. The queue is
|
||||||
|
// a crash-safe, bounded online nudge: offline users are completed after the
|
||||||
|
// first attempt because getDialogs/getHistory hydration independently returns
|
||||||
|
// the authoritative tombstone. This avoids an immortal retry queue for a
|
||||||
|
// non-pts update that cannot participate in getDifference.
|
||||||
|
func (r *Router) RunAccountLifecycle(ctx context.Context, interval time.Duration, batch int) {
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = time.Minute
|
||||||
|
}
|
||||||
|
if batch <= 0 {
|
||||||
|
batch = 500
|
||||||
|
}
|
||||||
|
r.runAccountLifecycleOnce(ctx, batch)
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
r.runAccountLifecycleOnce(ctx, batch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) runAccountLifecycleOnce(ctx context.Context, batch int) {
|
||||||
|
svc, ok := r.deps.Account.(accountLifecycleWorkerService)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := r.clock.Now().UTC()
|
||||||
|
sweepCtx, cancel := context.WithTimeout(ctx, 45*time.Second)
|
||||||
|
results, err := svc.SweepDueAccountDeletions(sweepCtx, now, batch)
|
||||||
|
cancel()
|
||||||
|
for _, result := range results {
|
||||||
|
if !result.Changed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r.invalidateRPCProjectionForUser(result.User.ID)
|
||||||
|
r.finishDeletedAccountAuthorizations(context.Background(), result.User.ID, result.RevokedAuthorizations)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
// SweepDueAccountDeletions may return already-committed results before a
|
||||||
|
// later candidate fails. Always finish those sessions/caches and drain
|
||||||
|
// their durable notifications; the failed and remaining candidates are
|
||||||
|
// retried from their authoritative due rows on the next tick.
|
||||||
|
r.log.Warn("account lifecycle deletion sweep partially failed", zap.Int("completed", len(results)), zap.Error(err))
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
claimCtx, claimCancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
notifications, err := svc.ClaimAccountDeletionNotifications(claimCtx, now, batch, 2*time.Minute)
|
||||||
|
claimCancel()
|
||||||
|
if err != nil {
|
||||||
|
r.log.Warn("claim account deletion notifications failed", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, notification := range notifications {
|
||||||
|
r.dispatchAccountDeletionNotification(ctx, svc, notification)
|
||||||
|
}
|
||||||
|
if len(notifications) < batch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) dispatchAccountDeletionNotification(ctx context.Context, svc accountLifecycleWorkerService, notification domain.AccountDeletionNotification) {
|
||||||
|
now := r.clock.Now().UTC()
|
||||||
|
updates := &tg.Updates{
|
||||||
|
Updates: []tg.UpdateClass{&tg.UpdateUser{UserID: notification.DeletedUserID}},
|
||||||
|
Users: []tg.UserClass{tgUser(domain.User{
|
||||||
|
ID: notification.DeletedUserID,
|
||||||
|
Deleted: true,
|
||||||
|
})},
|
||||||
|
Date: int(now.Unix()),
|
||||||
|
}
|
||||||
|
r.pushUserUpdates(ctx, notification.TargetUserID, updates)
|
||||||
|
if err := svc.CompleteAccountDeletionNotification(ctx, notification.ID, now); err != nil {
|
||||||
|
r.log.Warn("complete account deletion notification failed", zap.Int64("notification_id", notification.ID), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -490,6 +490,19 @@ func (r *Router) onAuthResendCode(ctx context.Context, req *tg.AuthResendCodeReq
|
||||||
if err := r.checkAuthCodeRateLimit(ctx, req.PhoneNumber); err != nil {
|
if err := r.checkAuthCodeRateLimit(ctx, req.PhoneNumber); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if userID, authorized, err := r.currentUserID(ctx); err == nil && authorized && userID != 0 {
|
||||||
|
if svc, ok := r.deps.Account.(accountDeletionService); ok {
|
||||||
|
authKeyID, _ := AuthKeyIDFrom(ctx)
|
||||||
|
sessionID, _ := SessionIDFrom(ctx)
|
||||||
|
hash, delivery, handled, err := svc.ResendConfirmPhoneCode(ctx, userID, authKeyID, sessionID, req.PhoneNumber, req.PhoneCodeHash)
|
||||||
|
if handled {
|
||||||
|
if err != nil {
|
||||||
|
return nil, accountDeletionErr(err)
|
||||||
|
}
|
||||||
|
return tgSMSSentCode(hash, delivery.Length), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
var hash string
|
var hash string
|
||||||
var err error
|
var err error
|
||||||
if scoped, ok := r.deps.Auth.(interface {
|
if scoped, ok := r.deps.Auth.(interface {
|
||||||
|
|
@ -507,6 +520,18 @@ func (r *Router) onAuthResendCode(ctx context.Context, req *tg.AuthResendCodeReq
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Router) onAuthCancelCode(ctx context.Context, req *tg.AuthCancelCodeRequest) (bool, error) {
|
func (r *Router) onAuthCancelCode(ctx context.Context, req *tg.AuthCancelCodeRequest) (bool, error) {
|
||||||
|
if userID, authorized, err := r.currentUserID(ctx); err == nil && authorized && userID != 0 {
|
||||||
|
if svc, ok := r.deps.Account.(accountDeletionService); ok {
|
||||||
|
authKeyID, _ := AuthKeyIDFrom(ctx)
|
||||||
|
handled, err := svc.CancelConfirmPhoneCode(ctx, userID, authKeyID, req.PhoneNumber, req.PhoneCodeHash)
|
||||||
|
if handled {
|
||||||
|
if err != nil {
|
||||||
|
return false, accountDeletionErr(err)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
var err error
|
var err error
|
||||||
if scoped, ok := r.deps.Auth.(interface {
|
if scoped, ok := r.deps.Auth.(interface {
|
||||||
CancelCodeForAuthKey(context.Context, [8]byte, string, string) error
|
CancelCodeForAuthKey(context.Context, [8]byte, string, string) error
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,9 @@ func rpcAllowedWithoutAuthorization(id uint32) bool {
|
||||||
tg.AuthReportMissingCodeRequestTypeID,
|
tg.AuthReportMissingCodeRequestTypeID,
|
||||||
tg.AuthResetLoginEmailRequestTypeID,
|
tg.AuthResetLoginEmailRequestTypeID,
|
||||||
tg.AccountGetPasswordRequestTypeID,
|
tg.AccountGetPasswordRequestTypeID,
|
||||||
|
// deleteAccount may complete the narrow password_pending login path when
|
||||||
|
// the user forgot 2FA. The handler resolves only that bound identity.
|
||||||
|
tg.AccountDeleteAccountRequestTypeID,
|
||||||
// 登录邮箱 setup(emailVerifyPurposeLoginSetup)发生在登录流程中、尚未鉴权,
|
// 登录邮箱 setup(emailVerifyPurposeLoginSetup)发生在登录流程中、尚未鉴权,
|
||||||
// 故这两个 account.* 方法必须放行 pre-auth;loginChange 分支内部仍校验 userID。
|
// 故这两个 account.* 方法必须放行 pre-auth;loginChange 分支内部仍校验 userID。
|
||||||
tg.AccountSendVerifyEmailCodeRequestTypeID,
|
tg.AccountSendVerifyEmailCodeRequestTypeID,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ import (
|
||||||
|
|
||||||
// tgSelfUser 把 domain.User 转为 self 标记的 tg.User(optional 字段由 Encode 自动 SetFlags)。
|
// tgSelfUser 把 domain.User 转为 self 标记的 tg.User(optional 字段由 Encode 自动 SetFlags)。
|
||||||
func tgSelfUser(u domain.User) *tg.User {
|
func tgSelfUser(u domain.User) *tg.User {
|
||||||
|
if u.Deleted {
|
||||||
|
return &tg.User{ID: u.ID, Deleted: true}
|
||||||
|
}
|
||||||
out := &tg.User{
|
out := &tg.User{
|
||||||
ID: u.ID,
|
ID: u.ID,
|
||||||
AccessHash: u.AccessHash,
|
AccessHash: u.AccessHash,
|
||||||
|
|
@ -34,6 +37,9 @@ func tgSelfUser(u domain.User) *tg.User {
|
||||||
}
|
}
|
||||||
|
|
||||||
func tgUser(u domain.User) *tg.User {
|
func tgUser(u domain.User) *tg.User {
|
||||||
|
if u.Deleted {
|
||||||
|
return &tg.User{ID: u.ID, Deleted: true}
|
||||||
|
}
|
||||||
out := &tg.User{
|
out := &tg.User{
|
||||||
ID: u.ID,
|
ID: u.ID,
|
||||||
AccessHash: u.AccessHash,
|
AccessHash: u.AccessHash,
|
||||||
|
|
|
||||||
56
internal/rpc/convert_users_deleted_test.go
Normal file
56
internal/rpc/convert_users_deleted_test.go
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
package rpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/iamxvbaba/td/clock"
|
||||||
|
"go.uber.org/zap/zaptest"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDeletedUserTLProjectionContainsOnlyTombstoneIdentity(t *testing.T) {
|
||||||
|
u := domain.User{
|
||||||
|
ID: 42, AccessHash: 99, Phone: "secret", FirstName: "Alice", LastName: "Private",
|
||||||
|
Username: "released", About: "hidden", Verified: true, PremiumUntil: 2_000_000_000,
|
||||||
|
PhotoID: 123, Deleted: true, DeletedAt: 1_800_000_000,
|
||||||
|
}
|
||||||
|
got := tgUser(u)
|
||||||
|
if got.ID != u.ID || !got.Deleted {
|
||||||
|
t.Fatalf("deleted user = %+v", got)
|
||||||
|
}
|
||||||
|
if got.AccessHash != 0 || got.Phone != "" || got.FirstName != "" || got.LastName != "" || got.Username != "" || got.Verified || got.Premium || got.Photo != nil || got.Status != nil || len(got.Usernames) != 0 {
|
||||||
|
t.Fatalf("deleted user leaked profile state: %+v", got)
|
||||||
|
}
|
||||||
|
self := tgSelfUser(u)
|
||||||
|
if !self.Deleted || self.Self || self.ID != u.ID {
|
||||||
|
t.Fatalf("deleted self projection = %+v", self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHistoryHydrationReplacesStaleUserWithDeletedTombstone(t *testing.T) {
|
||||||
|
viewer := domain.User{ID: 7, FirstName: "Viewer"}
|
||||||
|
deleted := domain.User{ID: 42, AccessHash: 99, Deleted: true, DeletedAt: 1_800_000_000}
|
||||||
|
r := New(Config{}, Deps{Users: mapUsersService{users: map[int64]domain.User{
|
||||||
|
viewer.ID: viewer, deleted.ID: deleted,
|
||||||
|
}}}, zaptest.NewLogger(t), clock.System)
|
||||||
|
|
||||||
|
list := r.enrichMessageList(context.Background(), viewer.ID, domain.MessageList{
|
||||||
|
Messages: []domain.Message{{
|
||||||
|
OwnerUserID: viewer.ID,
|
||||||
|
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: deleted.ID},
|
||||||
|
From: domain.Peer{Type: domain.PeerTypeUser, ID: deleted.ID},
|
||||||
|
Body: "retained history",
|
||||||
|
}},
|
||||||
|
// Simulate an old denormalized message query row. The authoritative
|
||||||
|
// Users.ByIDs hydration must replace it, not keep an empty active user.
|
||||||
|
Users: []domain.User{{ID: deleted.ID, Phone: "stale", FirstName: "Stale"}},
|
||||||
|
})
|
||||||
|
if len(list.Users) != 1 || !list.Users[0].Deleted || list.Users[0].Phone != "" || list.Users[0].FirstName != "" {
|
||||||
|
t.Fatalf("history users = %+v, want authoritative tombstone", list.Users)
|
||||||
|
}
|
||||||
|
if got := tgUser(list.Users[0]); !got.Deleted || got.ID != deleted.ID {
|
||||||
|
t.Fatalf("history TL user = %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
22
internal/store/account_lifecycle.go
Normal file
22
internal/store/account_lifecycle.go
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AccountLifecycleStore owns the atomic boundary between tombstoning a user,
|
||||||
|
// purging private account state, revoking authorizations and enqueueing
|
||||||
|
// non-pts updateUser notifications.
|
||||||
|
type AccountLifecycleStore interface {
|
||||||
|
AccountDeletionSnapshot(ctx context.Context, userID int64) (domain.AccountDeletionSnapshot, bool, error)
|
||||||
|
ScheduleAccountDeletion(ctx context.Context, req domain.ScheduleAccountDeletion) (domain.AccountDeletionRequest, bool, error)
|
||||||
|
PendingAccountDeletionByHash(ctx context.Context, userID int64, digest [32]byte) (domain.AccountDeletionRequest, bool, error)
|
||||||
|
ExecuteAccountDeletion(ctx context.Context, userID int64, source domain.AccountDeletionSource, reason string, now time.Time) (domain.AccountDeletionResult, error)
|
||||||
|
CancelAccountDeletion(ctx context.Context, userID int64, digest [32]byte, now time.Time) ([]domain.Authorization, error)
|
||||||
|
DueAccountDeletions(ctx context.Context, now time.Time, limit int) ([]domain.AccountDeletionCandidate, error)
|
||||||
|
ClaimAccountDeletionNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountDeletionNotification, error)
|
||||||
|
CompleteAccountDeletionNotification(ctx context.Context, id int64, now time.Time) error
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
|
|
||||||
const (
|
const (
|
||||||
PhoneCodePurposeChangePhone = "change_phone"
|
PhoneCodePurposeChangePhone = "change_phone"
|
||||||
|
PhoneCodePurposeConfirmPhone = "confirm_phone"
|
||||||
PhoneCodeChannelPhone = "phone"
|
PhoneCodeChannelPhone = "phone"
|
||||||
PhoneCodeChannelSMS = "sms"
|
PhoneCodeChannelSMS = "sms"
|
||||||
PhoneCodeChannelEmailLogin = "email_login"
|
PhoneCodeChannelEmailLogin = "email_login"
|
||||||
|
|
@ -75,6 +76,10 @@ type PhoneCode struct {
|
||||||
VerifiedEmail bool
|
VerifiedEmail bool
|
||||||
RequireSignUp bool
|
RequireSignUp bool
|
||||||
LoginEmailHash string
|
LoginEmailHash string
|
||||||
|
// AccountDeletionHash is the hex-encoded SHA-256 digest of the validated
|
||||||
|
// confirmphone link token. It binds account.confirmPhone to one pending
|
||||||
|
// deletion without persisting the raw link credential in the code record.
|
||||||
|
AccountDeletionHash string
|
||||||
}
|
}
|
||||||
|
|
||||||
type PhoneCodeSnapshot struct {
|
type PhoneCodeSnapshot struct {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"telesrv/internal/domain"
|
"telesrv/internal/domain"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UserStore 是 store.UserStore 的内存实现。ID 与 PG identity 使用同一业务起点。
|
// UserStore 是 store.UserStore 的内存实现。ID 与 PG identity 使用同一业务起点。
|
||||||
|
|
@ -65,7 +66,7 @@ func (s *UserStore) ByPhone(_ context.Context, phone string) (domain.User, bool,
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
for _, u := range s.byID {
|
for _, u := range s.byID {
|
||||||
if u.Phone == phone {
|
if !u.Deleted && u.Phone == phone {
|
||||||
return u, true, nil
|
return u, true, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -87,6 +88,9 @@ func (s *UserStore) ByPhones(_ context.Context, phones []string) ([]domain.User,
|
||||||
out := make([]domain.User, 0, len(want))
|
out := make([]domain.User, 0, len(want))
|
||||||
seenIDs := map[int64]struct{}{}
|
seenIDs := map[int64]struct{}{}
|
||||||
for _, u := range s.byID {
|
for _, u := range s.byID {
|
||||||
|
if u.Deleted {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if _, ok := want[u.Phone]; !ok {
|
if _, ok := want[u.Phone]; !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -108,7 +112,7 @@ func (s *UserStore) ByUsername(_ context.Context, username string) (domain.User,
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
for _, u := range s.byID {
|
for _, u := range s.byID {
|
||||||
if strings.ToLower(u.Username) == username {
|
if !u.Deleted && strings.ToLower(u.Username) == username {
|
||||||
return u, true, nil
|
return u, true, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -123,7 +127,7 @@ func (s *UserStore) CheckUsername(_ context.Context, userID int64, username stri
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
for id, u := range s.byID {
|
for id, u := range s.byID {
|
||||||
if strings.ToLower(u.Username) == username && id != userID {
|
if !u.Deleted && strings.ToLower(u.Username) == username && id != userID {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -143,7 +147,7 @@ func (s *UserStore) Search(_ context.Context, currentUserID int64, query, phoneQ
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
users := make([]domain.User, 0)
|
users := make([]domain.User, 0)
|
||||||
for _, u := range s.byID {
|
for _, u := range s.byID {
|
||||||
if u.ID == currentUserID {
|
if u.ID == currentUserID || u.Deleted {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if userMatchesSearch(u, query, phoneQuery) {
|
if userMatchesSearch(u, query, phoneQuery) {
|
||||||
|
|
@ -165,7 +169,7 @@ func (s *UserStore) UpdateUsername(_ context.Context, userID int64, username str
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
u, ok := s.byID[userID]
|
u, ok := s.byID[userID]
|
||||||
if !ok {
|
if !ok || u.Deleted {
|
||||||
return domain.User{}, domain.ErrUsernameNotOccupied
|
return domain.User{}, domain.ErrUsernameNotOccupied
|
||||||
}
|
}
|
||||||
if usernameLower != "" {
|
if usernameLower != "" {
|
||||||
|
|
@ -184,7 +188,7 @@ func (s *UserStore) UpdateProfile(_ context.Context, userID int64, firstName, la
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
u, ok := s.byID[userID]
|
u, ok := s.byID[userID]
|
||||||
if !ok {
|
if !ok || u.Deleted {
|
||||||
return domain.User{}, domain.ErrUsernameNotOccupied
|
return domain.User{}, domain.ErrUsernameNotOccupied
|
||||||
}
|
}
|
||||||
u.FirstName = firstName
|
u.FirstName = firstName
|
||||||
|
|
@ -198,7 +202,7 @@ func (s *UserStore) UpdateBirthday(_ context.Context, userID int64, birthday dom
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
u, ok := s.byID[userID]
|
u, ok := s.byID[userID]
|
||||||
if !ok {
|
if !ok || u.Deleted {
|
||||||
return domain.User{}, domain.ErrUserNotFound
|
return domain.User{}, domain.ErrUserNotFound
|
||||||
}
|
}
|
||||||
u.Birthday = birthday
|
u.Birthday = birthday
|
||||||
|
|
@ -210,7 +214,7 @@ func (s *UserStore) UpdatePersonalChannel(_ context.Context, userID int64, chann
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
u, ok := s.byID[userID]
|
u, ok := s.byID[userID]
|
||||||
if !ok {
|
if !ok || u.Deleted {
|
||||||
return domain.User{}, domain.ErrUserNotFound
|
return domain.User{}, domain.ErrUserNotFound
|
||||||
}
|
}
|
||||||
u.PersonalChannelID = channelID
|
u.PersonalChannelID = channelID
|
||||||
|
|
@ -224,7 +228,7 @@ func (s *UserStore) bumpBotInfoVersion(userID int64) (int, bool) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
u, ok := s.byID[userID]
|
u, ok := s.byID[userID]
|
||||||
if !ok || !u.Bot {
|
if !ok || u.Deleted || !u.Bot {
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
u.BotInfoVersion++
|
u.BotInfoVersion++
|
||||||
|
|
@ -237,7 +241,7 @@ func (s *UserStore) updateBotProfile(userID int64, setName bool, name string, se
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
u, ok := s.byID[userID]
|
u, ok := s.byID[userID]
|
||||||
if !ok || !u.Bot {
|
if !ok || u.Deleted || !u.Bot {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if setName {
|
if setName {
|
||||||
|
|
@ -255,7 +259,7 @@ func (s *UserStore) SetPremiumUntil(_ context.Context, userID int64, until int)
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
u, ok := s.byID[userID]
|
u, ok := s.byID[userID]
|
||||||
if !ok {
|
if !ok || u.Deleted {
|
||||||
return domain.User{}, domain.ErrUserNotFound
|
return domain.User{}, domain.ErrUserNotFound
|
||||||
}
|
}
|
||||||
if until < 0 {
|
if until < 0 {
|
||||||
|
|
@ -271,7 +275,7 @@ func (s *UserStore) SetVerified(_ context.Context, userID int64, verified bool)
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
u, ok := s.byID[userID]
|
u, ok := s.byID[userID]
|
||||||
if !ok {
|
if !ok || u.Deleted {
|
||||||
return domain.User{}, domain.ErrUserNotFound
|
return domain.User{}, domain.ErrUserNotFound
|
||||||
}
|
}
|
||||||
u.Verified = verified
|
u.Verified = verified
|
||||||
|
|
@ -288,7 +292,7 @@ func (s *UserStore) SweepExpiredPremium(_ context.Context, now int64, limit int)
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
out := make([]domain.User, 0)
|
out := make([]domain.User, 0)
|
||||||
for id, u := range s.byID {
|
for id, u := range s.byID {
|
||||||
if u.PremiumUntil <= 0 || int64(u.PremiumUntil) > now {
|
if u.Deleted || u.PremiumUntil <= 0 || int64(u.PremiumUntil) > now {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
u.PremiumUntil = 0
|
u.PremiumUntil = 0
|
||||||
|
|
@ -307,7 +311,7 @@ func (s *UserStore) UpdateEmojiStatus(_ context.Context, userID int64, documentI
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
u, ok := s.byID[userID]
|
u, ok := s.byID[userID]
|
||||||
if !ok {
|
if !ok || u.Deleted {
|
||||||
return domain.User{}, domain.ErrUserNotFound
|
return domain.User{}, domain.ErrUserNotFound
|
||||||
}
|
}
|
||||||
if documentID == 0 {
|
if documentID == 0 {
|
||||||
|
|
@ -323,7 +327,7 @@ func (s *UserStore) UpdateColor(_ context.Context, userID int64, forProfile bool
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
u, ok := s.byID[userID]
|
u, ok := s.byID[userID]
|
||||||
if !ok {
|
if !ok || u.Deleted {
|
||||||
return domain.User{}, domain.ErrUserNotFound
|
return domain.User{}, domain.ErrUserNotFound
|
||||||
}
|
}
|
||||||
if forProfile {
|
if forProfile {
|
||||||
|
|
@ -342,7 +346,7 @@ func (s *UserStore) UpdateLastSeen(_ context.Context, userID int64, lastSeenAt i
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
u, ok := s.byID[userID]
|
u, ok := s.byID[userID]
|
||||||
if !ok {
|
if !ok || u.Deleted {
|
||||||
return domain.ErrUsernameNotOccupied
|
return domain.ErrUsernameNotOccupied
|
||||||
}
|
}
|
||||||
if lastSeenAt > u.LastSeenAt {
|
if lastSeenAt > u.LastSeenAt {
|
||||||
|
|
@ -379,6 +383,9 @@ func (s *UserStore) Create(_ context.Context, u domain.User) (domain.User, error
|
||||||
}
|
}
|
||||||
u.ID = s.nextID
|
u.ID = s.nextID
|
||||||
s.nextID++
|
s.nextID++
|
||||||
|
if u.CreatedAt.IsZero() {
|
||||||
|
u.CreatedAt = time.Now().UTC()
|
||||||
|
}
|
||||||
s.byID[u.ID] = u
|
s.byID[u.ID] = u
|
||||||
return u, nil
|
return u, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
737
internal/store/postgres/account_lifecycle.go
Normal file
737
internal/store/postgres/account_lifecycle.go
Normal file
|
|
@ -0,0 +1,737 @@
|
||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AccountLifecycleStore is the PostgreSQL implementation of the unified
|
||||||
|
// account tombstone, delayed deletion and deletion notification boundary.
|
||||||
|
type AccountLifecycleStore struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAccountLifecycleStore(pool *pgxpool.Pool) *AccountLifecycleStore {
|
||||||
|
return &AccountLifecycleStore{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AccountLifecycleStore) AccountDeletionSnapshot(ctx context.Context, userID int64) (domain.AccountDeletionSnapshot, bool, error) {
|
||||||
|
if s == nil || s.pool == nil || userID == 0 {
|
||||||
|
return domain.AccountDeletionSnapshot{}, false, nil
|
||||||
|
}
|
||||||
|
u, found, err := NewUserStore(s.pool).ByID(ctx, userID)
|
||||||
|
if err != nil || !found {
|
||||||
|
return domain.AccountDeletionSnapshot{}, found, err
|
||||||
|
}
|
||||||
|
var snapshot = domain.AccountDeletionSnapshot{User: u}
|
||||||
|
if err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT EXISTS (SELECT 1 FROM account_passwords WHERE user_id = $1 AND has_password),
|
||||||
|
COALESCE((SELECT password_changed_at FROM account_passwords WHERE user_id = $1), u.created_at)
|
||||||
|
FROM users u WHERE u.id = $1`, userID).Scan(&snapshot.HasPassword, &snapshot.PasswordUpdatedAt); err != nil {
|
||||||
|
return domain.AccountDeletionSnapshot{}, false, fmt.Errorf("load account deletion password facts: %w", err)
|
||||||
|
}
|
||||||
|
pending, ok, err := pendingAccountDeletion(ctx, s.pool, userID, nil)
|
||||||
|
if err != nil {
|
||||||
|
return domain.AccountDeletionSnapshot{}, false, err
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
snapshot.Pending = &pending
|
||||||
|
}
|
||||||
|
return snapshot, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AccountLifecycleStore) ScheduleAccountDeletion(ctx context.Context, req domain.ScheduleAccountDeletion) (domain.AccountDeletionRequest, bool, error) {
|
||||||
|
if s == nil || s.pool == nil || req.UserID == 0 || req.RequestedAt.IsZero() || !req.ExecuteAt.After(req.RequestedAt) {
|
||||||
|
return domain.AccountDeletionRequest{}, false, domain.ErrAccountDeletionForbidden
|
||||||
|
}
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return domain.AccountDeletionRequest{}, false, fmt.Errorf("begin schedule account deletion: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
if err := lockUsersForUpdate(ctx, tx, req.UserID, domain.OfficialSystemUserID); err != nil {
|
||||||
|
return domain.AccountDeletionRequest{}, false, fmt.Errorf("lock schedule account deletion users: %w", err)
|
||||||
|
}
|
||||||
|
var deletedAt *time.Time
|
||||||
|
if err := tx.QueryRow(ctx, `SELECT deleted_at FROM users WHERE id = $1 FOR UPDATE`, req.UserID).Scan(&deletedAt); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return domain.AccountDeletionRequest{}, false, domain.ErrUserNotFound
|
||||||
|
}
|
||||||
|
return domain.AccountDeletionRequest{}, false, fmt.Errorf("lock account deletion user: %w", err)
|
||||||
|
}
|
||||||
|
if deletedAt != nil {
|
||||||
|
return domain.AccountDeletionRequest{}, false, domain.ErrAccountDeleted
|
||||||
|
}
|
||||||
|
if existing, ok, err := pendingAccountDeletion(ctx, tx, req.UserID, nil); err != nil {
|
||||||
|
return domain.AccountDeletionRequest{}, false, err
|
||||||
|
} else if ok {
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return domain.AccountDeletionRequest{}, false, fmt.Errorf("commit existing account deletion: %w", err)
|
||||||
|
}
|
||||||
|
return existing, false, nil
|
||||||
|
}
|
||||||
|
row := tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO account_deletion_requests (
|
||||||
|
user_id, requester_auth_key_id, reason, confirm_hash_digest, requested_at, execute_at
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING id, user_id, requester_auth_key_id, state, reason, confirm_hash_digest,
|
||||||
|
requested_at, execute_at, completed_at`,
|
||||||
|
req.UserID, authKeyIDToInt64(req.RequesterAuthKeyID), req.Reason,
|
||||||
|
req.ConfirmHashDigest[:], req.RequestedAt, req.ExecuteAt)
|
||||||
|
pending, err := scanAccountDeletionRequest(row)
|
||||||
|
if err != nil {
|
||||||
|
return domain.AccountDeletionRequest{}, false, fmt.Errorf("insert account deletion request: %w", err)
|
||||||
|
}
|
||||||
|
randomID := int64(binary.LittleEndian.Uint64(req.ConfirmHashDigest[:8]))
|
||||||
|
if randomID == 0 {
|
||||||
|
randomID = pending.ID
|
||||||
|
}
|
||||||
|
if _, err := NewMessageStore(tx).SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||||||
|
SenderUserID: domain.OfficialSystemUserID,
|
||||||
|
RecipientUserID: req.UserID,
|
||||||
|
RandomID: randomID,
|
||||||
|
Message: req.ServiceMessage,
|
||||||
|
Date: int(req.RequestedAt.Unix()),
|
||||||
|
}); err != nil {
|
||||||
|
return domain.AccountDeletionRequest{}, false, fmt.Errorf("send account deletion confirmation message: %w", err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return domain.AccountDeletionRequest{}, false, fmt.Errorf("commit schedule account deletion: %w", err)
|
||||||
|
}
|
||||||
|
return pending, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AccountLifecycleStore) PendingAccountDeletionByHash(ctx context.Context, userID int64, digest [32]byte) (domain.AccountDeletionRequest, bool, error) {
|
||||||
|
if s == nil || s.pool == nil || userID == 0 {
|
||||||
|
return domain.AccountDeletionRequest{}, false, nil
|
||||||
|
}
|
||||||
|
return pendingAccountDeletion(ctx, s.pool, userID, digest[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AccountLifecycleStore) ExecuteAccountDeletion(ctx context.Context, userID int64, source domain.AccountDeletionSource, reason string, now time.Time) (domain.AccountDeletionResult, error) {
|
||||||
|
if s == nil || s.pool == nil || userID == 0 || now.IsZero() || !validAccountDeletionSource(source) {
|
||||||
|
return domain.AccountDeletionResult{}, domain.ErrAccountDeletionForbidden
|
||||||
|
}
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return domain.AccountDeletionResult{}, fmt.Errorf("begin execute account deletion: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
if err := lockUsersForUpdate(ctx, tx, userID); err != nil {
|
||||||
|
return domain.AccountDeletionResult{}, fmt.Errorf("lock account deletion user: %w", err)
|
||||||
|
}
|
||||||
|
var lockedID int64
|
||||||
|
if err := tx.QueryRow(ctx, `SELECT id FROM users WHERE id = $1 FOR UPDATE`, userID).Scan(&lockedID); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return domain.AccountDeletionResult{}, domain.ErrUserNotFound
|
||||||
|
}
|
||||||
|
return domain.AccountDeletionResult{}, fmt.Errorf("lock account deletion row: %w", err)
|
||||||
|
}
|
||||||
|
u, found, err := NewUserStore(tx).ByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.AccountDeletionResult{}, err
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return domain.AccountDeletionResult{}, domain.ErrUserNotFound
|
||||||
|
}
|
||||||
|
if u.Deleted {
|
||||||
|
return domain.AccountDeletionResult{User: u, Changed: false}, nil
|
||||||
|
}
|
||||||
|
if u.Bot || domain.IsSystemUserID(u.ID) {
|
||||||
|
return domain.AccountDeletionResult{}, domain.ErrAccountDeletionForbidden
|
||||||
|
}
|
||||||
|
due, err := accountDeletionStillDue(ctx, tx, u, source, now)
|
||||||
|
if err != nil {
|
||||||
|
return domain.AccountDeletionResult{}, err
|
||||||
|
}
|
||||||
|
if !due {
|
||||||
|
return domain.AccountDeletionResult{User: u, Changed: false}, nil
|
||||||
|
}
|
||||||
|
if err := enqueueAccountDeletionNotifications(ctx, tx, userID); err != nil {
|
||||||
|
return domain.AccountDeletionResult{}, err
|
||||||
|
}
|
||||||
|
if err := settleDeletedAccountFinancialState(ctx, tx, userID, now); err != nil {
|
||||||
|
return domain.AccountDeletionResult{}, err
|
||||||
|
}
|
||||||
|
revoked, err := revokeByUserExceptTx(ctx, tx, userID, 0)
|
||||||
|
if err != nil {
|
||||||
|
return domain.AccountDeletionResult{}, fmt.Errorf("revoke deleted account authorizations: %w", err)
|
||||||
|
}
|
||||||
|
if err := purgeDeletedAccountPrivateState(ctx, tx, userID, now); err != nil {
|
||||||
|
return domain.AccountDeletionResult{}, err
|
||||||
|
}
|
||||||
|
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, userID, ""); err != nil {
|
||||||
|
return domain.AccountDeletionResult{}, fmt.Errorf("release deleted account username: %w", err)
|
||||||
|
}
|
||||||
|
reason = strings.TrimSpace(reason)
|
||||||
|
reason = truncateUTF8Bytes(reason, 1024)
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
UPDATE users SET
|
||||||
|
phone = '', first_name = '', last_name = '', username = '', country_code = '', about = '',
|
||||||
|
verified = false, support = false, last_seen_at = 0,
|
||||||
|
premium_expires_at = NULL, emoji_status_document_id = 0, emoji_status_until = 0,
|
||||||
|
color_set = false, color = 0, color_background_emoji_id = 0,
|
||||||
|
profile_color_set = false, profile_color = 0, profile_color_background_emoji_id = 0,
|
||||||
|
birthday_day = 0, birthday_month = 0, birthday_year = 0, personal_channel_id = 0,
|
||||||
|
deleted_at = $2, deletion_source = $3, deletion_reason = $4,
|
||||||
|
account_delete_at = NULL, updated_at = $2
|
||||||
|
WHERE id = $1 AND deleted_at IS NULL`, userID, now, string(source), reason); err != nil {
|
||||||
|
return domain.AccountDeletionResult{}, fmt.Errorf("write deleted account tombstone: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
UPDATE account_deletion_requests
|
||||||
|
SET state = 'executed', completed_at = $2, updated_at = $2
|
||||||
|
WHERE user_id = $1 AND state = 'pending'`, userID, now); err != nil {
|
||||||
|
return domain.AccountDeletionResult{}, fmt.Errorf("complete account deletion request: %w", err)
|
||||||
|
}
|
||||||
|
u, found, err = NewUserStore(tx).ByID(ctx, userID)
|
||||||
|
if err != nil || !found {
|
||||||
|
if err == nil {
|
||||||
|
err = domain.ErrUserNotFound
|
||||||
|
}
|
||||||
|
return domain.AccountDeletionResult{}, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return domain.AccountDeletionResult{}, fmt.Errorf("commit execute account deletion: %w", err)
|
||||||
|
}
|
||||||
|
return domain.AccountDeletionResult{User: u, Changed: true, RevokedAuthorizations: revoked}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AccountLifecycleStore) CancelAccountDeletion(ctx context.Context, userID int64, digest [32]byte, now time.Time) ([]domain.Authorization, error) {
|
||||||
|
if s == nil || s.pool == nil || userID == 0 || now.IsZero() {
|
||||||
|
return nil, domain.ErrAccountDeletionHashInvalid
|
||||||
|
}
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("begin cancel account deletion: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
if err := lockUsersForUpdate(ctx, tx, userID); err != nil {
|
||||||
|
return nil, fmt.Errorf("lock cancel account deletion: %w", err)
|
||||||
|
}
|
||||||
|
pending, ok, err := pendingAccountDeletionForUpdate(ctx, tx, userID, digest[:])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return nil, domain.ErrAccountDeletionHashInvalid
|
||||||
|
}
|
||||||
|
revoked, err := revokeOneAuthorizationTx(ctx, tx, userID, pending.RequesterAuthKeyID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
UPDATE account_deletion_requests
|
||||||
|
SET state = 'cancelled', completed_at = $2, updated_at = $2
|
||||||
|
WHERE id = $1 AND state = 'pending'`, pending.ID, now); err != nil {
|
||||||
|
return nil, fmt.Errorf("cancel account deletion request: %w", err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("commit cancel account deletion: %w", err)
|
||||||
|
}
|
||||||
|
return revoked, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AccountLifecycleStore) DueAccountDeletions(ctx context.Context, now time.Time, limit int) ([]domain.AccountDeletionCandidate, error) {
|
||||||
|
if s == nil || s.pool == nil || limit <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
WITH candidates AS (
|
||||||
|
SELECT user_id, 'password_reset_expiry'::text AS source, execute_at AS due_at, 1 AS priority
|
||||||
|
FROM account_deletion_requests WHERE state = 'pending' AND execute_at <= $1
|
||||||
|
UNION ALL
|
||||||
|
SELECT id, 'account_ttl', account_delete_at, 2
|
||||||
|
FROM users WHERE deleted_at IS NULL AND is_bot = false AND account_delete_at <= $1
|
||||||
|
UNION ALL
|
||||||
|
SELECT r.user_id, 'freeze_expiry', r.frozen_until, 3
|
||||||
|
FROM account_restrictions r JOIN users u ON u.id = r.user_id
|
||||||
|
WHERE r.frozen = true AND r.frozen_until IS NOT NULL AND r.frozen_until <= $1
|
||||||
|
AND u.deleted_at IS NULL AND u.is_bot = false
|
||||||
|
), dedup AS (
|
||||||
|
SELECT DISTINCT ON (user_id) user_id, source, due_at
|
||||||
|
FROM candidates ORDER BY user_id, priority, due_at
|
||||||
|
)
|
||||||
|
SELECT user_id, source, due_at FROM dedup ORDER BY due_at, user_id LIMIT $2`, now, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list due account deletions: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := make([]domain.AccountDeletionCandidate, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var c domain.AccountDeletionCandidate
|
||||||
|
var source string
|
||||||
|
if err := rows.Scan(&c.UserID, &source, &c.DueAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan due account deletion: %w", err)
|
||||||
|
}
|
||||||
|
c.Source = domain.AccountDeletionSource(source)
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AccountLifecycleStore) ClaimAccountDeletionNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountDeletionNotification, error) {
|
||||||
|
if s == nil || s.pool == nil || limit <= 0 || lease <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
WITH claim AS (
|
||||||
|
SELECT id FROM account_deletion_notifications
|
||||||
|
WHERE (status = 'pending' AND next_attempt_at <= $1)
|
||||||
|
OR (status = 'dispatching' AND lease_until <= $1)
|
||||||
|
ORDER BY next_attempt_at, id FOR UPDATE SKIP LOCKED LIMIT $2
|
||||||
|
)
|
||||||
|
UPDATE account_deletion_notifications n
|
||||||
|
SET status = 'dispatching', attempts = attempts + 1, lease_until = $3, updated_at = $1
|
||||||
|
FROM claim WHERE n.id = claim.id
|
||||||
|
RETURNING n.id, n.target_user_id, n.deleted_user_id, n.attempts`, now, limit, now.Add(lease))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("claim account deletion notifications: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := make([]domain.AccountDeletionNotification, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var n domain.AccountDeletionNotification
|
||||||
|
if err := rows.Scan(&n.ID, &n.TargetUserID, &n.DeletedUserID, &n.Attempts); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan account deletion notification: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AccountLifecycleStore) CompleteAccountDeletionNotification(ctx context.Context, id int64, now time.Time) error {
|
||||||
|
_, err := s.pool.Exec(ctx, `UPDATE account_deletion_notifications
|
||||||
|
SET status = 'delivered', lease_until = NULL, last_error = '', updated_at = $2 WHERE id = $1`, id, now)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("complete account deletion notification: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type accountDeletionRowScanner interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanAccountDeletionRequest(row accountDeletionRowScanner) (domain.AccountDeletionRequest, error) {
|
||||||
|
var (
|
||||||
|
r domain.AccountDeletionRequest
|
||||||
|
authKey int64
|
||||||
|
state string
|
||||||
|
digest []byte
|
||||||
|
completedAt *time.Time
|
||||||
|
)
|
||||||
|
if err := row.Scan(&r.ID, &r.UserID, &authKey, &state, &r.Reason, &digest,
|
||||||
|
&r.RequestedAt, &r.ExecuteAt, &completedAt); err != nil {
|
||||||
|
return domain.AccountDeletionRequest{}, err
|
||||||
|
}
|
||||||
|
if len(digest) != len(r.ConfirmHashDigest) {
|
||||||
|
return domain.AccountDeletionRequest{}, fmt.Errorf("invalid account deletion digest length %d", len(digest))
|
||||||
|
}
|
||||||
|
copy(r.ConfirmHashDigest[:], digest)
|
||||||
|
r.RequesterAuthKeyID = authKeyIDFromInt64(authKey)
|
||||||
|
r.State = domain.AccountDeletionRequestState(state)
|
||||||
|
if completedAt != nil {
|
||||||
|
r.CompletedAt = *completedAt
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pendingAccountDeletion(ctx context.Context, db interface {
|
||||||
|
QueryRow(context.Context, string, ...any) pgx.Row
|
||||||
|
}, userID int64, digest []byte) (domain.AccountDeletionRequest, bool, error) {
|
||||||
|
query := `SELECT id, user_id, requester_auth_key_id, state, reason, confirm_hash_digest,
|
||||||
|
requested_at, execute_at, completed_at FROM account_deletion_requests
|
||||||
|
WHERE user_id = $1 AND state = 'pending'`
|
||||||
|
args := []any{userID}
|
||||||
|
if digest != nil {
|
||||||
|
query += ` AND confirm_hash_digest = $2`
|
||||||
|
args = append(args, digest)
|
||||||
|
}
|
||||||
|
r, err := scanAccountDeletionRequest(db.QueryRow(ctx, query, args...))
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return domain.AccountDeletionRequest{}, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return domain.AccountDeletionRequest{}, false, fmt.Errorf("load pending account deletion: %w", err)
|
||||||
|
}
|
||||||
|
return r, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pendingAccountDeletionForUpdate(ctx context.Context, tx pgx.Tx, userID int64, digest []byte) (domain.AccountDeletionRequest, bool, error) {
|
||||||
|
row := tx.QueryRow(ctx, `SELECT id, user_id, requester_auth_key_id, state, reason, confirm_hash_digest,
|
||||||
|
requested_at, execute_at, completed_at FROM account_deletion_requests
|
||||||
|
WHERE user_id = $1 AND confirm_hash_digest = $2 AND state = 'pending' FOR UPDATE`, userID, digest)
|
||||||
|
r, err := scanAccountDeletionRequest(row)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return domain.AccountDeletionRequest{}, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return domain.AccountDeletionRequest{}, false, fmt.Errorf("lock pending account deletion: %w", err)
|
||||||
|
}
|
||||||
|
return r, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validAccountDeletionSource(source domain.AccountDeletionSource) bool {
|
||||||
|
switch source {
|
||||||
|
case domain.AccountDeletionManual, domain.AccountDeletionForgotPassword, domain.AccountDeletionTOSDecline,
|
||||||
|
domain.AccountDeletionPasswordResetExpiry, domain.AccountDeletionAccountTTL, domain.AccountDeletionFreezeExpiry:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// accountDeletionStillDue closes the list-then-execute race for every scheduled
|
||||||
|
// source. The user row is already locked; source-specific facts are read and,
|
||||||
|
// where applicable, locked again immediately before destructive work begins.
|
||||||
|
// Manual sources are admission decisions made by the caller and have no
|
||||||
|
// independently mutable deadline to revalidate.
|
||||||
|
func accountDeletionStillDue(ctx context.Context, tx pgx.Tx, user domain.User, source domain.AccountDeletionSource, now time.Time) (bool, error) {
|
||||||
|
switch source {
|
||||||
|
case domain.AccountDeletionManual, domain.AccountDeletionForgotPassword, domain.AccountDeletionTOSDecline:
|
||||||
|
return true, nil
|
||||||
|
case domain.AccountDeletionPasswordResetExpiry:
|
||||||
|
var requestID int64
|
||||||
|
err := tx.QueryRow(ctx, `
|
||||||
|
SELECT id FROM account_deletion_requests
|
||||||
|
WHERE user_id = $1 AND state = 'pending' AND execute_at <= $2
|
||||||
|
ORDER BY execute_at, id LIMIT 1 FOR UPDATE`, user.ID, now).Scan(&requestID)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("revalidate pending account deletion: %w", err)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
case domain.AccountDeletionAccountTTL:
|
||||||
|
return !user.AccountDeleteAt.IsZero() && !user.AccountDeleteAt.After(now), nil
|
||||||
|
case domain.AccountDeletionFreezeExpiry:
|
||||||
|
var frozen bool
|
||||||
|
var frozenUntil *time.Time
|
||||||
|
err := tx.QueryRow(ctx, `
|
||||||
|
SELECT frozen, frozen_until FROM account_restrictions WHERE user_id = $1 FOR UPDATE`, user.ID).Scan(&frozen, &frozenUntil)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("revalidate frozen account deletion: %w", err)
|
||||||
|
}
|
||||||
|
return frozen && frozenUntil != nil && !frozenUntil.After(now), nil
|
||||||
|
default:
|
||||||
|
return false, domain.ErrAccountDeletionForbidden
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateUTF8Bytes(value string, maxBytes int) string {
|
||||||
|
if maxBytes < 1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if len(value) <= maxBytes {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
cut := maxBytes
|
||||||
|
for cut > 0 && !utf8.ValidString(value[:cut]) {
|
||||||
|
cut--
|
||||||
|
}
|
||||||
|
return value[:cut]
|
||||||
|
}
|
||||||
|
|
||||||
|
func enqueueAccountDeletionNotifications(ctx context.Context, tx pgx.Tx, userID int64) error {
|
||||||
|
const maxAccountDeletionNotificationAudience = 4096
|
||||||
|
_, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO account_deletion_notifications (target_user_id, deleted_user_id)
|
||||||
|
SELECT audience.user_id, $1
|
||||||
|
FROM (
|
||||||
|
SELECT user_id
|
||||||
|
FROM (
|
||||||
|
SELECT contact_user_id AS user_id, 0 AS priority, 0 AS activity
|
||||||
|
FROM contacts WHERE user_id = $1
|
||||||
|
UNION ALL
|
||||||
|
SELECT user_id, 0, 0 FROM contacts WHERE contact_user_id = $1
|
||||||
|
UNION ALL
|
||||||
|
SELECT peer_id, 1, top_message_date
|
||||||
|
FROM dialogs WHERE user_id = $1 AND peer_type = 'user'
|
||||||
|
UNION ALL
|
||||||
|
SELECT user_id, 1, top_message_date
|
||||||
|
FROM dialogs WHERE peer_type = 'user' AND peer_id = $1
|
||||||
|
) candidates
|
||||||
|
GROUP BY user_id
|
||||||
|
ORDER BY min(priority), max(activity) DESC, user_id
|
||||||
|
LIMIT $2
|
||||||
|
) audience
|
||||||
|
JOIN users u ON u.id = audience.user_id
|
||||||
|
WHERE audience.user_id <> $1 AND u.deleted_at IS NULL
|
||||||
|
ON CONFLICT (target_user_id, deleted_user_id) DO NOTHING`, userID, maxAccountDeletionNotificationAudience)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("enqueue account deletion notifications: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func revokeOneAuthorizationTx(ctx context.Context, tx pgx.Tx, userID int64, authKeyID [8]byte) ([]domain.Authorization, error) {
|
||||||
|
id := authKeyIDToInt64(authKeyID)
|
||||||
|
if id == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err := lockPermanentAuthIdentities(ctx, tx, []int64{id}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var locked int64
|
||||||
|
if err := tx.QueryRow(ctx, `SELECT auth_key_id FROM auth_keys WHERE auth_key_id = $1 FOR UPDATE`, id).Scan(&locked); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("lock password reset auth key: %w", err)
|
||||||
|
}
|
||||||
|
a, found, err := scanRevokedAuthorization(tx.QueryRow(ctx, `
|
||||||
|
SELECT auth_key_id, user_id, hash, layer, device_model, platform, system_version,
|
||||||
|
api_id, app_version, ip, password_pending, created_at, active_at
|
||||||
|
FROM authorizations WHERE auth_key_id = $1 AND user_id = $2 FOR UPDATE`, id, userID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load password reset authorization: %w", err)
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err := deleteRevocationTargetsTx(ctx, tx, []int64{id}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return []domain.Authorization{a}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func purgeDeletedAccountPrivateState(ctx context.Context, tx pgx.Tx, userID int64, now time.Time) error {
|
||||||
|
// Leave shared private_messages/channel_messages and immutable transaction
|
||||||
|
// ledgers intact. Only the deleted user's private projections and settings are
|
||||||
|
// removed; other users continue to reference the tombstone sender.
|
||||||
|
statements := []string{
|
||||||
|
`DELETE FROM account_privacy_rules WHERE owner_user_id = $1`,
|
||||||
|
`DELETE FROM account_reaction_settings WHERE user_id = $1`,
|
||||||
|
`DELETE FROM account_restrictions WHERE user_id = $1`,
|
||||||
|
`DELETE FROM account_settings WHERE user_id = $1`,
|
||||||
|
`DELETE FROM account_passwords WHERE user_id = $1`,
|
||||||
|
`DELETE FROM notify_settings WHERE owner_user_id = $1`,
|
||||||
|
`DELETE FROM passkey_credentials WHERE user_id = $1`,
|
||||||
|
`DELETE FROM contacts WHERE user_id = $1 OR contact_user_id = $1`,
|
||||||
|
`DELETE FROM contact_blocks WHERE owner_user_id = $1 OR blocked_user_id = $1`,
|
||||||
|
`DELETE FROM dialog_drafts WHERE user_id = $1`,
|
||||||
|
`DELETE FROM dialog_filter_settings WHERE user_id = $1`,
|
||||||
|
`DELETE FROM dialog_filters WHERE user_id = $1`,
|
||||||
|
`DELETE FROM chatlist_memberships WHERE user_id = $1 OR owner_user_id = $1`,
|
||||||
|
`DELETE FROM chatlist_invites WHERE owner_user_id = $1`,
|
||||||
|
`DELETE FROM saved_dialog_pins WHERE user_id = $1`,
|
||||||
|
`DELETE FROM message_box_media WHERE owner_user_id = $1`,
|
||||||
|
`DELETE FROM private_media_category_counts WHERE owner_user_id = $1`,
|
||||||
|
`DELETE FROM message_boxes WHERE owner_user_id = $1`,
|
||||||
|
`DELETE FROM dialogs WHERE user_id = $1`,
|
||||||
|
`DELETE FROM dispatch_outbox WHERE target_user_id = $1`,
|
||||||
|
`DELETE FROM dispatch_outbox_user_heads WHERE target_user_id = $1`,
|
||||||
|
`DELETE FROM user_update_events WHERE user_id = $1`,
|
||||||
|
`DELETE FROM user_update_retention WHERE user_id = $1`,
|
||||||
|
`DELETE FROM user_update_watermarks WHERE user_id = $1`,
|
||||||
|
`DELETE FROM update_states WHERE user_id = $1`,
|
||||||
|
`DELETE FROM bootstrap_update_jobs WHERE user_id = $1`,
|
||||||
|
`DELETE FROM scheduled_messages WHERE owner_user_id = $1`,
|
||||||
|
`DELETE FROM quick_reply_messages WHERE owner_user_id = $1`,
|
||||||
|
`DELETE FROM quick_replies WHERE owner_user_id = $1`,
|
||||||
|
`DELETE FROM saved_music WHERE user_id = $1`,
|
||||||
|
`DELETE FROM user_sticker_collections WHERE owner_user_id = $1`,
|
||||||
|
`DELETE FROM user_sticker_sets WHERE owner_user_id = $1`,
|
||||||
|
`DELETE FROM user_recent_reactions WHERE user_id = $1`,
|
||||||
|
`DELETE FROM user_saved_reaction_tags WHERE user_id = $1`,
|
||||||
|
`DELETE FROM user_top_reactions WHERE user_id = $1`,
|
||||||
|
`DELETE FROM theme_user_installs WHERE user_id = $1`,
|
||||||
|
`DELETE FROM peer_translation_settings WHERE user_id = $1`,
|
||||||
|
`DELETE FROM ai_compose_tone_saves WHERE user_id = $1`,
|
||||||
|
`DELETE FROM ai_compose_tones WHERE owner_user_id = $1`,
|
||||||
|
`DELETE FROM business_automation_deliveries WHERE owner_user_id = $1 OR peer_user_id = $1`,
|
||||||
|
`DELETE FROM business_connected_bot_peer_states WHERE owner_user_id = $1 OR peer_user_id = $1`,
|
||||||
|
`DELETE FROM business_connected_bots WHERE owner_user_id = $1`,
|
||||||
|
`DELETE FROM business_chat_links WHERE owner_user_id = $1`,
|
||||||
|
`DELETE FROM user_business_profiles WHERE user_id = $1`,
|
||||||
|
`DELETE FROM attach_menu_user_states WHERE user_id = $1`,
|
||||||
|
`DELETE FROM bot_emoji_status_permissions WHERE user_id = $1`,
|
||||||
|
`DELETE FROM bot_user_permissions WHERE user_id = $1`,
|
||||||
|
`DELETE FROM login_code_message_deliveries WHERE user_id = $1`,
|
||||||
|
`DELETE FROM webview_custom_method_queries WHERE user_id = $1`,
|
||||||
|
`DELETE FROM webview_requested_buttons WHERE user_id = $1`,
|
||||||
|
`DELETE FROM profile_photos WHERE owner_peer_type = 'user' AND owner_peer_id = $1`,
|
||||||
|
`DELETE FROM story_views WHERE viewer_user_id = $1 OR (owner_peer_type = 'user' AND owner_peer_id = $1)`,
|
||||||
|
`DELETE FROM story_exposures WHERE viewer_user_id = $1 OR (owner_peer_type = 'user' AND owner_peer_id = $1)`,
|
||||||
|
`DELETE FROM story_read_states WHERE viewer_user_id = $1 OR (owner_peer_type = 'user' AND owner_peer_id = $1)`,
|
||||||
|
`DELETE FROM story_hidden_peers WHERE viewer_user_id = $1 OR (owner_peer_type = 'user' AND owner_peer_id = $1)`,
|
||||||
|
`DELETE FROM stories WHERE owner_peer_type = 'user' AND owner_peer_id = $1`,
|
||||||
|
`DELETE FROM group_call_schedule_subscribers WHERE user_id = $1`,
|
||||||
|
`DELETE FROM group_call_participants WHERE user_id = $1`,
|
||||||
|
`DELETE FROM group_call_invites WHERE inviter_user_id = $1 OR invitee_user_id = $1`,
|
||||||
|
`DELETE FROM channel_boost_slots WHERE user_id = $1`,
|
||||||
|
`DELETE FROM channel_invite_importers WHERE user_id = $1`,
|
||||||
|
`DELETE FROM channel_topic_read WHERE user_id = $1`,
|
||||||
|
`DELETE FROM channel_unread_mentions WHERE user_id = $1`,
|
||||||
|
`DELETE FROM channel_unread_mention_index WHERE user_id = $1`,
|
||||||
|
`DELETE FROM channel_dialogs WHERE user_id = $1`,
|
||||||
|
`DELETE FROM user_channel_member_index WHERE user_id = $1`,
|
||||||
|
`DELETE FROM account_deletion_notifications WHERE target_user_id = $1`,
|
||||||
|
`DELETE FROM uploaded_media_receipts WHERE owner_user_id = $1`,
|
||||||
|
`DELETE FROM upload_parts WHERE owner_user_id = $1`,
|
||||||
|
`DELETE FROM encrypted_files WHERE owner_user_id = $1`,
|
||||||
|
}
|
||||||
|
for _, statement := range statements {
|
||||||
|
if _, err := tx.Exec(ctx, statement, userID); err != nil {
|
||||||
|
return fmt.Errorf("purge deleted account private state (%s): %w", statement, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
WITH changed AS (
|
||||||
|
UPDATE channel_members
|
||||||
|
SET status = 'left', left_at = $2, unread_mark = false, updated_at = $3
|
||||||
|
WHERE user_id = $1 AND status = 'active'
|
||||||
|
RETURNING channel_id, role
|
||||||
|
), counts AS (
|
||||||
|
SELECT channel_id, count(*) AS participants,
|
||||||
|
count(*) FILTER (WHERE role IN ('creator', 'admin')) AS admins
|
||||||
|
FROM changed GROUP BY channel_id
|
||||||
|
)
|
||||||
|
UPDATE channels c
|
||||||
|
SET participants_count = GREATEST(0, c.participants_count - counts.participants::int),
|
||||||
|
admins_count = GREATEST(0, c.admins_count - counts.admins::int),
|
||||||
|
updated_at = $3
|
||||||
|
FROM counts WHERE c.id = counts.channel_id`, userID, int(now.Unix()), now); err != nil {
|
||||||
|
return fmt.Errorf("leave deleted account channels: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
UPDATE secret_chats SET state = 'discarded', history_deleted = true,
|
||||||
|
g_a = ''::bytea, g_b = ''::bytea, key_fingerprint = 0
|
||||||
|
WHERE admin_user_id = $1 OR participant_user_id = $1`, userID); err != nil {
|
||||||
|
return fmt.Errorf("discard deleted account secret chats: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func settleDeletedAccountFinancialState(ctx context.Context, tx pgx.Tx, userID int64, now time.Time) error {
|
||||||
|
nowUnix := int(now.Unix())
|
||||||
|
rows, err := tx.Query(ctx, `
|
||||||
|
SELECT id, buyer_user_id, currency, amount
|
||||||
|
FROM star_gift_offers
|
||||||
|
WHERE owner_peer_type = 'user' AND owner_peer_id = $1 AND status = 'pending'
|
||||||
|
ORDER BY id FOR UPDATE`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("lock deleted account gift offers: %w", err)
|
||||||
|
}
|
||||||
|
type offer struct {
|
||||||
|
id, buyer, amount int64
|
||||||
|
currency string
|
||||||
|
}
|
||||||
|
offers := make([]offer, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var o offer
|
||||||
|
if err := rows.Scan(&o.id, &o.buyer, &o.currency, &o.amount); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return fmt.Errorf("scan deleted account gift offer: %w", err)
|
||||||
|
}
|
||||||
|
offers = append(offers, o)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
for _, o := range offers {
|
||||||
|
var balance int64
|
||||||
|
if o.currency == "XTR" {
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO stars_balances (user_id, balance) VALUES ($1, $2)
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET balance = stars_balances.balance + EXCLUDED.balance, updated_at = now()
|
||||||
|
RETURNING balance`, o.buyer, o.amount).Scan(&balance); err != nil {
|
||||||
|
return fmt.Errorf("refund deleted account stars offer: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `INSERT INTO stars_transactions
|
||||||
|
(user_id, peer_type, peer_id, amount, reason, title, description, date)
|
||||||
|
VALUES ($1, 'user', $2, $3, 'gift_offer_refund_account_deleted', 'Gift offer refunded', '', $4)`, o.buyer, userID, o.amount, nowUnix); err != nil {
|
||||||
|
return fmt.Errorf("record deleted account stars refund: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO ton_balances (user_id, balance_nanoton) VALUES ($1, $2)
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET balance_nanoton = ton_balances.balance_nanoton + EXCLUDED.balance_nanoton, updated_at = now()
|
||||||
|
RETURNING balance_nanoton`, o.buyer, o.amount).Scan(&balance); err != nil {
|
||||||
|
return fmt.Errorf("refund deleted account TON offer: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `INSERT INTO ton_transactions
|
||||||
|
(user_id, amount_nanoton, reason, peer_type, peer_id, date)
|
||||||
|
VALUES ($1, $2, 'gift_offer_refund_account_deleted', 'user', $3, $4)`, o.buyer, o.amount, userID, nowUnix); err != nil {
|
||||||
|
return fmt.Errorf("record deleted account TON refund: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `UPDATE star_gift_offers
|
||||||
|
SET status = 'cancelled', resolved_at = $2, balance_after = $3
|
||||||
|
WHERE id = $1 AND status = 'pending'`, o.id, nowUnix, balance); err != nil {
|
||||||
|
return fmt.Errorf("cancel deleted account gift offer: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `UPDATE star_gift_offers
|
||||||
|
SET status = 'cancelled', resolved_at = $2, balance_after = 0
|
||||||
|
WHERE buyer_user_id = $1 AND status = 'pending'`, userID, nowUnix); err != nil {
|
||||||
|
return fmt.Errorf("cancel deleted buyer gift offers: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `UPDATE star_gift_withdrawal_requests
|
||||||
|
SET status = 'failed', completed_at = $2 WHERE owner_user_id = $1 AND status = 'pending'`, userID, nowUnix); err != nil {
|
||||||
|
return fmt.Errorf("fail deleted account withdrawals: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `UPDATE star_gift_auction_bids SET active = false, version = version + 1
|
||||||
|
WHERE bidder_user_id = $1 AND active = true`, userID); err != nil {
|
||||||
|
return fmt.Errorf("deactivate deleted account auction bids: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `UPDATE unique_star_gifts
|
||||||
|
SET burned = true, owner_name = '', updated_at = $2
|
||||||
|
WHERE owner_peer_type = 'user' AND owner_peer_id = $1`, userID, now); err != nil {
|
||||||
|
return fmt.Errorf("burn deleted account unique gifts: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts
|
||||||
|
SET lifecycle_status = 'burned', unsaved = true, pinned_order = 0
|
||||||
|
WHERE owner_peer_type = 'user' AND owner_peer_id = $1 AND unique_gift_id IS NOT NULL`, userID); err != nil {
|
||||||
|
return fmt.Errorf("burn deleted account saved gifts: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `DELETE FROM peer_star_gifts
|
||||||
|
WHERE owner_peer_type = 'user' AND owner_peer_id = $1 AND unique_gift_id IS NULL`, userID); err != nil {
|
||||||
|
return fmt.Errorf("delete deleted account regular gifts: %w", err)
|
||||||
|
}
|
||||||
|
var stars int64
|
||||||
|
if err := tx.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id = $1 FOR UPDATE`, userID).Scan(&stars); err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return fmt.Errorf("lock deleted account stars balance: %w", err)
|
||||||
|
}
|
||||||
|
if stars != 0 {
|
||||||
|
if _, err := tx.Exec(ctx, `UPDATE stars_balances SET balance = 0, updated_at = $2 WHERE user_id = $1`, userID, now); err != nil {
|
||||||
|
return fmt.Errorf("zero deleted account stars: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `INSERT INTO stars_transactions
|
||||||
|
(user_id, peer_type, peer_id, amount, reason, title, description, date)
|
||||||
|
VALUES ($1, 'user', $1, $2, 'account_deleted', 'Account deleted', '', $3)`, userID, -stars, nowUnix); err != nil {
|
||||||
|
return fmt.Errorf("record deleted account stars clearing: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var ton int64
|
||||||
|
if err := tx.QueryRow(ctx, `SELECT balance_nanoton FROM ton_balances WHERE user_id = $1 FOR UPDATE`, userID).Scan(&ton); err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return fmt.Errorf("lock deleted account TON balance: %w", err)
|
||||||
|
}
|
||||||
|
if ton != 0 {
|
||||||
|
if _, err := tx.Exec(ctx, `UPDATE ton_balances SET balance_nanoton = 0, updated_at = $2 WHERE user_id = $1`, userID, now); err != nil {
|
||||||
|
return fmt.Errorf("zero deleted account TON: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `INSERT INTO ton_transactions
|
||||||
|
(user_id, amount_nanoton, reason, date) VALUES ($1, $2, 'account_deleted', $3)`, userID, -ton, nowUnix); err != nil {
|
||||||
|
return fmt.Errorf("record deleted account TON clearing: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
289
internal/store/postgres/account_lifecycle_integration_test.go
Normal file
289
internal/store/postgres/account_lifecycle_integration_test.go
Normal file
|
|
@ -0,0 +1,289 @@
|
||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
"telesrv/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAccountLifecycleScheduleCancelAndTombstonePostgres(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
nonce := time.Now().UnixNano()
|
||||||
|
users := NewUserStore(pool)
|
||||||
|
deleted := createTestUser(t, ctx, users, fmt.Sprintf("15571%d", nonce), "Delete", "Me")
|
||||||
|
peer := createTestUser(t, ctx, users, fmt.Sprintf("15572%d", nonce), "Keep", "Peer")
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM stars_transactions WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM ton_transactions WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM stars_balances WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM ton_balances WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM account_deletion_notifications WHERE target_user_id = ANY($1) OR deleted_user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM account_deletion_requests WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM private_messages WHERE sender_user_id = ANY($1) OR recipient_user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = ANY($1)`, []int64{deleted.ID, peer.ID})
|
||||||
|
})
|
||||||
|
|
||||||
|
authOne := saveLifecycleTestAuthorization(t, ctx, pool, deleted.ID, 1)
|
||||||
|
authTwo := saveLifecycleTestAuthorization(t, ctx, pool, deleted.ID, 2)
|
||||||
|
if _, err := pool.Exec(ctx, `INSERT INTO contacts
|
||||||
|
(user_id, contact_user_id, contact_phone, contact_first_name, contact_last_name)
|
||||||
|
VALUES ($1, $2, 'stale-phone', 'Stale', 'Alias')`, peer.ID, deleted.ID); err != nil {
|
||||||
|
t.Fatalf("insert reverse contact: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := NewMessageStore(pool).SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||||||
|
SenderUserID: deleted.ID, RecipientUserID: peer.ID, RandomID: nonce, Message: "keep shared history",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("send shared message: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `INSERT INTO account_settings (user_id, account_ttl_days) VALUES ($1, 30)`, deleted.ID); err != nil {
|
||||||
|
t.Fatalf("insert account settings: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `INSERT INTO stars_balances (user_id, balance) VALUES ($1, 50)`, deleted.ID); err != nil {
|
||||||
|
t.Fatalf("insert stars balance: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `INSERT INTO ton_balances (user_id, balance_nanoton) VALUES ($1, 100)`, deleted.ID); err != nil {
|
||||||
|
t.Fatalf("insert TON balance: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycle := NewAccountLifecycleStore(pool)
|
||||||
|
now := time.Now().UTC().Truncate(time.Second)
|
||||||
|
digestOne := sha256.Sum256([]byte("confirm-one"))
|
||||||
|
pending, created, err := lifecycle.ScheduleAccountDeletion(ctx, domain.ScheduleAccountDeletion{
|
||||||
|
UserID: deleted.ID, RequesterAuthKeyID: authOne, Reason: "Forgot password",
|
||||||
|
ConfirmHashDigest: digestOne, ServiceMessage: "tg://confirmphone?phone=hidden&hash=confirm-one",
|
||||||
|
RequestedAt: now, ExecuteAt: now.Add(7 * 24 * time.Hour),
|
||||||
|
})
|
||||||
|
if err != nil || !created || pending.UserID != deleted.ID {
|
||||||
|
t.Fatalf("schedule deletion = %+v created=%v err=%v", pending, created, err)
|
||||||
|
}
|
||||||
|
if got, found, err := lifecycle.PendingAccountDeletionByHash(ctx, deleted.ID, digestOne); err != nil || !found || got.ID != pending.ID {
|
||||||
|
t.Fatalf("pending deletion by hash = %+v found=%v err=%v", got, found, err)
|
||||||
|
}
|
||||||
|
revoked, err := lifecycle.CancelAccountDeletion(ctx, deleted.ID, digestOne, now.Add(time.Minute))
|
||||||
|
if err != nil || len(revoked) != 1 || revoked[0].AuthKeyID != authOne {
|
||||||
|
t.Fatalf("cancel deletion revoked=%+v err=%v", revoked, err)
|
||||||
|
}
|
||||||
|
if _, found, err := NewAuthKeyStore(pool).Get(ctx, authOne); err != nil || found {
|
||||||
|
t.Fatalf("requester auth key after cancel found=%v err=%v, want revoked", found, err)
|
||||||
|
}
|
||||||
|
if _, found, err := NewAuthKeyStore(pool).Get(ctx, authTwo); err != nil || !found {
|
||||||
|
t.Fatalf("other auth key after cancel found=%v err=%v, want retained", found, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := lifecycle.ExecuteAccountDeletion(ctx, deleted.ID, domain.AccountDeletionManual, "manual", now.Add(2*time.Minute))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("execute account deletion: %v", err)
|
||||||
|
}
|
||||||
|
if !result.Changed || !result.User.Deleted || result.User.Phone != "" || result.User.FirstName != "" || len(result.RevokedAuthorizations) != 1 {
|
||||||
|
t.Fatalf("deletion result = %+v", result)
|
||||||
|
}
|
||||||
|
if _, found, err := users.ByPhone(ctx, deleted.Phone); err != nil || found {
|
||||||
|
t.Fatalf("released phone found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
if tombstone, found, err := users.ByID(ctx, deleted.ID); err != nil || !found || !tombstone.Deleted || tombstone.FirstName != "" {
|
||||||
|
t.Fatalf("tombstone = %+v found=%v err=%v", tombstone, found, err)
|
||||||
|
}
|
||||||
|
if _, err := users.UpdateProfile(ctx, deleted.ID, "Resurrected", "", ""); err == nil {
|
||||||
|
t.Fatal("deleted account profile mutation unexpectedly succeeded")
|
||||||
|
}
|
||||||
|
history, err := NewMessageStore(pool).ListByUser(ctx, peer.ID, domain.MessageFilter{
|
||||||
|
HasPeer: true,
|
||||||
|
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: deleted.ID},
|
||||||
|
Limit: 10,
|
||||||
|
})
|
||||||
|
if err != nil || len(history.Messages) != 1 || history.Messages[0].Body != "keep shared history" || history.Messages[0].From.ID != deleted.ID {
|
||||||
|
t.Fatalf("peer history after deletion = %+v err=%v", history, err)
|
||||||
|
}
|
||||||
|
var peerBoxes, settings, contacts, notifications int
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT count(*) FROM message_boxes WHERE owner_user_id = $1 AND from_user_id = $2`, peer.ID, deleted.ID).Scan(&peerBoxes); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT count(*) FROM account_settings WHERE user_id = $1`, deleted.ID).Scan(&settings); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT count(*) FROM contacts WHERE user_id = $1 OR contact_user_id = $1`, deleted.ID).Scan(&contacts); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT count(*) FROM account_deletion_notifications WHERE target_user_id = $1 AND deleted_user_id = $2`, peer.ID, deleted.ID).Scan(¬ifications); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if peerBoxes != 1 || settings != 0 || contacts != 0 || notifications != 1 {
|
||||||
|
t.Fatalf("post-delete state peerBoxes=%d settings=%d contacts=%d notifications=%d", peerBoxes, settings, contacts, notifications)
|
||||||
|
}
|
||||||
|
var stars, ton, starClear, tonClear int64
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id = $1`, deleted.ID).Scan(&stars); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT balance_nanoton FROM ton_balances WHERE user_id = $1`, deleted.ID).Scan(&ton); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT COALESCE(sum(amount), 0) FROM stars_transactions WHERE user_id = $1 AND reason = 'account_deleted'`, deleted.ID).Scan(&starClear); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT COALESCE(sum(amount_nanoton), 0) FROM ton_transactions WHERE user_id = $1 AND reason = 'account_deleted'`, deleted.ID).Scan(&tonClear); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if stars != 0 || ton != 0 || starClear != -50 || tonClear != -100 {
|
||||||
|
t.Fatalf("financial clearing stars=%d ton=%d star_tx=%d ton_tx=%d", stars, ton, starClear, tonClear)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountLifecycleDueSourcesAndTTLWatermarkPostgres(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
nonce := time.Now().UnixNano()
|
||||||
|
users := NewUserStore(pool)
|
||||||
|
ttlUser := createTestUser(t, ctx, users, fmt.Sprintf("15671%d", nonce), "TTL", "User")
|
||||||
|
freezeUser := createTestUser(t, ctx, users, fmt.Sprintf("15672%d", nonce), "Frozen", "User")
|
||||||
|
pendingUser := createTestUser(t, ctx, users, fmt.Sprintf("15673%d", nonce), "Pending", "User")
|
||||||
|
ids := []int64{ttlUser.ID, freezeUser.ID, pendingUser.ID}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM account_deletion_notifications WHERE target_user_id = ANY($1) OR deleted_user_id = ANY($1)`, ids)
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM account_deletion_requests WHERE user_id = ANY($1)`, ids)
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = ANY($1)`, ids)
|
||||||
|
})
|
||||||
|
now := time.Now().UTC().Truncate(time.Second)
|
||||||
|
if _, err := pool.Exec(ctx, `UPDATE users SET account_delete_at = $2 WHERE id = $1`, ttlUser.ID, now.Add(-time.Minute)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `INSERT INTO account_restrictions
|
||||||
|
(user_id, frozen, reason, actor, command_id, frozen_since, frozen_until, appeal_url)
|
||||||
|
VALUES ($1, true, 'abuse', 'test', 'freeze-test', $2, $3, 'https://example.test/appeal')`, freezeUser.ID, now.Add(-time.Hour), now.Add(-time.Minute)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
digest := sha256.Sum256([]byte("due-pending"))
|
||||||
|
if _, err := pool.Exec(ctx, `INSERT INTO account_deletion_requests
|
||||||
|
(user_id, requester_auth_key_id, reason, confirm_hash_digest, requested_at, execute_at)
|
||||||
|
VALUES ($1, 123, 'forgot', $2, $3, $4)`, pendingUser.ID, digest[:], now.Add(-8*24*time.Hour), now.Add(-time.Minute)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
candidates, err := NewAccountLifecycleStore(pool).DueAccountDeletions(ctx, now, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sources := make(map[int64]domain.AccountDeletionSource, len(candidates))
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
sources[candidate.UserID] = candidate.Source
|
||||||
|
}
|
||||||
|
if sources[ttlUser.ID] != domain.AccountDeletionAccountTTL || sources[freezeUser.ID] != domain.AccountDeletionFreezeExpiry || sources[pendingUser.ID] != domain.AccountDeletionPasswordResetExpiry {
|
||||||
|
t.Fatalf("due sources = %+v", sources)
|
||||||
|
}
|
||||||
|
seen := now.Add(time.Hour)
|
||||||
|
if err := users.UpdateLastSeen(ctx, ttlUser.ID, int(seen.Unix())); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
lifecycle := NewAccountLifecycleStore(pool)
|
||||||
|
if stale, err := lifecycle.ExecuteAccountDeletion(ctx, ttlUser.ID, domain.AccountDeletionAccountTTL, "", now); err != nil || stale.Changed {
|
||||||
|
t.Fatalf("stale TTL candidate changed=%v err=%v", stale.Changed, err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `UPDATE account_restrictions SET frozen_until = $2, updated_at = $3 WHERE user_id = $1`, freezeUser.ID, now.Add(24*time.Hour), now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if stale, err := lifecycle.ExecuteAccountDeletion(ctx, freezeUser.ID, domain.AccountDeletionFreezeExpiry, "", now); err != nil || stale.Changed {
|
||||||
|
t.Fatalf("extended freeze candidate changed=%v err=%v", stale.Changed, err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `UPDATE account_deletion_requests SET state = 'cancelled', completed_at = $2, updated_at = $2 WHERE user_id = $1`, pendingUser.ID, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if stale, err := lifecycle.ExecuteAccountDeletion(ctx, pendingUser.ID, domain.AccountDeletionPasswordResetExpiry, "", now); err != nil || stale.Changed {
|
||||||
|
t.Fatalf("cancelled pending candidate changed=%v err=%v", stale.Changed, err)
|
||||||
|
}
|
||||||
|
var deadline time.Time
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT account_delete_at FROM users WHERE id = $1`, ttlUser.ID).Scan(&deadline); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if want := seen.Add(365 * 24 * time.Hour); deadline.Sub(want) > time.Second || want.Sub(deadline) > time.Second {
|
||||||
|
t.Fatalf("TTL watermark deadline=%v want=%v", deadline, want)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `INSERT INTO account_settings (user_id, account_ttl_days) VALUES ($1, 30)
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET account_ttl_days = EXCLUDED.account_ttl_days`, ttlUser.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT account_delete_at FROM users WHERE id = $1`, ttlUser.ID).Scan(&deadline); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if want := seen.Add(30 * 24 * time.Hour); deadline.Sub(want) > time.Second || want.Sub(deadline) > time.Second {
|
||||||
|
t.Fatalf("custom TTL deadline=%v want=%v", deadline, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountPasswordChangedAtIgnoresSRPChallengeRotationPostgres(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
user := createTestUser(t, ctx, NewUserStore(pool), fmt.Sprintf("15771%d", time.Now().UnixNano()), "Password", "Clock")
|
||||||
|
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, user.ID) })
|
||||||
|
if _, err := pool.Exec(ctx, `INSERT INTO account_passwords
|
||||||
|
(user_id, has_password, current_algo_salt1, current_algo_salt2, current_algo_g, current_algo_p, srp_verifier, srp_id, srp_b)
|
||||||
|
VALUES ($1, true, '\x01', '\x02', 3, '\x03', '\x04', 10, '\x05')`, user.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var initial, afterChallenge, afterPassword time.Time
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT password_changed_at FROM account_passwords WHERE user_id = $1`, user.ID).Scan(&initial); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `SELECT pg_sleep(0.02)`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `UPDATE account_passwords SET srp_id = 11, srp_b = '\x06', updated_at = now() WHERE user_id = $1`, user.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT password_changed_at FROM account_passwords WHERE user_id = $1`, user.ID).Scan(&afterChallenge); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !afterChallenge.Equal(initial) {
|
||||||
|
t.Fatalf("SRP challenge rotation changed password clock: initial=%v after=%v", initial, afterChallenge)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `SELECT pg_sleep(0.02)`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `UPDATE account_passwords SET srp_verifier = '\x07', updated_at = now() WHERE user_id = $1`, user.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT password_changed_at FROM account_passwords WHERE user_id = $1`, user.ID).Scan(&afterPassword); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !afterPassword.After(afterChallenge) {
|
||||||
|
t.Fatalf("password verifier change did not advance clock: before=%v after=%v", afterChallenge, afterPassword)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTruncateAccountDeletionReasonUTF8(t *testing.T) {
|
||||||
|
got := truncateUTF8Bytes(strings.Repeat("界", 400), 1024)
|
||||||
|
if !utf8.ValidString(got) || len(got) > 1024 {
|
||||||
|
t.Fatalf("truncateUTF8Bytes returned invalid result: valid=%v bytes=%d", utf8.ValidString(got), len(got))
|
||||||
|
}
|
||||||
|
if got == "" {
|
||||||
|
t.Fatal("truncateUTF8Bytes unexpectedly removed the whole reason")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveLifecycleTestAuthorization(t *testing.T, ctx context.Context, db *pgxpool.Pool, userID int64, marker byte) [8]byte {
|
||||||
|
t.Helper()
|
||||||
|
var id [8]byte
|
||||||
|
var value [256]byte
|
||||||
|
if _, err := rand.Read(id[:]); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
id[0] = marker
|
||||||
|
if _, err := rand.Read(value[:]); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := NewAuthKeyStore(db).Save(ctx, store.AuthKeyData{ID: id, Value: value}); err != nil {
|
||||||
|
t.Fatalf("save lifecycle auth key: %v", err)
|
||||||
|
}
|
||||||
|
if err := NewAuthorizationStore(db).Bind(ctx, domain.Authorization{AuthKeyID: id, UserID: userID, Hash: int64(marker)}); err != nil {
|
||||||
|
t.Fatalf("bind lifecycle authorization: %v", err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
@ -46,7 +46,7 @@ func (s *MessageStore) DefaultHistoryTTL(ctx context.Context, userID int64) (int
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
var period int
|
var period int
|
||||||
err := s.db.QueryRow(ctx, `SELECT COALESCE(default_history_ttl_period, 0)::int FROM users WHERE id = $1`, userID).Scan(&period)
|
err := s.db.QueryRow(ctx, `SELECT COALESCE(default_history_ttl_period, 0)::int FROM users WHERE id = $1 AND deleted_at IS NULL`, userID).Scan(&period)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|
@ -70,7 +70,7 @@ func (s *MessageStore) SetDefaultHistoryTTL(ctx context.Context, userID int64, p
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET default_history_ttl_period = $2,
|
SET default_history_ttl_period = $2,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = $1
|
WHERE id = $1 AND deleted_at IS NULL
|
||||||
`, userID, period)
|
`, userID, period)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("set default history ttl: %w", err)
|
return fmt.Errorf("set default history ttl: %w", err)
|
||||||
|
|
|
||||||
|
|
@ -8,16 +8,16 @@ WHERE id = ANY(sqlc.arg(ids)::bigint[])
|
||||||
ORDER BY id;
|
ORDER BY id;
|
||||||
|
|
||||||
-- name: GetUserByPhone :one
|
-- name: GetUserByPhone :one
|
||||||
SELECT * FROM users WHERE phone = $1;
|
SELECT * FROM users WHERE phone = $1 AND deleted_at IS NULL;
|
||||||
|
|
||||||
-- name: GetUsersByPhones :many
|
-- name: GetUsersByPhones :many
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM users
|
FROM users
|
||||||
WHERE phone = ANY(sqlc.arg(phones)::text[])
|
WHERE phone = ANY(sqlc.arg(phones)::text[]) AND deleted_at IS NULL
|
||||||
ORDER BY id;
|
ORDER BY id;
|
||||||
|
|
||||||
-- name: GetUserByUsername :one
|
-- name: GetUserByUsername :one
|
||||||
SELECT * FROM users WHERE lower(username) = lower($1) AND username <> '';
|
SELECT * FROM users WHERE lower(username) = lower($1) AND username <> '' AND deleted_at IS NULL;
|
||||||
|
|
||||||
-- name: SearchUsers :many
|
-- name: SearchUsers :many
|
||||||
WITH matched AS (
|
WITH matched AS (
|
||||||
|
|
@ -57,6 +57,7 @@ WITH matched AS (
|
||||||
FROM users u
|
FROM users u
|
||||||
LEFT JOIN contacts c ON c.user_id = sqlc.arg(current_user_id)::bigint AND c.contact_user_id = u.id
|
LEFT JOIN contacts c ON c.user_id = sqlc.arg(current_user_id)::bigint AND c.contact_user_id = u.id
|
||||||
WHERE u.id <> sqlc.arg(current_user_id)::bigint
|
WHERE u.id <> sqlc.arg(current_user_id)::bigint
|
||||||
|
AND u.deleted_at IS NULL
|
||||||
AND sqlc.arg(query_lower)::text <> ''
|
AND sqlc.arg(query_lower)::text <> ''
|
||||||
AND (
|
AND (
|
||||||
(sqlc.arg(phone_query)::text <> '' AND u.phone LIKE sqlc.arg(phone_query)::text || '%')
|
(sqlc.arg(phone_query)::text <> '' AND u.phone LIKE sqlc.arg(phone_query)::text || '%')
|
||||||
|
|
@ -107,14 +108,14 @@ RETURNING *;
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET username = $2,
|
SET username = $2,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = $1
|
WHERE id = $1 AND deleted_at IS NULL
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: UpdateUserLastSeen :exec
|
-- name: UpdateUserLastSeen :exec
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET last_seen_at = GREATEST(last_seen_at, sqlc.arg(last_seen_at)::bigint),
|
SET last_seen_at = GREATEST(last_seen_at, sqlc.arg(last_seen_at)::bigint),
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = sqlc.arg(id)::bigint;
|
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL;
|
||||||
|
|
||||||
-- name: UpdateUserProfile :one
|
-- name: UpdateUserProfile :one
|
||||||
UPDATE users
|
UPDATE users
|
||||||
|
|
@ -122,28 +123,28 @@ SET first_name = $2,
|
||||||
last_name = $3,
|
last_name = $3,
|
||||||
about = $4,
|
about = $4,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = $1
|
WHERE id = $1 AND deleted_at IS NULL
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: UpdateUserPhone :one
|
-- name: UpdateUserPhone :one
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET phone = sqlc.arg(phone)::text,
|
SET phone = sqlc.arg(phone)::text,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = sqlc.arg(id)::bigint
|
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: SetUserPremiumUntil :one
|
-- name: SetUserPremiumUntil :one
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET premium_expires_at = sqlc.narg(premium_expires_at)::timestamptz,
|
SET premium_expires_at = sqlc.narg(premium_expires_at)::timestamptz,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = sqlc.arg(id)::bigint
|
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: SetUserVerified :one
|
-- name: SetUserVerified :one
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET verified = sqlc.arg(verified)::boolean,
|
SET verified = sqlc.arg(verified)::boolean,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = sqlc.arg(id)::bigint
|
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: SweepExpiredPremium :many
|
-- name: SweepExpiredPremium :many
|
||||||
|
|
@ -153,6 +154,7 @@ SET premium_expires_at = NULL,
|
||||||
WHERE id IN (
|
WHERE id IN (
|
||||||
SELECT id FROM users
|
SELECT id FROM users
|
||||||
WHERE premium_expires_at IS NOT NULL
|
WHERE premium_expires_at IS NOT NULL
|
||||||
|
AND deleted_at IS NULL
|
||||||
AND premium_expires_at <= sqlc.arg(now)::timestamptz
|
AND premium_expires_at <= sqlc.arg(now)::timestamptz
|
||||||
ORDER BY premium_expires_at
|
ORDER BY premium_expires_at
|
||||||
LIMIT sqlc.arg(limit_count)::int
|
LIMIT sqlc.arg(limit_count)::int
|
||||||
|
|
@ -164,7 +166,7 @@ UPDATE users
|
||||||
SET emoji_status_document_id = sqlc.arg(emoji_status_document_id)::bigint,
|
SET emoji_status_document_id = sqlc.arg(emoji_status_document_id)::bigint,
|
||||||
emoji_status_until = sqlc.arg(emoji_status_until)::bigint,
|
emoji_status_until = sqlc.arg(emoji_status_until)::bigint,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = sqlc.arg(id)::bigint
|
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: UpdateUserBirthday :one
|
-- name: UpdateUserBirthday :one
|
||||||
|
|
@ -173,14 +175,14 @@ SET birthday_day = sqlc.arg(birthday_day)::int,
|
||||||
birthday_month = sqlc.arg(birthday_month)::int,
|
birthday_month = sqlc.arg(birthday_month)::int,
|
||||||
birthday_year = sqlc.arg(birthday_year)::int,
|
birthday_year = sqlc.arg(birthday_year)::int,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = sqlc.arg(id)::bigint
|
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: UpdateUserPersonalChannel :one
|
-- name: UpdateUserPersonalChannel :one
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET personal_channel_id = sqlc.arg(personal_channel_id)::bigint,
|
SET personal_channel_id = sqlc.arg(personal_channel_id)::bigint,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = sqlc.arg(id)::bigint
|
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: UpdateUserColor :one
|
-- name: UpdateUserColor :one
|
||||||
|
|
@ -189,7 +191,7 @@ SET color_set = sqlc.arg(color_set)::boolean,
|
||||||
color = sqlc.arg(color)::int,
|
color = sqlc.arg(color)::int,
|
||||||
color_background_emoji_id = sqlc.arg(background_emoji_id)::bigint,
|
color_background_emoji_id = sqlc.arg(background_emoji_id)::bigint,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = sqlc.arg(id)::bigint
|
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: UpdateUserProfileColor :one
|
-- name: UpdateUserProfileColor :one
|
||||||
|
|
@ -198,5 +200,5 @@ SET profile_color_set = sqlc.arg(color_set)::boolean,
|
||||||
profile_color = sqlc.arg(color)::int,
|
profile_color = sqlc.arg(color)::int,
|
||||||
profile_color_background_emoji_id = sqlc.arg(background_emoji_id)::bigint,
|
profile_color_background_emoji_id = sqlc.arg(background_emoji_id)::bigint,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = sqlc.arg(id)::bigint
|
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
|
||||||
|
|
@ -167,7 +167,7 @@ func (q *Queries) InsertBot(ctx context.Context, arg InsertBotParams) error {
|
||||||
const insertBotUser = `-- name: InsertBotUser :one
|
const insertBotUser = `-- name: InsertBotUser :one
|
||||||
INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code, is_bot, bot_info_version)
|
INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code, is_bot, bot_info_version)
|
||||||
VALUES ($1, '', $2, '', $3, '', TRUE, 1)
|
VALUES ($1, '', $2, '', $3, '', TRUE, 1)
|
||||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id
|
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
|
||||||
`
|
`
|
||||||
|
|
||||||
type InsertBotUserParams struct {
|
type InsertBotUserParams struct {
|
||||||
|
|
@ -209,6 +209,10 @@ func (q *Queries) InsertBotUser(ctx context.Context, arg InsertBotUserParams) (U
|
||||||
&i.BirthdayMonth,
|
&i.BirthdayMonth,
|
||||||
&i.BirthdayYear,
|
&i.BirthdayYear,
|
||||||
&i.PersonalChannelID,
|
&i.PersonalChannelID,
|
||||||
|
&i.DeletedAt,
|
||||||
|
&i.DeletionSource,
|
||||||
|
&i.DeletionReason,
|
||||||
|
&i.AccountDeleteAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,32 @@ import (
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type AccountDeletionNotification struct {
|
||||||
|
ID int64
|
||||||
|
TargetUserID int64
|
||||||
|
DeletedUserID int64
|
||||||
|
Status string
|
||||||
|
Attempts int32
|
||||||
|
NextAttemptAt pgtype.Timestamptz
|
||||||
|
LeaseUntil pgtype.Timestamptz
|
||||||
|
LastError string
|
||||||
|
CreatedAt pgtype.Timestamptz
|
||||||
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
|
type AccountDeletionRequest struct {
|
||||||
|
ID int64
|
||||||
|
UserID int64
|
||||||
|
RequesterAuthKeyID int64
|
||||||
|
State string
|
||||||
|
Reason string
|
||||||
|
ConfirmHashDigest []byte
|
||||||
|
RequestedAt pgtype.Timestamptz
|
||||||
|
ExecuteAt pgtype.Timestamptz
|
||||||
|
CompletedAt pgtype.Timestamptz
|
||||||
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
type AccountPassword struct {
|
type AccountPassword struct {
|
||||||
UserID int64
|
UserID int64
|
||||||
HasRecovery bool
|
HasRecovery bool
|
||||||
|
|
@ -30,6 +56,7 @@ type AccountPassword struct {
|
||||||
RecoveryCode string
|
RecoveryCode string
|
||||||
RecoveryCodeExpiresAt pgtype.Timestamptz
|
RecoveryCodeExpiresAt pgtype.Timestamptz
|
||||||
LoginEmail string
|
LoginEmail string
|
||||||
|
PasswordChangedAt pgtype.Timestamptz
|
||||||
}
|
}
|
||||||
|
|
||||||
type AccountPrivacyRule struct {
|
type AccountPrivacyRule struct {
|
||||||
|
|
@ -694,6 +721,42 @@ type ChannelMessageViewer struct {
|
||||||
CreatedAt pgtype.Timestamptz
|
CreatedAt pgtype.Timestamptz
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ChannelStarsBalance struct {
|
||||||
|
ChannelID int64
|
||||||
|
Balance int64
|
||||||
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChannelStarsTransaction struct {
|
||||||
|
ID int64
|
||||||
|
ChannelID int64
|
||||||
|
ActorUserID int64
|
||||||
|
Amount int64
|
||||||
|
Reason string
|
||||||
|
PeerType string
|
||||||
|
PeerID int64
|
||||||
|
GiftID *int64
|
||||||
|
Date int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChannelTonBalance struct {
|
||||||
|
ChannelID int64
|
||||||
|
BalanceNanoton int64
|
||||||
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChannelTonTransaction struct {
|
||||||
|
ID int64
|
||||||
|
ChannelID int64
|
||||||
|
ActorUserID int64
|
||||||
|
AmountNanoton int64
|
||||||
|
Reason string
|
||||||
|
PeerType string
|
||||||
|
PeerID int64
|
||||||
|
GiftID *int64
|
||||||
|
Date int32
|
||||||
|
}
|
||||||
|
|
||||||
type ChannelTopicRead struct {
|
type ChannelTopicRead struct {
|
||||||
ChannelID int64
|
ChannelID int64
|
||||||
UserID int64
|
UserID int64
|
||||||
|
|
@ -1156,19 +1219,33 @@ type PasskeyCredential struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type PeerStarGift struct {
|
type PeerStarGift struct {
|
||||||
ID int64
|
ID int64
|
||||||
OwnerPeerID int64
|
OwnerPeerID int64
|
||||||
FromUserID int64
|
FromUserID int64
|
||||||
GiftID int64
|
GiftID int64
|
||||||
MsgID int32
|
MsgID int32
|
||||||
GiftDate int32
|
GiftDate int32
|
||||||
NameHidden bool
|
NameHidden bool
|
||||||
Unsaved bool
|
Unsaved bool
|
||||||
Converted bool
|
Converted bool
|
||||||
ConvertStars int64
|
ConvertStars int64
|
||||||
Message string
|
Message string
|
||||||
OwnerPeerType string
|
OwnerPeerType string
|
||||||
SavedID int64
|
SavedID int64
|
||||||
|
CatalogRevisionID int64
|
||||||
|
UniqueGiftID *int64
|
||||||
|
UpgradeMsgID int32
|
||||||
|
PinnedOrder int32
|
||||||
|
PrepaidUpgradeStars int64
|
||||||
|
LifecycleStatus string
|
||||||
|
TransferStars int64
|
||||||
|
PrepaidUpgradeHash string
|
||||||
|
GiftNum int32
|
||||||
|
CanExportAt int32
|
||||||
|
CanTransferAt int32
|
||||||
|
CanResellAt int32
|
||||||
|
DropOriginalDetailsStars int64
|
||||||
|
CanCraftAt int32
|
||||||
}
|
}
|
||||||
|
|
||||||
type PeerTranslationSetting struct {
|
type PeerTranslationSetting struct {
|
||||||
|
|
@ -1417,6 +1494,382 @@ type SeedState struct {
|
||||||
UpdatedAt pgtype.Timestamptz
|
UpdatedAt pgtype.Timestamptz
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StarGiftAuction struct {
|
||||||
|
GiftID int64
|
||||||
|
Slug string
|
||||||
|
Version int32
|
||||||
|
StartDate int32
|
||||||
|
EndDate int32
|
||||||
|
RoundDuration int32
|
||||||
|
GiftsPerRound int32
|
||||||
|
TotalRounds int32
|
||||||
|
CurrentRound int32
|
||||||
|
NextRoundAt int32
|
||||||
|
LastGiftNum int32
|
||||||
|
GiftsLeft int32
|
||||||
|
MinBidAmount int64
|
||||||
|
Status string
|
||||||
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
|
type StarGiftAuctionAcquired struct {
|
||||||
|
ID int64
|
||||||
|
GiftID int64
|
||||||
|
BidderUserID int64
|
||||||
|
RecipientPeerType string
|
||||||
|
RecipientPeerID int64
|
||||||
|
SavedGiftID *int64
|
||||||
|
BidAmount int64
|
||||||
|
Round int32
|
||||||
|
Pos int32
|
||||||
|
GiftNum *int32
|
||||||
|
AcquiredAt int32
|
||||||
|
HideName bool
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
type StarGiftAuctionBid struct {
|
||||||
|
GiftID int64
|
||||||
|
BidderUserID int64
|
||||||
|
RecipientPeerType string
|
||||||
|
RecipientPeerID int64
|
||||||
|
Amount int64
|
||||||
|
BidDate int32
|
||||||
|
HideName bool
|
||||||
|
Message string
|
||||||
|
Returned bool
|
||||||
|
AcquiredCount int32
|
||||||
|
Active bool
|
||||||
|
Version int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type StarGiftAuctionBidPayment struct {
|
||||||
|
UserID int64
|
||||||
|
FormID int64
|
||||||
|
GiftID int64
|
||||||
|
BidAmount int64
|
||||||
|
BalanceAfter int64
|
||||||
|
CreatedAt int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type StarGiftCatalog struct {
|
||||||
|
GiftID int64
|
||||||
|
ActiveRevisionID int64
|
||||||
|
Enabled bool
|
||||||
|
SortOrder int32
|
||||||
|
CreatedAt pgtype.Timestamptz
|
||||||
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
CollectibleRevisionID *int64
|
||||||
|
AvailabilityRemains int32
|
||||||
|
AvailabilityResale int64
|
||||||
|
ResellMinStars int64
|
||||||
|
FirstSaleDate int32
|
||||||
|
LastSaleDate int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type StarGiftCatalogRevision struct {
|
||||||
|
ID int64
|
||||||
|
GiftID int64
|
||||||
|
Revision int32
|
||||||
|
Title string
|
||||||
|
Stars int64
|
||||||
|
ConvertStars int64
|
||||||
|
DocumentID int64
|
||||||
|
AnimationJson []byte
|
||||||
|
AnimationSha256 []byte
|
||||||
|
SourceName string
|
||||||
|
SourceFormat string
|
||||||
|
Width int32
|
||||||
|
Height int32
|
||||||
|
FrameRate float64
|
||||||
|
InPoint float64
|
||||||
|
OutPoint float64
|
||||||
|
CreatedBy string
|
||||||
|
CommandID string
|
||||||
|
CreatedAt pgtype.Timestamptz
|
||||||
|
OfficialGiftID *int64
|
||||||
|
SourceManifestSha256 []byte
|
||||||
|
OfficialSource []byte
|
||||||
|
Limited bool
|
||||||
|
SoldOut bool
|
||||||
|
Birthday bool
|
||||||
|
RequirePremium bool
|
||||||
|
LimitedPerUser bool
|
||||||
|
PeerColorAvailable bool
|
||||||
|
Auction bool
|
||||||
|
AvailabilityTotal int32
|
||||||
|
ReleasedByPeerType *string
|
||||||
|
ReleasedByPeerID *int64
|
||||||
|
PerUserTotal int32
|
||||||
|
LockedUntilDate int32
|
||||||
|
AuctionSlug string
|
||||||
|
GiftsPerRound int32
|
||||||
|
AuctionStartDate int32
|
||||||
|
UpgradeVariants int32
|
||||||
|
BackgroundCenterColor *int32
|
||||||
|
BackgroundEdgeColor *int32
|
||||||
|
BackgroundTextColor *int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type StarGiftCollectibleBackdrop struct {
|
||||||
|
ID int64
|
||||||
|
CollectibleRevisionID int64
|
||||||
|
Name string
|
||||||
|
BackdropID int32
|
||||||
|
CenterColor int32
|
||||||
|
EdgeColor int32
|
||||||
|
PatternColor int32
|
||||||
|
TextColor int32
|
||||||
|
RarityPermille *int32
|
||||||
|
SortOrder int32
|
||||||
|
RarityKind string
|
||||||
|
}
|
||||||
|
|
||||||
|
type StarGiftCollectibleModel struct {
|
||||||
|
ID int64
|
||||||
|
CollectibleRevisionID int64
|
||||||
|
Name string
|
||||||
|
DocumentID int64
|
||||||
|
AnimationJson []byte
|
||||||
|
AnimationSha256 []byte
|
||||||
|
SourceName string
|
||||||
|
SourceFormat string
|
||||||
|
Width int32
|
||||||
|
Height int32
|
||||||
|
FrameRate float64
|
||||||
|
InPoint float64
|
||||||
|
OutPoint float64
|
||||||
|
RarityPermille *int32
|
||||||
|
SortOrder int32
|
||||||
|
RarityKind string
|
||||||
|
Crafted bool
|
||||||
|
OfficialDocumentID *int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type StarGiftCollectiblePattern struct {
|
||||||
|
ID int64
|
||||||
|
CollectibleRevisionID int64
|
||||||
|
Name string
|
||||||
|
DocumentID int64
|
||||||
|
AnimationJson []byte
|
||||||
|
AnimationSha256 []byte
|
||||||
|
SourceName string
|
||||||
|
SourceFormat string
|
||||||
|
Width int32
|
||||||
|
Height int32
|
||||||
|
FrameRate float64
|
||||||
|
InPoint float64
|
||||||
|
OutPoint float64
|
||||||
|
RarityPermille *int32
|
||||||
|
SortOrder int32
|
||||||
|
RarityKind string
|
||||||
|
OfficialDocumentID *int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type StarGiftCollectibleRevision struct {
|
||||||
|
ID int64
|
||||||
|
GiftID int64
|
||||||
|
Revision int32
|
||||||
|
UpgradeStars int64
|
||||||
|
SupplyTotal int32
|
||||||
|
Issued int32
|
||||||
|
SlugPrefix string
|
||||||
|
Status string
|
||||||
|
CreatedBy string
|
||||||
|
CommandID string
|
||||||
|
CreatedAt pgtype.Timestamptz
|
||||||
|
PublishedAt pgtype.Timestamptz
|
||||||
|
OfficialGiftID *int64
|
||||||
|
SourceManifestSha256 []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type StarGiftCollection struct {
|
||||||
|
CollectionID int32
|
||||||
|
OwnerPeerType string
|
||||||
|
OwnerPeerID int64
|
||||||
|
Title string
|
||||||
|
SortOrder int32
|
||||||
|
Hash int64
|
||||||
|
CreatedAt pgtype.Timestamptz
|
||||||
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
|
type StarGiftCollectionItem struct {
|
||||||
|
CollectionID int32
|
||||||
|
SavedGiftID int64
|
||||||
|
SortOrder int32
|
||||||
|
CreatedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
|
type StarGiftConversion struct {
|
||||||
|
SavedGiftID int64
|
||||||
|
ActorUserID int64
|
||||||
|
OwnerPeerType string
|
||||||
|
OwnerPeerID int64
|
||||||
|
Amount int64
|
||||||
|
BalanceAfter int64
|
||||||
|
ConvertedAt int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type StarGiftCraftCommand struct {
|
||||||
|
UserID int64
|
||||||
|
CommandKey string
|
||||||
|
InputUniqueGiftIds []int64
|
||||||
|
GiftID int64
|
||||||
|
Success bool
|
||||||
|
ResultUniqueGiftID *int64
|
||||||
|
ChancePermille int32
|
||||||
|
CreatedAt int32
|
||||||
|
SourceEditPts []int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type StarGiftDropDetailsCommand struct {
|
||||||
|
UserID int64
|
||||||
|
CommandKey string
|
||||||
|
SavedGiftID int64
|
||||||
|
UniqueGiftID int64
|
||||||
|
FormID int64
|
||||||
|
ChargeStars int64
|
||||||
|
BalanceAfter int64
|
||||||
|
CreatedAt int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type StarGiftListing struct {
|
||||||
|
UniqueGiftID int64
|
||||||
|
SellerPeerType string
|
||||||
|
SellerPeerID int64
|
||||||
|
Currency string
|
||||||
|
Amount int64
|
||||||
|
ListedAt int32
|
||||||
|
UpdatedAt int32
|
||||||
|
Version int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type StarGiftNotificationSetting struct {
|
||||||
|
UserID int64
|
||||||
|
ChannelID int64
|
||||||
|
Enabled bool
|
||||||
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
|
type StarGiftOffer struct {
|
||||||
|
ID int64
|
||||||
|
BuyerUserID int64
|
||||||
|
OwnerPeerType string
|
||||||
|
OwnerPeerID int64
|
||||||
|
UniqueGiftID int64
|
||||||
|
Currency string
|
||||||
|
Amount int64
|
||||||
|
RandomID int64
|
||||||
|
OfferMsgID int32
|
||||||
|
BuyerMsgID int32
|
||||||
|
Status string
|
||||||
|
CreatedAt int32
|
||||||
|
ExpiresAt int32
|
||||||
|
ResolvedAt int32
|
||||||
|
BalanceAfter int64
|
||||||
|
ResolutionNotified bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type StarGiftPrepaidUpgradeCommand struct {
|
||||||
|
PayerUserID int64
|
||||||
|
CommandKey string
|
||||||
|
SavedGiftID int64
|
||||||
|
FormID int64
|
||||||
|
ChargeStars int64
|
||||||
|
BalanceAfter int64
|
||||||
|
CreatedAt int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type StarGiftPurchaseCommand struct {
|
||||||
|
BuyerUserID int64
|
||||||
|
CommandKey string
|
||||||
|
GiftID int64
|
||||||
|
RecipientPeerType string
|
||||||
|
RecipientPeerID int64
|
||||||
|
SavedGiftID int64
|
||||||
|
FormID int64
|
||||||
|
ChargeStars int64
|
||||||
|
BalanceAfter int64
|
||||||
|
CreatedAt int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type StarGiftPurchaseForm struct {
|
||||||
|
BuyerUserID int64
|
||||||
|
FormID int64
|
||||||
|
GiftID int64
|
||||||
|
RevisionID int64
|
||||||
|
RecipientPeerType string
|
||||||
|
RecipientPeerID int64
|
||||||
|
IncludeUpgrade bool
|
||||||
|
HideName bool
|
||||||
|
Message string
|
||||||
|
ChargeStars int64
|
||||||
|
IssuedAt int32
|
||||||
|
ExpiresAt int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type StarGiftSale struct {
|
||||||
|
ID int64
|
||||||
|
UniqueGiftID int64
|
||||||
|
SellerPeerType string
|
||||||
|
SellerPeerID int64
|
||||||
|
BuyerPeerType string
|
||||||
|
BuyerPeerID int64
|
||||||
|
Currency string
|
||||||
|
Amount int64
|
||||||
|
CommissionAmount int64
|
||||||
|
SoldAt int32
|
||||||
|
CommandKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
type StarGiftTransferCommand struct {
|
||||||
|
ActorUserID int64
|
||||||
|
CommandKey string
|
||||||
|
UniqueGiftID int64
|
||||||
|
FromPeerType string
|
||||||
|
FromPeerID int64
|
||||||
|
ToPeerType string
|
||||||
|
ToPeerID int64
|
||||||
|
ChargeStars int64
|
||||||
|
BalanceAfter int64
|
||||||
|
CreatedAt int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type StarGiftUpgradeCommand struct {
|
||||||
|
UserID int64
|
||||||
|
CommandKey string
|
||||||
|
SourceSavedGiftID int64
|
||||||
|
FormID int64
|
||||||
|
UniqueGiftID int64
|
||||||
|
BalanceAfter int64
|
||||||
|
CreatedAt pgtype.Timestamptz
|
||||||
|
ChargeStars int64
|
||||||
|
RequirePrepaid bool
|
||||||
|
KeepOriginalDetails bool
|
||||||
|
SourceEditPts int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type StarGiftUserPurchase struct {
|
||||||
|
UserID int64
|
||||||
|
GiftID int64
|
||||||
|
PurchasedCount int32
|
||||||
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
|
type StarGiftWithdrawalRequest struct {
|
||||||
|
ID int64
|
||||||
|
UniqueGiftID int64
|
||||||
|
OwnerUserID int64
|
||||||
|
Provider string
|
||||||
|
ProviderRequestID string
|
||||||
|
Url string
|
||||||
|
Status string
|
||||||
|
CreatedAt int32
|
||||||
|
ExpiresAt int32
|
||||||
|
CompletedAt int32
|
||||||
|
}
|
||||||
|
|
||||||
type StarsBalance struct {
|
type StarsBalance struct {
|
||||||
UserID int64
|
UserID int64
|
||||||
Balance int64
|
Balance int64
|
||||||
|
|
@ -1564,6 +2017,66 @@ type ThemeUserInstall struct {
|
||||||
InstalledAt pgtype.Timestamptz
|
InstalledAt pgtype.Timestamptz
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TonBalance struct {
|
||||||
|
UserID int64
|
||||||
|
BalanceNanoton int64
|
||||||
|
Granted bool
|
||||||
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
|
type TonTransaction struct {
|
||||||
|
ID int64
|
||||||
|
UserID int64
|
||||||
|
AmountNanoton int64
|
||||||
|
Reason string
|
||||||
|
PeerType *string
|
||||||
|
PeerID *int64
|
||||||
|
GiftID *int64
|
||||||
|
Date int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type UniqueStarGift struct {
|
||||||
|
ID int64
|
||||||
|
GiftID int64
|
||||||
|
CollectibleRevisionID int64
|
||||||
|
SourceSavedGiftID int64
|
||||||
|
Title string
|
||||||
|
Slug string
|
||||||
|
Num int32
|
||||||
|
OwnerPeerType *string
|
||||||
|
OwnerPeerID *int64
|
||||||
|
ModelAttributeID int64
|
||||||
|
PatternAttributeID int64
|
||||||
|
BackdropAttributeID int64
|
||||||
|
KeepOriginalDetails bool
|
||||||
|
CreatedAt pgtype.Timestamptz
|
||||||
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
RequirePremium bool
|
||||||
|
ResaleTonOnly bool
|
||||||
|
ThemeAvailable bool
|
||||||
|
Burned bool
|
||||||
|
Crafted bool
|
||||||
|
OriginalOwnerPeerType string
|
||||||
|
OriginalOwnerPeerID int64
|
||||||
|
OwnerName string
|
||||||
|
OwnerAddress string
|
||||||
|
GiftAddress string
|
||||||
|
ReleasedByPeerType *string
|
||||||
|
ReleasedByPeerID *int64
|
||||||
|
ValueAmount int64
|
||||||
|
ValueCurrency string
|
||||||
|
ValueUsdAmount int64
|
||||||
|
ThemePeerType *string
|
||||||
|
ThemePeerID *int64
|
||||||
|
HostPeerType *string
|
||||||
|
HostPeerID *int64
|
||||||
|
OfferMinStars int32
|
||||||
|
CraftChancePermille int32
|
||||||
|
LastSaleDate int32
|
||||||
|
LastSaleCurrency string
|
||||||
|
LastSaleAmount int64
|
||||||
|
}
|
||||||
|
|
||||||
type UpdateState struct {
|
type UpdateState struct {
|
||||||
AuthKeyID int64
|
AuthKeyID int64
|
||||||
Pts int32
|
Pts int32
|
||||||
|
|
@ -1627,6 +2140,10 @@ type User struct {
|
||||||
BirthdayMonth int32
|
BirthdayMonth int32
|
||||||
BirthdayYear int32
|
BirthdayYear int32
|
||||||
PersonalChannelID int64
|
PersonalChannelID int64
|
||||||
|
DeletedAt pgtype.Timestamptz
|
||||||
|
DeletionSource string
|
||||||
|
DeletionReason string
|
||||||
|
AccountDeleteAt pgtype.Timestamptz
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserBusinessProfile struct {
|
type UserBusinessProfile struct {
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import (
|
||||||
const createUser = `-- name: CreateUser :one
|
const createUser = `-- name: CreateUser :one
|
||||||
INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code, premium_expires_at)
|
INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code, premium_expires_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id
|
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateUserParams struct {
|
type CreateUserParams struct {
|
||||||
|
|
@ -68,12 +68,16 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
|
||||||
&i.BirthdayMonth,
|
&i.BirthdayMonth,
|
||||||
&i.BirthdayYear,
|
&i.BirthdayYear,
|
||||||
&i.PersonalChannelID,
|
&i.PersonalChannelID,
|
||||||
|
&i.DeletedAt,
|
||||||
|
&i.DeletionSource,
|
||||||
|
&i.DeletionReason,
|
||||||
|
&i.AccountDeleteAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getUserByID = `-- name: GetUserByID :one
|
const getUserByID = `-- name: GetUserByID :one
|
||||||
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id FROM users WHERE id = $1
|
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at FROM users WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
|
func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
|
||||||
|
|
@ -109,12 +113,16 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
|
||||||
&i.BirthdayMonth,
|
&i.BirthdayMonth,
|
||||||
&i.BirthdayYear,
|
&i.BirthdayYear,
|
||||||
&i.PersonalChannelID,
|
&i.PersonalChannelID,
|
||||||
|
&i.DeletedAt,
|
||||||
|
&i.DeletionSource,
|
||||||
|
&i.DeletionReason,
|
||||||
|
&i.AccountDeleteAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getUserByPhone = `-- name: GetUserByPhone :one
|
const getUserByPhone = `-- name: GetUserByPhone :one
|
||||||
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id FROM users WHERE phone = $1
|
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at FROM users WHERE phone = $1 AND deleted_at IS NULL
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetUserByPhone(ctx context.Context, phone string) (User, error) {
|
func (q *Queries) GetUserByPhone(ctx context.Context, phone string) (User, error) {
|
||||||
|
|
@ -150,12 +158,16 @@ func (q *Queries) GetUserByPhone(ctx context.Context, phone string) (User, error
|
||||||
&i.BirthdayMonth,
|
&i.BirthdayMonth,
|
||||||
&i.BirthdayYear,
|
&i.BirthdayYear,
|
||||||
&i.PersonalChannelID,
|
&i.PersonalChannelID,
|
||||||
|
&i.DeletedAt,
|
||||||
|
&i.DeletionSource,
|
||||||
|
&i.DeletionReason,
|
||||||
|
&i.AccountDeleteAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getUserByUsername = `-- name: GetUserByUsername :one
|
const getUserByUsername = `-- name: GetUserByUsername :one
|
||||||
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id FROM users WHERE lower(username) = lower($1) AND username <> ''
|
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at FROM users WHERE lower(username) = lower($1) AND username <> '' AND deleted_at IS NULL
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetUserByUsername(ctx context.Context, lower string) (User, error) {
|
func (q *Queries) GetUserByUsername(ctx context.Context, lower string) (User, error) {
|
||||||
|
|
@ -191,12 +203,16 @@ func (q *Queries) GetUserByUsername(ctx context.Context, lower string) (User, er
|
||||||
&i.BirthdayMonth,
|
&i.BirthdayMonth,
|
||||||
&i.BirthdayYear,
|
&i.BirthdayYear,
|
||||||
&i.PersonalChannelID,
|
&i.PersonalChannelID,
|
||||||
|
&i.DeletedAt,
|
||||||
|
&i.DeletionSource,
|
||||||
|
&i.DeletionReason,
|
||||||
|
&i.AccountDeleteAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getUsersByIDs = `-- name: GetUsersByIDs :many
|
const getUsersByIDs = `-- name: GetUsersByIDs :many
|
||||||
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id
|
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
|
||||||
FROM users
|
FROM users
|
||||||
WHERE id = ANY($1::bigint[])
|
WHERE id = ANY($1::bigint[])
|
||||||
ORDER BY id
|
ORDER BY id
|
||||||
|
|
@ -241,6 +257,10 @@ func (q *Queries) GetUsersByIDs(ctx context.Context, ids []int64) ([]User, error
|
||||||
&i.BirthdayMonth,
|
&i.BirthdayMonth,
|
||||||
&i.BirthdayYear,
|
&i.BirthdayYear,
|
||||||
&i.PersonalChannelID,
|
&i.PersonalChannelID,
|
||||||
|
&i.DeletedAt,
|
||||||
|
&i.DeletionSource,
|
||||||
|
&i.DeletionReason,
|
||||||
|
&i.AccountDeleteAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -253,9 +273,9 @@ func (q *Queries) GetUsersByIDs(ctx context.Context, ids []int64) ([]User, error
|
||||||
}
|
}
|
||||||
|
|
||||||
const getUsersByPhones = `-- name: GetUsersByPhones :many
|
const getUsersByPhones = `-- name: GetUsersByPhones :many
|
||||||
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id
|
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
|
||||||
FROM users
|
FROM users
|
||||||
WHERE phone = ANY($1::text[])
|
WHERE phone = ANY($1::text[]) AND deleted_at IS NULL
|
||||||
ORDER BY id
|
ORDER BY id
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|
@ -298,6 +318,10 @@ func (q *Queries) GetUsersByPhones(ctx context.Context, phones []string) ([]User
|
||||||
&i.BirthdayMonth,
|
&i.BirthdayMonth,
|
||||||
&i.BirthdayYear,
|
&i.BirthdayYear,
|
||||||
&i.PersonalChannelID,
|
&i.PersonalChannelID,
|
||||||
|
&i.DeletedAt,
|
||||||
|
&i.DeletionSource,
|
||||||
|
&i.DeletionReason,
|
||||||
|
&i.AccountDeleteAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -347,6 +371,7 @@ WITH matched AS (
|
||||||
FROM users u
|
FROM users u
|
||||||
LEFT JOIN contacts c ON c.user_id = $4::bigint AND c.contact_user_id = u.id
|
LEFT JOIN contacts c ON c.user_id = $4::bigint AND c.contact_user_id = u.id
|
||||||
WHERE u.id <> $4::bigint
|
WHERE u.id <> $4::bigint
|
||||||
|
AND u.deleted_at IS NULL
|
||||||
AND $3::text <> ''
|
AND $3::text <> ''
|
||||||
AND (
|
AND (
|
||||||
($2::text <> '' AND u.phone LIKE $2::text || '%')
|
($2::text <> '' AND u.phone LIKE $2::text || '%')
|
||||||
|
|
@ -479,8 +504,8 @@ const setUserPremiumUntil = `-- name: SetUserPremiumUntil :one
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET premium_expires_at = $1::timestamptz,
|
SET premium_expires_at = $1::timestamptz,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = $2::bigint
|
WHERE id = $2::bigint AND deleted_at IS NULL
|
||||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id
|
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
|
||||||
`
|
`
|
||||||
|
|
||||||
type SetUserPremiumUntilParams struct {
|
type SetUserPremiumUntilParams struct {
|
||||||
|
|
@ -521,6 +546,10 @@ func (q *Queries) SetUserPremiumUntil(ctx context.Context, arg SetUserPremiumUnt
|
||||||
&i.BirthdayMonth,
|
&i.BirthdayMonth,
|
||||||
&i.BirthdayYear,
|
&i.BirthdayYear,
|
||||||
&i.PersonalChannelID,
|
&i.PersonalChannelID,
|
||||||
|
&i.DeletedAt,
|
||||||
|
&i.DeletionSource,
|
||||||
|
&i.DeletionReason,
|
||||||
|
&i.AccountDeleteAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
@ -529,8 +558,8 @@ const setUserVerified = `-- name: SetUserVerified :one
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET verified = $1::boolean,
|
SET verified = $1::boolean,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = $2::bigint
|
WHERE id = $2::bigint AND deleted_at IS NULL
|
||||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id
|
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
|
||||||
`
|
`
|
||||||
|
|
||||||
type SetUserVerifiedParams struct {
|
type SetUserVerifiedParams struct {
|
||||||
|
|
@ -571,6 +600,10 @@ func (q *Queries) SetUserVerified(ctx context.Context, arg SetUserVerifiedParams
|
||||||
&i.BirthdayMonth,
|
&i.BirthdayMonth,
|
||||||
&i.BirthdayYear,
|
&i.BirthdayYear,
|
||||||
&i.PersonalChannelID,
|
&i.PersonalChannelID,
|
||||||
|
&i.DeletedAt,
|
||||||
|
&i.DeletionSource,
|
||||||
|
&i.DeletionReason,
|
||||||
|
&i.AccountDeleteAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
@ -582,11 +615,12 @@ SET premium_expires_at = NULL,
|
||||||
WHERE id IN (
|
WHERE id IN (
|
||||||
SELECT id FROM users
|
SELECT id FROM users
|
||||||
WHERE premium_expires_at IS NOT NULL
|
WHERE premium_expires_at IS NOT NULL
|
||||||
|
AND deleted_at IS NULL
|
||||||
AND premium_expires_at <= $1::timestamptz
|
AND premium_expires_at <= $1::timestamptz
|
||||||
ORDER BY premium_expires_at
|
ORDER BY premium_expires_at
|
||||||
LIMIT $2::int
|
LIMIT $2::int
|
||||||
)
|
)
|
||||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id
|
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
|
||||||
`
|
`
|
||||||
|
|
||||||
type SweepExpiredPremiumParams struct {
|
type SweepExpiredPremiumParams struct {
|
||||||
|
|
@ -633,6 +667,10 @@ func (q *Queries) SweepExpiredPremium(ctx context.Context, arg SweepExpiredPremi
|
||||||
&i.BirthdayMonth,
|
&i.BirthdayMonth,
|
||||||
&i.BirthdayYear,
|
&i.BirthdayYear,
|
||||||
&i.PersonalChannelID,
|
&i.PersonalChannelID,
|
||||||
|
&i.DeletedAt,
|
||||||
|
&i.DeletionSource,
|
||||||
|
&i.DeletionReason,
|
||||||
|
&i.AccountDeleteAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -650,8 +688,8 @@ SET birthday_day = $1::int,
|
||||||
birthday_month = $2::int,
|
birthday_month = $2::int,
|
||||||
birthday_year = $3::int,
|
birthday_year = $3::int,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = $4::bigint
|
WHERE id = $4::bigint AND deleted_at IS NULL
|
||||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id
|
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateUserBirthdayParams struct {
|
type UpdateUserBirthdayParams struct {
|
||||||
|
|
@ -699,6 +737,10 @@ func (q *Queries) UpdateUserBirthday(ctx context.Context, arg UpdateUserBirthday
|
||||||
&i.BirthdayMonth,
|
&i.BirthdayMonth,
|
||||||
&i.BirthdayYear,
|
&i.BirthdayYear,
|
||||||
&i.PersonalChannelID,
|
&i.PersonalChannelID,
|
||||||
|
&i.DeletedAt,
|
||||||
|
&i.DeletionSource,
|
||||||
|
&i.DeletionReason,
|
||||||
|
&i.AccountDeleteAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
@ -709,8 +751,8 @@ SET color_set = $1::boolean,
|
||||||
color = $2::int,
|
color = $2::int,
|
||||||
color_background_emoji_id = $3::bigint,
|
color_background_emoji_id = $3::bigint,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = $4::bigint
|
WHERE id = $4::bigint AND deleted_at IS NULL
|
||||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id
|
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateUserColorParams struct {
|
type UpdateUserColorParams struct {
|
||||||
|
|
@ -758,6 +800,10 @@ func (q *Queries) UpdateUserColor(ctx context.Context, arg UpdateUserColorParams
|
||||||
&i.BirthdayMonth,
|
&i.BirthdayMonth,
|
||||||
&i.BirthdayYear,
|
&i.BirthdayYear,
|
||||||
&i.PersonalChannelID,
|
&i.PersonalChannelID,
|
||||||
|
&i.DeletedAt,
|
||||||
|
&i.DeletionSource,
|
||||||
|
&i.DeletionReason,
|
||||||
|
&i.AccountDeleteAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
@ -767,8 +813,8 @@ UPDATE users
|
||||||
SET emoji_status_document_id = $1::bigint,
|
SET emoji_status_document_id = $1::bigint,
|
||||||
emoji_status_until = $2::bigint,
|
emoji_status_until = $2::bigint,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = $3::bigint
|
WHERE id = $3::bigint AND deleted_at IS NULL
|
||||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id
|
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateUserEmojiStatusParams struct {
|
type UpdateUserEmojiStatusParams struct {
|
||||||
|
|
@ -810,6 +856,10 @@ func (q *Queries) UpdateUserEmojiStatus(ctx context.Context, arg UpdateUserEmoji
|
||||||
&i.BirthdayMonth,
|
&i.BirthdayMonth,
|
||||||
&i.BirthdayYear,
|
&i.BirthdayYear,
|
||||||
&i.PersonalChannelID,
|
&i.PersonalChannelID,
|
||||||
|
&i.DeletedAt,
|
||||||
|
&i.DeletionSource,
|
||||||
|
&i.DeletionReason,
|
||||||
|
&i.AccountDeleteAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
@ -818,7 +868,7 @@ const updateUserLastSeen = `-- name: UpdateUserLastSeen :exec
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET last_seen_at = GREATEST(last_seen_at, $1::bigint),
|
SET last_seen_at = GREATEST(last_seen_at, $1::bigint),
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = $2::bigint
|
WHERE id = $2::bigint AND deleted_at IS NULL
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateUserLastSeenParams struct {
|
type UpdateUserLastSeenParams struct {
|
||||||
|
|
@ -835,8 +885,8 @@ const updateUserPersonalChannel = `-- name: UpdateUserPersonalChannel :one
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET personal_channel_id = $1::bigint,
|
SET personal_channel_id = $1::bigint,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = $2::bigint
|
WHERE id = $2::bigint AND deleted_at IS NULL
|
||||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id
|
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateUserPersonalChannelParams struct {
|
type UpdateUserPersonalChannelParams struct {
|
||||||
|
|
@ -877,6 +927,10 @@ func (q *Queries) UpdateUserPersonalChannel(ctx context.Context, arg UpdateUserP
|
||||||
&i.BirthdayMonth,
|
&i.BirthdayMonth,
|
||||||
&i.BirthdayYear,
|
&i.BirthdayYear,
|
||||||
&i.PersonalChannelID,
|
&i.PersonalChannelID,
|
||||||
|
&i.DeletedAt,
|
||||||
|
&i.DeletionSource,
|
||||||
|
&i.DeletionReason,
|
||||||
|
&i.AccountDeleteAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
@ -885,8 +939,8 @@ const updateUserPhone = `-- name: UpdateUserPhone :one
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET phone = $1::text,
|
SET phone = $1::text,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = $2::bigint
|
WHERE id = $2::bigint AND deleted_at IS NULL
|
||||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id
|
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateUserPhoneParams struct {
|
type UpdateUserPhoneParams struct {
|
||||||
|
|
@ -927,6 +981,10 @@ func (q *Queries) UpdateUserPhone(ctx context.Context, arg UpdateUserPhoneParams
|
||||||
&i.BirthdayMonth,
|
&i.BirthdayMonth,
|
||||||
&i.BirthdayYear,
|
&i.BirthdayYear,
|
||||||
&i.PersonalChannelID,
|
&i.PersonalChannelID,
|
||||||
|
&i.DeletedAt,
|
||||||
|
&i.DeletionSource,
|
||||||
|
&i.DeletionReason,
|
||||||
|
&i.AccountDeleteAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
@ -937,8 +995,8 @@ SET first_name = $2,
|
||||||
last_name = $3,
|
last_name = $3,
|
||||||
about = $4,
|
about = $4,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = $1
|
WHERE id = $1 AND deleted_at IS NULL
|
||||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id
|
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateUserProfileParams struct {
|
type UpdateUserProfileParams struct {
|
||||||
|
|
@ -986,6 +1044,10 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa
|
||||||
&i.BirthdayMonth,
|
&i.BirthdayMonth,
|
||||||
&i.BirthdayYear,
|
&i.BirthdayYear,
|
||||||
&i.PersonalChannelID,
|
&i.PersonalChannelID,
|
||||||
|
&i.DeletedAt,
|
||||||
|
&i.DeletionSource,
|
||||||
|
&i.DeletionReason,
|
||||||
|
&i.AccountDeleteAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
@ -996,8 +1058,8 @@ SET profile_color_set = $1::boolean,
|
||||||
profile_color = $2::int,
|
profile_color = $2::int,
|
||||||
profile_color_background_emoji_id = $3::bigint,
|
profile_color_background_emoji_id = $3::bigint,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = $4::bigint
|
WHERE id = $4::bigint AND deleted_at IS NULL
|
||||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id
|
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateUserProfileColorParams struct {
|
type UpdateUserProfileColorParams struct {
|
||||||
|
|
@ -1045,6 +1107,10 @@ func (q *Queries) UpdateUserProfileColor(ctx context.Context, arg UpdateUserProf
|
||||||
&i.BirthdayMonth,
|
&i.BirthdayMonth,
|
||||||
&i.BirthdayYear,
|
&i.BirthdayYear,
|
||||||
&i.PersonalChannelID,
|
&i.PersonalChannelID,
|
||||||
|
&i.DeletedAt,
|
||||||
|
&i.DeletionSource,
|
||||||
|
&i.DeletionReason,
|
||||||
|
&i.AccountDeleteAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
@ -1053,8 +1119,8 @@ const updateUserUsername = `-- name: UpdateUserUsername :one
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET username = $2,
|
SET username = $2,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = $1
|
WHERE id = $1 AND deleted_at IS NULL
|
||||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id
|
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateUserUsernameParams struct {
|
type UpdateUserUsernameParams struct {
|
||||||
|
|
@ -1095,6 +1161,10 @@ func (q *Queries) UpdateUserUsername(ctx context.Context, arg UpdateUserUsername
|
||||||
&i.BirthdayMonth,
|
&i.BirthdayMonth,
|
||||||
&i.BirthdayYear,
|
&i.BirthdayYear,
|
||||||
&i.PersonalChannelID,
|
&i.PersonalChannelID,
|
||||||
|
&i.DeletedAt,
|
||||||
|
&i.DeletionSource,
|
||||||
|
&i.DeletionReason,
|
||||||
|
&i.AccountDeleteAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ func TestStarGiftLifecycleMigrationsApply(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("migrate star gift lifecycle schema: %v", err)
|
t.Fatalf("migrate star gift lifecycle schema: %v", err)
|
||||||
}
|
}
|
||||||
if status.Dirty || status.Empty || status.Version != 106 {
|
if status.Dirty || status.Empty || status.Version != 107 {
|
||||||
t.Fatalf("migration status = %+v, want clean version 106", status)
|
t.Fatalf("migration status = %+v, want clean version 107", status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -200,7 +200,7 @@ func (s *UserStore) UpdateUsername(ctx context.Context, userID int64, username s
|
||||||
}()
|
}()
|
||||||
qtx := s.q.WithTx(tx)
|
qtx := s.q.WithTx(tx)
|
||||||
var lockedUserID int64
|
var lockedUserID int64
|
||||||
if err := tx.QueryRow(ctx, `SELECT id FROM users WHERE id = $1 FOR UPDATE`, userID).Scan(&lockedUserID); err != nil {
|
if err := tx.QueryRow(ctx, `SELECT id FROM users WHERE id = $1 AND deleted_at IS NULL FOR UPDATE`, userID).Scan(&lockedUserID); err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return domain.User{}, domain.ErrUsernameNotOccupied
|
return domain.User{}, domain.ErrUsernameNotOccupied
|
||||||
}
|
}
|
||||||
|
|
@ -444,7 +444,7 @@ func escapeLike(s string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func userFromModel(r sqlcgen.User) domain.User {
|
func userFromModel(r sqlcgen.User) domain.User {
|
||||||
return domain.User{
|
u := domain.User{
|
||||||
ID: r.ID,
|
ID: r.ID,
|
||||||
AccessHash: r.AccessHash,
|
AccessHash: r.AccessHash,
|
||||||
Phone: r.Phone,
|
Phone: r.Phone,
|
||||||
|
|
@ -465,7 +465,17 @@ func userFromModel(r sqlcgen.User) domain.User {
|
||||||
Color: peerColorFromModel(r.ColorSet, r.Color, r.ColorBackgroundEmojiID),
|
Color: peerColorFromModel(r.ColorSet, r.Color, r.ColorBackgroundEmojiID),
|
||||||
ProfileColor: peerColorFromModel(r.ProfileColorSet, r.ProfileColor, r.ProfileColorBackgroundEmojiID),
|
ProfileColor: peerColorFromModel(r.ProfileColorSet, r.ProfileColor, r.ProfileColorBackgroundEmojiID),
|
||||||
LastSeenAt: int(r.LastSeenAt),
|
LastSeenAt: int(r.LastSeenAt),
|
||||||
|
Deleted: r.DeletedAt.Valid,
|
||||||
|
DeletionSource: domain.AccountDeletionSource(r.DeletionSource),
|
||||||
|
DeletionReason: r.DeletionReason,
|
||||||
|
CreatedAt: r.CreatedAt.Time,
|
||||||
|
AccountDeleteAt: r.AccountDeleteAt.Time,
|
||||||
}
|
}
|
||||||
|
if r.DeletedAt.Valid {
|
||||||
|
u.DeletedAt = r.DeletedAt.Time.Unix()
|
||||||
|
return u.DeletedTombstone()
|
||||||
|
}
|
||||||
|
return u
|
||||||
}
|
}
|
||||||
|
|
||||||
func peerColorFromModel(hasColor bool, color int32, backgroundEmojiID int64) domain.PeerColor {
|
func peerColorFromModel(hasColor bool, color int32, backgroundEmojiID int64) domain.PeerColor {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue