*/ private static array $tableExistsCache = []; /** @var array> */ private static array $columnsCache = []; public function __construct() { $schema = account_deletion_schema_bootstrap::check(); if (!$schema['ready']) { throw new \RuntimeException('Account deletion schema is not ready; run the explicit schema CLI.'); } self::$tableExistsCache['account_deletion_requests'] = true; unset(self::$columnsCache['users'], self::$columnsCache['subusers']); } public static function apiEnabled(): bool { return self::featureEnabled('api_enabled'); } public static function workerEnabled(): bool { return self::featureEnabled('worker_enabled'); } private static function featureEnabled(string $variable): bool { global $db; if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) return false; try { $module = $db->escape_string('account_deletion'); $variable = $db->escape_string($variable); $result = $db->query("SELECT value FROM module_config WHERE module = '$module' AND variable = '$variable' LIMIT 1"); if ($result === false || $result->num_rows === 0) return false; $row = $result->fetch_assoc(); return self::normalizeBoolean((string)($row['value'] ?? '')); } catch (Throwable) { return false; } } public function passkeyChallenge(array $principal): array { if ($this->principalHasPassword($principal)) { throw new account_deletion_http_exception('Passkey reauthentication is only required for passwordless accounts', 400); } global $db; $id = (int)$principal['id']; $isSubuser = $principal['type'] === 'subuser' ? 1 : 0; $result = $db->query("SELECT credential_id, transports FROM passkeys WHERE user_id = $id AND is_subuser = $isSubuser AND deleted_at IS NULL"); if ($result === false) throw new \RuntimeException('Unable to load deletion passkeys.'); $rows = $db->fetch_all($result); if ($rows === []) throw new account_deletion_http_exception('No active passkey is available', 403); $token = bin2hex(random_bytes(32)); $this->execute("INSERT INTO tokens (user_id, token, type) VALUES ($id, " . self::sql($token) . ", 'ACCOUNT_DELETION_PASSKEY_CHALLENGE')", 'Unable to persist deletion passkey challenge.'); $originHost = parse_url((string)($_SERVER['HTTP_ORIGIN'] ?? ''), PHP_URL_HOST); $httpHost = isset($_SERVER['HTTP_HOST']) ? explode(':', (string)$_SERVER['HTTP_HOST'])[0] : null; $challenge = rtrim(strtr(base64_encode((string)hex2bin($token)), '+/', '-_'), '='); return ['challenge_token' => $token, 'publicKey' => [ 'challenge' => $challenge, 'rpId' => $originHost ?: $httpHost ?: ($_SERVER['SERVER_NAME'] ?? 'truckwash.io'), 'timeout' => 60000, 'userVerification' => 'required', 'allowCredentials' => array_map(static function (array $row): array { $transports = json_decode((string)($row['transports'] ?? '[]'), true); return ['type' => 'public-key', 'id' => (string)$row['credential_id'], 'transports' => is_array($transports) ? $transports : []]; }, $rows), ]]; } /** * Authentication hot-path guard. Missing pre-deployment schema is treated as no block; * once the deletion schema exists, either an active request or deleted_at blocks access. */ public static function principalIsBlocked(string $principalType, int $principalId): bool { if (!in_array($principalType, ['customer', 'subuser'], true) || $principalId <= 0) { return true; } global $db; if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) { return false; } if (self::tableExists('account_deletion_requests')) { $key = self::sql($principalType . ':' . $principalId); $result = $db->query( "SELECT id FROM account_deletion_requests WHERE active_principal_key = $key AND status IN ('requested', 'processing', 'failed', 'manual_review') LIMIT 1" ); if ($result === false) { throw new \RuntimeException('Unable to inspect active account deletion requests.'); } if ($result->num_rows > 0) { return true; } } $table = $principalType === 'customer' ? 'users' : 'subusers'; if (!self::tableExists($table) || !in_array('deleted_at', self::columns($table), true)) { return false; } $result = $db->query("SELECT deleted_at FROM `$table` WHERE id = $principalId LIMIT 1"); if ($result === false) { throw new \RuntimeException('Unable to inspect principal deletion state.'); } if ($result->num_rows === 0) { return false; } $row = $result->fetch_assoc(); return ($row['deleted_at'] ?? null) !== null; } /** @return array{type:string,id:int,customer_number:?int,object:users_o|subusers_o,token:string,email:?string,name:string} */ public function currentPrincipal(): array { $authentication = new authentication(); $user = $authentication->get_user(); if ($user !== false) { $bearerToken = $this->bearerToken(); $this->rejectImpersonationToken($bearerToken); $customerNumber = (int)$user->customer_number->value(); if ($customerNumber <= 0) { throw new account_deletion_http_exception( 'Account deletion is only available to customer accounts', 403 ); } return [ 'type' => 'customer', 'id' => (int)$user->id, 'customer_number' => $customerNumber, 'object' => $user, 'token' => $bearerToken, 'email' => $this->nullableString($user->email->value()), 'name' => $this->nullableString($user->display_name->value()) ?? 'Kunde', ]; } $subuser = $authentication->get_subuser(); if ($subuser !== false) { return [ 'type' => 'subuser', 'id' => (int)$subuser->id, 'customer_number' => null, 'object' => $subuser, 'token' => $this->bearerToken(), 'email' => $this->nullableString($subuser->email->value()), 'name' => $this->nullableString($subuser->name->value()) ?? 'Chauffør', ]; } throw new account_deletion_http_exception('Unauthorized', 401); } public function state(array $principal): array { $row = $this->activeRequest((string)$principal['type'], (int)$principal['id']); return $this->statePayload($principal, $row); } public function request(array $principal, array $input, ?string $ip, ?string $userAgent): array { $this->validateConfirmation($input); $throttleKey = $this->recordCredentialAttempt($principal, $ip); $this->verifyCredentials($principal, $input); $this->clearCredentialAttempts($throttleKey); $existing = $this->activeRequest((string)$principal['type'], (int)$principal['id']); if ($existing !== null) { return $this->acceptedPayload($existing); } global $db; $connection = $db->conn(); $connection->begin_transaction(); try { $existing = $this->activeRequest((string)$principal['type'], (int)$principal['id'], true); if ($existing !== null) { $connection->commit(); return $this->acceptedPayload($existing); } $requestId = self::uuidV4(); $principalType = (string)$principal['type']; $principalId = (int)$principal['id']; $customerNumber = $principal['customer_number'] !== null ? (int)$principal['customer_number'] : null; $retained = json_encode($this->retainedDataCategories($principalType), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if (!is_string($retained)) { throw new \RuntimeException('Could not encode retained-data categories.'); } $now = date('Y-m-d H:i:s'); $activeKey = $principalType . ':' . $principalId; $sql = "INSERT INTO account_deletion_requests (request_id, principal_type, principal_id, customer_number_snapshot, active_principal_key, status, policy_version, retained_data_json, request_ip, request_user_agent, requested_at, next_attempt_at) VALUES (" . self::sql($requestId) . ', ' . self::sql($principalType) . ', ' . $principalId . ', ' . ($customerNumber !== null ? (string)$customerNumber : 'NULL') . ', ' . self::sql($activeKey) . ", 'requested', " . self::sql(self::POLICY_VERSION) . ', ' . self::sql($retained) . ', ' . ($ip !== null ? self::sql(substr($ip, 0, 45)) : 'NULL') . ', ' . ($userAgent !== null ? self::sql(substr($userAgent, 0, 512)) : 'NULL') . ', ' . self::sql($now) . ', ' . self::sql($now) . ')'; $this->execute($sql, 'Unable to create account deletion request.'); $this->blockPrincipalImmediately($principalType, $principalId, $customerNumber, $now); $this->enqueueOutbox($requestId, 'requested', ['principal_type' => $principalType, 'principal_id' => $principalId]); $connection->commit(); } catch (Throwable $throwable) { $connection->rollback(); $existing = $this->activeRequest((string)$principal['type'], (int)$principal['id']); if ($existing !== null) { return $this->acceptedPayload($existing); } throw $throwable; } $this->revokeCaches($principal); $this->sendRequestedNotification($principal, $requestId); $created = $this->requestByPublicId($requestId); if ($created === null) { throw new \RuntimeException('Account deletion request was not persisted.'); } return $this->acceptedPayload($created); } /** @return array{processed:int,completed:int,failed:int} */ public function processPending(int $limit = 25): array { $limit = max(1, min(100, $limit)); global $db; $now = self::sql(date('Y-m-d H:i:s')); $staleBefore = self::sql(date('Y-m-d H:i:s', time() - self::PROCESSING_LEASE_SECONDS)); $result = $db->query( "SELECT * FROM account_deletion_requests WHERE ( (status IN ('requested', 'failed') AND retry_count < " . self::MAX_RETRIES . " AND (next_attempt_at IS NULL OR next_attempt_at <= $now)) OR (status = 'processing' AND (processing_at IS NULL OR processing_at <= $staleBefore)) ) ORDER BY requested_at ASC, id ASC LIMIT $limit" ); if ($result === false) { throw new \RuntimeException('Unable to inspect queued account deletion requests.'); } $rows = $db->fetch_all($result); $summary = ['processed' => 0, 'completed' => 0, 'failed' => 0]; foreach ($rows as $row) { $id = (int)($row['id'] ?? 0); if ($id <= 0 || !$this->claim($id)) { continue; } $summary['processed']++; try { $this->complete($row); $summary['completed']++; } catch (Throwable $throwable) { $this->recordFailure($id, (int)($row['retry_count'] ?? 0), $throwable); $summary['failed']++; } } $this->dispatchOutbox(50); return $summary; } private function dispatchOutbox(int $limit): void { global $db; $limit = max(1, min(100, $limit)); $result = $db->query("SELECT * FROM account_deletion_outbox WHERE (status = 'pending' AND available_at <= NOW()) OR (status = 'processing' AND processing_at <= DATE_SUB(NOW(), INTERVAL 15 MINUTE)) ORDER BY id ASC LIMIT $limit"); if ($result === false) throw new \RuntimeException('Unable to inspect account deletion outbox.'); foreach ($db->fetch_all($result) as $event) { $id = (int)($event['id'] ?? 0); if ($id <= 0) continue; $this->execute("UPDATE account_deletion_outbox SET status = 'processing', processing_at = NOW() WHERE id = $id AND (status = 'pending' OR (status = 'processing' AND processing_at <= DATE_SUB(NOW(), INTERVAL 15 MINUTE)))", 'Unable to claim deletion outbox event.'); if ((int)$db->conn()->affected_rows !== 1) continue; $connection = $db->conn(); $connection->begin_transaction(); try { $payload = json_decode((string)$event['payload_json'], true); $actor = is_array($payload) ? (int)($payload['principal_id'] ?? 0) : 0; $action = 'ACCOUNT_DELETION_' . strtoupper((string)$event['event_type']); $this->execute( "INSERT INTO logs (module, department, type, user_id, action, message, created_at, updated_at) VALUES " . "('account_deletion', 'global', 1, $actor, " . self::sql($action) . ', ' . self::sql('request:' . (string)$event['request_id']) . ', NOW(), NOW())', 'Unable to persist account deletion audit event.' ); $this->execute("UPDATE account_deletion_outbox SET status = 'delivered', processing_at = NULL, delivered_at = NOW(), last_error = NULL WHERE id = $id AND status = 'processing'", 'Unable to acknowledge deletion outbox event.'); $connection->commit(); } catch (Throwable $throwable) { $connection->rollback(); $error = substr((new \ReflectionClass($throwable))->getShortName(), 0, 191); $this->execute("UPDATE account_deletion_outbox SET status = 'pending', processing_at = NULL, attempts = attempts + 1, available_at = DATE_ADD(NOW(), INTERVAL 5 MINUTE), last_error = " . self::sql($error) . " WHERE id = $id", 'Unable to reschedule deletion outbox event.'); } } } private function validateConfirmation(array $input): void { if (($input['confirmation'] ?? null) !== self::CONFIRMATION_PHRASE) { throw new account_deletion_http_exception('Confirmation phrase does not match', 400); } if (($input['acknowledge_legal_retention'] ?? null) !== true) { throw new account_deletion_http_exception('Legal retention acknowledgement is required', 400); } } private function verifyCredentials(array $principal, array $input): void { $object = $principal['object']; $password = $input['password'] ?? null; $passwordMatches = false; if ($object instanceof users_o) { $hash = $object->getPassword(); if (is_string($hash) && $hash !== '') { if (!is_string($password) || $password === '') { throw new account_deletion_http_exception('Password is required', 400); } try { $passwordMatches = $object->passwordMatches($password); } catch (Throwable) { $passwordMatches = false; } } else { $passwordMatches = $this->verifyDeletionPasskey($principal, $input); } } elseif ($object instanceof subusers_o) { $hash = $object->password->value(); if (is_string($hash) && $hash !== '') { if (!is_string($password) || $password === '') { throw new account_deletion_http_exception('Password is required', 400); } $passwordMatches = password_verify($password, $hash); } else { $passwordMatches = $this->verifyDeletionPasskey($principal, $input); } } if (!$passwordMatches) { throw new account_deletion_http_exception('Invalid credentials', 401); } if ($object->isTwoFactorEnabled()) { $code = $input['two_factor_code'] ?? null; if (!is_string($code) || trim($code) === '') { throw new account_deletion_http_exception('Two-factor code is required', 400); } if (!(new authentication())->verify_2fa_code($object, $code)) { throw new account_deletion_http_exception('Invalid two-factor code', 401); } } } private function verifyDeletionPasskey(array $principal, array $input): bool { global $db; $challenge = $input['passkey_challenge_token'] ?? null; $credential = $input['passkey_credential'] ?? null; if (!is_string($challenge) || strlen($challenge) !== 64 || $credential === null) { throw new account_deletion_http_exception('A fresh deletion passkey assertion is required', 400); } $credentialJson = is_string($credential) ? $credential : json_encode($credential, JSON_UNESCAPED_SLASHES); $credentialArray = is_array($credential) ? $credential : json_decode((string)$credentialJson, true); $credentialId = is_array($credentialArray) ? ($credentialArray['id'] ?? $credentialArray['rawId'] ?? null) : null; $id = (int)$principal['id']; $challengeSql = self::sql($challenge); $result = $db->query("SELECT id FROM tokens WHERE token = $challengeSql AND user_id = $id AND type = 'ACCOUNT_DELETION_PASSKEY_CHALLENGE' AND created_at >= DATE_SUB(NOW(), INTERVAL 5 MINUTE) LIMIT 1"); if ($result === false) throw new \RuntimeException('Unable to inspect deletion passkey challenge.'); $this->execute("DELETE FROM tokens WHERE token = $challengeSql AND type = 'ACCOUNT_DELETION_PASSKEY_CHALLENGE'", 'Unable to consume deletion passkey challenge.'); if ($result->num_rows === 0 || (int)$db->conn()->affected_rows !== 1 || !is_string($credentialId) || !is_string($credentialJson)) return false; $passkey = (new passkeys_o())->findByCredentialId($credentialId, $id); if ($passkey === null || (bool)$passkey->is_subuser->value() !== ($principal['type'] === 'subuser')) return false; $host = parse_url((string)($_SERVER['HTTP_ORIGIN'] ?? ''), PHP_URL_HOST) ?: ($_SERVER['SERVER_NAME'] ?? 'localhost'); return (new webauthn())->verifyAssertion($credentialJson, $challenge, $passkey, $host); } private function recordCredentialAttempt(array $principal, ?string $ip): string { $key = hash( 'sha256', (string)$principal['type'] . ':' . (int)$principal['id'] . ':' . trim((string)$ip) ); $keySql = self::sql($key); $maximumAttempts = self::MAX_CREDENTIAL_ATTEMPTS; $this->execute( "INSERT INTO account_deletion_credential_attempts (throttle_key, attempt_count, window_started_at) VALUES ($keySql, 1, NOW()) ON DUPLICATE KEY UPDATE blocked_until = IF( window_started_at < DATE_SUB(NOW(), INTERVAL 15 MINUTE), NULL, IF(attempt_count + 1 >= $maximumAttempts, DATE_ADD(NOW(), INTERVAL 15 MINUTE), blocked_until) ), attempt_count = IF(window_started_at < DATE_SUB(NOW(), INTERVAL 15 MINUTE), 1, attempt_count + 1), window_started_at = IF(window_started_at < DATE_SUB(NOW(), INTERVAL 15 MINUTE), NOW(), window_started_at)", 'Unable to record deletion credential attempt.' ); global $db; $result = $db->query("SELECT attempt_count, blocked_until FROM account_deletion_credential_attempts WHERE throttle_key = $keySql LIMIT 1"); if ($result === false || $result->num_rows !== 1) throw new \RuntimeException('Unable to enforce deletion credential throttle.'); $row = $result->fetch_assoc(); if ((int)$row['attempt_count'] >= self::MAX_CREDENTIAL_ATTEMPTS || (($row['blocked_until'] ?? null) !== null && strtotime((string)$row['blocked_until']) > time())) { throw new account_deletion_http_exception('Too many deletion confirmation attempts', 429); } return $key; } private function clearCredentialAttempts(string $key): void { $this->execute('DELETE FROM account_deletion_credential_attempts WHERE throttle_key = ' . self::sql($key), 'Unable to clear deletion credential attempts.'); } private function blockPrincipalImmediately(string $type, int $id, ?int $customerNumber, string $now): void { global $db; if ($type === 'customer') { if (self::tableExists('limited_backoffice_employees')) { $employeeLock = $db->query( "SELECT user_id FROM limited_backoffice_employees WHERE user_id = $id LIMIT 1 FOR UPDATE" ); if ($employeeLock === false) { throw new \RuntimeException('Unable to lock deleted employee.'); } } $this->updateExistingColumns('users', $id, ['deleted_at' => $now]); if (self::tableExists('limited_backoffice_login_grants')) { $this->execute( "UPDATE limited_backoffice_login_grants SET revoked_at = COALESCE(revoked_at, " . self::sql($now) . ") WHERE (target_user_id = $id OR actor_user_id = $id) AND consumed_at IS NULL", 'Unable to revoke deleted principal login grants.' ); } $this->deleteTokensForPrincipal('customer', $id); } else { $this->updateExistingColumns('subusers', $id, ['suspended_at' => $now, 'deleted_at' => $now]); $this->execute( "UPDATE subuser_grants SET enabled = 0, deleted_at = COALESCE(deleted_at, " . self::sql($now) . "), note = NULL WHERE subuser = $id", 'Unable to revoke deleted subuser grants.' ); $this->deleteTokensForPrincipal('subuser', $id); } if (self::tableExists('passkeys')) { $this->execute( "UPDATE passkeys SET deleted_at = COALESCE(deleted_at, " . self::sql($now) . ") WHERE user_id = $id AND is_subuser = " . ($type === 'subuser' ? '1' : '0'), 'Unable to revoke deleted principal passkeys.' ); } } private function deleteTokensForPrincipal(string $type, int $id): void { global $db; $types = $type === 'customer' ? "'AUTH_TOKEN', '2FA_VERIFICATION_USER', 'PASSKEY_CHALLENGE'" : "'AUTH_TOKEN_SUBUSER', '2FA_VERIFICATION_SUBUSER', 'PASSKEY_CHALLENGE'"; $typeWhere = "type IN ($types)"; if ($type === 'customer') { $typeWhere = "($typeWhere OR LEFT(type, 25) = 'AUTH_TOKEN_IMPERSONATION:')"; } $result = $db->query("SELECT token FROM tokens WHERE user_id = $id AND $typeWhere"); if ($result === false) { throw new \RuntimeException('Unable to inspect principal credentials.'); } $tokens = $db->fetch_all($result); $this->execute("DELETE FROM tokens WHERE user_id = $id AND $typeWhere", 'Unable to revoke deleted principal tokens.'); if (!defined('redis')) { return; } foreach ($tokens as $tokenRow) { $token = (string)($tokenRow['token'] ?? ''); if ($token === '') { continue; } try { constant('redis')->clear_token($token)->clear_auth_session($token); constant('redis')->delete('passkey_challenge_principal:' . $token); } catch (Throwable) { } } } private function revokeCaches(array $principal): void { if (!defined('redis')) { return; } $redis = constant('redis'); $id = (int)$principal['id']; $token = (string)($principal['token'] ?? ''); try { if ($token !== '') { $redis->clear_token($token)->clear_auth_session($token); (new subusers_o())->invalidateSessionToken($token); } if ($principal['type'] === 'customer') { $customerNumber = (int)($principal['customer_number'] ?? 0); if ($customerNumber > 0) { $redis->clear_user_id_from_customer_number($customerNumber); } $redis->clear_customer_number_from_user_id($id); } else { foreach ($redis->get_keys('*subuser_sessions_session_token:*') as $key) { if ((int)$redis->get((string)$key) === $id) { $redis->delete((string)$key); } } foreach ($redis->get_keys('*subuser_setup_token*') as $key) { $key = (string)$key; if (str_ends_with($key, ':' . $id) || (int)$redis->get($key) === $id) { $redis->delete($key); } } } foreach ($redis->get_keys('obj_prop:' . ($principal['type'] === 'customer' ? 'users' : 'subusers') . ':' . $id . ':*') as $key) { $redis->delete((string)$key); } } catch (Throwable) { // Database token deletion and auth guards remain authoritative. } } private function claim(int $id): bool { global $db; $now = self::sql(date('Y-m-d H:i:s')); $staleBefore = self::sql(date('Y-m-d H:i:s', time() - self::PROCESSING_LEASE_SECONDS)); $this->execute( "UPDATE account_deletion_requests SET status = 'processing', processing_at = $now, failure_code = NULL WHERE id = $id AND ( status IN ('requested', 'failed') OR (status = 'processing' AND (processing_at IS NULL OR processing_at <= $staleBefore)) )", 'Unable to claim account deletion request.' ); return (int)$db->conn()->affected_rows === 1; } private function complete(array $row): void { global $db; $id = (int)$row['id']; $principalType = (string)$row['principal_type']; $principalId = (int)$row['principal_id']; $email = $this->principalEmail($principalType, $principalId); $name = $this->principalName($principalType, $principalId); $connection = $db->conn(); $connection->begin_transaction(); try { $now = date('Y-m-d H:i:s'); if ($principalType === 'customer') { $this->updateExistingColumns('users', $principalId, [ 'display_name' => 'Slettet konto', 'email' => null, 'phone_country_code' => null, 'phone' => null, 'password' => null, 'xlvask_customer_id' => null, 'sms_notifications_enabled' => 0, 'email_notifications_enabled' => 0, 'wash_certificate_email' => null, 'two_factor_secret' => null, 'two_factor_enabled' => 0, 'deleted_at' => $now, ]); $this->deleteIfColumnsExist('user_key_value_pairs', ['user_id' => $principalId]); $this->deleteIfColumnsExist('customer_attributes', ['user_id' => $principalId]); } else { $this->updateExistingColumns('subusers', $principalId, [ 'username' => null, 'password' => null, 'name' => 'Slettet chauffør', 'email' => null, 'email_verified_at' => null, 'phone_country_code' => null, 'phone' => null, 'phone_verified_at' => null, 'two_factor_secret' => null, 'two_factor_enabled' => 0, 'suspended_at' => $now, 'deleted_at' => $now, ]); } $this->softDeleteAttachments($principalType, $principalId, $now); $this->deleteSessionActivity($principalType, $principalId); $this->execute( "UPDATE account_deletion_requests SET status = 'completed', active_principal_key = NULL, completed_at = " . self::sql($now) . ", next_attempt_at = NULL, failure_code = NULL WHERE id = $id AND status = 'processing'", 'Unable to complete account deletion request.' ); if ((int)$db->conn()->affected_rows !== 1) throw new \RuntimeException('Account deletion completion lease was lost.'); $this->enqueueOutbox((string)$row['request_id'], 'completed', ['principal_type' => $principalType, 'principal_id' => $principalId]); $connection->commit(); } catch (Throwable $throwable) { $connection->rollback(); throw $throwable; } $principal = [ 'type' => $principalType, 'id' => $principalId, 'customer_number' => $row['customer_number_snapshot'] !== null ? (int)$row['customer_number_snapshot'] : null, 'token' => '', ]; $this->revokeCaches($principal); $this->sendCompletedNotification($email, $name, (string)$row['request_id']); } private function recordFailure(int $id, int $previousRetries, Throwable $throwable): void { global $db; $retryCount = $previousRetries + 1; $delay = min(86400, 300 * (2 ** max(0, $retryCount - 1))); $nextAttempt = $retryCount >= self::MAX_RETRIES ? null : date('Y-m-d H:i:s', time() + $delay); $failureCode = substr((new \ReflectionClass($throwable))->getShortName(), 0, 191); $nextAttemptSql = $nextAttempt !== null ? self::sql($nextAttempt) : 'NULL'; $terminal = $retryCount >= self::MAX_RETRIES; $status = $terminal ? 'manual_review' : 'failed'; $connection = $db->conn(); $connection->begin_transaction(); try { $this->execute( "UPDATE account_deletion_requests SET status = " . self::sql($status) . ", retry_count = $retryCount, failure_code = " . self::sql($failureCode) . ", next_attempt_at = $nextAttemptSql, manual_review_required_at = " . ($terminal ? 'NOW()' : 'NULL') . " WHERE id = $id", 'Unable to persist account deletion failure.' ); if ($terminal) { $result = $db->query("SELECT request_id FROM account_deletion_requests WHERE id = $id LIMIT 1"); if ($result === false || $result->num_rows !== 1) throw new \RuntimeException('Unable to identify terminal deletion failure.'); $failureRow = $result->fetch_assoc(); $this->enqueueOutbox((string)$failureRow['request_id'], 'manual_review_required', ['failure_code' => $failureCode]); } $connection->commit(); } catch (Throwable $failure) { $connection->rollback(); throw $failure; } $this->auditRaw('ACCOUNT_DELETION_FAILED', 0, 'request_row:' . $id . ';failure:' . $failureCode); if ($terminal) { $message = 'request_row:' . $id . ';failure:' . $failureCode . ';action:manual_support_review'; $this->auditRaw('ACCOUNT_DELETION_REQUIRES_SUPPORT', 0, $message); error_log('[account-deletion] Terminal failure requires support review: ' . $message); } } private function activeRequest(string $type, int $id, bool $forUpdate = false): ?array { global $db; $key = self::sql($type . ':' . $id); $result = $db->query( "SELECT * FROM account_deletion_requests WHERE active_principal_key = $key LIMIT 1" . ($forUpdate ? ' FOR UPDATE' : '') ); if ($result === false) { throw new \RuntimeException('Unable to inspect active account deletion requests.'); } if ($result->num_rows === 0) { return null; } return $result->fetch_assoc() ?: null; } private function requestByPublicId(string $requestId): ?array { global $db; $result = $db->query( 'SELECT * FROM account_deletion_requests WHERE request_id = ' . self::sql($requestId) . ' LIMIT 1' ); if ($result === false) { throw new \RuntimeException('Unable to inspect account deletion request state.'); } if ($result->num_rows === 0) { return null; } return $result->fetch_assoc() ?: null; } private function statePayload(array $principal, ?array $row): array { $status = $row !== null ? (string)$row['status'] : 'available'; return [ 'principal_type' => $principal['type'], 'status' => $status, 'confirmation_phrase' => self::CONFIRMATION_PHRASE, 'password_required' => $this->principalHasPassword($principal), 'two_factor_required' => (bool)$principal['object']->isTwoFactorEnabled(), 'access_effect' => $principal['type'] === 'customer' ? 'Din loginidentitet lukkes straks. Andre loginidentiteter og virksomhedens historiske data bevares.' : 'Din chaufførkonto lukkes straks, og alle dine virksomhedsadgange deaktiveres.', 'retained_data_categories' => $this->retainedDataCategories((string)$principal['type']), 'privacy_policy_version' => self::POLICY_VERSION, 'request_id' => $row['request_id'] ?? null, 'requested_at' => $row['requested_at'] ?? null, ]; } private function principalHasPassword(array $principal): bool { $object = $principal['object']; if ($object instanceof users_o) { return $object->hasPassword(); } $password = $object->password->value(); return is_string($password) && trim($password) !== ''; } private function acceptedPayload(array $row): array { $retained = json_decode((string)($row['retained_data_json'] ?? '[]'), true); return [ 'request_id' => (string)$row['request_id'], 'status' => (string)$row['status'], 'requested_at' => (string)$row['requested_at'], 'access_revoked' => true, 'retained_data_categories' => is_array($retained) ? array_values($retained) : [], ]; } /** @return array */ private function retainedDataCategories(string $principalType): array { $categories = [ 'invoices_payments_accounting', 'orders_wash_history', 'security_audit_logs', 'legal_obligations', ]; if ($principalType === 'customer') { $categories[] = 'customer_reference'; } else { $categories[] = 'driver_reference'; } return $categories; } private function updateExistingColumns(string $table, int $id, array $updates): void { global $db; $columns = self::columns($table); $set = []; foreach ($updates as $column => $value) { if (!in_array($column, $columns, true)) { continue; } $set[] = '`' . $column . '` = ' . self::valueSql($value); } if ($set !== []) { $this->execute("UPDATE `$table` SET " . implode(', ', $set) . " WHERE id = $id", "Unable to update deletion principal in $table."); } } private function deleteIfColumnsExist(string $table, array $conditions): void { if (!self::tableExists($table)) { return; } $columns = self::columns($table); foreach (array_keys($conditions) as $column) { if (!in_array($column, $columns, true)) { return; } } global $db; $where = []; foreach ($conditions as $column => $value) { $where[] = '`' . $column . '` = ' . self::valueSql($value); } $this->execute("DELETE FROM `$table` WHERE " . implode(' AND ', $where), "Unable to delete identity-owned data from $table."); } private function softDeleteAttachments(string $principalType, int $principalId, string $now): void { if (!self::tableExists('object_attachments')) { return; } $columns = self::columns('object_attachments'); if (!array_diff(['object_type', 'object_id', 'content', 'deleted_at'], $columns)) { global $db; $objectType = $principalType === 'customer' ? 'users' : 'subusers'; $this->execute( "UPDATE object_attachments SET deleted_at = COALESCE(deleted_at, " . self::sql($now) . '), content = NULL' . ' WHERE object_type = ' . self::sql($objectType) . " AND object_id = $principalId", 'Unable to delete identity attachments.' ); } } private function deleteSessionActivity(string $principalType, int $principalId): void { if (!self::tableExists('system_session_activity')) { return; } $columns = self::columns('system_session_activity'); if (!array_diff(['session_kind', 'principal_id'], $columns)) { global $db; $sessionKind = $principalType === 'customer' ? 'user' : 'subuser'; $this->execute( 'DELETE FROM system_session_activity WHERE session_kind = ' . self::sql($sessionKind) . " AND principal_id = $principalId", 'Unable to delete identity session activity.' ); } } private function principalEmail(string $type, int $id): ?string { return $this->principalField($type, $id, 'email'); } private function principalName(string $type, int $id): string { $field = $type === 'customer' ? 'display_name' : 'name'; return $this->principalField($type, $id, $field) ?? ($type === 'customer' ? 'Kunde' : 'Chauffør'); } private function principalField(string $type, int $id, string $field): ?string { $table = $type === 'customer' ? 'users' : 'subusers'; if (!in_array($field, self::columns($table), true)) { return null; } global $db; $result = $db->query("SELECT `$field` FROM `$table` WHERE id = $id LIMIT 1"); if ($result === false || $result->num_rows === 0) { return null; } $row = $result->fetch_assoc(); return $this->nullableString($row[$field] ?? null); } private function sendRequestedNotification(array $principal, string $requestId): void { $email = $principal['email'] ?? null; if (!is_string($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) { return; } try { (new email())->sendEmail( $email, (string)$principal['name'], 'Vi har modtaget din anmodning om kontosletning', '

Din adgang er nu lukket, og anmodningen behandles.

Reference: ' . htmlspecialchars($requestId, ENT_QUOTES, 'UTF-8') . '

' ); } catch (Throwable $throwable) { $this->auditRaw('ACCOUNT_DELETION_NOTIFICATION_FAILED', (int)$principal['id'], 'requested'); } } private function sendCompletedNotification(?string $email, string $name, string $requestId): void { if ($email === null || !filter_var($email, FILTER_VALIDATE_EMAIL)) { return; } try { (new email())->sendEmail( $email, $name, 'Din kontosletning er gennemført', '

Dine personlige kontooplysninger er anonymiseret. Lovpligtige historiske data opbevares som beskrevet i privatlivspolitikken.

Reference: ' . htmlspecialchars($requestId, ENT_QUOTES, 'UTF-8') . '

' ); } catch (Throwable) { $this->auditRaw('ACCOUNT_DELETION_NOTIFICATION_FAILED', 0, 'completed'); } } private function auditRaw(string $action, int $actorId, string $message): void { try { (new logs_o())->add('account_deletion', 'global', 1, $actorId, $action, $message); } catch (Throwable) { } } private function enqueueOutbox(string $requestId, string $eventType, array $payload): void { $json = json_encode($payload, JSON_UNESCAPED_SLASHES); if (!is_string($json)) throw new \RuntimeException('Unable to encode deletion outbox event.'); $this->execute( 'INSERT INTO account_deletion_outbox (request_id, event_type, payload_json, available_at) VALUES (' . self::sql($requestId) . ', ' . self::sql($eventType) . ', ' . self::sql($json) . ', NOW()) ' . 'ON DUPLICATE KEY UPDATE payload_json = VALUES(payload_json), available_at = LEAST(available_at, VALUES(available_at))', 'Unable to persist account deletion outbox event.' ); } private function bearerToken(): string { $headers = function_exists('getallheaders') ? getallheaders() : []; $value = (string)($headers['Authorization'] ?? $_SERVER['HTTP_AUTHORIZATION'] ?? ''); return trim(str_replace('Bearer ', '', $value)); } private function rejectImpersonationToken(string $token): void { if ($token === '') { return; } global $db; $result = $db->query( 'SELECT type, created_at FROM tokens WHERE token = ' . self::sql($token) . ' LIMIT 1' ); if ($result === false) { throw new \RuntimeException('Unable to inspect account deletion session provenance.'); } $row = $result->fetch_assoc(); if (str_starts_with((string)($row['type'] ?? ''), 'AUTH_TOKEN_IMPERSONATION:')) { throw new account_deletion_http_exception( 'Account deletion is unavailable during support impersonation', 403 ); } if (($row['type'] ?? '') === 'AUTH_TOKEN' && strtotime((string)($row['created_at'] ?? '')) < strtotime(self::LEGACY_SESSION_CUTOFF)) { throw new account_deletion_http_exception('A fresh login is required before account deletion', 403); } } private function nullableString(mixed $value): ?string { if (!is_scalar($value)) { return null; } $value = trim((string)$value); return $value !== '' ? $value : null; } private static function tableExists(string $table): bool { if (array_key_exists($table, self::$tableExistsCache)) { return self::$tableExistsCache[$table]; } global $db; $tableSql = $db->escape_string($table); $result = $db->query("SHOW TABLES LIKE '$tableSql'"); if ($result === false) { throw new \RuntimeException('Unable to inspect database table availability.'); } return self::$tableExistsCache[$table] = $result->num_rows > 0; } /** @return array */ private static function columns(string $table): array { if (isset(self::$columnsCache[$table])) { return self::$columnsCache[$table]; } if (!self::tableExists($table)) { return self::$columnsCache[$table] = []; } global $db; $result = $db->query('SHOW COLUMNS FROM `' . preg_replace('/[^a-zA-Z0-9_]/', '', $table) . '`'); if ($result === false) { throw new \RuntimeException('Unable to inspect database column availability.'); } $rows = $db->fetch_all($result); return self::$columnsCache[$table] = array_values(array_map('strval', array_column($rows, 'Field'))); } private static function valueSql(mixed $value): string { if ($value === null) { return 'NULL'; } if (is_bool($value)) { return $value ? '1' : '0'; } if (is_int($value) || is_float($value)) { return (string)$value; } return self::sql((string)$value); } private function execute(string $sql, string $message): void { global $db; if ($db->query($sql) === false) throw new \RuntimeException($message); } private static function sql(string $value): string { global $db; return "'" . $db->escape_string($value) . "'"; } private static function uuidV4(): string { $bytes = random_bytes(16); $bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40); $bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80); $hex = bin2hex($bytes); return substr($hex, 0, 8) . '-' . substr($hex, 8, 4) . '-' . substr($hex, 12, 4) . '-' . substr($hex, 16, 4) . '-' . substr($hex, 20); } }