capacity($review_id); if (!is_array($capacity)) return null; $bytes = max(0, (int) $capacity['bytes']); $directory = $this->review_private_directory($review_id); $archives = $this->review_export_archives($review_id); if (null === $directory || null === $archives) return null; try { if (is_string($directory)) { $files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS)); foreach ($files as $file) { if ($file->isLink()) return null; if ($file->isFile()) $bytes += $file->getSize(); } } foreach ($archives as $archive) { $size = @filesize($archive); // A concurrent cleanup may already have removed this temporary archive. if (false === $size) return null; $bytes += $size; } } catch (\RuntimeException $exception) { return null; } return $bytes; } /** * Starts a cleanup or resumes its durable state if it was already started. * It locks/pauses the review, revokes guest access, verifies promoted Media * Library files, then advances a bounded amount of private-file deletion. * * A failure leaves the retry state and the per-review encryption-key option * intact. Routing can call resume() after the underlying condition is fixed. * * @return array|\WP_Error */ public function begin(int $review_id, int $actor_user_id, bool $allow_without_export = false, ?int $stopped_version = null): array|\WP_Error { $permission = $this->authorize($actor_user_id); if (is_wp_error($permission)) { return $permission; } if ($review_id < 1) { return Security::error('cleanup_review_invalid', __('A valid review is required for cleanup.', 'commentmate'), 400); } $state = $this->load_state($review_id); if (is_wp_error($state)) { return $state; } // A state created before the review row was deleted must remain resumable // through final key/residue verification. Never reject it merely because // the database-erasure stage already completed. if (is_array($state)) { if (null !== $stopped_version && ($stopped_version !== (int) ($state['stoppedVersion'] ?? 0))) { return Security::error('cleanup_stale', __('This confirmation does not match the pending cleanup. Reload CommentMate.', 'commentmate'), 409); } return $this->advance($state, $actor_user_id, self::MAX_FILE_BATCH); } if (!$this->review_exists($review_id)) { return Security::error('cleanup_review_not_found', __('That review no longer exists.', 'commentmate'), 404); } $receipt = $allow_without_export ? false : (new Export_Service())->verified_receipt($review_id, $actor_user_id); if (is_wp_error($receipt)) { return $receipt; } if (!$allow_without_export && !is_array($receipt)) { return Security::error('cleanup_export_required', __('Download and verify a current private export before erasing this review, or explicitly choose to erase without an export.', 'commentmate'), 409); } if (!is_array($state)) { $state = array( 'schema' => 1, 'stoppedVersion' => $stopped_version, 'reviewId' => $review_id, 'actorUserId' => $actor_user_id, 'stage' => 'prepare', 'resumeStage' => 'prepare', 'startedAt' => gmdate('c'), 'updatedAt' => gmdate('c'), // The fingerprint is rechecked while the review row is locked in the // prepare stage. A new comment, reply, screenshot, or attachment after // export therefore blocks cleanup until it is exported again. 'exportRequired' => !$allow_without_export, 'exportFingerprint' => is_array($receipt) ? (string) ($receipt['contentFingerprint'] ?? '') : null, 'manifest' => array(), 'progress' => array('fileCursor' => 0, 'deletedFiles' => 0), 'lastError' => null, ); if (!$this->create_state($review_id, $state)) { $state = $this->load_state($review_id); if (is_wp_error($state) || !is_array($state)) { return Security::error('cleanup_state_unavailable', __('Cleanup could not store its retry manifest.', 'commentmate'), 503); } } } // A competing request may have created a different checkpoint after our read. if (null !== $stopped_version && ($stopped_version !== (int) ($state['stoppedVersion'] ?? 0))) { return Security::error('cleanup_stale', __('This confirmation does not match the pending cleanup. Reload CommentMate.', 'commentmate'), 409); } return $this->advance($state, $actor_user_id, self::MAX_FILE_BATCH); } /** Delete only the stopped generation whose owner confirmed the offer. */ public function delete_stopped_for_owner(int $review_id, int $version, int $actor_user_id): array|\WP_Error { $permission = $this->authorize($actor_user_id); if (is_wp_error($permission)) { return $permission; } if ($review_id < 1 || $version < 1) { return Security::error('cleanup_stale', __('This stopped review confirmation is invalid. Reload CommentMate.', 'commentmate'), 409); } $state = $this->load_state($review_id); if (is_wp_error($state)) { return $state; } if (is_array($state)) { if ($version !== (int) ($state['stoppedVersion'] ?? 0)) { return Security::error('cleanup_stale', __('This confirmation does not match the pending cleanup. Reload CommentMate.', 'commentmate'), 409); } return $this->resume($review_id, $actor_user_id); } return $this->begin($review_id, $actor_user_id, true, $version); } /** * Advances the one owner-facing CommentMate deletion flow. The review id is * intentionally resolved on the server so a browser cannot repurpose the * destructive control for another retained review. New states deliberately * bypass the former private-export requirement; any older durable state * keeps its recorded behaviour until it is safely finished. * * @return array|\WP_Error */ public function delete_all_for_owner(int $actor_user_id): array|\WP_Error { $permission = $this->authorize($actor_user_id); if (is_wp_error($permission)) { return $permission; } $pending = $this->pending_review_id($actor_user_id); if (is_wp_error($pending)) { return $pending; } if (is_int($pending)) { $legacy_state = $this->load_state($pending); if (is_wp_error($legacy_state)) { return $legacy_state; } // A 0.1.9-era checkpoint can still be at the pre-lock export gate. The // new, explicitly confirmed Delete all data dialog is intentionally the // single owner workflow, so normalize only that safe pre-lock state. if (is_array($legacy_state) && $this->state_can_drop_export_requirement($legacy_state)) { $legacy_state['exportRequired'] = false; $legacy_state['exportFingerprint'] = null; if (!$this->save_state($pending, $legacy_state)) { return Security::error('cleanup_state_unavailable', __('CommentMate could not update its deletion checkpoint. Try again.', 'commentmate'), 503); } } return $this->resume($pending, $actor_user_id); } $review = (new Review_Service())->latest(); if (!is_array($review) || (int) ($review['id'] ?? 0) < 1) { return Security::error('cleanup_review_not_found', __('There is no retained CommentMate review data to delete.', 'commentmate'), 404); } return $this->begin((int) $review['id'], $actor_user_id, true); } /** * Advances an existing cleanup. A caller may use this after a failed media * verification, interrupted filesystem operation, or a bounded file batch. * * @return array|\WP_Error */ public function resume(int $review_id, int $actor_user_id, int $max_files = self::MAX_FILE_BATCH): array|\WP_Error { $permission = $this->authorize($actor_user_id); if (is_wp_error($permission)) { return $permission; } $state = $this->load_state($review_id); if (is_wp_error($state)) { return $state; } if (!is_array($state)) { return Security::error('cleanup_not_started', __('There is no cleanup retry manifest for that review.', 'commentmate'), 404); } $max_files = min(100, max(1, $max_files)); return $this->advance($state, $actor_user_id, $max_files); } /** * Returns non-sensitive cleanup progress. This intentionally never exposes * private storage paths, encryption material, or share/session secrets. * * @return array|\WP_Error */ public function status(int $review_id, int $actor_user_id): array|\WP_Error { $permission = $this->authorize($actor_user_id); if (is_wp_error($permission)) { return $permission; } $state = $this->load_state($review_id); if (is_wp_error($state)) { return $state; } if (!is_array($state)) { return array('reviewId' => $review_id, 'state' => 'not_started'); } $manifest = is_array($state['manifest'] ?? null) ? $state['manifest'] : array(); $progress = is_array($state['progress'] ?? null) ? $state['progress'] : array(); return array( 'reviewId' => $review_id, 'state' => (string) ($state['stage'] ?? 'unknown'), 'retryStage' => (string) ($state['resumeStage'] ?? 'unknown'), 'updatedAt' => (string) ($state['updatedAt'] ?? ''), 'lastError' => is_array($state['lastError'] ?? null) ? $state['lastError'] : null, 'transientFiles' => is_array($manifest['transientAttachments'] ?? null) ? count($manifest['transientAttachments']) : 0, 'promotedMedia' => is_array($manifest['promotedAttachments'] ?? null) ? count($manifest['promotedAttachments']) : 0, 'deletedFiles' => (int) ($progress['deletedFiles'] ?? 0), 'nextFileIndex' => (int) ($progress['fileCursor'] ?? 0), ); } /** * Finds a durable cleanup state even after its review row has been removed. * This is used only by the owner UI to surface a safe RESUME control; it * intentionally returns an id rather than manifest contents or file paths. * * @return int|null|\WP_Error */ public function pending_review_id(int $actor_user_id): int|null|\WP_Error { $permission = $this->authorize($actor_user_id); if (is_wp_error($permission)) { return $permission; } global $wpdb; $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Cleanup leases and residue verification require current plugin-table state; a cached result could delete or retain the wrong data. $wpdb->prepare('SELECT option_name, option_value FROM %i WHERE option_name LIKE %s ORDER BY option_id DESC LIMIT 20', $wpdb->options, $wpdb->esc_like(self::STATE_PREFIX) . '%'), ARRAY_A ); if (!is_array($rows)) { return Security::error('cleanup_state_unavailable', __('Cleanup retry state could not be checked.', 'commentmate'), 503); } foreach ($rows as $row) { $name = (string) ($row['option_name'] ?? ''); $value = (string) ($row['option_value'] ?? ''); if (!preg_match('/\A' . preg_quote(self::STATE_PREFIX, '/') . '([1-9][0-9]*)\z/', $name, $matches)) { continue; } $state = json_decode($value, true); $review_id = (int) $matches[1]; if (is_array($state) && 1 === (int) ($state['schema'] ?? 0) && $review_id === (int) ($state['reviewId'] ?? 0) && 'completed' !== (string) ($state['stage'] ?? '')) { return $review_id; } } return null; } /** * @param array $state * @return array|\WP_Error */ private function advance(array $state, int $actor_user_id, int $max_files): array|\WP_Error { $review_id = (int) ($state['reviewId'] ?? 0); if ($review_id < 1) { return Security::error('cleanup_state_invalid', __('The cleanup retry manifest is invalid.', 'commentmate'), 503); } $lease_token = $this->claim_lease($review_id); if (null === $lease_token) { return Security::error('cleanup_in_progress', __('Cleanup is already running in another owner request. Wait a moment, then refresh its status.', 'commentmate'), 409); } try { if (!$this->renew_lease($review_id, $lease_token)) { return $this->lease_lost_error(); } if ('failed' === ($state['stage'] ?? '')) { $state['stage'] = (string) ($state['resumeStage'] ?? 'prepare'); $state['lastError'] = null; if (!$this->save_state($review_id, $state)) { return Security::error('cleanup_state_unavailable', __('Cleanup could not update its retry manifest.', 'commentmate'), 503); } } for ($step = 0; $step < 8; $step++) { if (!$this->renew_lease($review_id, $lease_token)) { return $this->lease_lost_error(); } $stage = (string) ($state['stage'] ?? 'prepare'); switch ($stage) { case 'prepare': $export_required = !array_key_exists('exportRequired', $state) || (bool) $state['exportRequired']; $expected_fingerprint = isset($state['exportFingerprint']) && is_string($state['exportFingerprint']) ? $state['exportFingerprint'] : ''; $prepared = $this->lock_and_snapshot($review_id, '' === $expected_fingerprint ? null : $expected_fingerprint, $export_required, isset($state['stoppedVersion']) ? (int) $state['stoppedVersion'] : null); if (!$this->renew_lease($review_id, $lease_token)) { return $this->lease_lost_error(); } if (is_wp_error($prepared)) { if (in_array($prepared->get_error_code(), array('commentmate_cleanup_export_stale', 'commentmate_cleanup_export_required', 'commentmate_cleanup_stale'), true)) { // No review lock was acquired for this path, so discard the // provisional state and let the owner make a fresh export. delete_option($this->state_option($review_id)); return $prepared; } if ($this->is_lease_error($prepared)) { return $prepared; } $error_data = $prepared->get_error_data(); $status = is_array($error_data) && isset($error_data['status']) ? (int) $error_data['status'] : 503; return $this->fail($state, 'prepare', $prepared->get_error_message(), $status); } $state['manifest'] = $prepared; $state['progress'] = array('fileCursor' => 0, 'deletedFiles' => 0); $state['stage'] = 'verify_promoted_media'; $state['resumeStage'] = 'verify_promoted_media'; if (!$this->save_state($review_id, $state)) { return Security::error('cleanup_state_unavailable', __('Cleanup could not save its retry manifest.', 'commentmate'), 503); } break; case 'verify_promoted_media': $verified = $this->verify_promoted_media($state, $lease_token); if (is_wp_error($verified)) { return $this->is_lease_error($verified) ? $verified : $this->fail($state, 'verify_promoted_media', $verified->get_error_message(), 409); } $state['stage'] = 'delete_transient_files'; $state['resumeStage'] = 'delete_transient_files'; if (!$this->save_state($review_id, $state)) { return Security::error('cleanup_state_unavailable', __('Cleanup could not save its retry manifest.', 'commentmate'), 503); } break; case 'delete_transient_files': $deleted = $this->delete_transient_file_batch($state, $max_files, $lease_token); if (is_wp_error($deleted)) { return $this->is_lease_error($deleted) ? $deleted : $this->fail($state, 'delete_transient_files', $deleted->get_error_message(), 503); } if (!$this->save_state($review_id, $state)) { return Security::error('cleanup_state_unavailable', __('Cleanup could not save its file-deletion progress.', 'commentmate'), 503); } if (!$deleted) { return $this->status($review_id, $actor_user_id); } $state['stage'] = 'delete_database_rows'; $state['resumeStage'] = 'delete_database_rows'; if (!$this->save_state($review_id, $state)) { return Security::error('cleanup_state_unavailable', __('Cleanup could not save its retry manifest.', 'commentmate'), 503); } break; case 'delete_database_rows': $deleted = $this->delete_database_rows($review_id); if (!$this->renew_lease($review_id, $lease_token)) { return $this->lease_lost_error(); } if (is_wp_error($deleted)) { return $this->fail($state, 'delete_database_rows', $deleted->get_error_message(), 503); } $state['stage'] = 'verify_residue_before_key'; $state['resumeStage'] = 'verify_residue_before_key'; if (!$this->save_state($review_id, $state)) { return Security::error('cleanup_state_unavailable', __('Cleanup could not save its retry manifest.', 'commentmate'), 503); } break; case 'verify_residue_before_key': $verified = $this->verify_no_residue($review_id, $state, false, $lease_token); if (is_wp_error($verified)) { return $this->is_lease_error($verified) ? $verified : $this->fail($state, 'verify_residue_before_key', $verified->get_error_message(), 503); } $state['stage'] = 'delete_encryption_key'; $state['resumeStage'] = 'delete_encryption_key'; if (!$this->save_state($review_id, $state)) { return Security::error('cleanup_state_unavailable', __('Cleanup could not save its retry manifest.', 'commentmate'), 503); } break; case 'delete_encryption_key': $key_option = $this->key_option($review_id); if (!delete_option($key_option) && false !== get_option($key_option, false)) { return $this->fail($state, 'delete_encryption_key', __('The review encryption key could not be removed.', 'commentmate'), 503); } $receipt_option = Export_Service::receipt_option($review_id); if (!delete_option($receipt_option) && false !== get_option($receipt_option, false)) { return $this->fail($state, 'delete_encryption_key', __('The private-export receipt could not be removed.', 'commentmate'), 503); } $state['stage'] = 'verify_residue'; $state['resumeStage'] = 'verify_residue'; if (!$this->save_state($review_id, $state)) { return Security::error('cleanup_state_unavailable', __('Cleanup could not save its retry manifest.', 'commentmate'), 503); } break; case 'verify_residue': $verified = $this->verify_no_residue($review_id, $state, true, $lease_token); if (is_wp_error($verified)) { return $this->is_lease_error($verified) ? $verified : $this->fail($state, 'verify_residue', $verified->get_error_message(), 503); } $state['stage'] = 'finalize'; $state['resumeStage'] = 'finalize'; if (!$this->save_state($review_id, $state)) { return Security::error('cleanup_state_unavailable', __('Cleanup could not save its final verification.', 'commentmate'), 503); } break; case 'finalize': if (!delete_option($this->state_option($review_id)) && false !== get_option($this->state_option($review_id), false)) { return $this->fail($state, 'finalize', __('Cleanup could not remove its completed retry manifest.', 'commentmate'), 503); } return array( 'reviewId' => $review_id, 'state' => 'completed', 'message' => __('Review data, transient assets, sessions, tokens, audit events, and review key were removed. Media Library items were preserved.', 'commentmate'), ); default: return $this->fail($state, 'state_invalid', __('The cleanup retry manifest has an unknown stage.', 'commentmate'), 503); } } return $this->status($review_id, $actor_user_id); } finally { $this->release_lease($review_id, $lease_token); } } /** * Locks review mutations and captures every record needed for safe retry. * * @return array|\WP_Error */ private function lock_and_snapshot(int $review_id, ?string $expected_export_fingerprint = null, bool $export_required = true, ?int $stopped_version = null): array|\WP_Error { global $wpdb; $reviews = Database::table('commentmate_reviews'); $tokens = Database::table('commentmate_share_tokens'); $sessions = Database::table('commentmate_guest_sessions'); $attachments = Database::table('commentmate_attachments'); $comments = Database::table('commentmate_comments'); $now = current_time('mysql', true); // Notification_Service takes this same review-row lock across its final // wp_mail() hand-off. Starting the lifecycle transaction first therefore // makes cleanup and delivery strictly ordered rather than race-prone. if (false === $wpdb->query('START TRANSACTION')) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Explicit transaction boundary for the surrounding plugin-table operation; caching cannot provide atomicity or row locks. return Security::error('cleanup_lock_unavailable', __('Cleanup could not lock the review.', 'commentmate'), 503); } try { $review = $wpdb->get_row($wpdb->prepare('SELECT * FROM %i WHERE id = %d FOR UPDATE', $reviews, $review_id), ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Cleanup leases and residue verification require current plugin-table state; a cached result could delete or retain the wrong data. if (!is_array($review)) { throw new \RuntimeException('The review no longer exists.'); } $already_locked = (bool) $review['cleanup_locked']; if (!$already_locked && null !== $stopped_version) { $latest = (new Review_Service())->latest(); if ('paused' !== $review['state'] || $stopped_version !== (int) $review['token_version'] || !is_array($latest) || $review_id !== (int) $latest['id']) { $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Explicit transaction boundary for the surrounding plugin-table operation; caching cannot provide atomicity or row locks. return Security::error('cleanup_stale', __('Sharing or the review changed after this offer. No data was deleted. Reload CommentMate.', 'commentmate'), 409); } } if (!$already_locked) { if ($export_required && (!is_string($expected_export_fingerprint) || !preg_match('/\A[a-f0-9]{64}\z/', $expected_export_fingerprint))) { $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Explicit transaction boundary for the surrounding plugin-table operation; caching cannot provide atomicity or row locks. return Security::error('cleanup_export_required', __('A current verified private export is required before cleanup. Download a new export, then start cleanup again.', 'commentmate'), 409); } if (is_string($expected_export_fingerprint) && '' !== $expected_export_fingerprint) { $current_fingerprint = (new Export_Service())->content_fingerprint_for_locked_review($review); if (is_wp_error($current_fingerprint)) { throw new \RuntimeException('The current review data could not be verified against its export.'); } if (!hash_equals($expected_export_fingerprint, $current_fingerprint)) { $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Explicit transaction boundary for the surrounding plugin-table operation; caching cannot provide atomicity or row locks. return Security::error('cleanup_export_stale', __('The review changed after its verified export. Download a new private export before cleanup.', 'commentmate'), 409); } } $updated = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Cleanup leases and residue verification require current plugin-table state; a cached result could delete or retain the wrong data. $wpdb->prepare('UPDATE %i SET cleanup_locked = 1, state = \'paused\', active_slot = NULL, token_version = token_version + 1, paused_at = %s, updated_at = %s WHERE id = %d AND cleanup_locked = 0', $reviews, $now, $now, $review_id) ); if (1 !== $updated) { throw new \RuntimeException('The review changed while cleanup was being locked.'); } } if (false === $wpdb->query($wpdb->prepare('UPDATE %i SET revoked_at = %s, secret_ciphertext = NULL WHERE review_id = %d AND revoked_at IS NULL', $tokens, $now, $review_id)) // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Cleanup leases and residue verification require current plugin-table state; a cached result could delete or retain the wrong data. || false === $wpdb->query($wpdb->prepare('UPDATE %i SET revoked_at = %s WHERE review_id = %d AND revoked_at IS NULL', $sessions, $now, $review_id))) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Cleanup leases and residue verification require current plugin-table state; a cached result could delete or retain the wrong data. throw new \RuntimeException('Guest access could not be revoked.'); } $attachment_rows = $wpdb->get_results($wpdb->prepare('SELECT id, storage_key, checksum, disposition, media_attachment_id, byte_size, deleted_at FROM %i WHERE review_id = %d ORDER BY id ASC', $attachments, $review_id), ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Cleanup leases and residue verification require current plugin-table state; a cached result could delete or retain the wrong data. $comment_ids = $wpdb->get_col($wpdb->prepare('SELECT id FROM %i WHERE review_id = %d ORDER BY id ASC', $comments, $review_id)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Cleanup leases and residue verification require current plugin-table state; a cached result could delete or retain the wrong data. if (!is_array($attachment_rows) || !is_array($comment_ids) || false === $wpdb->query('COMMIT')) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Explicit transaction boundary for the surrounding plugin-table operation; caching cannot provide atomicity or row locks. throw new \RuntimeException('The cleanup retry manifest could not be created.'); } } catch (\Throwable $exception) { $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Explicit transaction boundary for the surrounding plugin-table operation; caching cannot provide atomicity or row locks. return Security::error('cleanup_lock_failed', __('Cleanup could not lock and snapshot this review.', 'commentmate'), 503); } $transient = array(); $promoted = array(); foreach ($attachment_rows as $row) { $entry = array( 'id' => (int) $row['id'], 'storageKey' => (string) $row['storage_key'], 'checksum' => (string) $row['checksum'], 'mediaAttachmentId' => (int) ($row['media_attachment_id'] ?? 0), 'byteSize' => (int) $row['byte_size'], ); if ('promoted' === (string) $row['disposition']) { $promoted[] = $entry; } // A promoted image remains in Media Library, but any original encrypted // private blob still belongs to this review and must be removed. if ('' !== $entry['storageKey']) { $transient[] = $entry; } } return array( 'keyOption' => $this->key_option($review_id), 'transientAttachments' => $transient, 'promotedAttachments' => $promoted, 'commentIds' => array_map('intval', $comment_ids), 'lockedAt' => gmdate('c'), ); } /** * @param array $state * @return true|\WP_Error */ private function verify_promoted_media(array $state, string $lease_token): bool|\WP_Error { // Keep this legacy checkpoint resumable. Media Library files are outside // cleanup's deletion boundary and may be edited, optimised or removed by // WordPress. Their old upload checksum must not block private-data cleanup. return $this->renew_lease((int) ($state['reviewId'] ?? 0), $lease_token) ? true : $this->lease_lost_error(); } /** * @param array $state * @return bool|\WP_Error True when every transient file was handled. */ private function delete_transient_file_batch(array &$state, int $max_files, string $lease_token): bool|\WP_Error { $review_id = (int) ($state['reviewId'] ?? 0); if ($review_id < 1) { return Security::error('cleanup_export_delete_failed', __('A temporary private export could not be removed.', 'commentmate'), 503); } if (!$this->renew_lease($review_id, $lease_token)) { return $this->lease_lost_error(); } $archives_deleted = $this->delete_review_export_archives($review_id, $lease_token); if (is_wp_error($archives_deleted)) { return $archives_deleted; } if (!$archives_deleted) { return Security::error('cleanup_export_delete_failed', __('A temporary private export could not be removed.', 'commentmate'), 503); } $manifest = is_array($state['manifest'] ?? null) ? $state['manifest'] : array(); $assets = is_array($manifest['transientAttachments'] ?? null) ? $manifest['transientAttachments'] : array(); $progress = is_array($state['progress'] ?? null) ? $state['progress'] : array(); $cursor = max(0, (int) ($progress['fileCursor'] ?? 0)); $deleted = max(0, (int) ($progress['deletedFiles'] ?? 0)); $handled = 0; for (; $cursor < count($assets) && $handled < $max_files; $cursor++, $handled++) { if (!$this->renew_lease($review_id, $lease_token)) { return $this->lease_lost_error(); } $asset = $assets[$cursor]; if (!is_array($asset)) { return Security::error('cleanup_asset_invalid', __('A transient attachment retry record is invalid.', 'commentmate'), 503); } $path = $this->private_storage_path($review_id, (string) ($asset['storageKey'] ?? '')); if (is_wp_error($path)) { return $path; } if (file_exists($path)) { if (is_link($path) || !is_file($path) || !@unlink($path)) { // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- validated private transient asset only. return Security::error('cleanup_file_delete_failed', __('A transient private attachment could not be removed.', 'commentmate'), 503); } } if (!$this->renew_lease($review_id, $lease_token)) { return $this->lease_lost_error(); } $deleted++; } $state['progress'] = array('fileCursor' => $cursor, 'deletedFiles' => $deleted); if ($cursor >= count($assets)) { $directory_deleted = $this->delete_review_private_directory($review_id, $lease_token); if (is_wp_error($directory_deleted)) { return $directory_deleted; } if (!$directory_deleted) { return Security::error('cleanup_file_delete_failed', __('The private review storage directory could not be removed.', 'commentmate'), 503); } } return $cursor >= count($assets); } /** * @return true|\WP_Error */ private function delete_database_rows(int $review_id): bool|\WP_Error { // The lifecycle row was locked in the prepare stage before this method is // reachable. Clear only review-scoped notification jobs and watermarks now; // reusable notification preferences and owner configuration remain intact. Notification_Service::clear_review($review_id); if (Notification_Service::has_review_state($review_id)) { return Security::error('cleanup_notification_residue', __('CommentMate could not clear queued notification activity before deleting review data. Try again.', 'commentmate'), 503); } global $wpdb; $reviews = Database::table('commentmate_reviews'); $tokens = Database::table('commentmate_share_tokens'); $sessions = Database::table('commentmate_guest_sessions'); $pages = Database::table('commentmate_pages'); $comments = Database::table('commentmate_comments'); $messages = Database::table('commentmate_comment_messages'); $attachments = Database::table('commentmate_attachments'); $events = Database::table('commentmate_audit_events'); $idempotency = Database::table('commentmate_idempotency'); $limits = Database::table('commentmate_rate_limits'); if (false === $wpdb->query('START TRANSACTION')) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Explicit transaction boundary for the surrounding plugin-table operation; caching cannot provide atomicity or row locks. return Security::error('cleanup_database_unavailable', __('Cleanup could not start its database transaction.', 'commentmate'), 503); } try { // Acquire the parent lifecycle lock before deleting children. Every // review-scoped mutation takes this lock first, so no delayed audit or // child-row write can land after cleanup has started deleting rows. $review = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Cleanup leases and residue verification require current plugin-table state; a cached result could delete or retain the wrong data. $wpdb->prepare('SELECT id, cleanup_locked FROM %i WHERE id = %d FOR UPDATE', $reviews, $review_id), ARRAY_A ); if (is_array($review) && (int) $review['cleanup_locked'] !== 1) { throw new \RuntimeException('The review is not locked for cleanup.'); } $this->must_query($wpdb->prepare('DELETE m FROM %i AS m INNER JOIN %i AS c ON c.id = m.comment_id WHERE c.review_id = %d', $messages, $comments, $review_id)); $this->must_query($wpdb->prepare('DELETE FROM %i WHERE review_id = %d', $attachments, $review_id)); $this->must_query($wpdb->prepare('DELETE FROM %i WHERE review_id = %d', $idempotency, $review_id)); $this->must_query($wpdb->prepare('DELETE FROM %i WHERE review_id = %d', $tokens, $review_id)); $this->must_query($wpdb->prepare('DELETE FROM %i WHERE review_id = %d', $sessions, $review_id)); $this->must_query($wpdb->prepare('DELETE FROM %i WHERE review_id = %d', $comments, $review_id)); $this->must_query($wpdb->prepare('DELETE FROM %i WHERE review_id = %d', $pages, $review_id)); $this->must_query($wpdb->prepare('DELETE FROM %i WHERE review_id = %d', $events, $review_id)); $this->must_query($wpdb->prepare('DELETE FROM %i WHERE review_id = %d', $limits, $review_id)); $this->must_query($wpdb->prepare('DELETE FROM %i WHERE id = %d AND cleanup_locked = 1', $reviews, $review_id)); if (false === $wpdb->query('COMMIT')) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Explicit transaction boundary for the surrounding plugin-table operation; caching cannot provide atomicity or row locks. throw new \RuntimeException('The cleanup database transaction could not be committed.'); } } catch (\Throwable $exception) { $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Explicit transaction boundary for the surrounding plugin-table operation; caching cannot provide atomicity or row locks. return Security::error('cleanup_database_failed', __('Cleanup could not remove the review database records.', 'commentmate'), 503); } return true; } /** * @param array $state * @return true|\WP_Error */ private function verify_no_residue(int $review_id, array $state, bool $check_key = true, ?string $lease_token = null): bool|\WP_Error { // An export can be finishing in another owner tab when cleanup reaches a // verification checkpoint. Sweep only review-scoped temporary archives on // every pass so a retry cannot become permanently blocked by that race. $archives_deleted = $this->delete_review_export_archives($review_id, $lease_token); if (is_wp_error($archives_deleted)) { return $archives_deleted; } if (!$archives_deleted) { return Security::error('cleanup_residue_exports', __('Cleanup could not remove a temporary private export archive.', 'commentmate'), 503); } if (Notification_Service::has_review_state($review_id)) { return Security::error('cleanup_residue_notifications', __('Cleanup verification found remaining CommentMate notification activity for this review.', 'commentmate'), 503); } global $wpdb; $tables = array( Database::table('commentmate_reviews') => 'id', Database::table('commentmate_share_tokens') => 'review_id', Database::table('commentmate_guest_sessions') => 'review_id', Database::table('commentmate_pages') => 'review_id', Database::table('commentmate_comments') => 'review_id', Database::table('commentmate_attachments') => 'review_id', Database::table('commentmate_audit_events') => 'review_id', Database::table('commentmate_idempotency') => 'review_id', ); foreach ($tables as $table => $column) { if (!$this->heartbeat($review_id, $lease_token)) { return $this->lease_lost_error(); } $count = (int) $wpdb->get_var($wpdb->prepare('SELECT COUNT(*) FROM %i WHERE %i = %d', $table, $column, $review_id)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Cleanup leases and residue verification require current plugin-table state; a cached result could delete or retain the wrong data. if ($count > 0) { return Security::error('cleanup_residue_database', __('Cleanup verification found remaining review database records.', 'commentmate'), 503); } } $manifest = is_array($state['manifest'] ?? null) ? $state['manifest'] : array(); $comment_ids = is_array($manifest['commentIds'] ?? null) ? $manifest['commentIds'] : array(); $remaining_messages = $this->remaining_messages_for_comments($comment_ids, $review_id, $lease_token); if (is_wp_error($remaining_messages)) { return $remaining_messages; } if ($remaining_messages > 0) { return Security::error('cleanup_residue_messages', __('Cleanup verification found remaining review reply records.', 'commentmate'), 503); } if ((int) $wpdb->get_var($wpdb->prepare('SELECT COUNT(*) FROM %i WHERE review_id = %d', Database::table('commentmate_rate_limits'), $review_id)) > 0) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Cleanup leases and residue verification require current plugin-table state; a cached result could delete or retain the wrong data. return Security::error('cleanup_residue_limits', __('Cleanup verification found remaining plugin rate-limit records.', 'commentmate'), 503); } $assets = is_array($manifest['transientAttachments'] ?? null) ? $manifest['transientAttachments'] : array(); foreach ($assets as $asset) { if (!$this->heartbeat($review_id, $lease_token)) { return $this->lease_lost_error(); } if (!is_array($asset)) { return Security::error('cleanup_residue_files', __('Cleanup verification found an invalid private-file record.', 'commentmate'), 503); } $path = $this->private_storage_path($review_id, (string) ($asset['storageKey'] ?? '')); if (is_wp_error($path) || file_exists($path)) { return Security::error('cleanup_residue_files', __('Cleanup verification found a remaining transient private attachment.', 'commentmate'), 503); } } if (!$this->heartbeat($review_id, $lease_token)) { return $this->lease_lost_error(); } if (!$this->review_private_directory_is_empty($review_id)) { return Security::error('cleanup_residue_files', __('Cleanup verification found remaining private review files.', 'commentmate'), 503); } if (!$this->review_export_archives_are_empty($review_id)) { return Security::error('cleanup_residue_exports', __('Cleanup verification found a remaining private export archive.', 'commentmate'), 503); } if ($check_key && false !== get_option($this->key_option($review_id), false)) { return Security::error('cleanup_residue_key', __('Cleanup verification found the review encryption key.', 'commentmate'), 503); } if ($check_key && false !== get_option(Export_Service::receipt_option($review_id), false)) { return Security::error('cleanup_residue_export_receipt', __('Cleanup verification found the private-export receipt.', 'commentmate'), 503); } return true; } /** * commentmate_comment_messages does not duplicate review_id, so final cleanup * verification checks the bounded comment ids captured in its durable * manifest. Chunking keeps the prepared IN clause below host limits. * * @param list $comment_ids */ private function remaining_messages_for_comments(array $comment_ids, int $review_id = 0, ?string $lease_token = null): int|\WP_Error { $ids = array_values(array_unique(array_filter(array_map('absint', $comment_ids)))); if (empty($ids)) { return 0; } global $wpdb; $messages = Database::table('commentmate_comment_messages'); $total = 0; foreach (array_chunk($ids, 250) as $chunk) { if (!$this->heartbeat($review_id, $lease_token)) { return $this->lease_lost_error(); } $total += (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Cleanup leases and residue verification require current plugin-table state; a cached result could delete or retain the wrong data. $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE comment_id IN (' . implode(',', array_fill(0, count($chunk), '%d')) . ')', array_merge(array($messages), $chunk) ) ); } return $total; } private function must_query(string $query): void { global $wpdb; if (false === $wpdb->query($query)) { // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private must_query callers pass only wpdb->prepare results with fixed schema identifiers and bound review IDs; cleanup integration exercises every stage. Cleanup leases and residue verification require current plugin-table state; a cached result could delete or retain the wrong data. throw new \RuntimeException('Database operation failed.'); } } /** * @return string|\WP_Error */ private function private_storage_path(int $review_id, string $storage_key): string|\WP_Error { $storage_key = trim($storage_key); if ($review_id < 1 || '' === $storage_key || strlen($storage_key) > 191 || str_contains($storage_key, '\\') || str_starts_with($storage_key, '/') || preg_match('#(?:^|/)\.\.?($|/)#', $storage_key)) { return Security::error('cleanup_asset_path_invalid', __('A transient private attachment has an unsafe storage path.', 'commentmate'), 503); } $base = realpath(Private_Storage::path()); if (false === $base || is_link(Private_Storage::path())) { return Security::error('cleanup_storage_unavailable', __('Private transient storage is unavailable.', 'commentmate'), 503); } if (preg_match('/\Acommentmate-[A-Za-z0-9_-]{43}\.bin\z/', $storage_key)) { $path = $base . DIRECTORY_SEPARATOR . $storage_key; } elseif (preg_match('/\Areviews\/([1-9][0-9]*)\/(commentmate-[A-Za-z0-9_-]{43}\.bin)\z/', $storage_key, $matches) && $review_id === (int) $matches[1]) { $path = $base . DIRECTORY_SEPARATOR . 'reviews' . DIRECTORY_SEPARATOR . $review_id . DIRECTORY_SEPARATOR . $matches[2]; } else { return Security::error('cleanup_asset_path_invalid', __('A transient private attachment has an unsafe storage path.', 'commentmate'), 503); } if (is_link($path)) { return Security::error('cleanup_asset_path_invalid', __('A transient private attachment path is symlinked.', 'commentmate'), 503); } if (file_exists($path)) { $real = realpath($path); if (false === $real || !str_starts_with($real, $base . DIRECTORY_SEPARATOR)) { return Security::error('cleanup_asset_path_invalid', __('A transient private attachment path escapes storage.', 'commentmate'), 503); } } return $path; } /** * Removes all new-format review-scoped asset files and plaintext temporary * files. The directory name is derived only from the locked review id, so no * untracked write can survive cleanup or escape into another review. */ private function delete_review_private_directory(int $review_id, string $lease_token): bool|\WP_Error { if (!$this->renew_lease($review_id, $lease_token)) { return $this->lease_lost_error(); } $directory = $this->review_private_directory($review_id); if (false === $directory) { return true; } if (null === $directory) { return false; } try { $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST ); foreach ($iterator as $item) { if (!$this->renew_lease($review_id, $lease_token)) { return $this->lease_lost_error(); } /** @var \SplFileInfo $item */ if ($item->isLink()) { return false; } if ($item->isFile()) { if (!@unlink($item->getPathname())) { // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- child of validated review-scoped private directory. return false; } } elseif ($item->isDir() && !@rmdir($item->getPathname())) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- child of validated review-scoped private directory. return false; } } } catch (\UnexpectedValueException) { return false; } if (!$this->renew_lease($review_id, $lease_token)) { return $this->lease_lost_error(); } return @rmdir($directory); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- exact validated review-scoped private directory. } private function review_private_directory_is_empty(int $review_id): bool { $directory = $this->review_private_directory($review_id); return false === $directory; } /** * Returns false when no directory exists, null when it is unsafe, or a * verified exact review-scoped directory path. */ private function review_private_directory(int $review_id): string|false|null { if ($review_id < 1) { return null; } $root = $this->cleanup_storage_root(); if (false === $root) { return null; } $reviews = $root . DIRECTORY_SEPARATOR . 'reviews'; if (is_link($reviews)) return null; if (!file_exists($reviews)) { return false; } if (!is_dir($reviews) || realpath($reviews) !== $reviews) { return null; } $directory = $reviews . DIRECTORY_SEPARATOR . $review_id; if (is_link($directory)) return null; if (!file_exists($directory)) { return false; } if (is_link($directory) || !is_dir($directory) || realpath($directory) !== $directory || dirname($directory) !== $reviews) { return null; } return $directory; } private function delete_review_export_archives(int $review_id, ?string $lease_token = null): bool|\WP_Error { if (!$this->heartbeat($review_id, $lease_token)) { return $this->lease_lost_error(); } $archives = $this->review_export_archives($review_id); if (null === $archives) { return false; } foreach ($archives as $archive) { if (!$this->heartbeat($review_id, $lease_token)) { return $this->lease_lost_error(); } if (!@unlink($archive)) { // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- exact generated private export archive. return false; } } return true; } private function review_export_archives_are_empty(int $review_id): bool { $archives = $this->review_export_archives($review_id); return is_array($archives) && empty($archives); } /** * @return list|null Null denotes an unsafe/malformed export root. */ private function review_export_archives(int $review_id): ?array { if ($review_id < 1) { return null; } $root = $this->cleanup_storage_root(); if (false === $root) { return null; } $directory = $root . DIRECTORY_SEPARATOR . 'exports'; if (is_link($directory)) return null; if (!file_exists($directory)) { return array(); } if (!is_dir($directory) || realpath($directory) !== $directory) { return null; } $entries = scandir($directory); if (!is_array($entries)) { return null; } $pattern = '/\\Acommentmate-export-' . preg_quote((string) $review_id, '/') . '-[A-Za-z0-9_-]+\\.zip\\z/'; $archives = array(); foreach ($entries as $entry) { if ('.' === $entry || '..' === $entry || !preg_match($pattern, $entry)) { continue; } $path = $directory . DIRECTORY_SEPARATOR . $entry; if (is_link($path) || !is_file($path)) { return null; } $real = realpath($path); if (false === $real || dirname($real) !== $directory) { return null; } $archives[] = $real; } return $archives; } /** Empty reviews may not have created their guarded install-owned storage yet. */ private function cleanup_storage_root(): string|false { $path = Private_Storage::path(); if ('' === $path || is_link($path)) return false; if (!file_exists($path) && !Private_Storage::ensure_directory()) return false; return is_dir($path) && !is_link($path) ? realpath($path) : false; } /** * @return true|\WP_Error */ private function authorize(int $actor_user_id): bool|\WP_Error { if ($actor_user_id < 1 || !Owner::is_owner($actor_user_id) || !user_can($actor_user_id, Capabilities::MANAGE)) { return Security::error('cleanup_forbidden', __('Only a site administrator may delete review data.', 'commentmate'), 403); } return true; } /** * @return array|null|\WP_Error */ private function load_state(int $review_id): array|null|\WP_Error { $value = get_option($this->state_option($review_id), false); if (false === $value || '' === $value) { return null; } if (!is_string($value)) { return Security::error('cleanup_state_invalid', __('The cleanup retry manifest is invalid.', 'commentmate'), 503); } $state = json_decode($value, true); if (!is_array($state) || (int) ($state['reviewId'] ?? 0) !== $review_id || 1 !== (int) ($state['schema'] ?? 0)) { return Security::error('cleanup_state_invalid', __('The cleanup retry manifest is invalid.', 'commentmate'), 503); } return $state; } /** * @param array $state */ private function create_state(int $review_id, array $state): bool { $json = wp_json_encode($state); return is_string($json) && add_option($this->state_option($review_id), $json, '', false); } /** * @param array $state */ private function save_state(int $review_id, array &$state): bool { $state['updatedAt'] = gmdate('c'); $json = wp_json_encode($state); if (!is_string($json)) { return false; } update_option($this->state_option($review_id), $json, false); return $json === get_option($this->state_option($review_id), false); } /** * @param array $state */ private function fail(array &$state, string $stage, string $message, int $status): \WP_Error { $review_id = (int) ($state['reviewId'] ?? 0); $state['stage'] = 'failed'; $state['resumeStage'] = $stage; $state['lastError'] = array('stage' => $stage, 'message' => $message, 'at' => gmdate('c')); if ($review_id > 0) { $this->save_state($review_id, $state); } return Security::error('cleanup_failed', $message, $status); } private function state_option(int $review_id): string { return self::STATE_PREFIX . $review_id; } /** * @param array $state */ private function state_can_drop_export_requirement(array $state): bool { $stage = (string) ($state['stage'] ?? ''); if ('failed' === $stage) { $stage = (string) ($state['resumeStage'] ?? ''); } return 'prepare' === $stage && !empty($state['exportRequired']); } private function lease_lost_error(): \WP_Error { return Security::error('cleanup_lease_lost', __('Cleanup ownership changed while this request was running. Refresh its status before continuing.', 'commentmate'), 409); } private function is_lease_error(\WP_Error $error): bool { return 'commentmate_cleanup_lease_lost' === $error->get_error_code(); } /** * A null lease token is used only by non-advancing verification callers. * An advancing cleanup renews its durable lease before every bounded unit of * filesystem work so a slow Media Library hash cannot be reclaimed mid-step. */ private function heartbeat(int $review_id, ?string $lease_token): bool { return null === $lease_token || '' === $lease_token || $this->renew_lease($review_id, $lease_token); } private function lease_payload(string $token): ?string { $payload = wp_json_encode(array( 'token' => $token, 'expiresAt' => time() + self::LEASE_SECONDS, )); return is_string($payload) ? $payload : null; } /** * Reads a lease directly from wp_options. Lease compare-and-swap operations * intentionally bypass get_option(): a raw UPDATE/DELETE must never consult * a stale persistent object-cache value before deciding who owns cleanup. */ private function lease_option_value(string $option): string|false { global $wpdb; $value = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Cleanup leases and residue verification require current plugin-table state; a cached result could delete or retain the wrong data. $wpdb->prepare('SELECT option_value FROM %i WHERE option_name = %s LIMIT 1', $wpdb->options, $option) ); return is_string($value) ? $value : false; } /** * @return array{token:string,expiresAt:int}|null */ private function decode_lease(string $value): ?array { $decoded = json_decode($value, true); if (!is_array($decoded) || !is_string($decoded['token'] ?? null) || '' === $decoded['token'] || !isset($decoded['expiresAt'])) { return null; } return array( 'token' => $decoded['token'], 'expiresAt' => (int) $decoded['expiresAt'], ); } private function invalidate_lease_cache(string $option): void { // The row is non-autoloaded, but clear all option lookup layers as well in // case a prior plugin version or a persistent cache classified it differently. wp_cache_delete($option, 'options'); wp_cache_delete('alloptions', 'options'); wp_cache_delete('notoptions', 'options'); } /** * Claims a short durable lease so two tabs or cron/manual requests cannot * advance the same resumable cleanup state concurrently. An expired lease is * replaced with a compare-and-swap update; a crash therefore remains * recoverable after the bounded timeout. */ private function claim_lease(int $review_id): ?string { if ($review_id < 1) { return null; } $token = Security::token(18); $payload = $this->lease_payload($token); if (!is_string($payload)) { return null; } $option = self::LEASE_PREFIX . $review_id; if (add_option($option, $payload, '', false)) { return $token; } $current = $this->lease_option_value($option); if (!is_string($current)) { return null; } $decoded = $this->decode_lease($current); if (!is_array($decoded) || $decoded['expiresAt'] >= time()) { return null; } global $wpdb; $updated = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Cleanup leases and residue verification require current plugin-table state; a cached result could delete or retain the wrong data. $wpdb->prepare('UPDATE %i SET option_value = %s WHERE option_name = %s AND option_value = %s', $wpdb->options, $payload, $option, $current) ); if (1 !== $updated) { return null; } $this->invalidate_lease_cache($option); return $token; } /** * Extends a lease only when this request still owns its exact token. A token * may be renewed after nominal expiry if no other request won the CAS race; * once reclaimed, the new token makes this operation fail closed. */ private function renew_lease(int $review_id, string $token): bool { if ($review_id < 1 || '' === $token) { return false; } $option = self::LEASE_PREFIX . $review_id; $current = $this->lease_option_value($option); if (!is_string($current)) { return false; } $decoded = $this->decode_lease($current); if (!is_array($decoded) || !hash_equals($token, $decoded['token'])) { return false; } $payload = $this->lease_payload($token); if (!is_string($payload)) { return false; } if (hash_equals($payload, $current)) { return true; } global $wpdb; $updated = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Cleanup leases and residue verification require current plugin-table state; a cached result could delete or retain the wrong data. $wpdb->prepare('UPDATE %i SET option_value = %s WHERE option_name = %s AND option_value = %s', $wpdb->options, $payload, $option, $current) ); if (1 === $updated) { $this->invalidate_lease_cache($option); return true; } if (0 !== $updated) { return false; } // A same-token renewal may have won an overlapping local call. It is safe // to continue only while the replacement still belongs to this token. $latest = $this->lease_option_value($option); $latest_decoded = is_string($latest) ? $this->decode_lease($latest) : null; return is_array($latest_decoded) && hash_equals($token, $latest_decoded['token']) && $latest_decoded['expiresAt'] >= time(); } private function release_lease(int $review_id, string $token): void { if ($review_id < 1 || '' === $token) { return; } $option = self::LEASE_PREFIX . $review_id; $current = $this->lease_option_value($option); if (!is_string($current)) { return; } $decoded = $this->decode_lease($current); if (!is_array($decoded) || !hash_equals($token, $decoded['token'])) { return; } global $wpdb; $deleted = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Cleanup leases and residue verification require current plugin-table state; a cached result could delete or retain the wrong data. $wpdb->prepare('DELETE FROM %i WHERE option_name = %s AND option_value = %s', $wpdb->options, $option, $current) ); if (1 === $deleted) { $this->invalidate_lease_cache($option); } } private function review_exists(int $review_id): bool { global $wpdb; return null !== $wpdb->get_var($wpdb->prepare('SELECT id FROM %i WHERE id = %d LIMIT 1', Database::table('commentmate_reviews'), $review_id)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Cleanup leases and residue verification require current plugin-table state; a cached result could delete or retain the wrong data. } private function key_option(int $review_id): string { return 'commentmate_review_key_' . $review_id; } }