authorize($actor_user_id); if (is_wp_error($permission)) { return $permission; } if ($review_id < 1) { return Security::error('export_review_invalid', __('A valid review is required for export.', 'commentmate'), 400); } if (!class_exists('\ZipArchive')) { return Security::error('export_unsupported', __('This server does not support ZIP exports.', 'commentmate'), 501); } // Hold the same review row lock used by cleanup until the ZIP is closed. // This prevents an export archive from being published after cleanup has // already swept temporary exports for the review. global $wpdb; $reviews = Database::table('commentmate_reviews'); 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('export_unavailable', __('The private export could not lock this review.', 'commentmate'), 503); } $transaction_open = true; $zip_open = false; $path = ''; $zip = new \ZipArchive(); try { $review = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private exports read plugin tables under review locks; fresh receipt and cleanup state is required before access or deletion. $wpdb->prepare('SELECT * FROM %i WHERE id = %d FOR UPDATE', $reviews, $review_id), ARRAY_A ); if (!is_array($review) || !Owner::is_owner($actor_user_id)) { throw new \RuntimeException('review_not_found'); } if ((int) ($review['cleanup_locked'] ?? 0) === 1) { throw new \RuntimeException('cleanup_locked'); } if (!Private_Storage::ensure_directory()) { throw new \RuntimeException('storage_unavailable'); } $directory = $this->export_directory(); if (is_wp_error($directory)) { throw new \RuntimeException('storage_unavailable'); } $filename = sprintf('commentmate-export-%d-%s.zip', $review_id, gmdate('Ymd-His') . '-' . substr(Security::token(12), 0, 16)); $path = $directory . DIRECTORY_SEPARATOR . $filename; $handle = @fopen($path, 'x+b'); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- exclusive private archive creation. if (false === $handle) throw new \RuntimeException('zip_open'); $private = @chmod($path, 0600); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- secure this exclusively-created local private archive before ZipArchive writes; never use remote filesystem transports. fclose($handle); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- close the native exclusive-creation handle before ZipArchive opens the same local file. if (!$private) throw new \RuntimeException('zip_open'); $opened = $zip->open($path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE); if (true !== $opened) { throw new \RuntimeException('zip_open'); } $zip_open = true; if (!@chmod($path, 0600)) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- exact private archive. throw new \RuntimeException('zip_open'); } $snapshot = $this->snapshot($review); if (is_wp_error($snapshot)) { throw new \RuntimeException($snapshot->get_error_code()); } // Bind a cleanup receipt to the exact logical review contents captured by // this archive. Session refreshes and audit logging are intentionally // excluded, while pages, comments, replies, screenshots and attachments // are all included. $content_fingerprint = $this->content_fingerprint($snapshot); $asset_manifest = $this->add_assets($zip, $snapshot['attachments'], $review_id); if (is_wp_error($asset_manifest)) { throw new \RuntimeException($asset_manifest->get_error_code()); } $snapshot['attachments'] = $asset_manifest['attachments']; $json = wp_json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); if (!is_string($json) || !$zip->addFromString('review.json', $json)) { throw new \RuntimeException('review_json'); } $html = $this->summary_html($snapshot); if (!$zip->addFromString('summary.html', $html)) { throw new \RuntimeException('summary_html'); } $checksums = array( 'schema' => 1, 'generatedAt' => gmdate('c'), 'build' => Build::identity(), 'buildReceipt' => Build::receipt(), 'files' => array_merge( array( array('path' => 'review.json', 'sha256' => hash('sha256', $json), 'byteSize' => strlen($json)), array('path' => 'summary.html', 'sha256' => hash('sha256', $html), 'byteSize' => strlen($html)), ), $asset_manifest['checksums'] ), ); $checksums_json = wp_json_encode($checksums, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); if (!is_string($checksums_json) || !$zip->addFromString('checksums.json', $checksums_json)) { throw new \RuntimeException('checksums_json'); } if (!$zip->close()) { throw new \RuntimeException('zip_close'); } $zip_open = false; if (false === $wpdb->query('COMMIT')) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Explicit transaction boundary for the surrounding plugin-table operation; caching cannot provide atomicity or row locks. throw new \RuntimeException('transaction_commit'); } $transaction_open = false; } catch (\Throwable $exception) { if ($zip_open) { $zip->close(); } if ($transaction_open) { $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 ('' !== $path && file_exists($path)) { $this->delete_path($path); } return match ($exception->getMessage()) { 'review_not_found' => Security::error('export_review_not_found', __('That review no longer exists.', 'commentmate'), 404), 'cleanup_locked' => Security::error('export_cleanup_locked', __('This review is locked for cleanup. Resolve the cleanup before creating another export.', 'commentmate'), 409), 'storage_unavailable' => Security::error('export_storage_unavailable', __('Private export storage is unavailable.', 'commentmate'), 503), default => Security::error('export_failed', __('The review export could not be completed. No review data was changed.', 'commentmate'), 503), }; } if (!is_file($path) || is_link($path)) { $this->delete_path($path); return Security::error('export_failed', __('The private export archive could not be verified.', 'commentmate'), 503); } if (!@chmod($path, 0600)) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- resecure the exact local private archive after ZipArchive closes it, failing closed on permission failure. $this->delete_path($path); return Security::error('export_failed', __('The private archive permissions could not be secured.', 'commentmate'), 503); } $checksum = hash_file('sha256', $path); if (!is_string($checksum)) { $this->delete_path($path); return Security::error('export_failed', __('The private export archive could not be checksummed.', 'commentmate'), 503); } Audit::record($review_id, 'admin', $actor_user_id, 'review_export_created', array('checksum' => $checksum)); return array( 'path' => $path, 'filename' => 'commentmate-export-' . $review_id . '.zip', 'mimeType' => 'application/zip', 'byteSize' => (int) filesize($path), 'checksum' => $checksum, 'contentFingerprint' => $content_fingerprint, 'reviewId' => $review_id, 'createdAt' => gmdate('c'), ); } /** * Streams a server-created archive to an authenticated owner. Callers should * terminate their route after a successful stream, then call destroy() when * their response lifecycle permits removal of the temporary private file. * * @param array{path:string,filename:string,mimeType:string,byteSize:int,checksum:string,contentFingerprint:string,reviewId:int,createdAt:string} $export * @return bool|\WP_Error */ public function stream(array $export, int $actor_user_id): bool|\WP_Error { if (!Security::is_https()) { return Security::error('https_required', __('CommentMate requires HTTPS before private exports can be downloaded.', 'commentmate'), 403); } $permission = $this->authorize($actor_user_id); if (is_wp_error($permission)) { return $permission; } $path = isset($export['path']) ? (string) $export['path'] : ''; if (!$this->is_export_path($path) || !is_file($path) || is_link($path) || !is_readable($path)) { return Security::error('export_not_found', __('The private export is no longer available.', 'commentmate'), 404); } 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('export_unavailable', __('The private download could not lock this review.', 'commentmate'), 503); } try { // Transfer and cleanup wait until an already-authorised download ends. // Recheck durable ownership after waiting, before any private bytes leave. if (!Owner::lock_actor($actor_user_id)) { return Security::error('export_forbidden', __('Only the current CommentMate owner may download a review.', 'commentmate'), 403); } if (!is_file($path) || is_link($path)) { return Security::error('export_not_found', __('The private export is no longer available.', 'commentmate'), 404); } Security::send_no_cache_headers(); nocache_headers(); header('Content-Type: application/zip'); header('Content-Length: ' . (string) filesize($path)); header('Content-Disposition: attachment; filename="' . $this->download_filename((string) ($export['filename'] ?? 'commentmate-export.zip')) . '"'); header('X-Content-Type-Options: nosniff'); $sent = readfile($path); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- authenticated private download. return is_int($sent) && $sent === (int) filesize($path); } finally { $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. } } /** * Removes a server-created temporary archive. It never accepts an arbitrary * filesystem path and cannot affect Media Library files. * * @param array{path:string} $export * @return bool|\WP_Error */ public function destroy(array $export, int $actor_user_id): bool|\WP_Error { $permission = $this->authorize($actor_user_id); if (is_wp_error($permission)) { return $permission; } $path = isset($export['path']) ? (string) $export['path'] : ''; if (!$this->is_export_path($path)) { return Security::error('export_path_invalid', __('The requested export path is invalid.', 'commentmate'), 400); } if (!file_exists($path)) { return true; } if (is_link($path) || !is_file($path) || !$this->delete_path($path)) { return Security::error('export_remove_failed', __('The temporary private export could not be removed.', 'commentmate'), 503); } return true; } /** * Records that the server checksummed and fully streamed a private archive. * A browser cannot prove a person retained the download, so cleanup still * offers a deliberately typed no-export override; ordinary cleanup requires * this durable verified receipt. * * @param array{path:string,filename:string,mimeType:string,byteSize:int,checksum:string,reviewId:int,createdAt:string} $export * @return true|\WP_Error */ public function record_delivery(array $export, int $actor_user_id): bool|\WP_Error { $permission = $this->authorize($actor_user_id); if (is_wp_error($permission)) { return $permission; } $review_id = (int) ($export['reviewId'] ?? 0); $path = (string) ($export['path'] ?? ''); $expected = (string) ($export['checksum'] ?? ''); $content_fingerprint = (string) ($export['contentFingerprint'] ?? ''); if ($review_id < 1 || !$this->is_export_path($path) || !preg_match('/\A[a-f0-9]{64}\z/', $expected) || !preg_match('/\A[a-f0-9]{64}\z/', $content_fingerprint) || !is_file($path) || is_link($path)) { return Security::error('export_receipt_invalid', __('The private export could not be verified for cleanup.', 'commentmate'), 503); } $actual = hash_file('sha256', $path); if (!is_string($actual) || !hash_equals($expected, $actual)) { return Security::error('export_receipt_invalid', __('The private export checksum did not match.', 'commentmate'), 503); } // The archive can finish streaming after another owner tab begins cleanup. // Serialize receipt persistence with cleanup's review-row lock so a late // download can never recreate an orphan receipt after final verification. global $wpdb; $reviews = Database::table('commentmate_reviews'); $receipt_option = self::receipt_option($review_id); 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('export_receipt_invalid', __('The private export receipt could not be saved.', 'commentmate'), 503); } $transaction_open = true; try { if (!Owner::lock_actor($actor_user_id)) { throw new \RuntimeException('review_locked'); } $review = $wpdb->get_row($wpdb->prepare('SELECT id, cleanup_locked FROM %i WHERE id = %d FOR UPDATE', $reviews, $review_id), ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private exports read plugin tables under review locks; fresh receipt and cleanup state is required before access or deletion. if (!is_array($review) || (int) $review['cleanup_locked'] !== 0) { throw new \RuntimeException('review_locked'); } $receipt = wp_json_encode(array( 'schema' => 2, 'reviewId' => $review_id, 'checksum' => $actual, 'contentFingerprint' => $content_fingerprint, 'byteSize' => (int) filesize($path), 'createdAt' => (string) ($export['createdAt'] ?? ''), 'deliveredAt' => gmdate('c'), )); if (!is_string($receipt)) { throw new \RuntimeException('receipt_invalid'); } // Do not use add_option()/update_option() inside this explicit database // transaction. Their object-cache writes can survive a rollback and make a // receipt appear durable when it was not. This direct SQL write is checked // through the same connection, then the cache is purged only after commit. $stored = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private exports read plugin tables under review locks; fresh receipt and cleanup state is required before access or deletion. $wpdb->prepare('INSERT INTO %i (option_name, option_value, autoload) VALUES (%s, %s, \'no\') ON DUPLICATE KEY UPDATE option_value = VALUES(option_value), autoload = VALUES(autoload)', $wpdb->options, $receipt_option, $receipt) ); if (false === $stored) { throw new \RuntimeException('receipt_invalid'); } $stored_receipt = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private exports read plugin tables under review locks; fresh receipt and cleanup state is required before access or deletion. $wpdb->prepare('SELECT option_value FROM %i WHERE option_name = %s LIMIT 1', $wpdb->options, $receipt_option) ); if (!is_string($stored_receipt) || !hash_equals($receipt, $stored_receipt)) { throw new \RuntimeException('receipt_invalid'); } if (false === $wpdb->query('COMMIT')) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Explicit transaction boundary for the surrounding plugin-table operation; caching cannot provide atomicity or row locks. throw new \RuntimeException('receipt_invalid'); } $transaction_open = false; wp_cache_delete($receipt_option, 'options'); wp_cache_delete('alloptions', 'options'); wp_cache_delete('notoptions', 'options'); } catch (\Throwable $exception) { if ($transaction_open) { $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. } // Purge both successful and failed direct-write paths. A persistent // object cache must never retain a receipt from an uncommitted write. wp_cache_delete($receipt_option, 'options'); wp_cache_delete('alloptions', 'options'); wp_cache_delete('notoptions', 'options'); return 'review_locked' === $exception->getMessage() ? Security::error('export_cleanup_locked', __('The review entered cleanup before this export receipt could be recorded. Download a new export after cleanup is resolved.', 'commentmate'), 409) : Security::error('export_receipt_invalid', __('The private export receipt could not be saved.', 'commentmate'), 503); } Audit::record($review_id, 'admin', $actor_user_id, 'review_export_delivered', array('checksum' => $actual)); return true; } /** * @return bool|\WP_Error */ public function has_verified_receipt(int $review_id, int $actor_user_id): bool|\WP_Error { $receipt = $this->verified_receipt($review_id, $actor_user_id); return is_array($receipt) ? true : $receipt; } /** * Returns a syntactically valid, owner-authorized export receipt. Cleanup * compares its fingerprint after acquiring the review row lock, so a new * comment cannot race between receipt validation and erasure. * * @return array|false|\WP_Error */ public function verified_receipt(int $review_id, int $actor_user_id): array|false|\WP_Error { $permission = $this->authorize($actor_user_id); if (is_wp_error($permission)) { return $permission; } if ($review_id < 1) { return false; } $value = get_option(self::receipt_option($review_id), false); if (!is_string($value)) { return false; } $receipt = json_decode($value, true); if (!is_array($receipt) || 2 !== (int) ($receipt['schema'] ?? 0) || $review_id !== (int) ($receipt['reviewId'] ?? 0) || !preg_match('/\A[a-f0-9]{64}\z/', (string) ($receipt['checksum'] ?? '')) || !preg_match('/\A[a-f0-9]{64}\z/', (string) ($receipt['contentFingerprint'] ?? '')) || (int) ($receipt['byteSize'] ?? 0) < 1 || '' === (string) ($receipt['deliveredAt'] ?? '')) { return false; } return $receipt; } /** * Computes a fingerprint while the caller holds the matching review row * lock. This does not authorize or create a transaction; Cleanup_Service * calls it only after its own owner and lock checks have succeeded. * * @param array $locked_review * @return string|\WP_Error */ public function content_fingerprint_for_locked_review(array $locked_review): string|\WP_Error { $snapshot = $this->snapshot($locked_review); if (is_wp_error($snapshot)) { return $snapshot; } return $this->content_fingerprint($snapshot); } public static function receipt_option(int $review_id): string { return 'commentmate_export_receipt_' . max(0, $review_id); } /** * @return true|\WP_Error */ private function authorize(int $actor_user_id): bool|\WP_Error { if ($actor_user_id < 1 || !Owner::is_owner($actor_user_id) || !user_can($actor_user_id, Capabilities::MANAGE)) { return Security::error('export_forbidden', __('Only a site administrator may export review data.', 'commentmate'), 403); } return true; } /** * @return array|null */ private function review(int $review_id): ?array { global $wpdb; $row = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private exports read plugin tables under review locks; fresh receipt and cleanup state is required before access or deletion. $wpdb->prepare('SELECT * FROM %i WHERE id = %d LIMIT 1', Database::table('commentmate_reviews'), $review_id), ARRAY_A ); return is_array($row) ? $row : null; } /** * @param array $review * @return array|\WP_Error */ private function snapshot(array $review): array|\WP_Error { global $wpdb; $review_id = (int) $review['id']; $pages = Database::table('commentmate_pages'); $comments = Database::table('commentmate_comments'); $messages = Database::table('commentmate_comment_messages'); $attachments = Database::table('commentmate_attachments'); $sessions = Database::table('commentmate_guest_sessions'); $events = Database::table('commentmate_audit_events'); $page_rows = $wpdb->get_results($wpdb->prepare('SELECT id, canonical_url, page_title, created_at, updated_at FROM %i WHERE review_id = %d ORDER BY id ASC', $pages, $review_id), ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private exports read plugin tables under review locks; fresh receipt and cleanup state is required before access or deletion. $comment_rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private exports read plugin tables under review locks; fresh receipt and cleanup state is required before access or deletion. $wpdb->prepare('SELECT c.id, c.comment_number, c.page_id, c.author_session_id, c.author_name, c.status, c.anchor_json, c.body_html, c.screenshot_state, c.created_at, c.updated_at, c.resolved_at, c.resolved_by_session_id, c.deleted_at, p.canonical_url, p.page_title FROM %i AS c LEFT JOIN %i AS p ON p.id = c.page_id WHERE c.review_id = %d ORDER BY c.id ASC', $comments, $pages, $review_id), ARRAY_A ); $message_rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private exports read plugin tables under review locks; fresh receipt and cleanup state is required before access or deletion. $wpdb->prepare('SELECT m.id, m.comment_id, m.author_session_id, m.author_user_id, m.body_html, m.created_at, m.edited_at, m.deleted_at FROM %i AS m INNER JOIN %i AS c ON c.id = m.comment_id WHERE c.review_id = %d ORDER BY m.id ASC', $messages, $comments, $review_id), ARRAY_A ); $attachment_rows = $wpdb->get_results($wpdb->prepare('SELECT id, comment_id, message_id, storage_key, checksum, mime_type, original_name, byte_size, disposition, media_attachment_id, created_at, deleted_at FROM %i WHERE review_id = %d ORDER BY id ASC', $attachments, $review_id), ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private exports read plugin tables under review locks; fresh receipt and cleanup state is required before access or deletion. $session_rows = $wpdb->get_results($wpdb->prepare('SELECT id, guest_name, created_at, last_seen_at, expires_at, absolute_expires_at, revoked_at FROM %i WHERE review_id = %d ORDER BY id ASC', $sessions, $review_id), ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private exports read plugin tables under review locks; fresh receipt and cleanup state is required before access or deletion. $event_rows = $wpdb->get_results($wpdb->prepare('SELECT id, actor_type, actor_id, event_type, data_json, created_at FROM %i WHERE review_id = %d ORDER BY id ASC', $events, $review_id), ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private exports read plugin tables under review locks; fresh receipt and cleanup state is required before access or deletion. if (!is_array($page_rows) || !is_array($comment_rows) || !is_array($message_rows) || !is_array($attachment_rows) || !is_array($session_rows) || !is_array($event_rows)) { return Security::error('export_data_unavailable', __('Review data could not be read for export.', 'commentmate'), 503); } $names = array(); foreach ($session_rows as $session) $names[(int) $session['id']] = (string) $session['guest_name']; foreach ($message_rows as &$message) { $user = !empty($message['author_user_id']) ? get_user_by('id', (int) $message['author_user_id']) : false; $message['author_name'] = $user instanceof \WP_User ? $user->display_name : ($names[(int) ($message['author_session_id'] ?? 0)] ?? __('Guest', 'commentmate')); } unset($message); return array( 'schemaVersion' => 1, 'exportedAt' => gmdate('c'), 'build' => Build::identity(), 'buildReceipt' => Build::receipt(), 'review' => array( 'id' => $review_id, 'ownerUserId' => (int) $review['owner_user_id'], 'state' => (string) $review['state'], 'createdAt' => (string) $review['created_at'], 'updatedAt' => (string) $review['updated_at'], 'pausedAt' => $review['paused_at'] ? (string) $review['paused_at'] : null, 'endedAt' => $review['ended_at'] ? (string) $review['ended_at'] : null, 'metadata' => $this->decode_json((string) ($review['metadata'] ?? '')), ), 'pages' => $page_rows, 'comments' => $this->decode_columns($comment_rows, array('anchor_json')), 'messages' => $message_rows, 'attachments' => $attachment_rows, 'sessions' => $session_rows, 'auditEvents' => $this->decode_columns($event_rows, array('data_json')), 'notes' => array( 'Share-token, CSRF, session, and client hashes are intentionally excluded.', 'Promoted Media Library items are exported as references only and are never copied or deleted by CommentMate.', ), ); } /** * Produces a stable digest of review data whose loss would make an earlier * export incomplete. It includes stable guest identity/session state and * meaningful audit history, but excludes idle-session churn and the two * self-generated export receipt events so an ordinary page view or the act * of verifying this archive does not immediately invalidate it. * * @param array $snapshot */ private function content_fingerprint(array $snapshot): string { $review = is_array($snapshot['review'] ?? null) ? $snapshot['review'] : array(); $stable_sessions = array(); foreach (is_array($snapshot['sessions'] ?? null) ? $snapshot['sessions'] : array() as $session) { if (!is_array($session)) { continue; } // last_seen_at and the rolling idle expiry change on normal reads. They // cannot create or revive a session and are intentionally not a reason // to force an owner to generate a new archival export. $stable_sessions[] = array( 'id' => (int) ($session['id'] ?? 0), 'guestName' => (string) ($session['guest_name'] ?? ''), 'createdAt' => (string) ($session['created_at'] ?? ''), 'absoluteExpiresAt' => (string) ($session['absolute_expires_at'] ?? ''), 'revokedAt' => isset($session['revoked_at']) ? $session['revoked_at'] : null, ); } $audit_events = array(); foreach (is_array($snapshot['auditEvents'] ?? null) ? $snapshot['auditEvents'] : array() as $event) { if (!is_array($event)) { continue; } // create() and record_delivery() intentionally append these events after // the snapshot/fingerprint is created. Including them would make every // otherwise valid receipt stale before cleanup can begin. if (in_array((string) ($event['event_type'] ?? ''), array('review_export_created', 'review_export_delivered'), true)) { continue; } $audit_events[] = $event; } $canonical = array( 'schema' => 2, 'review' => array( 'id' => (int) ($review['id'] ?? 0), 'ownerUserId' => (int) ($review['ownerUserId'] ?? 0), 'state' => (string) ($review['state'] ?? ''), 'createdAt' => (string) ($review['createdAt'] ?? ''), 'pausedAt' => isset($review['pausedAt']) ? $review['pausedAt'] : null, 'endedAt' => isset($review['endedAt']) ? $review['endedAt'] : null, 'metadata' => $review['metadata'] ?? null, ), 'pages' => is_array($snapshot['pages'] ?? null) ? $snapshot['pages'] : array(), 'comments' => is_array($snapshot['comments'] ?? null) ? $snapshot['comments'] : array(), 'messages' => is_array($snapshot['messages'] ?? null) ? $snapshot['messages'] : array(), 'attachments' => is_array($snapshot['attachments'] ?? null) ? $snapshot['attachments'] : array(), 'sessions' => $stable_sessions, 'auditEvents' => $audit_events, ); $json = wp_json_encode($canonical, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); return is_string($json) ? hash('sha256', $json) : hash('sha256', serialize($canonical)); } /** * @param list> $attachments * @return array{attachments:list>,checksums:list}|\WP_Error */ private function add_assets(\ZipArchive $zip, array $attachments, int $review_id): array|\WP_Error { $updated = array(); $checksums = array(); $assets = new Asset_Service(); foreach ($attachments as $attachment) { $id = (int) ($attachment['id'] ?? 0); $disposition = (string) ($attachment['disposition'] ?? 'transient'); if ($id < 1) { return Security::error('export_asset_invalid', __('An attachment record is invalid.', 'commentmate'), 503); } if ('promoted' === $disposition) { $attachment['export'] = array( 'kind' => 'media-library-reference', 'mediaAttachmentId' => (int) ($attachment['media_attachment_id'] ?? 0), 'mediaUrl' => $this->media_url((int) ($attachment['media_attachment_id'] ?? 0)), ); unset($attachment['storage_key']); $updated[] = $attachment; continue; } if ('deleted' === $disposition || !empty($attachment['deleted_at'])) { $attachment['export'] = array('kind' => 'deleted', 'archivePath' => null, 'available' => false); unset($attachment['storage_key']); $updated[] = $attachment; continue; } $plaintext = $assets->export_plaintext_for_owner($review_id, $id); if (is_wp_error($plaintext)) { return $plaintext; } $entry = 'assets/attachment-' . $id . '-' . $this->archive_filename((string) ($attachment['original_name'] ?? ''), $id); $bytes = (string) $plaintext['bytes']; $actual_checksum = hash('sha256', $bytes); if (!hash_equals((string) $plaintext['checksum'], $actual_checksum) || !$zip->addFromString($entry, $bytes)) { return Security::error('export_asset_unavailable', __('A private attachment could not be added to the export.', 'commentmate'), 503); } $attachment['export'] = array( 'kind' => 'decrypted-private-asset', 'contentEncoding' => 'plaintext-verified', 'archivePath' => $entry, 'available' => true, 'checksum' => $actual_checksum, 'checksumMatches' => hash_equals((string) ($attachment['checksum'] ?? ''), $actual_checksum), ); unset($attachment['storage_key']); $checksums[] = array('path' => $entry, 'sha256' => $actual_checksum, 'byteSize' => strlen($bytes)); $updated[] = $attachment; } return array('attachments' => $updated, 'checksums' => $checksums); } /** * @param array $snapshot */ private function summary_html(array $snapshot): string { $review = is_array($snapshot['review'] ?? null) ? $snapshot['review'] : array(); $html = '' . esc_html__('CommentMate review download', 'commentmate') . ''; $html .= '

' . esc_html__('CommentMate review download', 'commentmate') . '

' . esc_html__('This is a readable review record, not a complete website recovery backup. Media Library copies are linked and are not included as files.', 'commentmate') . '

'; $html .= '

' . esc_html__('Review:', 'commentmate') . ' ' . esc_html((string) ($review['id'] ?? '')) . '

' . esc_html__('Exported:', 'commentmate') . ' ' . esc_html((string) ($snapshot['exportedAt'] ?? '')) . '

'; foreach ($snapshot['comments'] ?? array() as $comment) { $id = (int) ($comment['id'] ?? 0); $html .= '

' . esc_html__('Comment', 'commentmate') . ' ' . esc_html((string) $id) . '

' . esc_html__('Page:', 'commentmate') . ' ' . esc_html((string) ($comment['canonical_url'] ?? '')) . '

' . esc_html__('Status:', 'commentmate') . ' ' . esc_html((string) ($comment['status'] ?? '')) . '

'; $html .= '
' . wp_kses_post((string) ($comment['body_html'] ?? '')) . '
'; foreach ($snapshot['messages'] ?? array() as $message) { if ((int) ($message['comment_id'] ?? 0) !== $id || !empty($message['deleted_at'])) continue; $html .= '

' . esc_html__('Message / reply', 'commentmate') . '

' . esc_html((string) ($message['author_name'] ?? '')) . ' — ' . esc_html((string) ($message['created_at'] ?? '')) . '

' . wp_kses_post((string) ($message['body_html'] ?? '')) . '
'; } $html .= '
'; } $html .= '

' . esc_html__('Attachments', 'commentmate') . '

    '; foreach ($snapshot['attachments'] ?? array() as $asset) { $export = $asset['export'] ?? array(); $url = 'media-library-reference' === ($export['kind'] ?? '') ? (string) ($export['mediaUrl'] ?? '') : (string) ($export['archivePath'] ?? ''); $name = (string) ($asset['original_name'] ?? ''); $href = 'media-library-reference' === ($export['kind'] ?? '') ? esc_url($url) : esc_attr($url); $html .= '
  • ' . ('' !== $url ? '' . esc_html($name) . '' : esc_html($name) . ' (' . esc_html__('No longer available', 'commentmate') . ')') . '
  • '; } return $html . '
'; } /** * @return string|\WP_Error */ private function export_directory(): string|\WP_Error { $base = Private_Storage::path(); if (!is_dir($base) || is_link($base)) { return Security::error('export_storage_unavailable', __('Private export storage is unavailable.', 'commentmate'), 503); } $directory = trailingslashit($base) . self::EXPORT_DIRECTORY; if (!is_dir($directory) && !wp_mkdir_p($directory)) { return Security::error('export_storage_unavailable', __('Private export storage is unavailable.', 'commentmate'), 503); } if (!is_dir($directory) || is_link($directory)) { return Security::error('export_storage_unavailable', __('Private export storage is unsafe.', 'commentmate'), 503); } return $directory; } /** * @return string|\WP_Error */ private function storage_path(string $storage_key): string|\WP_Error { $storage_key = trim($storage_key); if ('' === $storage_key || strlen($storage_key) > 191 || str_contains($storage_key, '\\') || str_starts_with($storage_key, '/') || preg_match('#(?:^|/)\.\.?($|/)#', $storage_key)) { return Security::error('export_asset_path_invalid', __('A private attachment has an unsafe storage path.', 'commentmate'), 503); } $base = realpath(Private_Storage::path()); if (false === $base || is_link(Private_Storage::path())) { return Security::error('export_storage_unavailable', __('Private attachment storage is unavailable.', 'commentmate'), 503); } $path = $base . DIRECTORY_SEPARATOR . $storage_key; if (is_link($path)) { return Security::error('export_asset_path_invalid', __('A private attachment path is symlinked.', 'commentmate'), 503); } if (file_exists($path)) { $real = realpath($path); if (false === $real || !str_starts_with($real, $base . DIRECTORY_SEPARATOR)) { return Security::error('export_asset_path_invalid', __('A private attachment path escapes storage.', 'commentmate'), 503); } } return $path; } /** * @param list> $rows * @param list $columns * @return list> */ private function decode_columns(array $rows, array $columns): array { foreach ($rows as &$row) { foreach ($columns as $column) { if (isset($row[$column])) { $row[$column] = $this->decode_json((string) $row[$column]); } } } unset($row); return $rows; } private function decode_json(string $value): mixed { $decoded = json_decode($value, true); return JSON_ERROR_NONE === json_last_error() ? $decoded : $value; } private function media_url(int $attachment_id): ?string { if ($attachment_id < 1 || 'attachment' !== get_post_type($attachment_id)) { return null; } $url = wp_get_attachment_url($attachment_id); return is_string($url) ? $url : null; } private function archive_filename(string $original_name, int $id): string { $name = sanitize_file_name($original_name); $name = preg_replace('/[^A-Za-z0-9._-]/', '-', $name) ?? ''; return '' === $name ? 'attachment-' . $id . '.bin' : $name; } private function download_filename(string $filename): string { $filename = sanitize_file_name($filename); return '' === $filename ? 'commentmate-export.zip' : $filename; } private function is_export_path(string $path): bool { $directory = $this->export_directory(); if (is_wp_error($directory) || '' === $path || !str_ends_with($path, '.zip')) { return false; } $directory = rtrim($directory, DIRECTORY_SEPARATOR); return dirname($path) === $directory && (bool) preg_match('/\Acommentmate-export-[0-9]+-[A-Za-z0-9_-]+\.zip\z/', basename($path)); } private function delete_path(string $path): bool { if (!$this->is_export_path($path) || is_link($path) || !is_file($path)) { return false; } return @unlink($path); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- exact private export path is validated above. } }