rate_limiter = new Rate_Limiter(); $this->sessions = new Session_Service($this->rate_limiter); $this->comments = new Comment_Service(); $this->reviews = new Review_Service(); $this->idempotency = new Idempotency(); $this->assets = new Asset_Service($this->reviews); } public function register(): void { add_action('rest_api_init', array($this, 'register_routes')); add_filter('rest_post_dispatch', array($this, 'add_response_headers'), 10, 3); } public function register_routes(): void { register_rest_route(self::NAMESPACE, '/session/exchange', array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array($this, 'exchange_session'), 'permission_callback' => '__return_true', )); register_rest_route(self::NAMESPACE, '/session', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array($this, 'session_status'), 'permission_callback' => array($this, 'guest_read_permission'), )); register_rest_route(self::NAMESPACE, '/session/name', array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array($this, 'update_guest_name'), 'permission_callback' => array($this, 'comment_mutation_permission'), )); register_rest_route(self::NAMESPACE, '/session/logout', array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array($this, 'logout_session'), 'permission_callback' => array($this, 'guest_mutation_permission'), )); register_rest_route(self::NAMESPACE, '/review/complete', array( 'methods' => 'POST', 'callback' => array($this, 'complete_review'), 'permission_callback' => array($this, 'comment_mutation_permission'), )); register_rest_route(self::NAMESPACE, '/admin/reviews/(?P\d+)/attachments/(?P\d+)/compressed', array( 'methods' => 'POST', 'callback' => array($this, 'save_compressed_attachment'), 'permission_callback' => array($this, 'admin_permission'), )); register_rest_route(self::NAMESPACE, '/bootstrap', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array($this, 'bootstrap'), 'permission_callback' => array($this, 'bootstrap_permission'), )); register_rest_route(self::NAMESPACE, '/comments', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array($this, 'list_comments'), 'permission_callback' => array($this, 'guest_read_permission'), ), array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array($this, 'create_comment'), 'permission_callback' => array($this, 'comment_mutation_permission'), ), )); register_rest_route(self::NAMESPACE, '/comments/(?P\d+)', array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array($this, 'update_comment'), 'permission_callback' => array($this, 'comment_mutation_permission'), ), array( 'methods' => \WP_REST_Server::DELETABLE, 'callback' => array($this, 'delete_comment'), 'permission_callback' => array($this, 'comment_mutation_permission'), ), )); register_rest_route(self::NAMESPACE, '/comments/(?P\d+)/status', array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array($this, 'update_comment_status'), 'permission_callback' => array($this, 'admin_permission'), )); register_rest_route(self::NAMESPACE, '/comments/(?P\d+)/reposition', array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array($this, 'reposition_comment'), 'permission_callback' => array($this, 'admin_permission'), )); register_rest_route(self::NAMESPACE, '/comments/(?P\d+)/messages', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array($this, 'list_messages'), 'permission_callback' => array($this, 'guest_read_permission'), ), array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array($this, 'create_message'), 'permission_callback' => array($this, 'comment_mutation_permission'), ), )); register_rest_route(self::NAMESPACE, '/comments/(?P\d+)/messages/(?P\d+)', array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array($this, 'update_message'), 'permission_callback' => array($this, 'guest_mutation_permission'), ), array( 'methods' => \WP_REST_Server::DELETABLE, 'callback' => array($this, 'delete_message'), 'permission_callback' => array($this, 'guest_mutation_permission'), ), )); register_rest_route(self::NAMESPACE, '/comments/(?P\d+)/attachments', array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array($this, 'upload_attachment'), 'permission_callback' => array($this, 'comment_mutation_permission'), )); register_rest_route(self::NAMESPACE, '/comments/(?P\d+)/screenshot', array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array($this, 'store_screenshot'), 'permission_callback' => array($this, 'comment_mutation_permission'), )); register_rest_route(self::NAMESPACE, '/comments/(?P\d+)/screenshot/unavailable', array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array($this, 'mark_screenshot_unavailable'), 'permission_callback' => array($this, 'comment_mutation_permission'), )); register_rest_route(self::NAMESPACE, '/attachments/(?P\d+)', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array($this, 'stream_attachment'), 'permission_callback' => array($this, 'guest_read_permission'), ), array( 'methods' => \WP_REST_Server::DELETABLE, 'callback' => array($this, 'delete_attachment'), 'permission_callback' => array($this, 'guest_mutation_permission'), ), )); register_rest_route(self::NAMESPACE, '/admin/review/start', array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array($this, 'start_review'), 'permission_callback' => array($this, 'admin_permission'), )); register_rest_route(self::NAMESPACE, '/admin/review/rotate', array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array($this, 'rotate_review'), 'permission_callback' => array($this, 'admin_permission'), )); register_rest_route(self::NAMESPACE, '/admin/storage-status', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array($this, 'storage_status'), 'permission_callback' => array($this, 'admin_permission'), )); register_rest_route(self::NAMESPACE, '/admin/build-identity', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array($this, 'build_identity'), 'permission_callback' => array($this, 'admin_permission'), )); register_rest_route(self::NAMESPACE, '/admin/reviews/(?P\d+)/attachments/(?P\d+)', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array($this, 'stream_owner_attachment'), 'permission_callback' => array($this, 'admin_permission'), ), array( 'methods' => \WP_REST_Server::DELETABLE, 'callback' => array($this, 'delete_owner_attachment'), 'permission_callback' => array($this, 'admin_permission'), ), )); register_rest_route(self::NAMESPACE, '/admin/reviews/(?P\d+)/attachments/(?P\d+)/promote', array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array($this, 'promote_owner_attachment'), 'permission_callback' => array($this, 'admin_permission'), )); } /** * Permission callbacks authenticate without touching sessions, consuming rate * limits or creating owner authorship records. Handlers recheck authorization * for direct callers; their existing row locks still fence concurrent changes. */ public function admin_permission(\WP_REST_Request $request): bool|\WP_Error { return Security::verify_admin_request($request); } public function guest_read_permission(\WP_REST_Request $request): bool|\WP_Error { $session = $this->sessions->verify_guest_request($request, false, false); return is_wp_error($session) ? $session : true; } public function guest_mutation_permission(\WP_REST_Request $request): bool|\WP_Error { $session = $this->sessions->verify_guest_request($request, true, false); return is_wp_error($session) ? $session : true; } public function bootstrap_permission(\WP_REST_Request $request): bool|\WP_Error { return '' !== trim((string) $request->get_header('x-wp-nonce')) ? $this->admin_permission($request) : $this->guest_read_permission($request); } public function comment_mutation_permission(\WP_REST_Request $request): bool|\WP_Error { $owner = $this->owner_comment_mutation_request($request); return false === $owner ? $this->guest_mutation_permission($request) : $owner; } public function exchange_session(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { $exchange = $this->sessions->exchange($request); if (is_wp_error($exchange)) { return $exchange; } $session = $exchange['_session'] ?? null; if (!is_array($session)) { return Security::error('session_unavailable', __('A secure review session could not be created.', 'commentmate'), 503); } $payload = $this->bootstrap_payload($session, $request, (string) $exchange['csrfToken']); return $this->respond($payload, 201); } public function session_status(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { $session = $this->guest_read_session($request, 'session-status', 60); if (is_wp_error($session)) { return $session; } $status = $this->sessions->status($request, $session); return $this->respond($status); } public function update_guest_name(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { $session = $this->commenting_mutation_session($request, 'session-name', 12); if (is_wp_error($session)) { return $session; } $data = $this->sessions->update_name($request, $session); if (!is_wp_error($data) && isset($session['_owner_user_id'])) { update_user_meta((int) $session['_owner_user_id'], 'commentmate_display_name', $data['guestName']); } return $this->respond($data); } public function logout_session(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { $data = $this->sessions->logout($request); return $this->respond($data); } public function bootstrap(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { // A logged-in owner uses a WordPress REST nonce instead of a share-link // session. This makes owner-only board context links useful without ever // retaining or reconstructing the fragment secret. Ordinary guests still // take the cookie-bound path below. if ('' !== trim((string) $request->get_header('x-wp-nonce'))) { $admin_permission = $this->admin_permission($request); return is_wp_error($admin_permission) ? $admin_permission : $this->admin_bootstrap($request); } $session = $this->guest_read_session($request, 'bootstrap-read', 30); if (is_wp_error($session)) { return $session; } $csrf = $this->sessions->refresh_csrf($session); if (is_wp_error($csrf)) { return $csrf; } return $this->respond($this->bootstrap_payload($session, $request, $csrf)); } public function list_comments(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { $session = $this->guest_read_session($request, 'comments-read', 30); if (is_wp_error($session)) { return $session; } $data = $this->comments->bootstrap($session, $request); if (is_wp_error($data)) { return $data; } return $this->respond(array('comments' => $data['comments'])); } public function create_comment(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { $session = $this->commenting_mutation_session($request, 'comment-create', 20); if (is_wp_error($session)) { return $session; } $key = $this->idempotency->key_from_request($request); if (!$this->idempotency->valid_key($key)) { return Security::error('idempotency_required', __('A valid idempotency key is required to create a comment.', 'commentmate'), 400); } $reservation = $this->idempotency->reserve($session, 'comments.create', $key); if (is_array($reservation)) { return $this->respond($reservation['body'], $reservation['code']); } if (false === $reservation) { return Security::error('idempotency_in_progress', __('This comment is already being saved. Please wait a moment before retrying.', 'commentmate'), 409); } if (null === $reservation) { return Security::error('idempotency_unavailable', __('The comment could not be safely queued. Please try again.', 'commentmate'), 503); } $data = $this->comments->create($session, $request, $this->idempotency, 'comments.create', $key); if (is_wp_error($data)) { $this->idempotency->abandon($session, 'comments.create', $key); return $data; } return $this->respond($data, 201); } public function update_comment(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { $owner_request = $this->owner_comment_mutation_request($request); if (true === $owner_request) { $review_id = $this->active_owner_review_id(); if (is_wp_error($review_id)) { return $review_id; } $params = $this->request_params($request); if (!isset($params['body']) || !is_scalar($params['body'])) { return Security::error('invalid_comment', __('Comment text is required.', 'commentmate'), 400); } return $this->respond((new Admin_Board())->update_body($review_id, absint($request['id']), (string) $params['body'], get_current_user_id())); } if (is_wp_error($owner_request)) { return $owner_request; } $session = $this->guest_mutation_session($request, 'comment-edit', 60); if (is_wp_error($session)) { return $session; } return $this->respond($this->comments->update($session, absint($request['id']), $request)); } public function delete_comment(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { $owner_request = $this->owner_comment_mutation_request($request); if (true === $owner_request) { $review_id = $this->active_owner_review_id(); if (is_wp_error($review_id)) { return $review_id; } $result = (new Admin_Board())->delete_comment($review_id, absint($request['id']), get_current_user_id()); return $this->respond(is_wp_error($result) ? $result : array('deleted' => true)); } if (is_wp_error($owner_request)) { return $owner_request; } $session = $this->guest_mutation_session($request, 'comment-delete', 30); if (is_wp_error($session)) { return $session; } return $this->respond($this->comments->delete($session, absint($request['id']))); } public function update_comment_status(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { $owner_request = $this->owner_comment_mutation_request($request); if (true === $owner_request) { $review_id = $this->active_owner_review_id(); if (is_wp_error($review_id)) { return $review_id; } $params = $this->request_params($request); $status = isset($params['status']) && is_scalar($params['status']) ? sanitize_key((string) $params['status']) : ''; return $this->respond((new Admin_Board())->set_status($review_id, absint($request['id']), $status, get_current_user_id())); } if (is_wp_error($owner_request)) { return $owner_request; } $session = $this->guest_mutation_session($request, 'comment-status', 60); if (is_wp_error($session)) { return $session; } return $this->respond($this->comments->set_status($session, absint($request['id']), $request)); } public function reposition_comment(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { $owner = $this->owner_comment_mutation_request($request); if (true !== $owner) return is_wp_error($owner) ? $owner : Security::error('owner_required', __('Only a site administrator may repair comment placement.', 'commentmate'), 403); $session = $this->commenting_mutation_session($request, 'comment-reposition', 30); if (is_wp_error($session)) { return $session; } return $this->respond($this->comments->reposition($session, absint($request['id']), $request)); } public function list_messages(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { $session = $this->guest_read_session($request, 'messages-read', 60); if (is_wp_error($session)) { return $session; } $data = $this->comments->messages($session, absint($request['id'])); if (is_wp_error($data)) { return $data; } return $this->respond(array('messages' => $data)); } public function create_message(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { $session = $this->commenting_mutation_session($request, 'message-create', 40); if (is_wp_error($session)) { return $session; } $idempotency = $this->reserve_guest_idempotency($session, 'messages.create', $request); if (is_wp_error($idempotency)) { return $idempotency; } if (isset($idempotency['replay'])) { return $this->respond($idempotency['replay']['body'], $idempotency['replay']['code']); } $data = $this->comments->create_message($session, absint($request['id']), $request, $this->idempotency, 'messages.create', $idempotency['key']); if (is_wp_error($data)) { $this->idempotency->abandon($session, 'messages.create', $idempotency['key']); return $data; } return $this->respond($data, 201); } public function update_message(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { $session = $this->guest_mutation_session($request, 'message-edit', 60); if (is_wp_error($session)) { return $session; } return $this->respond($this->comments->update_message($session, absint($request['comment_id']), absint($request['id']), $request)); } public function delete_message(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { $session = $this->guest_mutation_session($request, 'message-delete', 40); if (is_wp_error($session)) { return $session; } return $this->respond($this->comments->delete_message($session, absint($request['comment_id']), absint($request['id']))); } public function upload_attachment(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { $session = $this->commenting_mutation_session($request, 'attachment-upload', 12); if (is_wp_error($session)) { return $session; } $idempotency = $this->reserve_guest_idempotency($session, 'attachments.upload', $request); if (is_wp_error($idempotency)) { return $idempotency; } if (isset($idempotency['replay'])) { return $this->respond($idempotency['replay']['body'], $idempotency['replay']['code']); } $message_id = absint($request->get_param('messageId')); $data = $this->assets->upload_from_rest_file($session, absint($request['id']), $request, $message_id > 0 ? $message_id : null, 'file', $this->idempotency, 'attachments.upload', $idempotency['key']); if (is_wp_error($data)) { $this->idempotency->abandon($session, 'attachments.upload', $idempotency['key']); return $data; } return $this->respond($data, 201); } public function store_screenshot(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { $session = $this->commenting_mutation_session($request, 'screenshot-upload', 8); if (is_wp_error($session)) { return $session; } $idempotency = $this->reserve_guest_idempotency($session, 'screenshots.create', $request); if (is_wp_error($idempotency)) { return $idempotency; } if (isset($idempotency['replay'])) { return $this->respond($idempotency['replay']['body'], $idempotency['replay']['code']); } $params = $request->get_json_params(); $params = is_array($params) ? $params : $request->get_params(); $data = $this->assets->store_screenshot( $session, absint($request['id']), isset($params['payload']) && is_string($params['payload']) ? $params['payload'] : '', isset($params['mimeType']) && is_string($params['mimeType']) ? $params['mimeType'] : null, isset($params['state']) && is_string($params['state']) ? $params['state'] : 'captured', $this->idempotency, 'screenshots.create', $idempotency['key'] ); if (is_wp_error($data)) { $this->idempotency->abandon($session, 'screenshots.create', $idempotency['key']); return $data; } return $this->respond($data, 201); } public function mark_screenshot_unavailable(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { $session = $this->commenting_mutation_session($request, 'screenshot-unavailable', 20); if (is_wp_error($session)) { return $session; } $data = $this->assets->mark_screenshot_unavailable($session, absint($request['id'])); if (is_wp_error($data)) { return $data; } return $this->respond(array('commentId' => absint($request['id']), 'screenshotState' => 'unavailable')); } public function stream_attachment(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { $session = $this->guest_read_session($request, 'attachment-download', 60); if (is_wp_error($session)) { return $session; } $result = $this->assets->stream_for_session($session, absint($request['id'])); if (is_wp_error($result)) { return $result; } exit; } public function delete_attachment(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { $session = $this->guest_mutation_session($request, 'attachment-delete', 30); if (is_wp_error($session)) { return $session; } $data = $this->assets->delete_for_session($session, absint($request['id'])); if (is_wp_error($data)) { return $data; } return $this->respond(array('deleted' => $data)); } public function start_review(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { return $this->admin_review_response($request, 'start'); } public function rotate_review(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { return $this->admin_review_response($request, 'rotate'); } public function storage_status(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { $permission = Security::verify_admin_request($request); if (is_wp_error($permission)) { return $permission; } return $this->respond($this->reviews->storage_status()); } public function build_identity(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { $permission = Security::verify_admin_request($request); if (is_wp_error($permission)) { return $permission; } return $this->respond(array( 'identity' => Build::identity(), 'receipt' => Build::receipt(), )); } public function stream_owner_attachment(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { $permission = Security::verify_admin_request($request); if (is_wp_error($permission)) { return $permission; } $result = $this->assets->stream_for_owner(absint($request['review_id']), absint($request['id'])); if (is_wp_error($result)) { return $result; } exit; } public function delete_owner_attachment(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { $permission = Security::verify_admin_request($request); if (is_wp_error($permission)) { return $permission; } $data = $this->assets->delete_for_owner(absint($request['review_id']), absint($request['id'])); if (is_wp_error($data)) { return $data; } return $this->respond(array('deleted' => $data)); } public function complete_review(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { $owner = $this->owner_comment_mutation_request($request); if (is_wp_error($owner)) return $owner; if (true === $owner) { $review_id = $this->active_owner_review_id(); if (is_wp_error($review_id)) return $review_id; return $this->respond(Notification_Service::complete_review($review_id, 'owner-' . get_current_user_id(), Session_Service::owner_name())); } $session = $this->guest_mutation_session($request, 'review-complete', 6); if (is_wp_error($session)) return $session; return $this->respond(Notification_Service::complete_review((int) $session['review_id'], 'guest-' . (int) $session['id'], (string) $session['guest_name'])); } public function save_compressed_attachment(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { $permission = Security::verify_admin_request($request); if (is_wp_error($permission)) return $permission; return $this->respond($this->assets->save_compressed_to_media(absint($request['review_id']), absint($request['id']), $this->request_params($request))); } public function promote_owner_attachment(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { $permission = Security::verify_admin_request($request); if (is_wp_error($permission)) { return $permission; } return $this->respond($this->assets->promote_to_media(absint($request['review_id']), absint($request['id']))); } /** * @param mixed $response * @return mixed */ public function add_response_headers(mixed $response, \WP_REST_Server $server, \WP_REST_Request $request): mixed { if (0 !== strpos($request->get_route(), '/' . self::NAMESPACE . '/')) { return $response; } if ($response instanceof \WP_REST_Response) { $response->header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0, private'); $response->header('Pragma', 'no-cache'); $response->header('X-Robots-Tag', 'noindex, nofollow, noarchive'); } return $response; } /** * @return array|\WP_Error */ private function commenting_mutation_session(\WP_REST_Request $request, string $bucket, int $limit): array|\WP_Error { $owner = $this->owner_comment_mutation_request($request); if (is_wp_error($owner)) return $owner; if (!$owner) return $this->guest_mutation_session($request, $bucket, $limit); $session = $this->sessions->owner_session($request); if (is_wp_error($session)) return $session; $limited = $this->consume_guest_limits($session, $bucket, $limit, 10 * MINUTE_IN_SECONDS, true); return is_wp_error($limited) ? $limited : $session; } private function guest_mutation_session(\WP_REST_Request $request, string $bucket, int $limit): array|\WP_Error { $session = $this->sessions->verify_guest_request($request, true, false); if (is_wp_error($session)) { return $session; } $limited = $this->consume_guest_limits($session, $bucket, $limit, 10 * MINUTE_IN_SECONDS, true); if (is_wp_error($limited)) { return $limited; } return $session; } /** * @param array $session * @return array{key:string,replay?:array{code:int,body:array}}|\WP_Error */ private function reserve_guest_idempotency(array $session, string $route, \WP_REST_Request $request): array|\WP_Error { $key = $this->idempotency->key_from_request($request); if (!$this->idempotency->valid_key($key)) { return Security::error('idempotency_required', __('A valid idempotency key is required for this review change.', 'commentmate'), 400); } $reservation = $this->idempotency->reserve($session, $route, $key); if (is_array($reservation)) { return array('key' => $key, 'replay' => $reservation); } if (false === $reservation) { return Security::error('idempotency_in_progress', __('This review change is already being saved. Please wait a moment before retrying.', 'commentmate'), 409); } if (null === $reservation) { return Security::error('idempotency_unavailable', __('This review change could not be safely queued. Please try again.', 'commentmate'), 503); } return array('key' => $key); } /** * @return array|\WP_Error */ private function guest_read_session(\WP_REST_Request $request, string $bucket, int $limit): array|\WP_Error { $session = $this->sessions->verify_guest_request($request, false, false); if (is_wp_error($session)) { return $session; } $limited = $this->consume_guest_limits($session, $bucket, $limit, MINUTE_IN_SECONDS, false); if (is_wp_error($limited)) { return $limited; } return $session; } /** * Records review-scoped request counters while holding the active review row * lock. Cleanup takes the same lock before deleting its counters, so no * pre-lock request can leave an orphan rate-limit row behind. * * @param array $session * @return true|\WP_Error */ private function consume_guest_limits(array $session, string $bucket, int $limit, int $window_seconds, bool $include_review_wide): bool|\WP_Error { global $wpdb; if (false === $wpdb->query('START TRANSACTION')) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Explicit transaction boundary serializes review-scoped rate counters and session touch with cleanup; cached state cannot provide row-lock atomicity. return Security::error('rate_limit_unavailable', __('Request limits could not be checked. Please try again.', 'commentmate'), 503); } if (!Session_Service::lock_current_for_mutation($session)) { $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Explicit transaction boundary serializes review-scoped rate counters and session touch with cleanup; cached state cannot provide row-lock atomicity. return Security::error('session_revoked', __('This review session is no longer active.', 'commentmate'), 401); } $review_id = (int) $session['review_id']; $allowed = $this->rate_limiter->consume($bucket, 'session:' . (string) $session['id'], $limit, $window_seconds, $review_id, true); if ($allowed && $include_review_wide) { $allowed = $this->rate_limiter->consume($bucket . '-review-ip', 'review:' . (string) $review_id . '|ip:' . Security::client_ip(), max(60, $limit * 3), $window_seconds, $review_id, true) && $this->rate_limiter->consume($bucket . '-review', 'review:' . (string) $review_id, max(200, $limit * 10), $window_seconds, $review_id, true); } if ($allowed && !Session_Service::touch_locked_session($session)) { $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Explicit transaction boundary serializes review-scoped rate counters and session touch with cleanup; cached state cannot provide row-lock atomicity. return Security::error('session_revoked', __('This review session is no longer active.', 'commentmate'), 401); } // A rejected request must still retain its counter. Committing while the // review row is locked also gives cleanup a serialised point at which it can // purge every review-scoped limiter row. if (false === $wpdb->query('COMMIT')) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Explicit transaction boundary serializes review-scoped rate counters and session touch with cleanup; cached state cannot provide row-lock atomicity. $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Explicit transaction boundary serializes review-scoped rate counters and session touch with cleanup; cached state cannot provide row-lock atomicity. return Security::error('rate_limit_unavailable', __('Request limits could not be checked. Please try again.', 'commentmate'), 503); } if (!$allowed) { return Security::error( 'rate_limited', $include_review_wide ? __('This review is receiving too many requests. Please wait and try again.', 'commentmate') : __('Please wait a moment before refreshing review data.', 'commentmate'), 429 ); } return true; } private function admin_review_response(\WP_REST_Request $request, string $operation): \WP_REST_Response|\WP_Error { $permission = Security::verify_admin_request($request); if (is_wp_error($permission)) { return $permission; } $user_id = get_current_user_id(); $data = match ($operation) { 'start' => $this->reviews->start($user_id), 'rotate' => $this->reviews->rotate($user_id), default => Security::error('invalid_operation', __('Unknown review operation.', 'commentmate'), 400), }; return $this->respond($data); } /** * The public comment routes are intentionally shared by guests and the * logged-in designated owner. A WordPress REST nonce opts a request into the * owner path; an absent nonce remains the existing cookie-bound guest path. * An invalid or unauthorised supplied nonce must fail closed instead of being * allowed to fall through to a guest session. * * @return bool|\WP_Error True for an authenticated owner request, false for a guest request. */ private function owner_comment_mutation_request(\WP_REST_Request $request): bool|\WP_Error { if ('' === trim((string) $request->get_header('x-wp-nonce'))) { return false; } return Security::verify_admin_request($request); } /** * Resolves the only review that can be displayed in owner context. The review * id is never accepted from the browser for a shared comment endpoint. * * @return int|\WP_Error */ private function active_owner_review_id(): int|\WP_Error { $review = $this->reviews->current('active'); if (!is_array($review) || (int) ($review['id'] ?? 0) < 1) { return Security::error('review_not_active', __('There is no active review to update.', 'commentmate'), 409); } return (int) $review['id']; } /** * @return array */ private function request_params(\WP_REST_Request $request): array { $params = $request->get_json_params(); return is_array($params) ? $params : $request->get_params(); } /** * @return array|\WP_Error */ private function bootstrap_payload(array $session, \WP_REST_Request $request, string $csrf): array|\WP_Error { $data = $this->comments->bootstrap($session, $request); if (is_wp_error($data)) { return $data; } $absolute = strtotime((string) $session['absolute_expires_at'] . ' UTC'); return array( 'review' => array( 'id' => (string) $session['review_id'], 'status' => (string) $session['review_state'], ), 'session' => array( 'id' => 'guest-' . (int) $session['id'], 'csrfToken' => $csrf, 'expiresAt' => gmdate('c', $absolute ?: time()), 'guestName' => (string) $session['guest_name'], ), 'csrfToken' => $csrf, 'guestName' => (string) $session['guest_name'], 'notificationsEnabled' => Notification_Service::completion_enabled(), 'comments' => $data['comments'], 'pages' => $data['pages'], 'now' => gmdate('c'), ); } /** * Establishes an authenticated owner context. It deliberately does not issue a * guest CSRF token or cookie, so owner navigation cannot be confused with a * share-link session. Comment creation uses a separate nonce-authorized record. */ private function admin_bootstrap(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { $review = $this->reviews->current('active'); if (!is_array($review)) { return Security::error('review_not_active', __('There is no active review to display.', 'commentmate'), 409); } $display_name = Session_Service::owner_name(); $context = array( 'id' => 0, 'review_id' => (int) $review['id'], 'review_state' => 'active', 'guest_name' => $display_name, 'absolute_expires_at' => gmdate('Y-m-d H:i:s', time() + HOUR_IN_SECONDS), ); $data = $this->comments->bootstrap($context, $request); if (is_wp_error($data)) { return $data; } return $this->respond(array( 'review' => array( 'id' => (string) $review['id'], 'status' => 'active', ), 'session' => array( 'id' => 'owner-' . get_current_user_id(), 'csrfToken' => null, 'expiresAt' => gmdate('c', time() + HOUR_IN_SECONDS), 'guestName' => $display_name, 'isOwner' => true, ), 'isOwner' => true, 'guestName' => $display_name, 'notificationsEnabled' => Notification_Service::completion_enabled(), 'comments' => $data['comments'], 'pages' => $data['pages'], 'now' => gmdate('c'), )); } /** * @param array|\WP_Error $data */ private function respond(array|\WP_Error $data, int $status = 200): \WP_REST_Response|\WP_Error { if (is_wp_error($data)) { return $data; } Security::send_no_cache_headers(); $response = new \WP_REST_Response($data, $status); $response->header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0, private'); $response->header('Pragma', 'no-cache'); $response->header('X-Robots-Tag', 'noindex, nofollow, noarchive'); return $response; } }