|\WP_Error */ public function start(int $owner_user_id): array|\WP_Error { if (!Security::is_https()) { return Security::error('https_required', __('CommentMate requires HTTPS before a review link can be started.', 'commentmate'), 403); } global $wpdb; $reviews = Database::table('commentmate_reviews'); $current = $this->current(); $latest = $this->latest(); $pending_cleanup = (new Cleanup_Service())->pending_review_id($owner_user_id); if (is_wp_error($pending_cleanup)) { return $pending_cleanup; } if (is_int($pending_cleanup)) { return Security::error('cleanup_resume_required', __('A prior CommentMate data deletion still needs verification. Continue it from CommentMate before starting another review.', 'commentmate'), 409); } if (is_array($current) && (int) ($current['cleanup_locked'] ?? 0) === 1) { return Security::error('review_cleanup_locked', __('This review is locked while CommentMate deletes its data. Continue or resolve deletion from CommentMate.', 'commentmate'), 409); } if (!is_array($current) && is_array($latest) && 'ended' === (string) $latest['state']) { return Security::error('previous_review_requires_cleanup', __('Delete the previous CommentMate review data before starting another review.', 'commentmate'), 409); } if (is_array($current) && 'active' === $current['state']) { return Security::error('review_already_active', __('Sharing is already active for this review. Stop sharing before creating a new link.', 'commentmate'), 409); } $now = current_time('mysql', true); 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('review_not_saved', __('The review could not be started.', 'commentmate'), 503); } if (!Owner::lock_actor($owner_user_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('owner_changed', __('Only a site administrator may change this review. Reload CommentMate.', 'commentmate'), 403); } $review_id = 0; $version = 0; $review_key_option = ''; if (is_array($current) && 'paused' === $current['state']) { $locked = $this->lock_review_row((int) $current['id']); if (!is_array($locked)) { $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('review_changed', __('The review changed before it could be resumed. Refresh and try again.', 'commentmate'), 409); } if ((int) ($locked['cleanup_locked'] ?? 0) === 1) { $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('review_cleanup_locked', __('This review is locked while CommentMate deletes its data. Continue or resolve deletion from CommentMate.', 'commentmate'), 409); } if ('paused' !== (string) $locked['state'] || (int) $locked['token_version'] !== (int) $current['token_version']) { $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('review_changed', __('The review changed before it could be resumed. Refresh and try again.', 'commentmate'), 409); } $review_id = (int) $locked['id']; $version = (int) $locked['token_version'] + 1; $updated = $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Review lifecycle and token-version fences live in plugin tables; fresh reads enforce pause, cleanup and revocation immediately. $reviews, array( 'state' => 'active', 'active_slot' => 1, 'token_version' => $version, 'updated_at' => $now, 'paused_at' => null, ), array( 'id' => $review_id, 'state' => 'paused', 'cleanup_locked' => 0, 'token_version' => (int) $locked['token_version'], ), array('%s', '%d', '%d', '%s', '%s'), array('%d', '%s', '%d', '%d') ); if (1 !== $updated) { $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('review_changed', __('The review changed before it could be resumed. Refresh and try again.', 'commentmate'), false === $updated ? 503 : 409); } } else { // Recheck lifecycle state under a locking read. The earlier reads are // advisory only; this is the gate that prevents a new token from being // created after a concurrent cleanup has taken ownership of a review. $live = $this->lock_live_review_row(); if (is_array($live)) { $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. if ((int) ($live['cleanup_locked'] ?? 0) === 1) { return Security::error('review_cleanup_locked', __('This review is locked while CommentMate deletes its data. Continue or resolve deletion from CommentMate.', 'commentmate'), 409); } if ('active' === (string) $live['state']) { return Security::error('review_already_active', __('Sharing is already active for this review. Stop sharing before creating a new link.', 'commentmate'), 409); } return Security::error('review_changed', __('The review changed before it could be started. Refresh and try again.', 'commentmate'), 409); } $ended = $this->lock_latest_ended_review_row(); if (is_array($ended)) { $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('previous_review_requires_cleanup', __('Delete the previous CommentMate review data before starting another review.', 'commentmate'), 409); } $inserted = $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Review lifecycle and token-version fences live in plugin tables; fresh reads enforce pause, cleanup and revocation immediately. $reviews, array( 'owner_user_id' => $owner_user_id, 'state' => 'active', 'active_slot' => 1, 'token_version' => 1, 'metadata' => '{}', 'created_at' => $now, 'updated_at' => $now, ), array('%d', '%s', '%d', '%d', '%s', '%s', '%s') ); if (false === $inserted) { $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('review_not_saved', __('The review could not be started.', 'commentmate'), 503); } $review_id = (int) $wpdb->insert_id; $version = 1; $locked = $this->lock_review_row($review_id); if (!is_array($locked) || 'active' !== (string) $locked['state'] || (int) ($locked['cleanup_locked'] ?? 0) !== 0 || (int) $locked['token_version'] !== $version) { $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('review_not_saved', __('The review could not be started.', 'commentmate'), 503); } // Persist the non-autoloaded storage key through the same connection as // the review/token transaction. WordPress's option helpers mutate object // caches before commit, which could otherwise expose an orphan key after a // rollback. $review_key_option = $this->review_key_option($review_id); if (!$this->persist_review_key($review_id, $review_key_option)) { $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. $this->invalidate_option_cache($review_key_option); return Security::error('review_not_saved', __('The review could not be started.', 'commentmate'), 503); } } // The review row remains locked through revocation and token creation. A // cleanup request therefore either waits and revokes this new token, or // wins first and makes the lifecycle gate below fail before any insert. if (!$this->revoke_tokens_and_sessions($review_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. $this->invalidate_option_cache($review_key_option); return Security::error('review_not_saved', __('The review could not be started.', 'commentmate'), 503); } $link = $this->create_share_link($review_id, $version); if (is_wp_error($link)) { $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. $this->invalidate_option_cache($review_key_option); return $link; } 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. $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. $this->invalidate_option_cache($review_key_option); return Security::error('review_not_saved', __('The review could not be started.', 'commentmate'), 503); } $this->invalidate_option_cache($review_key_option); Audit::record($review_id, 'admin', $owner_user_id, 'review_started'); return array( 'review' => $this->review_payload($review_id), 'shareLink' => $link, ); } /** * Stops reviewer access without ending the retained review. The active token * secret is cleared along with the token/session revocation, so resuming * always issues a fresh share link. * * @return array|\WP_Error */ public function pause(int $owner_user_id): array|\WP_Error { $current = $this->current('active'); if (!is_array($current)) { return Security::error('review_not_active', __('There is no active review sharing to stop.', 'commentmate'), 409); } global $wpdb; 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('review_changed', __('Sharing changed before it could be stopped. Refresh and try again.', 'commentmate'), 503); } if (!Owner::lock_actor($owner_user_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('owner_changed', __('Only a site administrator may change this review. Reload CommentMate.', 'commentmate'), 403); } $locked = $this->lock_review_row((int) $current['id']); if (!is_array($locked)) { $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('review_changed', __('Sharing changed before it could be stopped. Refresh and try again.', 'commentmate'), 409); } if ((int) ($locked['cleanup_locked'] ?? 0) === 1) { $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('review_cleanup_locked', __('This review is locked while CommentMate deletes its data. Continue or resolve deletion from CommentMate.', 'commentmate'), 409); } if ('active' !== (string) $locked['state'] || (int) $locked['token_version'] !== (int) $current['token_version']) { $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('review_changed', __('Sharing changed before it could be stopped. Refresh and try again.', 'commentmate'), 409); } $review_id = (int) $locked['id']; $updated = $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Review lifecycle and token-version fences live in plugin tables; fresh reads enforce pause, cleanup and revocation immediately. Database::table('commentmate_reviews'), array( 'state' => 'paused', 'active_slot' => null, 'token_version' => (int) $locked['token_version'] + 1, 'paused_at' => current_time('mysql', true), 'updated_at' => current_time('mysql', true), ), array( 'id' => $review_id, 'state' => 'active', 'cleanup_locked' => 0, 'token_version' => (int) $locked['token_version'], ), array('%s', '%d', '%d', '%s', '%s'), array('%d', '%s', '%d', '%d') ); if (1 !== $updated) { $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('review_changed', __('Sharing changed before it could be stopped. Refresh and try again.', 'commentmate'), false === $updated ? 503 : 409); } if (!$this->revoke_tokens_and_sessions($review_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('review_changed', __('Sharing changed before it could be stopped. Refresh and try again.', 'commentmate'), 503); } 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. $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('review_changed', __('Sharing changed before it could be stopped. Refresh and try again.', 'commentmate'), 503); } Audit::record($review_id, 'admin', $owner_user_id, 'review_sharing_paused'); return array('review' => $this->review_payload($review_id)); } /** * @return array|\WP_Error */ public function rotate(int $owner_user_id): array|\WP_Error { if (!Security::is_https()) { return Security::error('https_required', __('CommentMate requires HTTPS before a review link can be rotated.', 'commentmate'), 403); } $current = $this->current('active'); if (!is_array($current)) { return Security::error('review_not_active', __('There is no active review link to rotate.', 'commentmate'), 409); } global $wpdb; 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('review_changed', __('The review changed before its link could be rotated. Refresh and try again.', 'commentmate'), 503); } if (!Owner::lock_actor($owner_user_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('owner_changed', __('Only a site administrator may change this review. Reload CommentMate.', 'commentmate'), 403); } $locked = $this->lock_review_row((int) $current['id']); if (!is_array($locked)) { $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('review_changed', __('The review changed before its link could be rotated. Refresh and try again.', 'commentmate'), 409); } if ((int) ($locked['cleanup_locked'] ?? 0) === 1) { $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('review_cleanup_locked', __('This review is locked while CommentMate deletes its data. Continue or resolve deletion from CommentMate.', 'commentmate'), 409); } if ('active' !== (string) $locked['state'] || (int) $locked['token_version'] !== (int) $current['token_version']) { $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('review_changed', __('The review changed before its link could be rotated. Refresh and try again.', 'commentmate'), 409); } $review_id = (int) $locked['id']; $version = (int) $locked['token_version'] + 1; $updated = $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Review lifecycle and token-version fences live in plugin tables; fresh reads enforce pause, cleanup and revocation immediately. Database::table('commentmate_reviews'), array( 'token_version' => $version, 'updated_at' => current_time('mysql', true), ), array( 'id' => $review_id, 'state' => 'active', 'cleanup_locked' => 0, 'token_version' => (int) $locked['token_version'], ), array('%d', '%s'), array('%d', '%s', '%d', '%d') ); if (1 !== $updated) { $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('review_changed', __('The review changed before its link could be rotated. Refresh and try again.', 'commentmate'), false === $updated ? 503 : 409); } if (!$this->revoke_tokens_and_sessions($review_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('review_changed', __('The review changed before its link could be rotated. Refresh and try again.', 'commentmate'), 503); } $link = $this->create_share_link($review_id, $version); if (is_wp_error($link)) { $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 $link; } 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. $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('review_changed', __('The review changed before its link could be rotated. Refresh and try again.', 'commentmate'), 503); } Audit::record($review_id, 'admin', $owner_user_id, 'review_link_rotated'); return array( 'review' => $this->review_payload($review_id), 'shareLink' => $link, ); } /** * @return array|\WP_Error */ public function end(int $owner_user_id): array|\WP_Error { $current = $this->current(); if (!is_array($current)) { return Security::error('review_not_found', __('There is no active or paused review to end.', 'commentmate'), 409); } if ((int) ($current['cleanup_locked'] ?? 0) === 1) { return Security::error('review_cleanup_locked', __('This review is locked while CommentMate deletes its data. Continue or resolve deletion from CommentMate.', 'commentmate'), 409); } global $wpdb; 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('review_changed', __('The review changed before it could be ended. Refresh and try again.', 'commentmate'), 503); } if (!Owner::lock_actor($owner_user_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('owner_changed', __('Only a site administrator may change this review. Reload CommentMate.', 'commentmate'), 403); } $locked = $this->lock_review_row((int) $current['id']); if (!is_array($locked)) { $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('review_changed', __('The review changed before it could be ended. Refresh and try again.', 'commentmate'), 409); } if ((int) ($locked['cleanup_locked'] ?? 0) === 1) { $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('review_cleanup_locked', __('This review is locked while CommentMate deletes its data. Continue or resolve deletion from CommentMate.', 'commentmate'), 409); } if (!in_array((string) $locked['state'], array('active', 'paused'), true) || (string) $locked['state'] !== (string) $current['state'] || (int) $locked['token_version'] !== (int) $current['token_version']) { $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('review_changed', __('The review changed before it could be ended. Refresh and try again.', 'commentmate'), 409); } $review_id = (int) $locked['id']; $updated = $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Review lifecycle and token-version fences live in plugin tables; fresh reads enforce pause, cleanup and revocation immediately. Database::table('commentmate_reviews'), array( 'state' => 'ended', 'active_slot' => null, 'token_version' => (int) $locked['token_version'] + 1, 'ended_at' => current_time('mysql', true), 'updated_at' => current_time('mysql', true), ), array( 'id' => $review_id, 'state' => (string) $locked['state'], 'cleanup_locked' => 0, 'token_version' => (int) $locked['token_version'], ), array('%s', '%d', '%d', '%s', '%s'), array('%d', '%s', '%d', '%d') ); if (1 !== $updated) { $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('review_changed', __('The review changed before it could be ended. Refresh and try again.', 'commentmate'), false === $updated ? 503 : 409); } if (!$this->revoke_tokens_and_sessions($review_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('review_changed', __('The review changed before it could be ended. Refresh and try again.', 'commentmate'), 503); } 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. $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('review_changed', __('The review changed before it could be ended. Refresh and try again.', 'commentmate'), 503); } Audit::record($review_id, 'admin', $owner_user_id, 'review_ended'); return array('review' => $this->review_payload($review_id)); } /** * @return array */ public function storage_status(?int $review_id = null): array { global $wpdb; $current = null === $review_id ? $this->current() : $this->find($review_id); $used = 0; if (is_array($current)) { $used = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Review lifecycle and token-version fences live in plugin tables; fresh reads enforce pause, cleanup and revocation immediately. $wpdb->prepare('SELECT COALESCE(SUM(byte_size), 0) FROM %i WHERE review_id = %d AND disposition = \'transient\' AND deleted_at IS NULL', Database::table('commentmate_attachments'), (int) $current['id']) ); } $limit = Private_Storage::byte_limit(); return array( 'usedBytes' => $used, 'limitBytes' => $limit, 'availableBytes' => max(0, $limit - $used), 'usage' => $limit > 0 ? round($used / $limit, 4) : 1, 'warning' => $used >= (int) ($limit * 0.85) ? 'critical' : ($used >= (int) ($limit * 0.70) ? 'warning' : 'ok'), 'acceptingFiles' => $used < $limit, ); } /** * Reconstructs the current active share link for an already-authorized * owner-facing screen. The reusable secret remains encrypted at rest on the * active token row and is never exposed through guest/session routes. * * @return string|\WP_Error|null Null means no retrievable active link exists, * including links created before encrypted * storage was introduced. */ public function active_share_link(int $review_id): string|\WP_Error|null { if ($review_id < 1) { return null; } if (!Owner::can_manage_current_user()) { return Security::error('share_link_forbidden', __('Only a site administrator may view the active share link.', 'commentmate'), 403); } global $wpdb; $tokens = Database::table('commentmate_share_tokens'); $reviews = Database::table('commentmate_reviews'); $row = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Review lifecycle and token-version fences live in plugin tables; fresh reads enforce pause, cleanup and revocation immediately. $wpdb->prepare('SELECT t.selector, t.secret_ciphertext FROM %i AS t INNER JOIN %i AS r ON r.id = t.review_id WHERE t.review_id = %d AND t.revoked_at IS NULL AND r.state = \'active\' AND r.cleanup_locked = 0 AND r.token_version = t.token_version ORDER BY t.id DESC LIMIT 1', $tokens, $reviews, $review_id), ARRAY_A ); if (!is_array($row)) { return null; } $selector = (string) ($row['selector'] ?? ''); $ciphertext = (string) ($row['secret_ciphertext'] ?? ''); if (!preg_match('/\A[A-Za-z0-9_-]{10,32}\z/', $selector) || '' === $ciphertext) { return null; } $secret = Security::decrypt_share_link_secret($review_id, $ciphertext); if (is_wp_error($secret)) { return $secret; } return add_query_arg('review', 'true', home_url('/')) . '#commentmate=' . $selector . '.' . $secret; } /** * @return array|null */ public function find(int $review_id): ?array { if ($review_id < 1) { return null; } global $wpdb; $row = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Review lifecycle and token-version fences live in plugin tables; fresh reads enforce pause, cleanup and revocation immediately. $wpdb->prepare('SELECT * FROM %i WHERE id = %d LIMIT 1', Database::table('commentmate_reviews'), $review_id), ARRAY_A ); return is_array($row) ? $row : null; } /** * @return array|null */ public function current(?string $state = null): ?array { global $wpdb; $table = Database::table('commentmate_reviews'); if (null !== $state) { $row = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Review lifecycle and token-version fences live in plugin tables; fresh reads enforce pause, cleanup and revocation immediately. $wpdb->prepare('SELECT * FROM %i WHERE state = %s ORDER BY id DESC LIMIT 1', $table, $state), ARRAY_A ); } else { $row = $wpdb->get_row($wpdb->prepare('SELECT * FROM %i WHERE state IN (\'active\', \'paused\') ORDER BY id DESC LIMIT 1', $table), ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Review lifecycle and token-version fences live in plugin tables; fresh reads enforce pause, cleanup and revocation immediately. } return is_array($row) ? $row : null; } /** * Returns the most recently retained review, including one that has been * ended. This is intentionally separate from current(), whose null-state * form is reserved for live review access checks. * * @return array|null */ public function latest(): ?array { global $wpdb; $row = $wpdb->get_row($wpdb->prepare('SELECT * FROM %i WHERE state IN (\'active\', \'paused\', \'ended\') ORDER BY id DESC LIMIT 1', Database::table('commentmate_reviews')), ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Review lifecycle and token-version fences live in plugin tables; fresh reads enforce pause, cleanup and revocation immediately. return is_array($row) ? $row : null; } /** * Locks one review row for a lifecycle transaction. Callers must already * have started the transaction; cleanup uses the same review-row lock before * it changes cleanup_locked, revokes access, or deletes review data. * * @return array|null */ private function lock_review_row(int $review_id): ?array { if ($review_id < 1) { return null; } global $wpdb; $row = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Review lifecycle and token-version fences live in plugin tables; fresh reads enforce pause, cleanup and revocation immediately. $wpdb->prepare('SELECT * FROM %i WHERE id = %d FOR UPDATE', Database::table('commentmate_reviews'), $review_id), ARRAY_A ); return is_array($row) ? $row : null; } /** * @return array|null */ private function lock_live_review_row(): ?array { global $wpdb; $row = $wpdb->get_row($wpdb->prepare('SELECT * FROM %i WHERE state IN (\'active\', \'paused\') ORDER BY id DESC LIMIT 1 FOR UPDATE', Database::table('commentmate_reviews')), ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Review lifecycle and token-version fences live in plugin tables; fresh reads enforce pause, cleanup and revocation immediately. return is_array($row) ? $row : null; } /** * @return array|null */ private function lock_latest_ended_review_row(): ?array { global $wpdb; $row = $wpdb->get_row($wpdb->prepare('SELECT * FROM %i WHERE state = \'ended\' ORDER BY id DESC LIMIT 1 FOR UPDATE', Database::table('commentmate_reviews')), ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Review lifecycle and token-version fences live in plugin tables; fresh reads enforce pause, cleanup and revocation immediately. return is_array($row) ? $row : null; } /** * Writes a new review key without invoking the WordPress options cache while * the caller's review lifecycle transaction is still open. */ private function persist_review_key(int $review_id, string $option): bool { if ($review_id < 1 || '' === $option) { return false; } global $wpdb; $material = Security::token(); $inserted = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Review lifecycle and token-version fences live in plugin tables; fresh reads enforce pause, cleanup and revocation immediately. $wpdb->prepare('INSERT INTO %i (option_name, option_value, autoload) VALUES (%s, %s, \'no\')', $wpdb->options, $option, $material) ); if (1 !== $inserted) { return false; } $stored = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Review lifecycle and token-version fences live in plugin tables; fresh reads enforce pause, cleanup and revocation immediately. $wpdb->prepare('SELECT option_value FROM %i WHERE option_name = %s LIMIT 1', $wpdb->options, $option) ); return is_string($stored) && hash_equals($material, $stored); } private function review_key_option(int $review_id): string { return 'commentmate_review_key_' . max(0, $review_id); } private function invalidate_option_cache(string $option): void { if ('' === $option) { return; } wp_cache_delete($option, 'options'); wp_cache_delete('alloptions', 'options'); wp_cache_delete('notoptions', 'options'); } /** * @return string|\WP_Error */ private function create_share_link(int $review_id, int $version): string|\WP_Error { global $wpdb; // The callers hold this row until their token insert commits. Recheck the // full lifecycle predicate here so this private helper cannot create a // token for a review cleanup has already claimed. $active_review = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Review lifecycle and token-version fences live in plugin tables; fresh reads enforce pause, cleanup and revocation immediately. $wpdb->prepare('SELECT id FROM %i WHERE id = %d AND state = \'active\' AND cleanup_locked = 0 AND token_version = %d FOR UPDATE', Database::table('commentmate_reviews'), $review_id, $version) ); if (null === $active_review) { return Security::error('review_changed', __('The review changed before its link could be created. Refresh and try again.', 'commentmate'), 409); } $selector = Security::token(12); $secret = Security::token(32); $ciphertext = Security::encrypt_share_link_secret($review_id, $secret); if (is_wp_error($ciphertext)) { return $ciphertext; } $inserted = $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Review lifecycle and token-version fences live in plugin tables; fresh reads enforce pause, cleanup and revocation immediately. Database::table('commentmate_share_tokens'), array( 'review_id' => $review_id, 'selector' => $selector, 'secret_hash' => Security::hash($secret), 'secret_ciphertext' => $ciphertext, 'token_version' => $version, 'created_at' => current_time('mysql', true), ), array('%d', '%s', '%s', '%s', '%d', '%s') ); if (false === $inserted) { return Security::error('share_link_not_created', __('The review link could not be created.', 'commentmate'), 503); } return add_query_arg('review', 'true', home_url('/')) . '#commentmate=' . $selector . '.' . $secret; } private function revoke_tokens_and_sessions(int $review_id): bool { global $wpdb; $now = current_time('mysql', true); $tokens = $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Review lifecycle and token-version fences live in plugin tables; fresh reads enforce pause, cleanup and revocation immediately. Database::table('commentmate_share_tokens'), array( 'revoked_at' => $now, 'secret_ciphertext' => null, ), array('review_id' => $review_id, 'revoked_at' => null), array('%s', '%s'), array('%d', '%s') ); $sessions = $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Review lifecycle and token-version fences live in plugin tables; fresh reads enforce pause, cleanup and revocation immediately. Database::table('commentmate_guest_sessions'), array('revoked_at' => $now), array('review_id' => $review_id, 'revoked_at' => null), array('%s'), array('%d', '%s') ); return false !== $tokens && false !== $sessions; } /** * @return array|null */ private function review_payload(int $review_id): ?array { global $wpdb; $row = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Review lifecycle and token-version fences live in plugin tables; fresh reads enforce pause, cleanup and revocation immediately. $wpdb->prepare('SELECT id, state, token_version, created_at, updated_at, paused_at, ended_at FROM %i WHERE id = %d LIMIT 1', Database::table('commentmate_reviews'), $review_id), ARRAY_A ); if (!is_array($row)) { return null; } return array( 'id' => (int) $row['id'], 'state' => (string) $row['state'], 'tokenVersion' => (int) $row['token_version'], 'createdAt' => (string) $row['created_at'], 'updatedAt' => (string) $row['updated_at'], 'pausedAt' => $row['paused_at'] ? (string) $row['paused_at'] : null, 'endedAt' => $row['ended_at'] ? (string) $row['ended_at'] : null, ); } }