From edb705775706fff93a281ba7549c9c0485f2fc01 Mon Sep 17 00:00:00 2001 From: A Date: Sun, 19 Jul 2026 20:35:58 +0800 Subject: [PATCH] feat: sync account deletion lifecycle Sync telesrv 73f5c91 (feat(account): implement unified account deletion lifecycle). Skipped telesrv docs changes per public sync rules. --- cmd/telesrv/main.go | 4 +- .../0107_account_lifecycle.down.sql | 25 + .../migrations/0107_account_lifecycle.up.sql | 216 +++++ internal/app/account/lifecycle.go | 360 +++++++++ internal/app/account/lifecycle_test.go | 149 ++++ internal/app/account/service.go | 9 + internal/app/userprojection/deleted_test.go | 19 + internal/app/userprojection/projection.go | 51 +- internal/domain/account.go | 9 +- internal/domain/account_deletion.go | 99 +++ internal/domain/user.go | 30 + internal/otpdelivery/delivery.go | 3 +- internal/rpc/account.go | 11 +- internal/rpc/account_deletion.go | 152 ++++ internal/rpc/account_deletion_rpc_test.go | 177 +++++ internal/rpc/account_lifecycle_worker.go | 99 +++ internal/rpc/auth.go | 25 + internal/rpc/auth_gate.go | 3 + internal/rpc/convert_users.go | 6 + internal/rpc/convert_users_deleted_test.go | 56 ++ internal/store/account_lifecycle.go | 22 + internal/store/code.go | 5 + internal/store/memory/users.go | 39 +- internal/store/postgres/account_lifecycle.go | 737 ++++++++++++++++++ .../account_lifecycle_integration_test.go | 289 +++++++ internal/store/postgres/history_ttl.go | 4 +- internal/store/postgres/queries/user.sql | 30 +- internal/store/postgres/sqlcgen/bot.sql.go | 6 +- internal/store/postgres/sqlcgen/models.go | 543 ++++++++++++- internal/store/postgres/sqlcgen/user.sql.go | 128 ++- ...ft_lifecycle_migration_integration_test.go | 4 +- internal/store/postgres/user.go | 14 +- 32 files changed, 3236 insertions(+), 88 deletions(-) create mode 100644 deploy/migrations/0107_account_lifecycle.down.sql create mode 100644 deploy/migrations/0107_account_lifecycle.up.sql create mode 100644 internal/app/account/lifecycle.go create mode 100644 internal/app/account/lifecycle_test.go create mode 100644 internal/app/userprojection/deleted_test.go create mode 100644 internal/domain/account_deletion.go create mode 100644 internal/rpc/account_deletion.go create mode 100644 internal/rpc/account_deletion_rpc_test.go create mode 100644 internal/rpc/account_lifecycle_worker.go create mode 100644 internal/rpc/convert_users_deleted_test.go create mode 100644 internal/store/account_lifecycle.go create mode 100644 internal/store/postgres/account_lifecycle.go create mode 100644 internal/store/postgres/account_lifecycle_integration_test.go diff --git a/cmd/telesrv/main.go b/cmd/telesrv/main.go index c9f793c5..003652dc 100644 --- a/cmd/telesrv/main.go +++ b/cmd/telesrv/main.go @@ -436,7 +436,7 @@ func run(logger *zap.Logger) error { return fmt.Errorf("seed appearance: %w", err) } else if !stats.Skipped { logger.Info("外观种子导入完成", - zap.String("source", "default-seed"), + zap.String("source", "orange-live"), zap.Int("wallpapers", stats.Wallpapers), zap.Int("documents", stats.Documents), zap.Int("blobs", stats.Blobs), @@ -526,6 +526,7 @@ func run(logger *zap.Logger) error { account.WithBusinessAutomation(passwordStore), account.WithUsers(userStore), account.WithPhoneChange(phoneChangeStore, authzStore, codeStore, userCache, cfg.DevAuthCode, cfg.AuthCodeTTL, cfg.AuthCodeMaxAttempts), + account.WithAccountLifecycle(postgres.NewAccountLifecycleStore(pool)), account.WithPublicBaseURL(cfg.PublicBaseURL), } var webhookSender otpdelivery.Sender @@ -871,6 +872,7 @@ func run(logger *zap.Logger) error { go router.RunPresenceSweeper(ctx, time.Minute) go activeSessions.RunPendingSweeper(ctx, time.Minute) go router.RunPremiumSweeper(ctx, cfg.PremiumSweepInterval, cfg.PremiumSweepBatch) + go router.RunAccountLifecycle(ctx, time.Minute, 500) go func() { interval := cfg.StarGiftSweepInterval if interval <= 0 { diff --git a/deploy/migrations/0107_account_lifecycle.down.sql b/deploy/migrations/0107_account_lifecycle.down.sql new file mode 100644 index 00000000..7e75f50d --- /dev/null +++ b/deploy/migrations/0107_account_lifecycle.down.sql @@ -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; diff --git a/deploy/migrations/0107_account_lifecycle.up.sql b/deploy/migrations/0107_account_lifecycle.up.sql new file mode 100644 index 00000000..c1f0e478 --- /dev/null +++ b/deploy/migrations/0107_account_lifecycle.up.sql @@ -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; diff --git a/internal/app/account/lifecycle.go b/internal/app/account/lifecycle.go new file mode 100644 index 00000000..90f202ba --- /dev/null +++ b/internal/app/account/lifecycle.go @@ -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) +} diff --git a/internal/app/account/lifecycle_test.go b/internal/app/account/lifecycle_test.go new file mode 100644 index 00000000..f5eca1f7 --- /dev/null +++ b/internal/app/account/lifecycle_test.go @@ -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 +} diff --git a/internal/app/account/service.go b/internal/app/account/service.go index 05da630a..e40e00eb 100644 --- a/internal/app/account/service.go +++ b/internal/app/account/service.go @@ -43,6 +43,7 @@ type Service struct { userCache store.UserCache authorizations store.AuthorizationStore phoneChanges store.PhoneChangeStore + lifecycle store.AccountLifecycleStore publicBaseURL string codes store.CodeStore 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 服务。 func NewService(passwords store.PasswordStore, opts ...ServiceOption) *Service { s := &Service{ diff --git a/internal/app/userprojection/deleted_test.go b/internal/app/userprojection/deleted_test.go new file mode 100644 index 00000000..93c85e0e --- /dev/null +++ b/internal/app/userprojection/deleted_test.go @@ -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) + } +} diff --git a/internal/app/userprojection/projection.go b/internal/app/userprojection/projection.go index 054b6fe3..60fe7d19 100644 --- a/internal/app/userprojection/projection.go +++ b/internal/app/userprojection/projection.go @@ -83,6 +83,7 @@ func New(opts ...Option) *Projector { // 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) { + users = sanitizeDeletedUsers(users) if p == 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 完整投影自愈。 // 调用方传入的 users 不被修改(内部复制)。 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)) if p == nil || len(users) == 0 { for _, v := range viewerUserIDs { @@ -170,6 +172,10 @@ func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users if u.ID == 0 { continue } + if u.Deleted { + projected[i] = u.DeletedTombstone() + continue + } if pj, ok := cache[u.ID]; ok { projected[i] = pj continue @@ -276,13 +282,14 @@ func dedupNonZeroInt64(ids []int64) []int64 { // WithProfilePhotos enriches users with their current avatar from profile photo storage. // 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 { + users = sanitizeDeletedUsers(users) if photos == nil || len(users) == 0 { return users } ids := make([]int64, 0, len(users)) seen := make(map[int64]struct{}, len(users)) for _, u := range users { - if u.ID == 0 { + if u.ID == 0 || u.Deleted { continue } 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 // 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) { + users = sanitizeDeletedUsers(users) if contacts == nil || viewerUserID == 0 || len(users) == 0 { 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)) for i := range out { 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 } 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)) copy(out, users) + out = sanitizeDeletedUsers(out) ids := uniqueUserIDs(out) var ( profileRefs = map[int64]domain.ProfilePhotoRef{} @@ -430,6 +439,10 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi if u.ID == 0 { continue } + if u.Deleted { + out[i] = u.DeletedTombstone() + continue + } if projected, ok := cache[u.ID]; ok { out[i] = projected continue @@ -463,7 +476,7 @@ func prefetchPrivacyVisibility(ctx context.Context, privacy PrivacyEvaluator, vi ids := make([]int64, 0, len(users)) seen := make(map[int64]struct{}, len(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 } 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) { + if user.Deleted { + return user.DeletedTombstone(), nil + } contact, found, err := contacts.Get(ctx, viewerUserID, user.ID) if err != nil { return domain.User{}, err @@ -513,7 +529,7 @@ func uniqueUserIDs(users []domain.User) []int64 { seen := make(map[int64]struct{}, len(users)) ids := make([]int64, 0, len(users)) for _, user := range users { - if user.ID == 0 { + if user.ID == 0 || user.Deleted { continue } 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 { + if user.Deleted { + return user.DeletedTombstone() + } if !hasPhotoLookups(profileRefs, fallbackRefs, personalRefs) { 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 { + if user.Deleted { + return user.DeletedTombstone() + } if !found { user.Phone = "" 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) { + if user.Deleted { + return user.DeletedTombstone(), nil + } if privacy == nil { return user, nil } @@ -647,3 +672,21 @@ func clearPhoto(user *domain.User) { user.PhotoPersonal = 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 +} diff --git a/internal/domain/account.go b/internal/domain/account.go index 9102e9a1..40c7cf6a 100644 --- a/internal/domain/account.go +++ b/internal/domain/account.go @@ -174,7 +174,12 @@ func DefaultAccountReactionSettings() AccountReactionSettings { } // 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 的业务层表达(账号级隐私开关)。 // DisallowedGifts 依赖礼物资产模型(当前未实现),故不建模、保持默认。 @@ -212,7 +217,7 @@ func DefaultAccountSettings() AccountSettings { // NormalizedTTLDays 返回钳制后的账号自毁期限(0/越界回落默认)。 func (s AccountSettings) NormalizedTTLDays() int { - if s.AccountTTLDays <= 0 { + if s.AccountTTLDays <= 0 || s.AccountTTLDays > MaxAccountTTLDays { return DefaultAccountTTLDays } return s.AccountTTLDays diff --git a/internal/domain/account_deletion.go b/internal/domain/account_deletion.go new file mode 100644 index 00000000..282b8593 --- /dev/null +++ b/internal/domain/account_deletion.go @@ -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 +} diff --git a/internal/domain/user.go b/internal/domain/user.go index 7070f084..9b78f6b9 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -1,5 +1,7 @@ package domain +import "time" + // UserIDSequenceBase 是普通用户 ID 的起始值。 // // 取 2026-06-01 00:00:00 Asia/Shanghai 的 Unix 秒级时间戳。 @@ -63,6 +65,15 @@ type User struct { PhotoHasVideo bool LastSeenAt int 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 秒)时刻是否为有效会员。 @@ -81,6 +92,25 @@ func (u User) EmojiStatusActiveAt(now int64) bool { 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. type UserStatusKind int diff --git a/internal/otpdelivery/delivery.go b/internal/otpdelivery/delivery.go index 1fed3a20..9a681c2f 100644 --- a/internal/otpdelivery/delivery.go +++ b/internal/otpdelivery/delivery.go @@ -29,6 +29,7 @@ const ( PurposeLoginEmailSetup Purpose = "login_email_setup" PurposeLoginEmailChange Purpose = "login_email_change" PurposeChangePhone Purpose = "change_phone" + PurposeConfirmPhone Purpose = "confirm_phone" ) 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") } switch r.Purpose { - case PurposeLoginEmail, PurposeLoginSMS, PurposeLoginEmailSetup, PurposeLoginEmailChange, PurposeChangePhone: + case PurposeLoginEmail, PurposeLoginSMS, PurposeLoginEmailSetup, PurposeLoginEmailChange, PurposeChangePhone, PurposeConfirmPhone: default: return fmt.Errorf("unsupported delivery purpose %q", r.Purpose) } diff --git a/internal/rpc/account.go b/internal/rpc/account.go index db2e0b8e..59024407 100644 --- a/internal/rpc/account.go +++ b/internal/rpc/account.go @@ -15,6 +15,15 @@ import ( // registerAccount 注册 account.* RPC handler。 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) { return true, nil }) @@ -902,7 +911,7 @@ func (r *Router) onAccountSetAccountTTL(ctx context.Context, ttl tg.AccountDaysT if err != nil { return false, internalErr() } - if ttl.Days <= 0 { + if ttl.Days <= 0 || ttl.Days > domain.MaxAccountTTLDays { return false, tgerr400("TTL_DAYS_INVALID") } if svc, ok := r.accountSettingsSvc(); ok { diff --git a/internal/rpc/account_deletion.go b/internal/rpc/account_deletion.go new file mode 100644 index 00000000..5bd90c98 --- /dev/null +++ b/internal/rpc/account_deletion.go @@ -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() + } +} diff --git a/internal/rpc/account_deletion_rpc_test.go b/internal/rpc/account_deletion_rpc_test.go new file mode 100644 index 00000000..779e69f3 --- /dev/null +++ b/internal/rpc/account_deletion_rpc_test.go @@ -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 +} diff --git a/internal/rpc/account_lifecycle_worker.go b/internal/rpc/account_lifecycle_worker.go new file mode 100644 index 00000000..a19de648 --- /dev/null +++ b/internal/rpc/account_lifecycle_worker.go @@ -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)) + } +} diff --git a/internal/rpc/auth.go b/internal/rpc/auth.go index 41675b06..6a46996b 100644 --- a/internal/rpc/auth.go +++ b/internal/rpc/auth.go @@ -490,6 +490,19 @@ func (r *Router) onAuthResendCode(ctx context.Context, req *tg.AuthResendCodeReq if err := r.checkAuthCodeRateLimit(ctx, req.PhoneNumber); err != nil { 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 err error 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) { + 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 if scoped, ok := r.deps.Auth.(interface { CancelCodeForAuthKey(context.Context, [8]byte, string, string) error diff --git a/internal/rpc/auth_gate.go b/internal/rpc/auth_gate.go index 1f69feb7..474cde98 100644 --- a/internal/rpc/auth_gate.go +++ b/internal/rpc/auth_gate.go @@ -32,6 +32,9 @@ func rpcAllowedWithoutAuthorization(id uint32) bool { tg.AuthReportMissingCodeRequestTypeID, tg.AuthResetLoginEmailRequestTypeID, 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)发生在登录流程中、尚未鉴权, // 故这两个 account.* 方法必须放行 pre-auth;loginChange 分支内部仍校验 userID。 tg.AccountSendVerifyEmailCodeRequestTypeID, diff --git a/internal/rpc/convert_users.go b/internal/rpc/convert_users.go index 9ccb617c..1a333599 100644 --- a/internal/rpc/convert_users.go +++ b/internal/rpc/convert_users.go @@ -9,6 +9,9 @@ import ( // tgSelfUser 把 domain.User 转为 self 标记的 tg.User(optional 字段由 Encode 自动 SetFlags)。 func tgSelfUser(u domain.User) *tg.User { + if u.Deleted { + return &tg.User{ID: u.ID, Deleted: true} + } out := &tg.User{ ID: u.ID, AccessHash: u.AccessHash, @@ -34,6 +37,9 @@ func tgSelfUser(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{ ID: u.ID, AccessHash: u.AccessHash, diff --git a/internal/rpc/convert_users_deleted_test.go b/internal/rpc/convert_users_deleted_test.go new file mode 100644 index 00000000..70e6da21 --- /dev/null +++ b/internal/rpc/convert_users_deleted_test.go @@ -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) + } +} diff --git a/internal/store/account_lifecycle.go b/internal/store/account_lifecycle.go new file mode 100644 index 00000000..cfa0d2ac --- /dev/null +++ b/internal/store/account_lifecycle.go @@ -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 +} diff --git a/internal/store/code.go b/internal/store/code.go index 034e0bf8..08355a7e 100644 --- a/internal/store/code.go +++ b/internal/store/code.go @@ -10,6 +10,7 @@ import ( const ( PhoneCodePurposeChangePhone = "change_phone" + PhoneCodePurposeConfirmPhone = "confirm_phone" PhoneCodeChannelPhone = "phone" PhoneCodeChannelSMS = "sms" PhoneCodeChannelEmailLogin = "email_login" @@ -75,6 +76,10 @@ type PhoneCode struct { VerifiedEmail bool RequireSignUp bool 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 { diff --git a/internal/store/memory/users.go b/internal/store/memory/users.go index 33f65060..049f64fa 100644 --- a/internal/store/memory/users.go +++ b/internal/store/memory/users.go @@ -6,6 +6,7 @@ import ( "strings" "sync" "telesrv/internal/domain" + "time" ) // UserStore 是 store.UserStore 的内存实现。ID 与 PG identity 使用同一业务起点。 @@ -65,7 +66,7 @@ func (s *UserStore) ByPhone(_ context.Context, phone string) (domain.User, bool, s.mu.RLock() defer s.mu.RUnlock() for _, u := range s.byID { - if u.Phone == phone { + if !u.Deleted && u.Phone == phone { 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)) seenIDs := map[int64]struct{}{} for _, u := range s.byID { + if u.Deleted { + continue + } if _, ok := want[u.Phone]; !ok { continue } @@ -108,7 +112,7 @@ func (s *UserStore) ByUsername(_ context.Context, username string) (domain.User, s.mu.RLock() defer s.mu.RUnlock() for _, u := range s.byID { - if strings.ToLower(u.Username) == username { + if !u.Deleted && strings.ToLower(u.Username) == username { return u, true, nil } } @@ -123,7 +127,7 @@ func (s *UserStore) CheckUsername(_ context.Context, userID int64, username stri s.mu.RLock() defer s.mu.RUnlock() 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 } } @@ -143,7 +147,7 @@ func (s *UserStore) Search(_ context.Context, currentUserID int64, query, phoneQ defer s.mu.RUnlock() users := make([]domain.User, 0) for _, u := range s.byID { - if u.ID == currentUserID { + if u.ID == currentUserID || u.Deleted { continue } if userMatchesSearch(u, query, phoneQuery) { @@ -165,7 +169,7 @@ func (s *UserStore) UpdateUsername(_ context.Context, userID int64, username str s.mu.Lock() defer s.mu.Unlock() u, ok := s.byID[userID] - if !ok { + if !ok || u.Deleted { return domain.User{}, domain.ErrUsernameNotOccupied } if usernameLower != "" { @@ -184,7 +188,7 @@ func (s *UserStore) UpdateProfile(_ context.Context, userID int64, firstName, la s.mu.Lock() defer s.mu.Unlock() u, ok := s.byID[userID] - if !ok { + if !ok || u.Deleted { return domain.User{}, domain.ErrUsernameNotOccupied } u.FirstName = firstName @@ -198,7 +202,7 @@ func (s *UserStore) UpdateBirthday(_ context.Context, userID int64, birthday dom s.mu.Lock() defer s.mu.Unlock() u, ok := s.byID[userID] - if !ok { + if !ok || u.Deleted { return domain.User{}, domain.ErrUserNotFound } u.Birthday = birthday @@ -210,7 +214,7 @@ func (s *UserStore) UpdatePersonalChannel(_ context.Context, userID int64, chann s.mu.Lock() defer s.mu.Unlock() u, ok := s.byID[userID] - if !ok { + if !ok || u.Deleted { return domain.User{}, domain.ErrUserNotFound } u.PersonalChannelID = channelID @@ -224,7 +228,7 @@ func (s *UserStore) bumpBotInfoVersion(userID int64) (int, bool) { s.mu.Lock() defer s.mu.Unlock() u, ok := s.byID[userID] - if !ok || !u.Bot { + if !ok || u.Deleted || !u.Bot { return 0, false } u.BotInfoVersion++ @@ -237,7 +241,7 @@ func (s *UserStore) updateBotProfile(userID int64, setName bool, name string, se s.mu.Lock() defer s.mu.Unlock() u, ok := s.byID[userID] - if !ok || !u.Bot { + if !ok || u.Deleted || !u.Bot { return false } if setName { @@ -255,7 +259,7 @@ func (s *UserStore) SetPremiumUntil(_ context.Context, userID int64, until int) s.mu.Lock() defer s.mu.Unlock() u, ok := s.byID[userID] - if !ok { + if !ok || u.Deleted { return domain.User{}, domain.ErrUserNotFound } if until < 0 { @@ -271,7 +275,7 @@ func (s *UserStore) SetVerified(_ context.Context, userID int64, verified bool) s.mu.Lock() defer s.mu.Unlock() u, ok := s.byID[userID] - if !ok { + if !ok || u.Deleted { return domain.User{}, domain.ErrUserNotFound } u.Verified = verified @@ -288,7 +292,7 @@ func (s *UserStore) SweepExpiredPremium(_ context.Context, now int64, limit int) defer s.mu.Unlock() out := make([]domain.User, 0) 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 } u.PremiumUntil = 0 @@ -307,7 +311,7 @@ func (s *UserStore) UpdateEmojiStatus(_ context.Context, userID int64, documentI s.mu.Lock() defer s.mu.Unlock() u, ok := s.byID[userID] - if !ok { + if !ok || u.Deleted { return domain.User{}, domain.ErrUserNotFound } if documentID == 0 { @@ -323,7 +327,7 @@ func (s *UserStore) UpdateColor(_ context.Context, userID int64, forProfile bool s.mu.Lock() defer s.mu.Unlock() u, ok := s.byID[userID] - if !ok { + if !ok || u.Deleted { return domain.User{}, domain.ErrUserNotFound } if forProfile { @@ -342,7 +346,7 @@ func (s *UserStore) UpdateLastSeen(_ context.Context, userID int64, lastSeenAt i s.mu.Lock() defer s.mu.Unlock() u, ok := s.byID[userID] - if !ok { + if !ok || u.Deleted { return domain.ErrUsernameNotOccupied } if lastSeenAt > u.LastSeenAt { @@ -379,6 +383,9 @@ func (s *UserStore) Create(_ context.Context, u domain.User) (domain.User, error } u.ID = s.nextID s.nextID++ + if u.CreatedAt.IsZero() { + u.CreatedAt = time.Now().UTC() + } s.byID[u.ID] = u return u, nil } diff --git a/internal/store/postgres/account_lifecycle.go b/internal/store/postgres/account_lifecycle.go new file mode 100644 index 00000000..df2a00b9 --- /dev/null +++ b/internal/store/postgres/account_lifecycle.go @@ -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 +} diff --git a/internal/store/postgres/account_lifecycle_integration_test.go b/internal/store/postgres/account_lifecycle_integration_test.go new file mode 100644 index 00000000..69565cbe --- /dev/null +++ b/internal/store/postgres/account_lifecycle_integration_test.go @@ -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 +} diff --git a/internal/store/postgres/history_ttl.go b/internal/store/postgres/history_ttl.go index e2090164..9d98fcec 100644 --- a/internal/store/postgres/history_ttl.go +++ b/internal/store/postgres/history_ttl.go @@ -46,7 +46,7 @@ func (s *MessageStore) DefaultHistoryTTL(ctx context.Context, userID int64) (int return 0, nil } 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) { return 0, nil } @@ -70,7 +70,7 @@ func (s *MessageStore) SetDefaultHistoryTTL(ctx context.Context, userID int64, p UPDATE users SET default_history_ttl_period = $2, updated_at = now() -WHERE id = $1 +WHERE id = $1 AND deleted_at IS NULL `, userID, period) if err != nil { return fmt.Errorf("set default history ttl: %w", err) diff --git a/internal/store/postgres/queries/user.sql b/internal/store/postgres/queries/user.sql index 11d93d77..1c0e96b4 100644 --- a/internal/store/postgres/queries/user.sql +++ b/internal/store/postgres/queries/user.sql @@ -8,16 +8,16 @@ WHERE id = ANY(sqlc.arg(ids)::bigint[]) ORDER BY id; -- name: GetUserByPhone :one -SELECT * FROM users WHERE phone = $1; +SELECT * FROM users WHERE phone = $1 AND deleted_at IS NULL; -- name: GetUsersByPhones :many SELECT * FROM users -WHERE phone = ANY(sqlc.arg(phones)::text[]) +WHERE phone = ANY(sqlc.arg(phones)::text[]) AND deleted_at IS NULL ORDER BY id; -- 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 WITH matched AS ( @@ -57,6 +57,7 @@ WITH matched AS ( FROM users u 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 + AND u.deleted_at IS NULL AND sqlc.arg(query_lower)::text <> '' AND ( (sqlc.arg(phone_query)::text <> '' AND u.phone LIKE sqlc.arg(phone_query)::text || '%') @@ -107,14 +108,14 @@ RETURNING *; UPDATE users SET username = $2, updated_at = now() -WHERE id = $1 +WHERE id = $1 AND deleted_at IS NULL RETURNING *; -- name: UpdateUserLastSeen :exec UPDATE users SET last_seen_at = GREATEST(last_seen_at, sqlc.arg(last_seen_at)::bigint), updated_at = now() -WHERE id = sqlc.arg(id)::bigint; +WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL; -- name: UpdateUserProfile :one UPDATE users @@ -122,28 +123,28 @@ SET first_name = $2, last_name = $3, about = $4, updated_at = now() -WHERE id = $1 +WHERE id = $1 AND deleted_at IS NULL RETURNING *; -- name: UpdateUserPhone :one UPDATE users SET phone = sqlc.arg(phone)::text, updated_at = now() -WHERE id = sqlc.arg(id)::bigint +WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL RETURNING *; -- name: SetUserPremiumUntil :one UPDATE users SET premium_expires_at = sqlc.narg(premium_expires_at)::timestamptz, updated_at = now() -WHERE id = sqlc.arg(id)::bigint +WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL RETURNING *; -- name: SetUserVerified :one UPDATE users SET verified = sqlc.arg(verified)::boolean, updated_at = now() -WHERE id = sqlc.arg(id)::bigint +WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL RETURNING *; -- name: SweepExpiredPremium :many @@ -153,6 +154,7 @@ SET premium_expires_at = NULL, WHERE id IN ( SELECT id FROM users WHERE premium_expires_at IS NOT NULL + AND deleted_at IS NULL AND premium_expires_at <= sqlc.arg(now)::timestamptz ORDER BY premium_expires_at LIMIT sqlc.arg(limit_count)::int @@ -164,7 +166,7 @@ UPDATE users SET emoji_status_document_id = sqlc.arg(emoji_status_document_id)::bigint, emoji_status_until = sqlc.arg(emoji_status_until)::bigint, updated_at = now() -WHERE id = sqlc.arg(id)::bigint +WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL RETURNING *; -- name: UpdateUserBirthday :one @@ -173,14 +175,14 @@ SET birthday_day = sqlc.arg(birthday_day)::int, birthday_month = sqlc.arg(birthday_month)::int, birthday_year = sqlc.arg(birthday_year)::int, updated_at = now() -WHERE id = sqlc.arg(id)::bigint +WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL RETURNING *; -- name: UpdateUserPersonalChannel :one UPDATE users SET personal_channel_id = sqlc.arg(personal_channel_id)::bigint, updated_at = now() -WHERE id = sqlc.arg(id)::bigint +WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL RETURNING *; -- name: UpdateUserColor :one @@ -189,7 +191,7 @@ SET color_set = sqlc.arg(color_set)::boolean, color = sqlc.arg(color)::int, color_background_emoji_id = sqlc.arg(background_emoji_id)::bigint, updated_at = now() -WHERE id = sqlc.arg(id)::bigint +WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL RETURNING *; -- name: UpdateUserProfileColor :one @@ -198,5 +200,5 @@ SET profile_color_set = sqlc.arg(color_set)::boolean, profile_color = sqlc.arg(color)::int, profile_color_background_emoji_id = sqlc.arg(background_emoji_id)::bigint, updated_at = now() -WHERE id = sqlc.arg(id)::bigint +WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL RETURNING *; diff --git a/internal/store/postgres/sqlcgen/bot.sql.go b/internal/store/postgres/sqlcgen/bot.sql.go index d2be43e7..2798528f 100644 --- a/internal/store/postgres/sqlcgen/bot.sql.go +++ b/internal/store/postgres/sqlcgen/bot.sql.go @@ -167,7 +167,7 @@ func (q *Queries) InsertBot(ctx context.Context, arg InsertBotParams) error { const insertBotUser = `-- name: InsertBotUser :one INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code, is_bot, bot_info_version) 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 { @@ -209,6 +209,10 @@ func (q *Queries) InsertBotUser(ctx context.Context, arg InsertBotUserParams) (U &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.DeletedAt, + &i.DeletionSource, + &i.DeletionReason, + &i.AccountDeleteAt, ) return i, err } diff --git a/internal/store/postgres/sqlcgen/models.go b/internal/store/postgres/sqlcgen/models.go index 9ce0097d..e463dcdc 100644 --- a/internal/store/postgres/sqlcgen/models.go +++ b/internal/store/postgres/sqlcgen/models.go @@ -8,6 +8,32 @@ import ( "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 { UserID int64 HasRecovery bool @@ -30,6 +56,7 @@ type AccountPassword struct { RecoveryCode string RecoveryCodeExpiresAt pgtype.Timestamptz LoginEmail string + PasswordChangedAt pgtype.Timestamptz } type AccountPrivacyRule struct { @@ -694,6 +721,42 @@ type ChannelMessageViewer struct { 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 { ChannelID int64 UserID int64 @@ -1156,19 +1219,33 @@ type PasskeyCredential struct { } type PeerStarGift struct { - ID int64 - OwnerPeerID int64 - FromUserID int64 - GiftID int64 - MsgID int32 - GiftDate int32 - NameHidden bool - Unsaved bool - Converted bool - ConvertStars int64 - Message string - OwnerPeerType string - SavedID int64 + ID int64 + OwnerPeerID int64 + FromUserID int64 + GiftID int64 + MsgID int32 + GiftDate int32 + NameHidden bool + Unsaved bool + Converted bool + ConvertStars int64 + Message string + OwnerPeerType string + 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 { @@ -1417,6 +1494,382 @@ type SeedState struct { 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 { UserID int64 Balance int64 @@ -1564,6 +2017,66 @@ type ThemeUserInstall struct { 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 { AuthKeyID int64 Pts int32 @@ -1627,6 +2140,10 @@ type User struct { BirthdayMonth int32 BirthdayYear int32 PersonalChannelID int64 + DeletedAt pgtype.Timestamptz + DeletionSource string + DeletionReason string + AccountDeleteAt pgtype.Timestamptz } type UserBusinessProfile struct { diff --git a/internal/store/postgres/sqlcgen/user.sql.go b/internal/store/postgres/sqlcgen/user.sql.go index 8b04ac8f..ba00aa72 100644 --- a/internal/store/postgres/sqlcgen/user.sql.go +++ b/internal/store/postgres/sqlcgen/user.sql.go @@ -14,7 +14,7 @@ import ( const createUser = `-- name: CreateUser :one INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code, premium_expires_at) 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 { @@ -68,12 +68,16 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.DeletedAt, + &i.DeletionSource, + &i.DeletionReason, + &i.AccountDeleteAt, ) return i, err } 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) { @@ -109,12 +113,16 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) { &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.DeletedAt, + &i.DeletionSource, + &i.DeletionReason, + &i.AccountDeleteAt, ) return i, err } 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) { @@ -150,12 +158,16 @@ func (q *Queries) GetUserByPhone(ctx context.Context, phone string) (User, error &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.DeletedAt, + &i.DeletionSource, + &i.DeletionReason, + &i.AccountDeleteAt, ) return i, err } 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) { @@ -191,12 +203,16 @@ func (q *Queries) GetUserByUsername(ctx context.Context, lower string) (User, er &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.DeletedAt, + &i.DeletionSource, + &i.DeletionReason, + &i.AccountDeleteAt, ) return i, err } 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 WHERE id = ANY($1::bigint[]) ORDER BY id @@ -241,6 +257,10 @@ func (q *Queries) GetUsersByIDs(ctx context.Context, ids []int64) ([]User, error &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.DeletedAt, + &i.DeletionSource, + &i.DeletionReason, + &i.AccountDeleteAt, ); err != nil { return nil, err } @@ -253,9 +273,9 @@ func (q *Queries) GetUsersByIDs(ctx context.Context, ids []int64) ([]User, error } 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 -WHERE phone = ANY($1::text[]) +WHERE phone = ANY($1::text[]) AND deleted_at IS NULL ORDER BY id ` @@ -298,6 +318,10 @@ func (q *Queries) GetUsersByPhones(ctx context.Context, phones []string) ([]User &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.DeletedAt, + &i.DeletionSource, + &i.DeletionReason, + &i.AccountDeleteAt, ); err != nil { return nil, err } @@ -347,6 +371,7 @@ WITH matched AS ( FROM users u LEFT JOIN contacts c ON c.user_id = $4::bigint AND c.contact_user_id = u.id WHERE u.id <> $4::bigint + AND u.deleted_at IS NULL AND $3::text <> '' AND ( ($2::text <> '' AND u.phone LIKE $2::text || '%') @@ -479,8 +504,8 @@ const setUserPremiumUntil = `-- name: SetUserPremiumUntil :one UPDATE users SET premium_expires_at = $1::timestamptz, updated_at = now() -WHERE id = $2::bigint -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 +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, deleted_at, deletion_source, deletion_reason, account_delete_at ` type SetUserPremiumUntilParams struct { @@ -521,6 +546,10 @@ func (q *Queries) SetUserPremiumUntil(ctx context.Context, arg SetUserPremiumUnt &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.DeletedAt, + &i.DeletionSource, + &i.DeletionReason, + &i.AccountDeleteAt, ) return i, err } @@ -529,8 +558,8 @@ const setUserVerified = `-- name: SetUserVerified :one UPDATE users SET verified = $1::boolean, updated_at = now() -WHERE id = $2::bigint -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 +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, deleted_at, deletion_source, deletion_reason, account_delete_at ` type SetUserVerifiedParams struct { @@ -571,6 +600,10 @@ func (q *Queries) SetUserVerified(ctx context.Context, arg SetUserVerifiedParams &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.DeletedAt, + &i.DeletionSource, + &i.DeletionReason, + &i.AccountDeleteAt, ) return i, err } @@ -582,11 +615,12 @@ SET premium_expires_at = NULL, WHERE id IN ( SELECT id FROM users WHERE premium_expires_at IS NOT NULL + AND deleted_at IS NULL AND premium_expires_at <= $1::timestamptz ORDER BY premium_expires_at 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 { @@ -633,6 +667,10 @@ func (q *Queries) SweepExpiredPremium(ctx context.Context, arg SweepExpiredPremi &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.DeletedAt, + &i.DeletionSource, + &i.DeletionReason, + &i.AccountDeleteAt, ); err != nil { return nil, err } @@ -650,8 +688,8 @@ SET birthday_day = $1::int, birthday_month = $2::int, birthday_year = $3::int, updated_at = now() -WHERE id = $4::bigint -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 +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, deleted_at, deletion_source, deletion_reason, account_delete_at ` type UpdateUserBirthdayParams struct { @@ -699,6 +737,10 @@ func (q *Queries) UpdateUserBirthday(ctx context.Context, arg UpdateUserBirthday &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.DeletedAt, + &i.DeletionSource, + &i.DeletionReason, + &i.AccountDeleteAt, ) return i, err } @@ -709,8 +751,8 @@ SET color_set = $1::boolean, color = $2::int, color_background_emoji_id = $3::bigint, updated_at = now() -WHERE id = $4::bigint -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 +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, deleted_at, deletion_source, deletion_reason, account_delete_at ` type UpdateUserColorParams struct { @@ -758,6 +800,10 @@ func (q *Queries) UpdateUserColor(ctx context.Context, arg UpdateUserColorParams &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.DeletedAt, + &i.DeletionSource, + &i.DeletionReason, + &i.AccountDeleteAt, ) return i, err } @@ -767,8 +813,8 @@ UPDATE users SET emoji_status_document_id = $1::bigint, emoji_status_until = $2::bigint, updated_at = now() -WHERE id = $3::bigint -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 +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, deleted_at, deletion_source, deletion_reason, account_delete_at ` type UpdateUserEmojiStatusParams struct { @@ -810,6 +856,10 @@ func (q *Queries) UpdateUserEmojiStatus(ctx context.Context, arg UpdateUserEmoji &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.DeletedAt, + &i.DeletionSource, + &i.DeletionReason, + &i.AccountDeleteAt, ) return i, err } @@ -818,7 +868,7 @@ const updateUserLastSeen = `-- name: UpdateUserLastSeen :exec UPDATE users SET last_seen_at = GREATEST(last_seen_at, $1::bigint), updated_at = now() -WHERE id = $2::bigint +WHERE id = $2::bigint AND deleted_at IS NULL ` type UpdateUserLastSeenParams struct { @@ -835,8 +885,8 @@ const updateUserPersonalChannel = `-- name: UpdateUserPersonalChannel :one UPDATE users SET personal_channel_id = $1::bigint, updated_at = now() -WHERE id = $2::bigint -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 +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, deleted_at, deletion_source, deletion_reason, account_delete_at ` type UpdateUserPersonalChannelParams struct { @@ -877,6 +927,10 @@ func (q *Queries) UpdateUserPersonalChannel(ctx context.Context, arg UpdateUserP &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.DeletedAt, + &i.DeletionSource, + &i.DeletionReason, + &i.AccountDeleteAt, ) return i, err } @@ -885,8 +939,8 @@ const updateUserPhone = `-- name: UpdateUserPhone :one UPDATE users SET phone = $1::text, updated_at = now() -WHERE id = $2::bigint -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 +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, deleted_at, deletion_source, deletion_reason, account_delete_at ` type UpdateUserPhoneParams struct { @@ -927,6 +981,10 @@ func (q *Queries) UpdateUserPhone(ctx context.Context, arg UpdateUserPhoneParams &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.DeletedAt, + &i.DeletionSource, + &i.DeletionReason, + &i.AccountDeleteAt, ) return i, err } @@ -937,8 +995,8 @@ SET first_name = $2, last_name = $3, about = $4, updated_at = now() -WHERE id = $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 +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, deleted_at, deletion_source, deletion_reason, account_delete_at ` type UpdateUserProfileParams struct { @@ -986,6 +1044,10 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.DeletedAt, + &i.DeletionSource, + &i.DeletionReason, + &i.AccountDeleteAt, ) return i, err } @@ -996,8 +1058,8 @@ SET profile_color_set = $1::boolean, profile_color = $2::int, profile_color_background_emoji_id = $3::bigint, updated_at = now() -WHERE id = $4::bigint -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 +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, deleted_at, deletion_source, deletion_reason, account_delete_at ` type UpdateUserProfileColorParams struct { @@ -1045,6 +1107,10 @@ func (q *Queries) UpdateUserProfileColor(ctx context.Context, arg UpdateUserProf &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.DeletedAt, + &i.DeletionSource, + &i.DeletionReason, + &i.AccountDeleteAt, ) return i, err } @@ -1053,8 +1119,8 @@ const updateUserUsername = `-- name: UpdateUserUsername :one UPDATE users SET username = $2, updated_at = now() -WHERE id = $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 +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, deleted_at, deletion_source, deletion_reason, account_delete_at ` type UpdateUserUsernameParams struct { @@ -1095,6 +1161,10 @@ func (q *Queries) UpdateUserUsername(ctx context.Context, arg UpdateUserUsername &i.BirthdayMonth, &i.BirthdayYear, &i.PersonalChannelID, + &i.DeletedAt, + &i.DeletionSource, + &i.DeletionReason, + &i.AccountDeleteAt, ) return i, err } diff --git a/internal/store/postgres/star_gift_lifecycle_migration_integration_test.go b/internal/store/postgres/star_gift_lifecycle_migration_integration_test.go index de34283e..24eb00ac 100644 --- a/internal/store/postgres/star_gift_lifecycle_migration_integration_test.go +++ b/internal/store/postgres/star_gift_lifecycle_migration_integration_test.go @@ -14,7 +14,7 @@ func TestStarGiftLifecycleMigrationsApply(t *testing.T) { if err != nil { t.Fatalf("migrate star gift lifecycle schema: %v", err) } - if status.Dirty || status.Empty || status.Version != 106 { - t.Fatalf("migration status = %+v, want clean version 106", status) + if status.Dirty || status.Empty || status.Version != 107 { + t.Fatalf("migration status = %+v, want clean version 107", status) } } diff --git a/internal/store/postgres/user.go b/internal/store/postgres/user.go index b5b944cd..9c754dd9 100644 --- a/internal/store/postgres/user.go +++ b/internal/store/postgres/user.go @@ -200,7 +200,7 @@ func (s *UserStore) UpdateUsername(ctx context.Context, userID int64, username s }() qtx := s.q.WithTx(tx) 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) { return domain.User{}, domain.ErrUsernameNotOccupied } @@ -444,7 +444,7 @@ func escapeLike(s string) string { } func userFromModel(r sqlcgen.User) domain.User { - return domain.User{ + u := domain.User{ ID: r.ID, AccessHash: r.AccessHash, Phone: r.Phone, @@ -465,7 +465,17 @@ func userFromModel(r sqlcgen.User) domain.User { Color: peerColorFromModel(r.ColorSet, r.Color, r.ColorBackgroundEmojiID), ProfileColor: peerColorFromModel(r.ProfileColorSet, r.ProfileColor, r.ProfileColorBackgroundEmojiID), 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 {