|\WP_Error */ public function owner_session(\WP_REST_Request $request): array|\WP_Error { $permission = Security::verify_admin_request($request); if (is_wp_error($permission)) return $permission; 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('session_unavailable', __('Your admin commenting session could not be prepared.', 'commentmate'), 503); } if (!Owner::lock_actor(get_current_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', __('Administrator access could not be verified. Reload CommentMate before commenting.', 'commentmate'), 409); } $review = $wpdb->get_row($wpdb->prepare('SELECT * FROM %i WHERE state = \'active\' AND cleanup_locked = 0 ORDER BY id DESC LIMIT 1 FOR UPDATE', Database::table('commentmate_reviews')), ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Session revocation, expiry and review fences require current plugin-table state; cached authorization could allow revoked sessions. if (!is_array($review)) { $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_active', __('There is no active review to comment on.', 'commentmate'), 409); } $actor = Security::hash('owner-authorship:' . get_current_user_id()); $table = Database::table('commentmate_guest_sessions'); $session = $wpdb->get_row($wpdb->prepare('SELECT * FROM %i WHERE review_id = %d AND token_version = %d AND client_hash = %s AND revoked_at IS NULL AND expires_at > UTC_TIMESTAMP() AND absolute_expires_at > UTC_TIMESTAMP() ORDER BY id DESC LIMIT 1 FOR UPDATE', $table, (int) $review['id'], (int) $review['token_version'], $actor), ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Session revocation, expiry and review fences require current plugin-table state; cached authorization could allow revoked sessions. if (!is_array($session)) { $now = current_time('mysql', true); $session = array( 'review_id' => (int) $review['id'], 'token_version' => (int) $review['token_version'], // Random values are discarded, so this row cannot authenticate a guest. 'session_hash' => Security::hash(Security::token()), 'csrf_hash' => Security::hash(Security::token()), 'client_hash' => $actor, 'guest_name' => self::owner_name(), 'created_at' => $now, 'last_seen_at' => $now, 'expires_at' => gmdate('Y-m-d H:i:s', time() + 7 * DAY_IN_SECONDS), 'absolute_expires_at' => gmdate('Y-m-d H:i:s', time() + 30 * DAY_IN_SECONDS), ); if (false === $wpdb->insert($table, $session)) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Session revocation, expiry and review fences require current plugin-table state; cached authorization could allow revoked sessions. $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('session_unavailable', __('Your admin commenting session could not be prepared.', 'commentmate'), 503); } $session['id'] = (int) $wpdb->insert_id; } $name = self::owner_name(); if ($session['guest_name'] !== $name) { if (false === $wpdb->update($table, array('guest_name' => $name), array('id' => (int) $session['id']))) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Session revocation, expiry and review fences require current plugin-table state; cached authorization could allow revoked sessions. $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('session_unavailable', __('Your admin display name could not be loaded.', 'commentmate'), 503); } $session['guest_name'] = $name; } 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('session_unavailable', __('Your admin commenting session could not be prepared.', 'commentmate'), 503); } $session['review_state'] = 'active'; $session['_owner_user_id'] = get_current_user_id(); return $session; } public static function owner_name(): string { $name = get_user_meta(get_current_user_id(), 'commentmate_display_name', true); return is_string($name) && '' !== trim($name) ? $name : __('Admin', 'commentmate'); } public function __construct(?Rate_Limiter $rate_limiter = null) { $this->rate_limiter = $rate_limiter ?? new Rate_Limiter(); } /** * @return array|\WP_Error */ public function exchange(\WP_REST_Request $request): array|\WP_Error { if (!Security::is_https()) { return Security::error('https_required', __('CommentMate share links require HTTPS.', 'commentmate'), 403); } if (!Security::is_same_origin_request($request)) { return Security::error('origin_denied', __('This request must originate from this website.', 'commentmate'), 403); } if (!$this->rate_limiter->consume('token-exchange-fingerprint', Security::client_fingerprint(), 10, 15 * MINUTE_IN_SECONDS) || !$this->rate_limiter->consume('token-exchange-ip', Security::client_ip(), 20, 15 * MINUTE_IN_SECONDS)) { return Security::error('rate_limited', __('Too many attempts. Please wait and try again.', 'commentmate'), 429); } $params = $this->params($request); // `reviewId` is retained as a frontend-facing alias for the public, // non-secret selector embedded before the dot in #commentmate=selector.secret. $selector = isset($params['selector']) ? trim((string) $params['selector']) : (isset($params['reviewId']) ? trim((string) $params['reviewId']) : ''); $secret = isset($params['secret']) ? trim((string) $params['secret']) : ''; if (!preg_match('/\A[A-Za-z0-9_-]{10,32}\z/', $selector) || !preg_match('/\A[A-Za-z0-9_-]{43}\z/', $secret)) { return Security::error('invalid_share_link', __('This review link is invalid or has expired.', 'commentmate'), 401); } global $wpdb; $tokens = Database::table('commentmate_share_tokens'); $reviews = Database::table('commentmate_reviews'); $raw_session = Security::token(); $csrf_token = Security::csrf_token($raw_session); $now = time(); $absolute_expiry = $now + (30 * DAY_IN_SECONDS); $idle_expiry = min($absolute_expiry, $now + (7 * DAY_IN_SECONDS)); $created_at = gmdate('Y-m-d H:i:s', $now); $sessions = Database::table('commentmate_guest_sessions'); 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('session_unavailable', __('A secure review session could not be created.', 'commentmate'), 503); } $row = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Session revocation, expiry and review fences require current plugin-table state; cached authorization could allow revoked sessions. $wpdb->prepare('SELECT t.review_id, t.secret_hash, t.token_version, r.state, r.cleanup_locked, r.token_version AS current_token_version FROM %i AS t INNER JOIN %i AS r ON r.id = t.review_id WHERE t.selector = %s AND t.revoked_at IS NULL AND (t.expires_at IS NULL OR t.expires_at > UTC_TIMESTAMP()) AND r.state = \'active\' AND r.cleanup_locked = 0 AND r.token_version = t.token_version LIMIT 1 FOR UPDATE', $tokens, $reviews, $selector), ARRAY_A ); if (!is_array($row) || 'active' !== $row['state'] || (int) $row['token_version'] !== (int) $row['current_token_version'] || !hash_equals((string) $row['secret_hash'], Security::hash($secret))) { $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('invalid_share_link', __('This review link is invalid or has expired.', 'commentmate'), 401); } if (!$this->rate_limiter->consume('token-exchange-review', 'review:' . (string) $row['review_id'], 50, 15 * MINUTE_IN_SECONDS, (int) $row['review_id'], true)) { // Keep the rate-limit increment while still holding the review lock. This // lets a concurrent cleanup observe and purge the row, rather than // allowing an over-limit request to roll its counter back. 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('session_unavailable', __('A secure review session could not be created.', 'commentmate'), 503); } return Security::error('rate_limited', __('Too many review sessions have been created. Please wait and try again.', 'commentmate'), 429); } $inserted = $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Session revocation, expiry and review fences require current plugin-table state; cached authorization could allow revoked sessions. $sessions, array( 'review_id' => (int) $row['review_id'], 'session_hash' => Security::hash($raw_session), 'csrf_hash' => Security::hash($csrf_token), 'client_hash' => Security::hash(Security::client_fingerprint()), 'token_version' => (int) $row['current_token_version'], 'guest_name' => __('Guest', 'commentmate'), 'created_at' => $created_at, 'last_seen_at' => $created_at, 'expires_at' => gmdate('Y-m-d H:i:s', $idle_expiry), 'absolute_expires_at' => gmdate('Y-m-d H:i:s', $absolute_expiry), ), array('%d', '%s', '%s', '%s', '%d', '%s', '%s', '%s', '%s', '%s') ); $session_database_id = (int) $wpdb->insert_id; $token_updated = $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Session revocation, expiry and review fences require current plugin-table state; cached authorization could allow revoked sessions. $tokens, array('last_used_at' => $created_at), array('selector' => $selector, 'review_id' => (int) $row['review_id'], 'revoked_at' => null), array('%s'), array('%s', '%d', '%s') ); if (false === $inserted || false === $token_updated || 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('session_unavailable', __('A secure review session could not be created.', 'commentmate'), 503); } if (!Security::set_session_cookie($raw_session, $absolute_expiry)) { $wpdb->delete($sessions, array('id' => $session_database_id), array('%d')); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Session revocation, expiry and review fences require current plugin-table state; cached authorization could allow revoked sessions. return Security::error('session_unavailable', __('A secure review session could not be created.', 'commentmate'), 503); } Audit::record((int) $row['review_id'], 'guest', null, 'guest_session_created'); return array( 'authenticated' => true, 'csrfToken' => $csrf_token, 'guestName' => __('Guest', 'commentmate'), 'expiresAt' => gmdate('c', $absolute_expiry), '_session' => array( 'id' => $session_database_id, 'review_id' => (int) $row['review_id'], 'review_state' => 'active', 'current_token_version' => (int) $row['current_token_version'], 'token_version' => (int) $row['current_token_version'], 'guest_name' => __('Guest', 'commentmate'), 'absolute_expires_at' => gmdate('Y-m-d H:i:s', $absolute_expiry), '_csrf_token' => $csrf_token, ), ); } /** * @return array|null */ public function current(bool $touch = true): ?array { $raw_session = Security::request_cookie(); if ('' === $raw_session) { return null; } global $wpdb; $sessions = Database::table('commentmate_guest_sessions'); $reviews = Database::table('commentmate_reviews'); $session = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Session revocation, expiry and review fences require current plugin-table state; cached authorization could allow revoked sessions. $wpdb->prepare('SELECT s.*, r.state AS review_state, r.token_version AS current_token_version FROM %i AS s INNER JOIN %i AS r ON r.id = s.review_id WHERE s.session_hash = %s AND s.revoked_at IS NULL AND s.expires_at > UTC_TIMESTAMP() AND s.absolute_expires_at > UTC_TIMESTAMP() AND r.cleanup_locked = 0 LIMIT 1', $sessions, $reviews, Security::hash($raw_session)), ARRAY_A ); if (!is_array($session) || 'active' !== $session['review_state'] || (int) $session['token_version'] !== (int) $session['current_token_version']) { return null; } if ($touch) { $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Session revocation, expiry and review fences require current plugin-table state; cached authorization could allow revoked sessions. $wpdb->prepare('UPDATE %i SET last_seen_at = UTC_TIMESTAMP(), expires_at = LEAST(absolute_expires_at, DATE_ADD(UTC_TIMESTAMP(), INTERVAL 7 DAY)) WHERE id = %d', $sessions, (int) $session['id']) ); } $session['_csrf_token'] = Security::csrf_token($raw_session); return $session; } /** * Revalidates and locks the review then session rows inside a mutation * transaction. Taking the review first matches cleanup's lock order, so a * guest write cannot commit after cleanup locks the lifecycle row. * * @param array $session */ public static function lock_current_for_mutation(array $session): bool { if (isset($session['_owner_user_id']) && ((int) $session['_owner_user_id'] !== get_current_user_id() || !Owner::lock_actor((int) $session['_owner_user_id']))) return false; $session_id = (int) ($session['id'] ?? 0); $review_id = (int) ($session['review_id'] ?? 0); $token_version = (int) ($session['token_version'] ?? 0); if ($session_id < 1 || $review_id < 1 || $token_version < 1) { return false; } global $wpdb; $sessions = Database::table('commentmate_guest_sessions'); $reviews = Database::table('commentmate_reviews'); $review_locked = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Session revocation, expiry and review fences require current plugin-table state; cached authorization could allow revoked sessions. $wpdb->prepare('SELECT id FROM %i WHERE id = %d AND state = \'active\' AND cleanup_locked = 0 AND token_version = %d FOR UPDATE', $reviews, $review_id, $token_version) ); if (null === $review_locked) { return false; } $locked = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Session revocation, expiry and review fences require current plugin-table state; cached authorization could allow revoked sessions. $wpdb->prepare('SELECT id FROM %i WHERE id = %d AND review_id = %d AND token_version = %d AND revoked_at IS NULL AND expires_at > UTC_TIMESTAMP() AND absolute_expires_at > UTC_TIMESTAMP() FOR UPDATE', $sessions, $session_id, $review_id, $token_version) ); return null !== $locked; } /** * Extends a session only after the current request has passed its rate checks * while the matching review and session rows are locked. This prevents a * rejected request from becoming a low-cost keepalive for a guest session. * * @param array $session */ public static function touch_locked_session(array $session): bool { $session_id = (int) ($session['id'] ?? 0); $review_id = (int) ($session['review_id'] ?? 0); $token_version = (int) ($session['token_version'] ?? 0); if ($session_id < 1 || $review_id < 1 || $token_version < 1) { return false; } global $wpdb; $sessions = Database::table('commentmate_guest_sessions'); $updated = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Session revocation, expiry and review fences require current plugin-table state; cached authorization could allow revoked sessions. $wpdb->prepare('UPDATE %i SET last_seen_at = UTC_TIMESTAMP(), expires_at = LEAST(absolute_expires_at, DATE_ADD(UTC_TIMESTAMP(), INTERVAL 7 DAY)) WHERE id = %d AND review_id = %d AND token_version = %d AND revoked_at IS NULL AND expires_at > UTC_TIMESTAMP() AND absolute_expires_at > UTC_TIMESTAMP()', $sessions, $session_id, $review_id, $token_version) ); return false !== $updated; } /** * @return array|\WP_Error */ public function verify_guest_request(\WP_REST_Request $request, bool $csrf_required = true, bool $touch = true): array|\WP_Error { if (!Security::is_https()) { return Security::error('https_required', __('CommentMate requires HTTPS.', 'commentmate'), 403); } if (!Security::is_same_origin_request($request)) { return Security::error('origin_denied', __('This request must originate from this website.', 'commentmate'), 403); } $session = $this->current($touch); if (!is_array($session)) { return Security::error('session_required', __('Your review session has expired. Open the review link again.', 'commentmate'), 401); } if ($csrf_required) { $token = trim((string) $request->get_header('x-commentmate-csrf')); if (!preg_match('/\A[A-Za-z0-9_-]{40,128}\z/', $token) || !hash_equals((string) $session['csrf_hash'], Security::hash($token))) { return Security::error('csrf_invalid', __('Your review session needs to be refreshed.', 'commentmate'), 403); } } return $session; } /** * A page reload no longer has the fragment token, so same-origin session * status returns the stable per-session CSRF token. The cookie remains * HttpOnly throughout. * * @return array|\WP_Error */ public function status(\WP_REST_Request $request, ?array $verified_session = null): array|\WP_Error { $session = $verified_session ?? $this->verify_guest_request($request, false); if (is_wp_error($session)) { return $session; } $csrf_token = $this->refresh_csrf($session); if (is_wp_error($csrf_token)) { return $csrf_token; } return array( 'authenticated' => true, 'csrfToken' => $csrf_token, 'guestName' => (string) $session['guest_name'], 'expiresAt' => gmdate('c', strtotime((string) $session['absolute_expires_at'] . ' UTC') ?: time()), ); } /** * @return string|\WP_Error */ public function refresh_csrf(array $session): string|\WP_Error { $csrf_token = isset($session['_csrf_token']) ? (string) $session['_csrf_token'] : ''; if ('' === $csrf_token) { $raw_session = Security::request_cookie(); if ('' === $raw_session) { return Security::error('session_required', __('Your review session has expired. Open the review link again.', 'commentmate'), 401); } $csrf_token = Security::csrf_token($raw_session); } if (!hash_equals((string) $session['csrf_hash'], Security::hash($csrf_token))) { global $wpdb; $locked = $this->begin_lifecycle_mutation($session, __('Your review session could not be refreshed.', 'commentmate')); if (is_wp_error($locked)) { return $locked; } $updated = $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Session revocation, expiry and review fences require current plugin-table state; cached authorization could allow revoked sessions. Database::table('commentmate_guest_sessions'), array('csrf_hash' => Security::hash($csrf_token)), array( 'id' => (int) $session['id'], 'review_id' => (int) $session['review_id'], ), array('%s'), array('%d', '%d') ); if (false === $updated) { $this->rollback_lifecycle_mutation(); return Security::error('session_unavailable', __('Your review session could not be refreshed.', 'commentmate'), 503); } if (!$this->commit_lifecycle_mutation()) { $this->rollback_lifecycle_mutation(); return Security::error('session_unavailable', __('Your review session could not be refreshed.', 'commentmate'), 503); } } return $csrf_token; } /** * @return array|\WP_Error */ public function update_name(\WP_REST_Request $request, ?array $verified_session = null): array|\WP_Error { $session = $verified_session ?? $this->verify_guest_request($request); if (is_wp_error($session)) { return $session; } $params = $this->params($request); $name = isset($params['name']) ? sanitize_text_field((string) $params['name']) : ''; $name = trim($name); $name = '' === $name ? (isset($session['_owner_user_id']) ? __('Admin', 'commentmate') : __('Guest', 'commentmate')) : $this->truncate($name, 80); if ($name === (string) ($session['guest_name'] ?? '')) { return array('guestName' => $name); } global $wpdb; $locked = $this->begin_lifecycle_mutation($session, __('Your review name could not be updated.', 'commentmate')); if (is_wp_error($locked)) { return $locked; } $stored_name = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Session revocation, expiry and review fences require current plugin-table state; cached authorization could allow revoked sessions. $wpdb->prepare('SELECT guest_name FROM %i WHERE id = %d AND review_id = %d LIMIT 1', Database::table('commentmate_guest_sessions'), (int) $session['id'], (int) $session['review_id']) ); if (is_string($stored_name) && $name === $stored_name) { if (!$this->commit_lifecycle_mutation()) { $this->rollback_lifecycle_mutation(); return Security::error('session_unavailable', __('Your review name could not be updated.', 'commentmate'), 503); } return array('guestName' => $name); } $updated = $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Session revocation, expiry and review fences require current plugin-table state; cached authorization could allow revoked sessions. Database::table('commentmate_guest_sessions'), array('guest_name' => $name), array( 'id' => (int) $session['id'], 'review_id' => (int) $session['review_id'], ), array('%s'), array('%d', '%d') ); if (false === $updated) { $this->rollback_lifecycle_mutation(); return Security::error('session_unavailable', __('Your review name could not be updated.', 'commentmate'), 503); } if (!$this->commit_lifecycle_mutation()) { $this->rollback_lifecycle_mutation(); return Security::error('session_unavailable', __('Your review name could not be updated.', 'commentmate'), 503); } Audit::record((int) $session['review_id'], isset($session['_owner_user_id']) ? 'admin' : 'guest', (int) ($session['_owner_user_id'] ?? $session['id']), 'guest_name_updated'); return array('guestName' => $name); } /** * @return array|\WP_Error */ public function logout(\WP_REST_Request $request): array|\WP_Error { $session = $this->verify_guest_request($request); if (is_wp_error($session)) { return $session; } global $wpdb; $locked = $this->begin_lifecycle_mutation($session, __('Your review session could not be logged out.', 'commentmate')); if (is_wp_error($locked)) { return $locked; } $updated = $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Session revocation, expiry and review fences require current plugin-table state; cached authorization could allow revoked sessions. Database::table('commentmate_guest_sessions'), array('revoked_at' => current_time('mysql', true)), array( 'id' => (int) $session['id'], 'review_id' => (int) $session['review_id'], ), array('%s'), array('%d', '%d') ); if (false === $updated) { $this->rollback_lifecycle_mutation(); return Security::error('session_unavailable', __('Your review session could not be logged out.', 'commentmate'), 503); } if (!$this->commit_lifecycle_mutation()) { $this->rollback_lifecycle_mutation(); return Security::error('session_unavailable', __('Your review session could not be logged out.', 'commentmate'), 503); } Security::clear_session_cookie(); Audit::record((int) $session['review_id'], isset($session['_owner_user_id']) ? 'admin' : 'guest', (int) ($session['_owner_user_id'] ?? $session['id']), 'guest_session_logged_out'); return array('authenticated' => false); } /** * Starts a guest-session mutation and holds the active review lifecycle row * until the caller commits or rolls back its session write. * * @param array $session * @return true|\WP_Error */ private function begin_lifecycle_mutation(array $session, string $unavailable_message): bool|\WP_Error { 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('session_unavailable', $unavailable_message, 503); } if (!self::lock_current_for_mutation($session)) { $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('session_required', __('Your review session has expired. Open the review link again.', 'commentmate'), 401); } return true; } private function commit_lifecycle_mutation(): bool { global $wpdb; return 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. } private function rollback_lifecycle_mutation(): void { global $wpdb; $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 array */ private function params(\WP_REST_Request $request): array { $params = $request->get_json_params(); return is_array($params) ? $params : $request->get_params(); } private function truncate(string $value, int $length): string { return function_exists('mb_substr') ? mb_substr($value, 0, $length) : substr($value, 0, $length); } }