$wpdb->base_prefix . 'logged_ai_export_jobs', 'items' => $wpdb->base_prefix . 'logged_ai_export_items', 'chunks' => $wpdb->base_prefix . 'logged_ai_export_chunks', ); } /** * Declare minimized site and network export lifecycle event fields. * * @return array Registered audit definitions for export request, completion, download and cancellation. */ public static function definitions(): array { $out = array(); foreach ( array( 'requested', 'completed', 'downloaded', 'cancelled' ) as $action ) { $out[ 'audit.export_' . $action ] = array( 'category' => 'audit', 'action' => 'export_' . $action, 'severity' => 'notice', 'object_type' => 'site', 'fields' => array( 'format' => array( 'csv', 'json' ), 'row_count' => 'count', 'job_id' => 'identifier', ), 'diffs' => array(), ); } foreach ( array( 'requested', 'completed', 'downloaded', 'cancelled' ) as $action ) { $out[ 'audit.network_export_' . $action ] = $out[ 'audit.export_' . $action ]; $out[ 'audit.network_export_' . $action ]['event_scope'] = 'network'; $out[ 'audit.network_export_' . $action ]['object_type'] = 'network'; } return $out; } /** * Install and verify export storage under the schema mutex. * * @return void * * @throws \RuntimeException When the schema mutex or required export storage cannot be verified. */ public static function activate(): void { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $tables = self::tables(); $lock = 'lai_export_schema_' . substr( hash( 'sha256', DB_NAME . $tables['jobs'] ), 0, 32 ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Acquire the export schema connection lock; cached results cannot serialize schema changes. if ( '1' !== (string) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s,5)', $lock ) ) ) { throw new \RuntimeException( 'Logged.ai export schema setup is busy.' ); } try { dbDelta( "CREATE TABLE {$tables['jobs']} ( id char(36) NOT NULL, network_id bigint unsigned NOT NULL, site_id bigint unsigned NOT NULL, principal bigint unsigned NOT NULL, personal_subject bigint unsigned NULL, personal_context_version tinyint unsigned NOT NULL DEFAULT 0, storage_version tinyint unsigned NOT NULL DEFAULT 0, baseline_decision_cursor bigint unsigned NOT NULL DEFAULT 0, baseline_history_cursor bigint unsigned NOT NULL DEFAULT 0, baseline_done tinyint unsigned NOT NULL DEFAULT 0, personal_record_count int unsigned NOT NULL DEFAULT 0, incident_case_cursor bigint unsigned NOT NULL DEFAULT 0, incident_done tinyint unsigned NOT NULL DEFAULT 0, request_binding char(64) NOT NULL DEFAULT '', request_valid_until datetime(6) NULL, report_search_cursor bigint unsigned NOT NULL DEFAULT 0, report_schedule_cursor bigint unsigned NOT NULL DEFAULT 0, report_job_cursor bigint unsigned NOT NULL DEFAULT 0, report_done tinyint unsigned NOT NULL DEFAULT 0, incident_runner_done tinyint unsigned NOT NULL DEFAULT 0, advanced_cursor varchar(2048) NOT NULL DEFAULT '', advanced_binding varchar(48) NOT NULL DEFAULT '', advanced_done tinyint unsigned NOT NULL DEFAULT 0, format varchar(8) NOT NULL, filters longtext NOT NULL, generation bigint unsigned NOT NULL, state varchar(16) NOT NULL, reason varchar(32) NOT NULL DEFAULT '', created_at datetime(6) NOT NULL, expires_at datetime(6) NOT NULL, membership_at datetime(6) NULL, completed_at datetime(6) NULL, purged_at datetime(6) NULL, lease_until datetime(6) NULL, cursor_id bigint unsigned NOT NULL DEFAULT 0, chunks int unsigned NOT NULL DEFAULT 0, membership_count bigint unsigned NOT NULL DEFAULT 0, row_count bigint unsigned NOT NULL DEFAULT 0, missing_count bigint unsigned NOT NULL DEFAULT 0, byte_count bigint unsigned NOT NULL DEFAULT 0, manifest longtext NULL, sha256 char(64) NULL, PRIMARY KEY (id), KEY scope_owner (network_id,site_id,principal,created_at), KEY scope_state (network_id,site_id,state,created_at), KEY expiry (expires_at) ) ENGINE=InnoDB " . $wpdb->get_charset_collate() . ';' ); dbDelta( "CREATE TABLE {$tables['items']} ( job_id char(36) NOT NULL, event_id bigint unsigned NOT NULL, event_uid char(36) NOT NULL, PRIMARY KEY (job_id,event_id) ) ENGINE=InnoDB " . $wpdb->get_charset_collate() . ';' ); dbDelta( "CREATE TABLE {$tables['chunks']} ( job_id char(36) NOT NULL, sequence int unsigned NOT NULL, part smallint unsigned NOT NULL, payload mediumblob NOT NULL, PRIMARY KEY (job_id,sequence,part) ) ENGINE=InnoDB " . $wpdb->get_charset_collate() . ';' ); foreach ( $tables as $table ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Verify actual export storage engines; cached schema metadata cannot establish readiness. if ( 'InnoDB' !== $wpdb->get_var( $wpdb->prepare( 'SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s', $table ) ) ) { throw new \RuntimeException( 'Logged.ai export schema verification failed.' ); } } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Verify actual export-job columns after dbDelta; cached metadata is not schema proof. SHOW COLUMNS identifier is the fixed native-prefix jobs table from tables(); required fields are a literal list. $columns = $wpdb->get_col( $wpdb->prepare( 'SHOW COLUMNS FROM %i', $tables['jobs'] ) ); if ( array_diff( array( 'id', 'network_id', 'site_id', 'principal', 'personal_subject', 'personal_context_version', 'storage_version', 'baseline_decision_cursor', 'baseline_history_cursor', 'baseline_done', 'personal_record_count', 'incident_case_cursor', 'incident_done', 'request_binding', 'request_valid_until', 'report_search_cursor', 'report_schedule_cursor', 'report_job_cursor', 'report_done', 'incident_runner_done', 'advanced_cursor', 'advanced_binding', 'advanced_done', 'format', 'filters', 'generation', 'state', 'reason', 'created_at', 'expires_at', 'membership_at', 'completed_at', 'purged_at', 'lease_until', 'cursor_id', 'chunks', 'membership_count', 'row_count', 'missing_count', 'byte_count', 'manifest', 'sha256' ), $columns ) ) { throw new \RuntimeException( 'Logged.ai export job columns could not be verified.' ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Verify actual export-membership columns after dbDelta; cached metadata is not schema proof. SHOW COLUMNS identifier is the fixed native-prefix items table; job_id/event_id/event_uid requirements are literal. $columns = $wpdb->get_col( $wpdb->prepare( 'SHOW COLUMNS FROM %i', $tables['items'] ) ); if ( array_diff( array( 'job_id', 'event_id', 'event_uid' ), $columns ) ) { throw new \RuntimeException( 'Logged.ai export membership columns could not be verified.' ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Verify installed ciphertext columns before reporting this schema ready. $columns = $wpdb->get_col( $wpdb->prepare( 'SHOW COLUMNS FROM %i', $tables['chunks'] ) ); if ( '' !== $wpdb->last_error || array_diff( array( 'job_id', 'sequence', 'part', 'payload' ), $columns ) ) { throw new \RuntimeException( 'Logged.ai export chunk columns could not be verified.' ); } update_option( 'logged_ai_export_schema_version', 2, false ); if ( ! wp_next_scheduled( 'logged_ai_export_cleanup' ) ) { wp_schedule_event( time() + 300, 'hourly', 'logged_ai_export_cleanup' ); } } finally { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Release the acquired export schema lock in finally; a cached result cannot release it. $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock ) ); } } /** * Register export workers, administration actions and site CLI access. * * @return void */ public static function boot(): void { add_action( 'logged_ai_export_worker', array( self::class, 'cron' ) ); add_action( 'logged_ai_export_cleanup', array( self::class, 'cleanup_cron' ) ); add_action( 'admin_menu', static function () { add_submenu_page( 'logged-ai', __( 'Exports', 'logged-ai' ), __( 'Exports', 'logged-ai' ), 'logged_ai_export_events', 'logged-ai-exports', array( self::class, 'page' ) ); }, 20 ); add_action( 'admin_post_logged_ai_export_job', array( self::class, 'handle_create' ) ); add_action( 'admin_post_logged_ai_export_cancel', array( self::class, 'handle_cancel' ) ); add_action( 'admin_post_logged_ai_export_download', array( self::class, 'handle_download' ) ); add_action( 'admin_post_logged_ai_network_export_job', array( self::class, 'handle_network_create' ) ); add_action( 'admin_post_logged_ai_network_export_cancel', array( self::class, 'handle_network_cancel' ) ); add_action( 'admin_post_logged_ai_network_export_download', array( self::class, 'handle_network_download' ) ); if ( defined( 'WP_CLI' ) && WP_CLI ) { \WP_CLI::add_command( 'logged-ai exports', array( self::class, 'cli' ) ); } } /** * Create a namespaced export error with its HTTP status metadata. * * @param string $code Finite export error suffix. * @param string $message Failure message associated with this export operation. * @param int $status HTTP status carried by the structured error. * * @return \WP_Error Structured export error for the selected failure. */ private static function error( string $code, string $message, int $status = 400 ): \WP_Error { return new \WP_Error( 'logged_ai_export_' . $code, $message, array( 'status' => $status ) ); } /** * Check that a supplied identifier is a canonical lowercase version-four UUID. * * @param mixed $id Untrusted identifier value to check without coercion. * * @return bool Whether the value matches the supported export identifier grammar. */ private static function uuid( mixed $id ): bool { return is_string( $id ) && 1 === preg_match( '/\A[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/D', $id ); } /** * Select the export capability for a site or network-origin scope. * * @param int $site Selected site identifier, with zero representing network-origin evidence. * * @return string Site or network export capability name. */ private static function export_cap( int $site ): string { return 0 === $site ? 'logged_ai_export_network_events' : 'logged_ai_export_events'; } /** * Select the settings capability for a site or network-origin scope. * * @param int $site Selected site identifier, with zero representing network-origin evidence. * * @return string Site or network settings capability name. */ private static function settings_cap( int $site ): string { return 0 === $site ? 'logged_ai_manage_network_settings' : 'logged_ai_manage_settings'; } /** * Resolve current export authority for the stored job scope kind. * * @param array $job Stored export or delivery job fields used by this operation. * * @return Scope|\WP_Error Current export scope, or an authorization error. */ private static function job_scope( array $job ): Scope|\WP_Error { return 0 === (int) $job['site_id'] ? Scope::current_network( 'logged_ai_export_network_events' ) : Scope::current( 'logged_ai_export_events' ); } /** * Resolve current job authority before returning its owner-visible status. * * @param array $job Stored export or delivery job fields used by this operation. * * @return array|\WP_Error Current projected status, or an authority or storage error. */ private static function job_status( array $job ): array|\WP_Error { $scope = self::job_scope( $job ); return is_wp_error( $scope ) ? $scope : self::status( $scope, $job['id'] ); } /** * Read the current site or network export-privacy generation. * * @param bool $network Whether to select current-network-origin jobs instead of current-site jobs. * * @return int|\WP_Error Current generation, or an error when privacy state cannot be trusted. */ private static function generation( bool $network = false ): int|\WP_Error { if ( $network ) { return NetworkPrivacy::generation(); } global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Read actual export privacy generation; cached options could authorize invalidated bytes. $value = $wpdb->get_var( $wpdb->prepare( 'SELECT option_value FROM %i WHERE option_name=%s', $wpdb->options, self::GENERATION_OPTION ) ); /** * Validate the saved export generation before null and decimal-string handling from the untyped wpdb adapter. * * @var mixed $value */ if ( '' !== $wpdb->last_error ) { return self::error( 'storage', 'Export privacy state is unavailable.', 503 ); } if ( null === $value ) { return 0; } return is_string( $value ) && ctype_digit( $value ) ? (int) $value : self::error( 'privacy', 'Export privacy state is invalid.', 503 ); } /** * Explain the optional encryption prerequisite without creating server files. * * @return string Empty when saved-export encryption functions are available. */ public static function readiness_message(): string { if ( ! function_exists( 'sodium_crypto_aead_xchacha20poly1305_ietf_encrypt' ) || ! function_exists( 'sodium_crypto_aead_xchacha20poly1305_ietf_decrypt' ) ) { return __( 'Your server is missing the encryption support needed for saved exports (PHP Sodium). Use Download now for a smaller file, or ask your hosting provider to enable PHP Sodium, then reload this page.', 'logged-ai' ); } return ''; } /** * Derive the local export-encryption key from the authentication salt and prefix. * * @return string Binary key for authenticated export encryption. */ private static function key(): string { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ return hash_hkdf( 'sha256', wp_salt( 'auth' ), SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES, 'logged-ai-private-export-v1', $wpdb->base_prefix ); } /** * Bind chunk authentication to its job, scope, owner, generation and sequence. * * @param array $job Stored export or delivery job fields used by this operation. * @param int $sequence Zero-based sequence of the encrypted export chunk. * * @return string Associated-data string for this exact export chunk. */ private static function aad( array $job, int $sequence ): string { return implode( ':', array( 'logged-ai-export-v1', $job['id'], $job['network_id'], $job['site_id'], $job['principal'], $job['generation'], $sequence ) ); } /** * Encrypt a bounded chunk and persist it in the plugin database table. * * The caller holds the job mutex. Progress advances only after every part is * stored; a retry replaces the same sequence. No PHP or payload file is made. * * @param array $job Stored owner-bound job. * @param int $sequence Zero-based chunk sequence. * @param string $plain Transient plaintext. * @return bool|\WP_Error True or a fixed storage error. */ private static function write_chunk( array $job, int $sequence, string $plain ): bool|\WP_Error { if ( ! function_exists( 'sodium_crypto_aead_xchacha20poly1305_ietf_encrypt' ) ) { return self::error( 'crypto', 'Private exports require the PHP Sodium extension.', 503 ); } if ( strlen( $plain ) > 8388608 ) { return self::error( 'size', 'A private export chunk exceeded its size limit.' ); } $nonce = random_bytes( SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES ); $encrypted = $nonce . sodium_crypto_aead_xchacha20poly1305_ietf_encrypt( $plain, self::aad( $job, $sequence ), $nonce, self::key() ); return ExportStorage::write( $job['id'], $sequence, $encrypted ); } /** * Read bounded private ciphertext and authenticate it against the selected job. * * @param array $job Stored export or delivery job fields used by this operation. * @param int $sequence Zero-based sequence of the encrypted export chunk. * * @return string|\WP_Error Decrypted chunk, or an integrity, crypto or storage error. */ private static function read_chunk( array $job, int $sequence ): string|\WP_Error { if ( ! function_exists( 'sodium_crypto_aead_xchacha20poly1305_ietf_decrypt' ) ) { return self::error( 'crypto', 'Private exports require the PHP Sodium extension.', 503 ); } $encrypted = ExportStorage::read( $job['id'], $sequence ); if ( is_wp_error( $encrypted ) ) { return $encrypted; } foreach ( null === $encrypted && 0 === (int) ( $job['storage_version'] ?? 0 ) ? array( false, true ) : array() as $legacy ) { $directory = ExportStorage::directory( $job['id'], false, $legacy ); if ( is_wp_error( $directory ) ) { return $directory; } if ( '' === $directory ) { continue; } $file = $directory . '/' . sprintf( '%08d', $sequence ) . ( $legacy ? '.bin' : '.php' ); if ( ! file_exists( $file ) && ! is_link( $file ) ) { continue; } $maximum = 8388648 + ( $legacy ? 0 : strlen( ExportStorage::LEGACY_GUARD ) ); if ( is_link( $file ) || ! is_file( $file ) || filesize( $file ) > $maximum ) { return self::error( 'integrity', 'Private export chunk is missing or invalid.', 503 ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPress.PHP.NoSilencedErrors.Discouraged -- Read only bounded ciphertext from the validated local uploads or read-only legacy directory. Fixed leading PHP guard is data, never included or evaluated by the plugin. $encrypted = @file_get_contents( $file, false, null, 0, $maximum + 1 ); if ( ! is_string( $encrypted ) || strlen( $encrypted ) > $maximum || ( ! $legacy && ! str_starts_with( $encrypted, ExportStorage::LEGACY_GUARD ) ) ) { return self::error( 'integrity', 'Private export chunk is invalid.', 503 ); } if ( ! $legacy ) { $encrypted = substr( $encrypted, strlen( ExportStorage::LEGACY_GUARD ) ); } break; } if ( ! is_string( $encrypted ) || strlen( $encrypted ) < 40 || strlen( $encrypted ) > 8388648 ) { return self::error( 'integrity', 'Private export chunk is missing or invalid.', 503 ); } $nonce = substr( $encrypted, 0, SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES ); $plain = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt( substr( $encrypted, strlen( $nonce ) ), self::aad( $job, $sequence ), $nonce, self::key() ); return false === $plain ? self::error( 'integrity', 'Private export verification failed.', 503 ) : $plain; } /** * Schedule the next site or network export worker batch. * * @param bool $network Whether to select current-network-origin jobs instead of current-site jobs. * * @return void */ private static function schedule( bool $network = false ): void { if ( $network ) { NetworkPrivacy::schedule( true ); return; } if ( ! wp_next_scheduled( 'logged_ai_export_worker' ) ) { wp_schedule_single_event( time() + 10, 'logged_ai_export_worker' ); } } /** * Admit an owner-bound export with finite capacity, expiry and privacy generation. * * @param Scope $scope Operation scope whose current permissions are checked. * @param string $format Requested CSV or JSON output format. * @param array $input Proposed event filters validated by the finite store filter schema. * @param int|null $personal Optional personal-export subject identifier. * * @return array|\WP_Error Saved job status, or a validation, authority, admission or audit error. */ public static function create( Scope $scope, string $format, array $input = array(), ?int $personal = null ): array|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $required = array( self::export_cap( $scope->site_id ) ); if ( null !== $personal ) { $required[] = 'manage_privacy_options'; $required[] = self::settings_cap( $scope->site_id ); } if ( ! $scope->valid_for_all( $required ) ) { return self::error( 'forbidden', 'Export permission is required.', 403 ); } if ( ! function_exists( 'sodium_crypto_aead_xchacha20poly1305_ietf_encrypt' ) ) { return self::error( 'crypto', 'Private exports require the PHP Sodium extension.', 503 ); } if ( ! in_array( $format, array( 'csv', 'json' ), true ) || ( null !== $personal && $personal < 1 ) ) { return self::error( 'request', 'Invalid export request.' ); } $filters = Store::filters( $input ); if ( is_wp_error( $filters ) ) { return $filters; } $generation = self::generation( 0 === $scope->site_id ); if ( is_wp_error( $generation ) ) { return $generation; } $tables = self::tables(); $lock = 'lai_export_admission_' . substr( hash( 'sha256', DB_NAME . $tables['jobs'] ), 0, 32 ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Acquire the installation export-admission mutex; cached results cannot enforce serialization. if ( '1' !== (string) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s,0)', $lock ) ) ) { return self::error( 'busy', 'Export requests are busy; retry shortly.', 409 ); } try { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Read live owner job usage under the admission mutex; cached counts cannot enforce the limit. Fixed jobs table; network/site/principal %d and current UTC cutoff %s; literal active states. $count = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE network_id=%d AND site_id=%d AND principal=%d AND state IN (\'queued\',\'writing\') AND expires_at>%s', $tables['jobs'], $scope->network_id, $scope->site_id, get_current_user_id(), Store::now() ) ); if ( null === $count || '' !== $wpdb->last_error ) { return self::error( 'storage', 'Export storage is unavailable.', 503 ); } if ( (int) $count >= 3 ) { return self::error( 'capacity', 'Finish or cancel an existing export before creating another.', 429 ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Read actual daily site and installation admissions under the mutex before accepting a job. Fixed jobs table; CASE-free SUM predicate binds scope IDs %d and day cutoff %s; no request-derived SQL identifier. $counts = $wpdb->get_row( $wpdb->prepare( 'SELECT COUNT(*) AS installation,COALESCE(SUM(network_id=%d AND site_id=%d),0) AS site FROM %i WHERE created_at>=%s', $scope->network_id, $scope->site_id, $tables['jobs'], gmdate('Y-m-d H:i:s', time() - DAY_IN_SECONDS) ), ARRAY_A ); if ( ! $counts || '' !== $wpdb->last_error ) { return self::error( 'storage', 'Export admission is unavailable.', 503 ); } if ( (int) $counts['site'] >= 25 || (int) $counts['installation'] >= 100 ) { return self::error( 'capacity', 'The daily export safety limit was reached: 25 per site or network scope, 100 per installation.', 429 ); } if ( ! $scope->valid_for_all( $required ) ) { return self::error( 'forbidden', 'Export authority changed during admission.', 403 ); } $id = wp_generate_uuid4(); $row = array( 'id' => $id, 'network_id' => $scope->network_id, 'site_id' => $scope->site_id, 'principal' => get_current_user_id(), 'personal_subject' => $personal, 'personal_context_version' => null !== $personal && $scope->site_id > 0 ? 8 : 0, 'storage_version' => 1, 'format' => $format, 'filters' => wp_json_encode( $filters ), 'generation' => $generation, 'state' => 'queued', 'created_at' => Store::now(), 'expires_at' => gmdate( 'Y-m-d H:i:s', time() + DAY_IN_SECONDS ), ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Persist the authorized owner-bound export job in custom storage before confirmation. if ( false === $wpdb->insert( $tables['jobs'], $row ) ) { return self::error( 'storage', 'Export request could not be saved.', 503 ); } } finally { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Release the export-admission mutex in finally; cached results cannot release it. $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock ) ); } if ( ! $scope->valid_for_all( $required ) ) { self::fail( $row, 'authorization_changed', 'cancelled' ); return self::error( 'forbidden', 'Export authority changed before request confirmation.', 403 ); } if ( ! self::audit( 'requested', $row, 0 ) ) { self::fail( $row, 'audit_failed' ); return self::error( 'audit', 'Export request was saved but its audit event failed; no output will be generated.', 503 ); } self::schedule( 0 === $scope->site_id ); return self::status( $scope, $id ); } /** * Capture a minimized export lifecycle fact in its site or network scope. * * @param string $action Finite export lifecycle action to audit. * @param array $job Stored export or delivery job fields used by this operation. * @param int $count Number of source rows associated with the audit action. * * @return bool Whether the audit event was captured. */ private static function audit( string $action, array $job, int $count ): bool { return Store::capture( array( 'code' => ( 0 === (int) $job['site_id'] ? 'audit.network_export_' : 'audit.export_' ) . $action, 'outcome' => 'completed', 'object_id' => (int) ( 0 === (int) $job['site_id'] ? $job['network_id'] : $job['site_id'] ), 'payload' => array( 'observed_via' => 'logged_ai_exports', 'facts' => array( 'format' => $job['format'], 'row_count' => $count, 'job_id' => $job['id'], ), ), ) ); } /** * Load one job by exact UUID in the current site or network-origin namespace. * * @param string $id Selected job identifier. * @param bool $network Whether to select current-network-origin jobs instead of current-site jobs. * * @return array|\WP_Error Stored supported job, or an invalid-identity, query or unavailable-job error. */ private static function load( string $id, bool $network = false ): array|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ if ( ! self::uuid( $id ) ) { return self::error( 'missing', 'Export unavailable.', 404 ); } $table = self::tables()['jobs']; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Load the current scoped export job; cached state could retain an invalidated manifest. Canonical UUID %s, current network and selected site %d; fixed jobs table; all SELECT syntax is literal. $job = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE id=%s AND network_id=%d AND site_id=%d', $table, $id, Scope::network(), $network ? 0 : get_current_blog_id() ), ARRAY_A ); if ( '' !== $wpdb->last_error ) { return self::error( 'storage', 'Export storage is unavailable.', 503 ); } if ( is_array( $job ) && ( ! isset( $job['personal_context_version'] ) || ! in_array( (string) $job['personal_context_version'], array( '0', '1', '2', '3', '4', '5', '6', '7', '8' ), true ) ) ) { return self::error( 'context_version', 'This personal export context version is unsupported.', 503 ); } if ( is_array( $job ) && ! in_array( (string) ( $job['storage_version'] ?? '' ), array( '0', '1' ), true ) ) { return self::error( 'storage_version', 'This saved export storage version is unsupported.', 503 ); } return is_array( $job ) ? $job : self::error( 'missing', 'Export unavailable in this site.', 404 ); } /** * Check current export capabilities, scope and optional job ownership. * * @phpstan-impure Reads current native authority or mutable context. * * @param array $job Stored export or delivery job fields used by this operation. * @param bool $owner Whether the current principal must also own the export job. * * @return bool Whether the current principal and namespace authorize this job. */ private static function authorized( array $job, bool $owner ): bool { $site = (int) $job['site_id']; $network = 0 === $site; $principal = get_current_user_id(); $origin = get_current_blog_id(); if ( Scope::network() !== (int) $job['network_id'] || ( ! $network && $site !== $origin ) || ( $owner && (int) $job['principal'] !== $principal ) ) { return false; } $scope = $network ? Scope::current_network( self::export_cap( $site ) ) : Scope::current( self::export_cap( $site ) ); if ( is_wp_error( $scope ) ) { return false; } $required = array( self::export_cap( $site ) ); if ( null !== $job['personal_subject'] ) { $required[] = 'manage_privacy_options'; $required[] = self::settings_cap( $site ); } return $scope->valid_for_all( $required ) && get_current_user_id() === $principal && get_current_blog_id() === $origin && $scope->site_id === $site && $scope->network_id === (int) $job['network_id']; } /** * Refresh native roles and user caches before selecting the worker principal. * * @param int $id Stored job owner identifier to select after cache refresh. * * @return void */ private static function refresh_principal( int $id ): void { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $role_key = $wpdb->get_blog_prefix( get_current_blog_id() ) . 'user_roles'; wp_cache_delete( $role_key, 'options' ); wp_cache_delete( 'alloptions', 'options' ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Refresh this site's native roles before reloading the export principal so stale worker grants cannot authorize export work or delivery. $GLOBALS['wp_roles'] = new \WP_Roles( get_current_blog_id() ); clean_user_cache( $id ); wp_set_current_user( 0 ); wp_set_current_user( $id ); } /** * Check job generation, expiry, authority and current personal-context bindings. * * @phpstan-impure Reads current native authority or mutable context. * * @param array $job Stored export or delivery job fields used by this operation. * @param bool $owner Whether the current principal must also own the export job. * * @return bool|\WP_Error Whether the job remains eligible, or an error reading required privacy state. */ private static function valid( array $job, bool $owner ): bool|\WP_Error { $generation = self::generation( 0 === (int) $job['site_id'] ); if ( is_wp_error( $generation ) ) { return $generation; } if ( (int) $job['generation'] !== $generation || $job['expires_at'] <= Store::now() || ! self::authorized( $job, $owner ) ) { return false; } $advanced = self::advanced_valid( $job ); return true === $advanced ? self::request_valid( $job ) : $advanced; } /** * Recheck personal-export request bindings and their finite source expiry. * * New personal exports retain request references only while their source policy permits. * * @param array $job Stored export or delivery job fields used by this operation. * * @return bool|\WP_Error Whether request context remains valid, or a context-read error. */ private static function request_valid( array $job ): bool|\WP_Error { if ( ! in_array( (int) $job['personal_context_version'], array( 7, 8 ), true ) ) { return true; } $binding = (string) ( $job['request_binding'] ?? '' ); if ( '' === $binding ) { return in_array( $job['state'], array( 'queued', 'writing' ), true ) && ! (int) $job['incident_done']; } if ( ! preg_match( '/\A[0-9a-f]{64}\z/D', $binding ) ) { return false; } $until = $job['request_valid_until'] ?? null; if ( null !== $until ) { $date = is_string( $until ) ? \DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s.u', $until, new \DateTimeZone( 'UTC' ) ) : false; if ( ! $date || $date->format( 'Y-m-d H:i:s.u' ) !== $until || $until <= Store::now() ) { return false; } } $scope = self::job_scope( $job ); if ( is_wp_error( $scope ) ) { return false; } $current = IncidentPrivacy::request_context( $scope, 8 === (int) $job['personal_context_version'] ); return is_wp_error( $current ) ? $current : hash_equals( $binding, $current ) && ( null === $until || $until > Store::now() ); } /** * Recheck the personal-export advanced-alert privacy binding. * * Ready personal bytes are tied to the current advanced-alert privacy generation. * * @param array $job Stored export or delivery job fields used by this operation. * * @return bool|\WP_Error Whether the advanced context remains valid, or a context-read error. */ private static function advanced_valid( array $job ): bool|\WP_Error { if ( ! in_array( (int) $job['personal_context_version'], array( 6, 7, 8 ), true ) ) { return true; } $binding = (string) ( $job['advanced_binding'] ?? '' ); if ( '' === $binding ) { return in_array( $job['state'], array( 'queued', 'writing' ), true ) && ! (int) $job['advanced_done']; } $scope = self::job_scope( $job ); if ( is_wp_error( $scope ) ) { return false; } $current = AdvancedAlertIdentityPrivacy::context_binding( $scope ); return is_wp_error( $current ) ? $current : hash_equals( $binding, $current ); } /** * Return owner-visible job status while hiding expired or invalidated manifests. * * @param Scope $scope Operation scope whose current permissions are checked. * @param string $id Selected job identifier. * * @return array|\WP_Error Operational status and any currently available manifest, or an access or storage error. */ public static function status( Scope $scope, string $id ): array|\WP_Error { if ( ! $scope->valid_for( self::export_cap( $scope->site_id ) ) ) { return self::error( 'forbidden', 'Export access denied.', 403 ); } $job = self::load( $id, 0 === $scope->site_id ); if ( is_wp_error( $job ) ) { return $job; } if ( ! self::authorized( $job, true ) ) { return self::error( 'forbidden', 'Export authorization changed.', 403 ); } $generation = self::generation( 0 === $scope->site_id ); if ( is_wp_error( $generation ) ) { return $generation; } $advanced_valid = in_array( $job['state'], array( 'queued', 'writing', 'ready' ), true ) ? self::advanced_valid( $job ) : true; if ( is_wp_error( $advanced_valid ) ) { return $advanced_valid; } if ( ! $advanced_valid ) { self::fail( $job, 'advanced_privacy_changed', 'cancelled' ); $job['state'] = 'cancelled'; $job['reason'] = 'advanced_privacy_changed'; $job['sha256'] = null; $job['manifest'] = null; } $request_valid = in_array( $job['state'], array( 'queued', 'writing', 'ready' ), true ) ? self::request_valid( $job ) : true; if ( is_wp_error( $request_valid ) ) { return $request_valid; } if ( ! $request_valid ) { self::fail( $job, 'request_privacy_changed', 'cancelled' ); $job['state'] = 'cancelled'; $job['reason'] = 'request_privacy_changed'; $job['sha256'] = null; $job['manifest'] = null; } // Owners may inspect terminal operational history, never stale manifests. if ( $job['expires_at'] <= Store::now() || (int) $job['generation'] !== $generation ) { $job['state'] = $job['expires_at'] <= Store::now() ? 'expired' : 'cancelled'; $job['reason'] = 'expired' === $job['state'] ? 'expiry_elapsed' : 'privacy_generation_changed'; $job['sha256'] = null; $job['manifest'] = null; } return array( 'id' => $job['id'], 'state' => $job['state'], 'reason' => $job['reason'], 'format' => $job['format'], 'created_at' => $job['created_at'], 'expires_at' => $job['expires_at'], 'membership_at' => $job['membership_at'], 'membership_count' => (int) $job['membership_count'], 'row_count' => (int) $job['row_count'], 'missing_count' => (int) $job['missing_count'], 'sha256' => $job['sha256'], 'manifest' => $job['manifest'] ? json_decode( $job['manifest'], true ) : null, ); } /** * Build fixed SQL predicates and ordered bindings from validated job filters. * * @param array $job Stored export or delivery job fields used by this operation. * * @return array SQL predicate followed by its placeholder values. * * @throws \RuntimeException When saved job filters are malformed or unsupported. */ private static function predicate( array $job ): array { $input = json_decode( $job['filters'], true ); if ( ! is_array( $input ) ) { throw new \RuntimeException( 'invalid_filters' ); } $filters = Store::filters( $input ); if ( is_wp_error( $filters ) ) { throw new \RuntimeException( 'invalid_filters' ); } $where = 'network_id=%d AND site_id=%d'; $args = array( $job['network_id'], $job['site_id'] ); foreach ( $filters as $key => $value ) { if ( 'q' === $key ) { $codes = Store::search_codes( $value ); if ( ! $codes ) { $where .= ' AND 1=0'; } else { $where .= ' AND code IN (' . implode( ',', array_fill( 0, count( $codes ), '%s' ) ) . ')'; array_push( $args, ...$codes ); } } elseif ( in_array( $key, array( 'from', 'to' ), true ) ) { $where .= 'from' === $key ? ' AND occurred_at >= %s' : ' AND occurred_at < %s'; $args[] = $value . ' 00:00:00.000000'; } else { $where .= ' AND ' . $key . '=%s'; $args[] = $value; } } if ( null !== $job['personal_subject'] ) { $where .= ' AND (actor_id=%d OR subject_id=%d OR (object_type=\'user\' AND object_id=%d))'; array_push( $args, $job['personal_subject'], $job['personal_subject'], $job['personal_subject'] ); } return array( $where, $args ); } /** * Run bounded internal work for a job in the current site. * * @param string $id Selected job identifier. * @param int $batches Requested work-batch count, clamped to the finite worker limit. * * @return array|\WP_Error Current job status, or a worker, authority or storage error. */ public static function work( string $id, int $batches = 10 ): array|\WP_Error { return self::run_work( $id, $batches, false ); } /** * Run bounded internal work for a current-network export job. * * Internal current-network worker; HTTP and CLI entry points check ownership first. * * @param string $id Selected job identifier. * @param int $batches Requested work-batch count, clamped to the finite worker limit. * * @return array|\WP_Error Current job status, or a worker, authority or storage error. */ public static function work_network( string $id, int $batches = 10 ): array|\WP_Error { return self::run_work( $id, $batches, true ); } /** * Freeze ID/UID membership and generate bounded encrypted export batches. * * @param string $id Selected job identifier. * @param int $batches Requested work-batch count, clamped to the finite worker limit. * @param bool $network Whether to select current-network-origin jobs instead of current-site jobs. * * @return array|\WP_Error Current job status, or a worker, privacy, capacity or storage error. */ private static function run_work( string $id, int $batches, bool $network ): array|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $job = self::load( $id, $network ); if ( is_wp_error( $job ) ) { return $job; } $tables = self::tables(); $lock = 'lai_export_' . substr( hash( 'sha256', DB_NAME . $tables['jobs'] . $id ), 0, 40 ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Acquire the exact export-job mutex before work; cached results cannot exclude another writer. if ( '1' !== (string) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s,0)', $lock ) ) ) { return self::error( 'busy', 'Export is already being processed.', 409 ); } $previous = get_current_user_id(); $previous_roles = $GLOBALS['wp_roles']; $old_errors = $wpdb->suppress_errors( true ); $started = microtime( true ); try { $job = self::load( $id, $network ); if ( is_wp_error( $job ) ) { return $job; } self::refresh_principal( (int) $job['principal'] ); $valid = self::valid( $job, true ); if ( is_wp_error( $valid ) ) { return $valid; } if ( ! $valid || ! get_userdata( (int) $job['principal'] ) ) { self::fail( $job, 'authorization_changed', 'cancelled' ); return self::error( 'forbidden', 'Export cancelled because authorization or privacy state changed.', 403 ); } if ( ! in_array( $job['state'], array( 'queued', 'writing' ), true ) ) { return self::job_status( $job ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Persist the actual worker lease in the locked custom job row. if ( false === $wpdb->update( $tables['jobs'], array( 'lease_until' => gmdate( 'Y-m-d H:i:s', time() + 60 ) ), array( 'id' => $id ) ) ) { return self::error( 'storage', 'Export worker lease could not be recorded.', 503 ); } if ( 'queued' === $job['state'] ) { // One SQL statement freezes ID/UID membership. It is not a copy // of row contents and is not advertised as a transaction snapshot. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Delete actual prior membership before rebuilding a queued job; cache clearing is insufficient. if ( false === $wpdb->delete( $tables['items'], array( 'job_id' => $id ) ) ) { return self::error( 'storage', 'Export membership preparation failed; retry is safe.', 503 ); } list( $where, $values ) = self::predicate( $job ); array_unshift( $values, $tables['items'], $id, Store::table() ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter -- MAX_ROWS is the private integer cap; predicate returns finite SQL placeholders with ordered values, including both native identifiers above. Persist bounded ID/UID membership in one statement; cached rows cannot freeze membership. Both fixed table identifiers use %i; UUID uses %s; predicate revalidates finite Store filters, uses prepared scope/filter/subject values and fixed operators; MAX_ROWS+1 is a private integer cap. Existing SQL rationale preserved. prepare accepts the single complete argument array: two %i tables and the job %s precede predicate bindings. predicate appends each fixed scope/filter/subject placeholder and value together, including count-matched code lists. $inserted = $wpdb->query( $wpdb->prepare( "INSERT INTO %i (job_id,event_id,event_uid) SELECT %s,id,uid FROM %i WHERE $where ORDER BY id LIMIT " . ( self::MAX_ROWS + 1 ), $values ) ); if ( false === $inserted || $inserted > self::MAX_ROWS ) { self::fail( $job, false === $inserted ? 'membership_failed' : 'row_limit' ); return self::error( 'membership', 'Export membership could not be frozen; narrow the date range.', 503 ); } $state = array( 'state' => 'writing', 'membership_at' => Store::now(), 'membership_count' => $inserted, 'cursor_id' => 0, 'chunks' => 0, 'row_count' => 0, 'missing_count' => 0, 'byte_count' => 0, ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Commit membership state only while the exact job is queued at its original privacy generation. if ( 1 !== $wpdb->update( $tables['jobs'], $state, array( 'id' => $id, 'state' => 'queued', 'generation' => $job['generation'], ) ) ) { self::fail( $job, 'cancelled_during_membership', 'cancelled' ); return self::error( 'storage', 'Export membership was cancelled or could not be committed.', 503 ); } $job = array_merge( $job, $state ); } // New personal exports carry one explicitly typed auxiliary record, even with no retained event. // Existing jobs retain version 0 and their original bytes/schema. if ( in_array( (int) $job['personal_context_version'], array( 1, 2, 3, 4, 5, 6, 7, 8 ), true ) && 0 === (int) $job['chunks'] ) { $authority = self::job_scope( $job ); if ( is_wp_error( $authority ) ) { return $authority; } $context = AccountAuthCollectors::personal_projection( $authority, (int) $job['personal_subject'] ); if ( is_wp_error( $context ) ) { self::fail( $job, 'personal_context_failed' ); return $context; } $record = array( 'record_type' => 'temporary_authentication_context', 'schema_version' => 1, 'id' => null, 'uid' => null, 'code' => null, 'scope' => array( 'network_id' => (string) $job['network_id'], 'site_id' => (string) $job['site_id'], ), 'subject_id' => (string) $job['personal_subject'], 'detail' => array( 'temporary_authentication' => $context ), ); $plain = 'json' === $job['format'] ? wp_json_encode( $record, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) : self::csv( array( '', '', '', '', 'unknown', '', (string) $job['personal_subject'], 'user', (string) $job['personal_subject'], '', '', wp_json_encode( array( 'record_type' => $record['record_type'] ) + $record['detail'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) ) ); $valid = self::valid( $job, true ); if ( is_wp_error( $valid ) ) { return $valid; } if ( ! is_string( $plain ) || ! $valid ) { self::fail( $job, 'personal_context_failed' ); return self::error( 'privacy', 'Personal context changed before protected export storage.', 403 ); } $advance = self::commit_chunk( $job, '0', 0, 0, $plain, array( 'personal_record_count' => 1 ) ); if ( is_wp_error( $advance ) ) { return $advance; } $job = array_merge( $job, $advance ); } // Baseline pages may include reviewer references across many actors. Persist cursors with ciphertext progress. if ( in_array( (int) $job['personal_context_version'], array( 2, 3, 4, 5, 6, 7, 8 ), true ) && ! (int) $job['baseline_done'] ) { for ( $page = 0; ; ++$page ) { if ( $page >= max( 1, min( 100, $batches ) ) || microtime( true ) - $started >= 10 ) { break; } $authority = self::job_scope( $job ); $valid = self::valid( $job, true ); if ( is_wp_error( $valid ) ) { return $valid; } if ( is_wp_error( $authority ) || ! $valid ) { return self::error( 'privacy', 'Baseline personal export authority changed.', 403 ); } $context = BaselinePrivacy::personal_projection( $authority, (int) $job['personal_subject'], array( 'decisions' => (string) $job['baseline_decision_cursor'], 'history' => (string) $job['baseline_history_cursor'], ), 100 ); if ( is_wp_error( $context ) ) { self::fail( $job, 'personal_context_failed' ); return $context; } $record = array( 'record_type' => 'baseline_governance_context', 'schema_version' => 1, 'id' => null, 'uid' => null, 'code' => null, 'scope' => array( 'network_id' => (string) $job['network_id'], 'site_id' => (string) $job['site_id'], ), 'subject_id' => (string) $job['personal_subject'], 'detail' => array( 'baseline_governance' => $context ), ); $plain = 'json' === $job['format'] ? wp_json_encode( $record, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) : self::csv( array( '', '', '', '', 'unknown', '', (string) $job['personal_subject'], 'user', (string) $job['personal_subject'], '', '', wp_json_encode( array( 'record_type' => $record['record_type'] ) + $record['detail'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) ) ); $valid = self::valid( $job, true ); if ( is_wp_error( $valid ) ) { return $valid; } if ( ! is_string( $plain ) || ! $valid ) { self::fail( $job, 'personal_context_failed' ); return self::error( 'privacy', 'Baseline context changed before protected storage.', 403 ); } $advance = self::commit_chunk( $job, (string) $job['cursor_id'], 0, 0, $plain, array( 'baseline_decision_cursor' => $context['next']['decisions'], 'baseline_history_cursor' => $context['next']['history'], 'baseline_done' => $context['done'] ? 1 : 0, 'personal_record_count' => (int) $job['personal_record_count'] + 1, ) ); if ( is_wp_error( $advance ) ) { return $advance; } $job = array_merge( $job, $advance ); if ( $context['done'] ) { break; } } if ( ! (int) $job['baseline_done'] ) { self::schedule( false ); return self::job_status( $job ); } } // Five-case pages also cover assignees/reviewers with no remaining source event. if ( in_array( (int) $job['personal_context_version'], array( 3, 4, 5, 6, 7, 8 ), true ) && ! (int) $job['incident_done'] ) { for ( $page = 0; ; ++$page ) { if ( $page >= max( 1, min( 100, $batches ) ) || microtime( true ) - $started >= 10 ) { break; } $authority = self::job_scope( $job ); $valid = self::valid( $job, true ); if ( is_wp_error( $valid ) ) { return $valid; } if ( is_wp_error( $authority ) || ! $valid ) { return self::error( 'privacy', 'Incident personal export authority changed.', 403 ); } $context = IncidentPrivacy::personal_projection( $authority, (int) $job['personal_subject'], (string) $job['incident_case_cursor'], in_array( (int) $job['personal_context_version'], array( 7, 8 ), true ), 8 === (int) $job['personal_context_version'] ); if ( is_wp_error( $context ) ) { self::fail( $job, 'personal_context_failed' ); return $context; } $request_context = array(); if ( in_array( (int) $job['personal_context_version'], array( 7, 8 ), true ) ) { $binding = $context['request_binding'] ?? null; if ( ! is_string( $binding ) || ! preg_match( '/\A[0-9a-f]{64}\z/D', $binding ) || ( '' !== $job['request_binding'] && ! hash_equals( $job['request_binding'], $binding ) ) ) { self::fail( $job, 'request_privacy_changed', 'cancelled' ); return self::error( 'privacy', 'Request-case privacy changed between export pages.', 403 ); } $until = $context['request_valid_until']; if ( null !== $job['request_valid_until'] ) { $until = null === $until ? $job['request_valid_until'] : min( $until, $job['request_valid_until'] ); } $request_context = array( 'request_binding' => $binding, 'request_valid_until' => $until, ); unset( $context['request_binding'], $context['request_valid_until'] ); } $record = array( 'record_type' => 'incident_governance_context', 'schema_version' => 8 === (int) $job['personal_context_version'] ? 3 : ( 7 === (int) $job['personal_context_version'] ? 2 : 1 ), 'id' => null, 'uid' => null, 'code' => null, 'scope' => array( 'network_id' => (string) $job['network_id'], 'site_id' => (string) $job['site_id'], ), 'subject_id' => (string) $job['personal_subject'], 'detail' => array( 'incident_governance' => $context ), ); $plain = 'json' === $job['format'] ? wp_json_encode( $record, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) : self::csv( array( '', '', '', '', 'unknown', '', (string) $job['personal_subject'], 'user', (string) $job['personal_subject'], '', '', wp_json_encode( array( 'record_type' => $record['record_type'] ) + $record['detail'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) ) ); $valid = self::valid( $job, true ); if ( is_wp_error( $valid ) ) { return $valid; } if ( ! is_string( $plain ) || ! $valid ) { self::fail( $job, 'personal_context_failed' ); return self::error( 'privacy', 'Incident context changed before protected storage.', 403 ); } $advance = self::commit_chunk( $job, (string) $job['cursor_id'], 0, 0, $plain, array( 'incident_case_cursor' => $context['next'], 'incident_done' => $context['done'] ? 1 : 0, 'personal_record_count' => (int) $job['personal_record_count'] + 1, ) + $request_context ); if ( is_wp_error( $advance ) ) { return $advance; } $job = array_merge( $job, $advance ); if ( $context['done'] ) { break; } } if ( ! (int) $job['incident_done'] ) { self::schedule( false ); return self::job_status( $job ); } } // Version4 adds retained saved-search/schedule context without changing earlier job bytes. if ( in_array( (int) $job['personal_context_version'], array( 4, 5, 6, 7, 8 ), true ) && ! (int) $job['report_done'] ) { for ( $page = 0; ; ++$page ) { if ( $page >= max( 1, min( 100, $batches ) ) || microtime( true ) - $started >= 10 ) { break; } $authority = self::job_scope( $job ); $valid = self::valid( $job, true ); if ( is_wp_error( $valid ) ) { return $valid; } if ( is_wp_error( $authority ) || ! $valid ) { return self::error( 'privacy', 'Report personal export authority changed.', 403 ); } $context = ReportPrivacy::personal_projection( $authority, (int) $job['personal_subject'], array( 'searches' => (string) $job['report_search_cursor'], 'schedules' => (string) $job['report_schedule_cursor'], 'jobs' => (string) $job['report_job_cursor'], ), 50 ); if ( is_wp_error( $context ) ) { self::fail( $job, 'personal_context_failed' ); return $context; } $record = array( 'record_type' => 'report_governance_context', 'schema_version' => 1, 'id' => null, 'uid' => null, 'code' => null, 'scope' => array( 'network_id' => (string) $job['network_id'], 'site_id' => (string) $job['site_id'], ), 'subject_id' => (string) $job['personal_subject'], 'detail' => array( 'report_governance' => $context ), ); $plain = 'json' === $job['format'] ? wp_json_encode( $record, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) : self::csv( array( '', '', '', '', 'unknown', '', (string) $job['personal_subject'], 'user', (string) $job['personal_subject'], '', '', wp_json_encode( array( 'record_type' => $record['record_type'] ) + $record['detail'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) ) ); $valid = self::valid( $job, true ); if ( is_wp_error( $valid ) ) { return $valid; } if ( ! is_string( $plain ) || ! $valid ) { self::fail( $job, 'personal_context_failed' ); return self::error( 'privacy', 'Report context changed before protected storage.', 403 ); } $advance = self::commit_chunk( $job, (string) $job['cursor_id'], 0, 0, $plain, array( 'report_search_cursor' => $context['next']['searches'], 'report_schedule_cursor' => $context['next']['schedules'], 'report_job_cursor' => $context['next']['jobs'], 'report_done' => $context['done'] ? 1 : 0, 'personal_record_count' => (int) $job['personal_record_count'] + 1, ) ); if ( is_wp_error( $advance ) ) { return $advance; } $job = array_merge( $job, $advance ); if ( $context['done'] ) { break; } } if ( ! (int) $job['report_done'] ) { self::schedule( false ); return self::job_status( $job ); } } if ( in_array( (int) $job['personal_context_version'], array( 5, 6, 7, 8 ), true ) && ! (int) $job['incident_runner_done'] ) { $authority = self::job_scope( $job ); if ( is_wp_error( $authority ) ) { return $authority; } $context = IncidentRunnerPrivacy::personal_projection( $authority, (int) $job['personal_subject'] ); if ( is_wp_error( $context ) ) { self::fail( $job, 'personal_context_failed' ); return $context; } $record = array( 'record_type' => 'incident_background_context', 'schema_version' => 1, 'id' => null, 'uid' => null, 'code' => null, 'scope' => array( 'network_id' => (string) $job['network_id'], 'site_id' => (string) $job['site_id'], ), 'subject_id' => (string) $job['personal_subject'], 'detail' => array( 'incident_background' => $context ), ); $plain = 'json' === $job['format'] ? wp_json_encode( $record, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) : self::csv( array( '', '', '', '', 'unknown', '', (string) $job['personal_subject'], 'user', (string) $job['personal_subject'], '', '', wp_json_encode( array( 'record_type' => $record['record_type'] ) + $record['detail'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) ) ); $valid = self::valid( $job, true ); if ( is_wp_error( $valid ) ) { return $valid; } if ( ! is_string( $plain ) || ! $valid ) { self::fail( $job, 'personal_context_failed' ); return self::error( 'privacy', 'Incident background context changed before protected export storage.', 403 ); } $advance = self::commit_chunk( $job, '0', 0, 0, $plain, array( 'incident_runner_done' => 1, 'personal_record_count' => (int) $job['personal_record_count'] + 1, ) ); if ( is_wp_error( $advance ) ) { return $advance; } $job = array_merge( $job, $advance ); } if ( in_array( (int) $job['personal_context_version'], array( 6, 7, 8 ), true ) && ! (int) $job['advanced_done'] ) { for ( $page = 0; ; ++$page ) { if ( $page >= max( 1, min( 100, $batches ) ) || microtime( true ) - $started >= 10 ) { break; } $authority = self::job_scope( $job ); $valid = self::valid( $job, true ); if ( is_wp_error( $valid ) ) { return $valid; } if ( is_wp_error( $authority ) || ! $valid ) { return self::error( 'privacy', 'Advanced alert export authority changed.', 403 ); } $cursor = '' === $job['advanced_cursor'] ? array() : json_decode( $job['advanced_cursor'], true ); if ( ! is_array( $cursor ) ) { self::fail( $job, 'personal_context_failed' ); return self::error( 'context', 'Advanced alert export progress is invalid.', 503 ); } $before_binding = AdvancedAlertIdentityPrivacy::context_binding( $authority ); if ( is_wp_error( $before_binding ) ) { return $before_binding; } $context = AdvancedAlertIdentityPrivacy::personal_projection( $authority, (int) $job['personal_subject'], $cursor ); if ( is_wp_error( $context ) ) { return $context; } $case_progress = AdvancedAlertCasePrivacy::personal_projection( $authority, (int) $job['personal_subject'] ); if ( is_wp_error( $case_progress ) ) { return $case_progress; } $after_binding = AdvancedAlertIdentityPrivacy::context_binding( $authority ); if ( is_wp_error( $after_binding ) ) { return $after_binding; } if ( ! hash_equals( $before_binding, $after_binding ) || ! hash_equals( $before_binding, $context['binding'] ) ) { return self::error( 'context_changed', 'Advanced personal context changed between protected projections; retry without appending these bytes.', 409 ); } $context['case_privacy'] = $case_progress; $next = wp_json_encode( $context['next'] ); $binding = $context['binding']; $done = $context['done']; unset( $context['next'], $context['binding'] ); if ( ! is_string( $next ) || strlen( $next ) > 2048 || ! is_string( $binding ) || strlen( $binding ) > 48 || ( '' !== $job['advanced_binding'] && ! hash_equals( $job['advanced_binding'], $binding ) ) ) { self::fail( $job, 'personal_context_failed' ); return self::error( 'context', 'Advanced alert export progress changed.', 503 ); } $record = array( 'record_type' => 'advanced_alert_context', 'schema_version' => 1, 'id' => null, 'uid' => null, 'code' => null, 'scope' => array( 'network_id' => (string) $job['network_id'], 'site_id' => (string) $job['site_id'], ), 'subject_id' => (string) $job['personal_subject'], 'detail' => array( 'advanced_alerts' => $context ), ); $plain = 'json' === $job['format'] ? wp_json_encode( $record, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) : self::csv( array( '', '', '', '', 'unknown', '', (string) $job['personal_subject'], 'user', (string) $job['personal_subject'], '', '', wp_json_encode( array( 'record_type' => $record['record_type'] ) + $record['detail'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) ) ); $progress = array( 'advanced_cursor' => $next, 'advanced_binding' => $binding, 'advanced_done' => $done ? 1 : 0, 'personal_record_count' => (int) $job['personal_record_count'] + 1, ); $valid = self::valid( array_merge( $job, $progress ), true ); if ( is_wp_error( $valid ) ) { return $valid; } if ( ! is_string( $plain ) || ! $valid ) { self::fail( $job, 'personal_context_failed' ); return self::error( 'privacy', 'Advanced alert context changed before storage.', 403 ); } $advance = self::commit_chunk( $job, (string) $job['cursor_id'], 0, 0, $plain, $progress ); if ( is_wp_error( $advance ) ) { return $advance; } $job = array_merge( $job, $advance ); if ( $done ) { break; } } if ( ! (int) $job['advanced_done'] ) { self::schedule( false ); return self::job_status( $job ); } } for ( $batch = 0; ; ++$batch ) { if ( $batch >= max( 1, min( 100, $batches ) ) || microtime( true ) - $started >= 10 ) { break; } $current = self::load( $id, $network ); self::refresh_principal( (int) $job['principal'] ); $valid = self::valid( $job, true ); if ( is_wp_error( $valid ) ) { return $valid; } if ( is_wp_error( $current ) || 'writing' !== $current['state'] || ! $valid ) { self::fail( $job, 'authorization_changed', 'cancelled' ); return self::error( 'privacy', 'Export cancelled because authorization or privacy state changed.', 403 ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Read current bounded membership progress; cached pages could outlive privacy cleanup. Fixed items table; UUID/cursor use %s and batch cap %d; ordered event_id keyset. $items = $wpdb->get_results( $wpdb->prepare( 'SELECT event_id,event_uid FROM %i WHERE job_id=%s AND event_id>%s ORDER BY event_id LIMIT %d', $tables['items'], $id, $job['cursor_id'], self::BATCH ), ARRAY_A ); if ( '' !== $wpdb->last_error || ! is_array( $items ) ) { return self::error( 'storage', 'Export membership could not be read.', 503 ); } if ( ! $items ) { return self::finish( $job ); } $last = end( $items ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Read current scoped ID/UID-matched source rows; cached evidence could survive erasure. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT e.* FROM %i i INNER JOIN %i e ON e.id=i.event_id AND e.uid=i.event_uid WHERE i.job_id=%s AND i.event_id>%s AND i.event_id<=%s AND e.network_id=%d AND e.site_id=%d ORDER BY i.event_id', $tables['items'], Store::table(), $id, $job['cursor_id'], $last['event_id'], $job['network_id'], $job['site_id'] ), ARRAY_A ); if ( '' !== $wpdb->last_error || ! is_array( $rows ) ) { return self::error( 'storage', 'Export data could not be read.', 503 ); } $plain = self::encode_rows( $job['format'], $rows, null !== $job['personal_subject'], (int) $job['personal_context_version'] ); if ( is_wp_error( $plain ) ) { self::fail( $job, 'invalid_data' ); return $plain; } if ( (int) $job['byte_count'] + strlen( $plain ) > self::MAX_BYTES ) { self::fail( $job, 'byte_limit' ); return self::error( 'capacity', 'Export exceeds the 256 MiB safety limit; use a narrower date range.' ); } $advance = self::commit_chunk( $job, $last['event_id'], count( $items ), count( $rows ), $plain ); if ( is_wp_error( $advance ) ) { return $advance; } $job = array_merge( $job, $advance ); } self::schedule( $network ); return self::job_status( $job ); } catch ( \Throwable $error ) { if ( is_array( $job ) ) { self::fail( $job, 'worker_failed' ); } return self::error( 'worker', 'Export worker failed safely; inspect System status and retry.', 503 ); } finally { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Clear the persisted worker lease on exit; an object-cache write cannot release job state. $wpdb->update( $tables['jobs'], array( 'lease_until' => null ), array( 'id' => $id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Release the exact job mutex in worker finally; cached results cannot release it. $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock ) ); $wpdb->suppress_errors( $old_errors ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore captured roles in worker finally before reloading the caller's original current user. $GLOBALS['wp_roles'] = $previous_roles; wp_set_current_user( 0 ); wp_set_current_user( $previous ); } } /** * Commit authenticated ciphertext and its bounded progress after current privacy checks. * * @param array $job Stored export or delivery job fields used by this operation. * @param string $cursor Last processed membership identifier retained as a decimal string. * @param int $items Number of selected membership entries represented by this chunk. * @param int $rows Number of source rows actually serialized into this chunk. * @param string $plain Encoded plaintext bytes held transiently before private encryption. * @param array $context Allowlisted personal-context cursor and privacy-binding progress. * * @return array|\WP_Error Persisted progress fields, or a privacy, capacity or storage error. */ private static function commit_chunk( array $job, string $cursor, int $items, int $rows, string $plain, array $context = array() ): array|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ if ( array_diff( array_keys( $context ), array( 'baseline_decision_cursor', 'baseline_history_cursor', 'baseline_done', 'personal_record_count', 'incident_case_cursor', 'incident_done', 'request_binding', 'request_valid_until', 'report_search_cursor', 'report_schedule_cursor', 'report_job_cursor', 'report_done', 'incident_runner_done', 'advanced_cursor', 'advanced_binding', 'advanced_done' ) ) ) { return self::error( 'context', 'Unsupported personal export progress.', 503 ); } if ( (int) $job['byte_count'] + strlen( $plain ) > self::MAX_BYTES ) { self::fail( $job, 'byte_limit' ); return self::error( 'capacity', 'Export exceeds the 256 MiB safety limit; use a narrower date range.' ); } $table = self::tables()['jobs']; $lock = 'lai_export_capacity_' . substr( hash( 'sha256', DB_NAME . $table ), 0, 32 ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Acquire the installation ciphertext-capacity mutex; cached results cannot serialize accounting. if ( '1' !== (string) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s,0)', $lock ) ) ) { self::schedule( 0 === (int) $job['site_id'] ); return self::error( 'busy', 'Private export storage is busy; retry is safe.', 409 ); } try { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Read live accounted ciphertext usage under the mutex; cached totals cannot enforce capacity. Fixed jobs table; conditional site sum binds network/site %d; byte_count+chunks*40 and purged_at IS NULL are fixed expressions. $usage = $wpdb->get_row( $wpdb->prepare( 'SELECT COALESCE(SUM(byte_count+chunks*40),0) AS installation,COALESCE(SUM(CASE WHEN network_id=%d AND site_id=%d THEN byte_count+chunks*40 ELSE 0 END),0) AS site FROM %i WHERE purged_at IS NULL', $job['network_id'], $job['site_id'], $table ), ARRAY_A ); if ( ! $usage || '' !== $wpdb->last_error ) { return self::error( 'storage', 'Private export capacity could not be checked.', 503 ); } $needed = strlen( $plain ) + 40; if ( (int) $usage['site'] + $needed > 512 * 1024 * 1024 || (int) $usage['installation'] + $needed > 1024 * 1024 * 1024 ) { self::fail( $job, 'storage_capacity' ); return self::error( 'capacity', 'Private export storage reached its safety capacity; remove older exports before retrying.', 429 ); } $valid = self::valid( array_merge( $job, $context ), true ); if ( is_wp_error( $valid ) ) { return $valid; } if ( ! $valid ) { self::fail( $job, 'privacy_changed', 'cancelled' ); return self::error( 'privacy', 'Export changed before private storage.', 403 ); } $saved = self::write_chunk( $job, (int) $job['chunks'], $plain ); if ( is_wp_error( $saved ) ) { self::fail( $job, 'storage_failed' ); return $saved; } $advance = array( 'cursor_id' => $cursor, 'chunks' => (int) $job['chunks'] + 1, 'row_count' => (int) $job['row_count'] + $rows, 'missing_count' => (int) $job['missing_count'] + $items - $rows, 'byte_count' => (int) $job['byte_count'] + strlen( $plain ), ) + $context; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Persist encrypted-chunk progress only for the exact writing job and privacy generation. $advanced = $wpdb->update( $table, $advance, array( 'id' => $job['id'], 'generation' => $job['generation'], 'state' => 'writing', ) ); if ( 1 !== $advanced ) { if ( 0 === $advanced ) { self::fail( $job, 'cancelled_during_write', 'cancelled' ); self::cleanup( 0 === (int) $job['site_id'] ); } return self::error( 'storage', 'Export progress could not be committed; retry is safe.', 503 ); } $valid = self::valid( array_merge( $job, $advance ), true ); if ( is_wp_error( $valid ) ) { return $valid; } if ( ! $valid ) { self::fail( $job, 'privacy_changed', 'cancelled' ); return self::error( 'privacy', 'Export changed while storing progress.', 403 ); } return $advance; } finally { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Release the ciphertext-capacity mutex in finally; cached results cannot release it. $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock ) ); } } /** * Serialize minimized event rows and authorized personal projections as CSV or JSON. * * @param string $format Requested CSV or JSON output format. * @param array $rows Scoped source-event rows to minimize and serialize. * @param bool $personal Whether to include authorized personal-context projections. * @param int $context_version Personal-context schema version stored with the job. * * @return string|\WP_Error Encoded row bytes, or a data or projection error. */ private static function encode_rows( string $format, array $rows, bool $personal = false, int $context_version = 0 ): string|\WP_Error { $encoded = array(); $authority = null; if ( $personal && $rows ) { $authority = 0 === (int) $rows[0]['site_id'] ? Scope::current_network( 'logged_ai_export_network_events' ) : Scope::current( 'logged_ai_export_events' ); if ( is_wp_error( $authority ) ) { return $authority; } } foreach ( $rows as $row ) { if ( strlen( $row['payload'] ) > 16384 ) { return self::error( 'data', 'Stored event exceeds its declared payload budget.', 503 ); } $event = Store::decode( $row ); if ( ! is_array( $event['detail'] ) ) { return self::error( 'data', 'Stored event payload is invalid.', 503 ); } if ( $personal ) { $assessments = Derived::personal_projection( $authority, $row ); if ( is_wp_error( $assessments ) ) { return $assessments; } // Separate derived evidence; never rewrite the source event or an old encrypted chunk. $event['detail']['linked_assessments'] = $assessments; if ( in_array( $context_version, array( 5, 6, 7, 8 ), true ) ) { $receipt = IncidentRunnerPrivacy::source_projection( $authority, $row ); if ( is_wp_error( $receipt ) ) { return $receipt; } $event['detail']['incident_processing'] = $receipt; } } if ( 'json' === $format ) { $value = wp_json_encode( $event, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); } else { $values = array(); foreach ( self::CSV_COLUMNS as $column ) { $values[] = 'details_json' === $column ? wp_json_encode( $event['detail'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) : (string) ( $event[ $column ] ?? '' ); } $value = self::csv( $values ); } if ( false === $value ) { return self::error( 'data', 'An event could not be encoded.', 503 ); } $encoded[] = $value; } return implode( 'json' === $format ? ',' : '', $encoded ); } /** * Read the same revocation generation used by protected background exports. * * @param bool $network Whether this is current-network-origin activity. * @return int|\WP_Error Current revocation generation or a refusal. */ public static function ordinary_generation( bool $network = false ): int|\WP_Error { return self::generation( $network ); } /** * Start a bounded immediate download with the established CSV columns or JSON array. * * @param string $format Validated CSV or JSON format. * @return string|\WP_Error Initial bytes or a refusal. */ public static function ordinary_header( string $format ): string|\WP_Error { return 'json' === $format ? '[' : ( 'csv' === $format ? self::memory_csv( self::CSV_COLUMNS ) : self::error( 'request', __( 'Choose CSV or JSON.', 'logged-ai' ) ) ); } /** * Encode only the ordinary activity projection, without personal-context extensions. * * Store::listing has already selected its fixed ordinary fields and decoded payload. * The explicit allowlist prevents callers from adding internal columns to downloads. * * @param string $format Validated CSV or JSON format. * @param array $rows Bounded decoded ordinary activity records. * @return string|\WP_Error Encoded bytes or a refusal. */ public static function ordinary_rows( string $format, array $rows ): string|\WP_Error { if ( ! in_array( $format, array( 'csv', 'json' ), true ) || count( $rows ) > 100 ) { return self::error( 'request', __( 'The immediate export request is invalid.', 'logged-ai' ) ); } $keys = explode( ',', 'id,uid,schema_version,network_id,site_id,occurred_at,ingested_at,code,category,action,outcome,severity,actor_kind,actor_id,subject_id,object_type,object_id,channel,request_id,operation_id,processing_state,detail' ); $encoded = array(); foreach ( $rows as $row ) { if ( ! is_array( $row ) || array_keys( $row ) !== $keys || ! is_array( $row['detail'] ) ) { return self::error( 'data', __( 'An activity record could not be exported safely.', 'logged-ai' ), 503 ); } $details = wp_json_encode( $row['detail'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); if ( false === $details || strlen( $details ) > 16384 ) { return self::error( 'data', __( 'An activity record exceeds its supported size.', 'logged-ai' ), 503 ); } if ( 'json' === $format ) { $value = wp_json_encode( $row, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); } else { $values = array(); foreach ( self::CSV_COLUMNS as $column ) { $values[] = 'details_json' === $column ? $details : (string) ( $row[ $column ] ?? '' ); } $value = self::memory_csv( $values ); } if ( is_wp_error( $value ) ) { return $value; } if ( false === $value ) { return self::error( 'data', __( 'An activity record could not be encoded.', 'logged-ai' ), 503 ); } $encoded[] = $value; } return implode( 'json' === $format ? ',' : '', $encoded ); } /** * Encode one CSV line directly in memory using native CSV quoting rules. * * The empty escape setting used by background exports doubles enclosure * characters. Fields containing the delimiter, enclosure or native CSV * whitespace are enclosed. No stream or filesystem access is needed. * * @param array $values Ordinary export header or scalar cells. * @return string CSV bytes with CRLF line ending. */ private static function memory_csv( array $values ): string { $cells = array(); foreach ( $values as $value ) { $cell = Activity::csv_cell( $value ); $cells[] = false !== strpbrk( $cell, ",\"\r\n\t " ) ? '"' . str_replace( '"', '""', $cell ) . '"' : $cell; } return implode( ',', $cells ) . "\r\n"; } /** * Encode one CRLF CSV row after neutralizing formula-like cell values. * * @param array $values CSV cell values to neutralize and encode. * * @return string Encoded CSV row bytes. */ private static function csv( array $values ): string { $handle = fopen( 'php://temp/maxmemory:65536', 'w+' ); fputcsv( $handle, array_map( array( Activity::class, 'csv_cell' ), $values ), ',', '"', '' ); rewind( $handle ); $line = stream_get_contents( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Close the owned local stream handle; WP_Filesystem does not manage PHP stream resources. fclose( $handle ); // PHP 8.0 has no fputcsv EOL parameter. Replace only its final LF so cell newlines stay byte-exact. return substr( $line, 0, -1 ) . "\r\n"; } /** * Verify encrypted chunks and record a current completion manifest and payload digest. * * @param array $job Stored export or delivery job fields used by this operation. * * @return array|\WP_Error Completed job status, or an integrity, privacy, audit or storage error. */ private static function finish( array $job ): array|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $digest = hash_init( 'sha256' ); hash_update( $digest, 'json' === $job['format'] ? '[' : self::csv( self::CSV_COLUMNS ) ); $seen = false; for ( $sequence = 0; $sequence < (int) $job['chunks']; ++$sequence ) { $plain = self::read_chunk( $job, $sequence ); if ( is_wp_error( $plain ) ) { self::fail( $job, 'integrity_failed' ); return $plain; } if ( '' === $plain ) { continue; } if ( 'json' === $job['format'] && $seen ) { hash_update( $digest, ',' ); } hash_update( $digest, $plain ); $seen = true; } if ( 'json' === $job['format'] ) { hash_update( $digest, ']' ); } $manifest = array( 'schema_version' => 1 + (int) $job['personal_context_version'], 'plugin_version' => LOGGED_AI_VERSION, 'job_id' => $job['id'], 'format' => $job['format'], 'scope' => array( 'network_id' => (string) $job['network_id'], 'site_id' => (string) $job['site_id'], ), 'filters' => json_decode( $job['filters'], true ), 'personal_export' => null !== $job['personal_subject'], 'requested_at_utc' => $job['created_at'], 'membership_at_utc' => $job['membership_at'], 'generated_at_utc' => Store::now(), 'expires_at_utc' => $job['expires_at'], 'timezone' => 'UTC', 'membership_count' => (int) $job['membership_count'], 'row_count' => (int) $job['row_count'], 'removed_after_membership' => (int) $job['missing_count'], 'membership_semantics' => 'Fixed IDs and UIDs selected by one scoped INSERT SELECT statement at worker execution. Database statement visibility determines concurrent or late-commit inclusion. Later commits are not appended. Row contents are read in bounded batches; retention deletions are counted, privacy erasure cancels the export. This is not a transactionally complete content snapshot.', 'payload_sha256' => hash_final( $digest ), 'digest_basis' => 'json' === $job['format'] ? 'Exact UTF-8 events array bytes without the surrounding manifest.' : 'Exact UTF-8 CSV bytes including the header and CRLF row endings.', ); if ( 1 === (int) $job['personal_context_version'] ) { $manifest += array( 'personal_context_schema' => 1, 'personal_context_records' => 1, 'serialized_record_count' => (int) $job['row_count'] + 1, 'record_semantics' => 'row_count counts source events. A separately typed temporary_authentication_context record is personal temporary state, not an audit event. CSV preserves its header and identifies auxiliary state inside details_json with a blank event code.', ); } if ( 2 === (int) $job['personal_context_version'] ) { if ( ! (int) $job['baseline_done'] || (int) $job['personal_record_count'] < 2 ) { return self::error( 'context', 'Personal context pages are incomplete.', 503 ); } $manifest += array( 'personal_context_schema' => 2, 'personal_context_records' => (int) $job['personal_record_count'], 'serialized_record_count' => (int) $job['row_count'] + (int) $job['personal_record_count'], 'record_semantics' => 'row_count counts source events. Separately typed temporary_authentication_context and paged baseline_governance_context records contain retained personal context, not additional audit events. CSV identifies these in details_json with a blank event code. Each baseline page is a bounded current read; later pages do not form a transactional snapshot.', ); } if ( 3 === (int) $job['personal_context_version'] ) { if ( ! (int) $job['baseline_done'] || ! (int) $job['incident_done'] || (int) $job['personal_record_count'] < 3 ) { return self::error( 'context', 'Personal context pages are incomplete.', 503 ); } $manifest += array( 'personal_context_schema' => 3, 'personal_context_records' => (int) $job['personal_record_count'], 'serialized_record_count' => (int) $job['row_count'] + (int) $job['personal_record_count'], 'record_semantics' => 'row_count counts source events. Separately typed temporary_authentication_context, baseline_governance_context and incident_governance_context pages contain retained personal context, not additional audit events. CSV identifies these in details_json with a blank event code. Each context page is a bounded current read; pages do not form a transactional snapshot.', ); } if ( 4 === (int) $job['personal_context_version'] ) { if ( ! (int) $job['baseline_done'] || ! (int) $job['incident_done'] || ! (int) $job['report_done'] || (int) $job['personal_record_count'] < 4 ) { return self::error( 'context', 'Personal context pages are incomplete.', 503 ); } $manifest += array( 'personal_context_schema' => 4, 'personal_context_records' => (int) $job['personal_record_count'], 'serialized_record_count' => (int) $job['row_count'] + (int) $job['personal_record_count'], 'record_semantics' => 'row_count counts source events. Separately typed temporary_authentication_context, baseline_governance_context, incident_governance_context and report_governance_context pages contain retained personal context, not additional audit events. CSV identifies these in details_json with a blank event code. Each context page is a bounded current read; pages do not form a transactional snapshot.', ); } if ( 5 === (int) $job['personal_context_version'] ) { if ( ! (int) $job['baseline_done'] || ! (int) $job['incident_done'] || ! (int) $job['report_done'] || ! (int) $job['incident_runner_done'] || (int) $job['personal_record_count'] < 5 ) { return self::error( 'context', 'Personal context pages are incomplete.', 503 ); } $manifest += array( 'personal_context_schema' => 5, 'personal_context_records' => (int) $job['personal_record_count'], 'serialized_record_count' => (int) $job['row_count'] + (int) $job['personal_record_count'], 'record_semantics' => 'row_count counts source events. Separately typed temporary_authentication_context, baseline_governance_context, incident_governance_context report_governance_context and incident_background_context pages contain retained personal context, not additional audit events. CSV identifies these in details_json with a blank event code. Source records may also include one separately labelled incident_processing receipt. Each context page is a bounded current read; pages do not form a transactional snapshot.', ); } if ( 6 === (int) $job['personal_context_version'] ) { if ( ! (int) $job['baseline_done'] || ! (int) $job['incident_done'] || ! (int) $job['report_done'] || ! (int) $job['incident_runner_done'] || ! (int) $job['advanced_done'] || '' === $job['advanced_binding'] || (int) $job['personal_record_count'] < 6 ) { return self::error( 'context', 'Personal context pages are incomplete.', 503 ); } $manifest += array( 'personal_context_schema' => 6, 'personal_context_records' => (int) $job['personal_record_count'], 'serialized_record_count' => (int) $job['row_count'] + (int) $job['personal_record_count'], 'record_semantics' => 'row_count counts source events. Separately typed authentication, baseline, incident, report, incident background and advanced_alert_context pages contain retained personal context, not additional audit events. Advanced alert credentials and internal cursors are excluded. CSV identifies context in details_json with a blank event code. Source records may include an incident_processing receipt. Pages are bounded current reads and do not form a transactional snapshot.', ); } if ( 7 === (int) $job['personal_context_version'] ) { if ( ! (int) $job['baseline_done'] || ! (int) $job['incident_done'] || ! (int) $job['report_done'] || ! (int) $job['incident_runner_done'] || ! (int) $job['advanced_done'] || '' === $job['advanced_binding'] || '' === $job['request_binding'] || (int) $job['personal_record_count'] < 6 ) { return self::error( 'context', 'Personal context pages are incomplete.', 503 ); } $manifest += array( 'personal_context_schema' => 7, 'personal_context_records' => (int) $job['personal_record_count'], 'serialized_record_count' => (int) $job['row_count'] + (int) $job['personal_record_count'], 'record_semantics' => 'row_count counts source events. Separately typed authentication, baseline, incident, report, incident background and advanced_alert_context pages contain retained personal context, not additional audit events. Incident pages include only retained related request references or unavailable markers; same-address associations do not establish identity or causation. Credentials, raw addresses and internal bindings are excluded. CSV identifies context in details_json with a blank event code. Source records may include an incident_processing receipt. Pages are bounded current reads and do not form a transactional snapshot.', ); } if ( 8 === (int) $job['personal_context_version'] ) { if ( ! (int) $job['baseline_done'] || ! (int) $job['incident_done'] || ! (int) $job['report_done'] || ! (int) $job['incident_runner_done'] || ! (int) $job['advanced_done'] || '' === $job['advanced_binding'] || '' === $job['request_binding'] || (int) $job['personal_record_count'] < 6 ) { return self::error( 'context', 'Personal context pages are incomplete.', 503 ); } $manifest += array( 'personal_context_schema' => 8, 'personal_context_records' => (int) $job['personal_record_count'], 'serialized_record_count' => (int) $job['row_count'] + (int) $job['personal_record_count'], 'record_semantics' => 'row_count counts source events. Separately typed authentication, baseline, incident, report, incident background and advanced_alert_context pages contain retained personal context, not additional audit events. Incident pages include only retained related request references or unavailable markers; same-address and server-pattern time associations do not establish identity or causation. Server context uses incident schema3 and remains available only while its original local proof, connector configuration and sources remain current. Credentials, raw addresses and internal bindings are excluded. CSV identifies context in details_json with a blank event code. Source records may include an incident_processing receipt. Pages are bounded current reads and do not form a transactional snapshot.', ); } $valid = self::valid( $job, true ); if ( is_wp_error( $valid ) ) { return $valid; } if ( ! $valid ) { self::fail( $job, 'privacy_changed', 'cancelled' ); return self::error( 'privacy', 'Export was invalidated before completion.', 403 ); } $state = array( 'state' => 'ready', 'completed_at' => $manifest['generated_at_utc'], 'manifest' => wp_json_encode( $manifest, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ), 'sha256' => $manifest['payload_sha256'], ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Durably publish the verified manifest only for the exact writing job and privacy generation. if ( 1 !== $wpdb->update( self::tables()['jobs'], $state, array( 'id' => $job['id'], 'state' => 'writing', 'generation' => $job['generation'], ) ) ) { self::fail( $job, 'cancelled_during_finish', 'cancelled' ); return self::error( 'storage', 'Export completion was cancelled or could not be committed.', 503 ); } if ( ! self::audit( 'completed', $job, (int) $job['row_count'] ) ) { self::fail( $job, 'audit_failed' ); return self::error( 'audit', 'Export output was generated but its completion audit failed.', 503 ); } return self::job_status( $job ); } /** * Record a terminal job reason and hide its manifest pending private-file cleanup. * * @param array $job Stored export or delivery job fields used by this operation. * @param string $reason Finite operational reason for the terminal job state. * @param string $state Finite processing state selected by the caller. * * @return bool Whether the job update avoided a storage error. */ private static function fail( array $job, string $reason, string $state = 'failed' ): bool { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Persist terminal job state and clear export manifest/digest; cache writes cannot revoke storage. $saved = $wpdb->update( self::tables()['jobs'], array( 'state' => $state, 'reason' => $reason, 'manifest' => null, 'sha256' => null, 'purged_at' => null, ), array( 'id' => $job['id'] ) ); if ( 'failed' === $state || false === $saved ) { Store::degraded( 'export_failed' ); } return false !== $saved; } /** * Validate a stored nonnegative counter before converting it to a native integer. * * @param mixed $value Stored counter without coercion. * @param int $maximum Inclusive supported bound. * @return int|null Bounded counter, or null for malformed or unsupported values. */ private static function bounded_count( mixed $value, int $maximum ): ?int { if ( ! is_int( $value ) && ! is_string( $value ) ) { return null; } $value = (string) $value; $bound = (string) $maximum; return preg_match( '/\A(?:0|[1-9][0-9]*)\z/D', $value ) && ( strlen( $value ) < strlen( $bound ) || ( strlen( $value ) === strlen( $bound ) && strcmp( $value, $bound ) <= 0 ) ) ? (int) $value : null; } /** * Validate ready metadata without requiring the current plugin version or rewriting old exports. * * @param array $job Stored supported job selected by exact scope and identity. * @return array|\WP_Error Expected event and personal-record counts, or an integrity error. */ private static function ready_metadata( array $job ): array|\WP_Error { $error = self::error( 'integrity', 'Private export verification failed.', 503 ); if ( 'ready' !== $job['state'] || ! in_array( $job['format'], array( 'json', 'csv' ), true ) || ! is_string( $job['manifest'] ) || strlen( $job['manifest'] ) > 65536 || ! is_string( $job['sha256'] ) || ! preg_match( '/\A[0-9a-f]{64}\z/D', $job['sha256'] ) ) { return $error; } foreach ( array( 'chunks' => self::MAX_ROWS + 1, 'row_count' => self::MAX_ROWS, 'membership_count' => self::MAX_ROWS, 'missing_count' => self::MAX_ROWS, 'personal_record_count' => self::MAX_ROWS, 'byte_count' => self::MAX_BYTES ) as $key => $maximum ) { if ( null === self::bounded_count( $job[ $key ] ?? null, $maximum ) ) { return $error; } } $manifest = json_decode( $job['manifest'], true ); $filters = is_string( $job['filters'] ) && strlen( $job['filters'] ) <= 16384 ? json_decode( $job['filters'], true ) : null; $version = (int) $job['personal_context_version']; // Version 1 predates the general personal-record counter; its manifest always declares one record. $personal = 1 === $version ? 1 : (int) $job['personal_record_count']; if ( ! is_array( $manifest ) || ! is_array( $filters ) || (int) $job['row_count'] + (int) $job['missing_count'] !== (int) $job['membership_count'] || ( 0 === $version && 0 !== $personal ) || ( $version > 0 && null === $job['personal_subject'] ) ) { return $error; } foreach ( array( 'plugin_version' => 64, 'membership_semantics' => 8192 ) + ( $version > 0 ? array( 'record_semantics' => 8192 ) : array() ) as $key => $maximum ) { if ( ! is_string( $manifest[ $key ] ?? null ) || '' === trim( $manifest[ $key ] ) || strlen( $manifest[ $key ] ) > $maximum ) { return $error; } } $expected = array( 'schema_version' => 1 + $version, 'job_id' => $job['id'], 'format' => $job['format'], 'scope' => array( 'network_id' => (string) $job['network_id'], 'site_id' => (string) $job['site_id'] ), 'filters' => $filters, 'personal_export' => null !== $job['personal_subject'], 'requested_at_utc' => $job['created_at'], 'membership_at_utc' => $job['membership_at'], 'generated_at_utc' => $job['completed_at'], 'expires_at_utc' => $job['expires_at'], 'timezone' => 'UTC', 'membership_count' => (int) $job['membership_count'], 'row_count' => (int) $job['row_count'], 'removed_after_membership' => (int) $job['missing_count'], 'payload_sha256' => $job['sha256'], 'digest_basis' => 'json' === $job['format'] ? 'Exact UTF-8 events array bytes without the surrounding manifest.' : 'Exact UTF-8 CSV bytes including the header and CRLF row endings.', ); if ( $version > 0 ) { $expected += array( 'personal_context_schema' => $version, 'personal_context_records' => $personal, 'serialized_record_count' => (int) $job['row_count'] + $personal ); } foreach ( $expected as $key => $value ) { if ( ! array_key_exists( $key, $manifest ) || $manifest[ $key ] !== $value ) { return $error; } } return array( 'rows' => (int) $job['row_count'], 'personal' => $personal ); } /** * Count complete records in one bounded authenticated chunk, including quoted CSV newlines. * * @param string $format Validated CSV or JSON format. * @param string $plain Authenticated plaintext from one bounded chunk. * @return array|\WP_Error Event and personal-record counts, or an integrity error. */ private static function chunk_counts( string $format, string $plain ): array|\WP_Error { $counts = array( 'rows' => 0, 'personal' => 0 ); $error = self::error( 'integrity', 'Private export verification failed.', 503 ); if ( '' === $plain ) { return $counts; } if ( 'json' === $format ) { $records = json_decode( '[' . $plain . ']', true ); if ( ! is_array( $records ) ) { return $error; } foreach ( $records as $record ) { if ( ! is_array( $record ) || ! array_key_exists( 'id', $record ) ) { return $error; } if ( null === $record['id'] && is_string( $record['record_type'] ?? null ) && '' !== $record['record_type'] ) { ++$counts['personal']; } elseif ( is_string( $record['id'] ) && preg_match( '/\A[1-9][0-9]{0,19}\z/D', $record['id'] ) ) { ++$counts['rows']; } else { return $error; } } return $counts; } // php://memory never spills plaintext to a temporary disk file; one chunk is at most 8 MiB. // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- The PHP memory stream is required by fgetcsv and never touches the filesystem; WP_Filesystem cannot provide this parser handle. $handle = fopen( 'php://memory', 'w+' ); if ( false === $handle ) { return $error; } try { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- Write one bounded authenticated chunk into php://memory for CSV parsing; no disk file or WordPress filesystem transport is used. if ( fwrite( $handle, $plain ) !== strlen( $plain ) || ! rewind( $handle ) ) { return $error; } while ( false !== ( $record = fgetcsv( $handle, 0, ',', '"', '' ) ) ) { if ( count( $record ) !== count( self::CSV_COLUMNS ) ) { return $error; } if ( '' === $record[0] && '' === $record[3] ) { $details = json_decode( $record[11], true ); if ( ! is_array( $details ) || ! is_string( $details['record_type'] ?? null ) || '' === $details['record_type'] ) { return $error; } ++$counts['personal']; } elseif ( preg_match( '/\A[1-9][0-9]{0,19}\z/D', $record[0] ) ) { ++$counts['rows']; } else { return $error; } } return $counts; } finally { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Close the bounded memory-only CSV parser; no plaintext file is created. fclose( $handle ); } } /** * Recheck live privacy, owner authority and the exact metadata whose output was verified. * * @param Scope $scope Captured operation scope requiring current export permission. * @param array $job Exact metadata selected for this stream. * @return bool|\WP_Error Whether authority and metadata remain current, or a storage error. */ private static function stream_current( Scope $scope, array $job ): bool|\WP_Error { self::refresh_principal( (int) $job['principal'] ); $valid = self::valid( $job, true ); if ( is_wp_error( $valid ) ) { return $valid; } if ( ! $valid || ! $scope->valid_for( self::export_cap( $scope->site_id ) ) ) { return false; } $current = self::load( $job['id'], 0 === $scope->site_id ); if ( is_wp_error( $current ) ) { return $current; } foreach ( array( 'id', 'network_id', 'site_id', 'principal', 'personal_subject', 'personal_context_version', 'storage_version', 'format', 'filters', 'generation', 'state', 'created_at', 'expires_at', 'membership_at', 'completed_at', 'chunks', 'membership_count', 'row_count', 'missing_count', 'byte_count', 'personal_record_count', 'manifest', 'sha256', 'request_binding', 'request_valid_until', 'advanced_binding' ) as $key ) { if ( $current[ $key ] !== $job[ $key ] ) { return false; } } return true; } /** * Verify the whole payload before disclosure, retaining only 32 bytes per bounded chunk. * * @param Scope $scope Captured operation scope requiring current export permission. * @param array $job Exact metadata selected for this stream. * @return string|\WP_Error Concatenated binary chunk fingerprints, or a refusal before plaintext output. */ private static function stream_preflight( Scope $scope, array $job ): string|\WP_Error { $expected = self::ready_metadata( $job ); if ( is_wp_error( $expected ) ) { return $expected; } $digest = hash_init( 'sha256' ); hash_update( $digest, 'json' === $job['format'] ? '[' : self::csv( self::CSV_COLUMNS ) ); $fingerprints = ''; $seen = false; $bytes = 0; $counts = array( 'rows' => 0, 'personal' => 0 ); for ( $sequence = 0; $sequence < (int) $job['chunks']; ++$sequence ) { $current = self::stream_current( $scope, $job ); if ( true !== $current ) { return is_wp_error( $current ) ? $current : self::error( 'privacy', 'Export was invalidated while downloading.', 403 ); } $plain = self::read_chunk( $job, $sequence ); if ( is_wp_error( $plain ) ) { if ( 'logged_ai_export_integrity' === $plain->get_error_code() && true === self::stream_current( $scope, $job ) ) { self::fail( $job, 'integrity_failed' ); } return $plain; } $part = self::chunk_counts( $job['format'], $plain ); if ( is_wp_error( $part ) ) { return $part; } $bytes += strlen( $plain ); $counts['rows'] += $part['rows']; $counts['personal'] += $part['personal']; if ( $bytes > (int) $job['byte_count'] || $counts['rows'] > $expected['rows'] || $counts['personal'] > $expected['personal'] ) { return self::error( 'integrity', 'Private export verification failed.', 503 ); } $fingerprints .= hash( 'sha256', $plain, true ); if ( '' !== $plain ) { if ( 'json' === $job['format'] && $seen ) { hash_update( $digest, ',' ); } hash_update( $digest, $plain ); $seen = true; } } if ( 'json' === $job['format'] ) { hash_update( $digest, ']' ); } return $bytes === (int) $job['byte_count'] && $counts === $expected && hash_equals( $job['sha256'], hash_final( $digest ) ) ? $fingerprints : self::error( 'integrity', 'Private export verification failed.', 503 ); } /** * Verify and stream an authorized export with fresh privacy checks before each bounded chunk. * * @param Scope $scope Operation scope whose current permissions are checked. * @param string $id Selected job identifier. * @param callable $write Consumer called with authorized plaintext output bytes. * @param bool $manifest_only Whether to emit only the manifest instead of the export payload. * * @return array|\WP_Error Download summary, or an access, privacy or integrity error; prior bytes cannot be recalled. */ public static function stream( Scope $scope, string $id, callable $write, bool $manifest_only = false ): array|\WP_Error { $previous = get_current_user_id(); $previous_roles = $GLOBALS['wp_roles']; try { self::refresh_principal( $previous ); if ( ! $scope->valid_for( self::export_cap( $scope->site_id ) ) ) { return self::error( 'forbidden', 'Export download denied.', 403 ); } $job = self::load( $id, 0 === $scope->site_id ); if ( is_wp_error( $job ) ) { return $job; } $valid = self::valid( $job, true ); if ( is_wp_error( $valid ) ) { return $valid; } if ( ! $valid || 'ready' !== $job['state'] ) { return self::error( 'unavailable', 'Export is not ready or its access expired.', 403 ); } $fingerprints = self::stream_preflight( $scope, $job ); if ( is_wp_error( $fingerprints ) ) { return $fingerprints; } $current = self::stream_current( $scope, $job ); if ( true !== $current ) { return is_wp_error( $current ) ? $current : self::error( 'privacy', 'Export was invalidated while downloading.', 403 ); } if ( $manifest_only ) { $write( $job['manifest'] . "\n" ); self::audit( 'downloaded', $job, 0 ); $current = self::stream_current( $scope, $job ); return true === $current ? array( 'downloaded' => 'manifest' ) : ( is_wp_error( $current ) ? $current : self::error( 'privacy', 'Export was invalidated while downloading.', 403 ) ); } $write( 'json' === $job['format'] ? '{"manifest":' . $job['manifest'] . ',"events":[' : self::csv( self::CSV_COLUMNS ) ); $seen = false; for ( $sequence = 0; $sequence < (int) $job['chunks']; ++$sequence ) { // A separate eraser may run during streaming. Bytes already sent cannot // be recalled; the next bounded chunk requires current authorization. $current = self::stream_current( $scope, $job ); if ( true !== $current ) { return is_wp_error( $current ) ? $current : self::error( 'privacy', 'Export was invalidated while downloading.', 403 ); } $plain = self::read_chunk( $job, $sequence ); if ( is_wp_error( $plain ) ) { if ( 'logged_ai_export_integrity' === $plain->get_error_code() && true === self::stream_current( $scope, $job ) ) { self::fail( $job, 'integrity_failed' ); } return $plain; } if ( ! hash_equals( substr( $fingerprints, $sequence * 32, 32 ), hash( 'sha256', $plain, true ) ) ) { return self::error( 'integrity', 'Private export verification failed.', 503 ); } $current = self::stream_current( $scope, $job ); if ( true !== $current ) { return is_wp_error( $current ) ? $current : self::error( 'privacy', 'Export authority or privacy changed during decryption.', 403 ); } if ( '' === $plain ) { continue; } $write( ( 'json' === $job['format'] && $seen ? ',' : '' ) . $plain ); $seen = true; } $current = self::stream_current( $scope, $job ); if ( true !== $current ) { return is_wp_error( $current ) ? $current : self::error( 'privacy', 'Export was invalidated while downloading.', 403 ); } if ( 'json' === $job['format'] ) { $write( ']}' . "\n" ); } self::audit( 'downloaded', $job, (int) $job['row_count'] ); $current = self::stream_current( $scope, $job ); if ( true !== $current ) { return is_wp_error( $current ) ? $current : self::error( 'privacy', 'Export was invalidated while downloading.', 403 ); } return array( 'downloaded' => $job['format'], 'row_count' => (int) $job['row_count'], 'payload_sha256' => $job['sha256'], ); } finally { // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore captured roles in delivery finally before reloading the caller's original current user. $GLOBALS['wp_roles'] = $previous_roles; wp_set_current_user( 0 ); wp_set_current_user( $previous ); } } /** * Preview one owned cancellation identity without enumerating later cleanup effects. * * CLI-only identity preview. Never inspect export contents or invoke status/cleanup. * * @param Scope $scope Operation scope whose current permissions are checked. * @param string $id Selected job identifier. * * @return array|\WP_Error Bounded identity and effect facts, or an authority or storage error. */ private static function cancel_preview( Scope $scope, string $id ): array|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $caller = $wpdb; $origin = Derived::native_context( $caller ); $current = static fn(): bool => ( $GLOBALS['wpdb'] ?? null ) === $caller && Derived::native_context( $caller ) === $origin; $allowed = static fn(): bool => $current() && $scope->valid_for( self::export_cap( $scope->site_id ) ) && $current(); if ( ! $allowed() ) { return self::error( 'forbidden', 'Export cancellation preview denied.', 403 ); } if ( ! self::uuid( $id ) ) { return self::error( 'missing', 'Export unavailable.', 404 ); } if ( ! class_exists( PreviewConnection::class, false ) ) { require_once __DIR__ . '/class-previewconnection.php'; } $table = self::tables()['jobs']; return PreviewConnection::run( $allowed, static function ( \wpdb $reader ) use ( $scope, $id, $table, $current ): array|\WP_Error { $rows = PreviewConnection::rows( $reader, $reader->prepare( 'SELECT id,network_id,site_id,principal,personal_context_version FROM %i WHERE id=%s AND network_id=%d AND site_id=%d LIMIT 2 FOR UPDATE', $table, $id, $scope->network_id, $scope->site_id ), array( 'id', 'network_id', 'site_id', 'principal', 'personal_context_version' ), 1 ); if ( is_wp_error( $rows ) || ! $current() ) { return self::error( 'storage', 'Export cancellation preview is unavailable.', 503 ); } if ( array() === $rows ) { return self::error( 'missing', 'Export unavailable in this scope.', 404 ); } $job = $rows[0]; if ( ! isset( $job['personal_context_version'] ) || ! in_array( (string) $job['personal_context_version'], array( '0', '1', '2', '3', '4', '5', '6', '7', '8' ), true ) ) { return self::error( 'context_version', 'This personal export context version is unsupported.', 503 ); } if ( ( $job['id'] ?? null ) !== $id || (string) ( $job['network_id'] ?? '' ) !== (string) $scope->network_id || (string) ( $job['site_id'] ?? '' ) !== (string) $scope->site_id || (string) ( $job['principal'] ?? '' ) !== (string) get_current_user_id() || ! $current() ) { return self::error( 'forbidden', 'Export cancellation preview authority changed.', 403 ); } return array( 'preview_version' => 1, 'dry_run' => true, 'id' => $id, 'network_id' => (string) $scope->network_id, 'site_id' => (string) $scope->site_id, 'selected_jobs' => 1, 'initial_state_change' => 'cancelled', 'original_events_deleted' => 0, 'complete_effect_estimate' => false, 'cleanup_limits' => array( 'jobs' => 10, 'file_entries_per_job' => 100, 'old_history_rows' => 100, ), 'cleanup_semantics' => 'Execution revokes the selected export, records its cancellation and attempts bounded cleanup across this same site or network scope, including other owners. Cleanup may subsequently expire or delete the operational job row. File removal, membership deletion, old-history cleanup and retry scheduling are not enumerated or performed by this preview.', 'selection_semantics' => 'One owned job identity selected by a current scoped read. This is not a reservation, a guarantee of later cancellation or a complete cleanup forecast. Existing terminal jobs remain cancellable under the original execution contract.', ); }, array( $table ) ); } /** * Revoke an owned export, record cancellation and attempt bounded scoped cleanup. * * @param Scope $scope Operation scope whose current permissions are checked. * @param string $id Selected job identifier. * * @return array|\WP_Error Cancellation status and cleanup-pending flag, or an authority, audit or storage error. */ public static function cancel( Scope $scope, string $id ): array|\WP_Error { if ( ! $scope->valid_for( self::export_cap( $scope->site_id ) ) ) { return self::error( 'forbidden', 'Export cancellation denied.', 403 ); } $job = self::load( $id, 0 === $scope->site_id ); if ( is_wp_error( $job ) ) { return $job; } if ( get_current_user_id() !== (int) $job['principal'] ) { return self::error( 'forbidden', 'Export cancellation denied.', 403 ); } if ( ! self::fail( $job, 'owner_cancelled', 'cancelled' ) ) { return self::error( 'storage', 'Export cancellation could not be saved; the export remains accessible until cancelled or expired.', 503 ); } if ( ! self::audit( 'cancelled', $job, (int) $job['row_count'] ) ) { return self::error( 'audit', 'Export access was cancelled, but its audit summary failed.', 503 ); } $clean = self::cleanup( 0 === $scope->site_id ); if ( is_wp_error( $clean ) ) { return $clean; } return array( 'id' => $id, 'state' => 'cancelled', 'cleanup_pending' => ! $clean, ); } /** * Invalidate private exports only for a non-network settings scope. * * @param Scope $scope Operation scope whose current permissions are checked. * @param int|null $erased_user Optional positive user identifier whose explicit job references must be cleared. * * @return bool|\WP_Error Cleanup completion, or an authority or storage error. */ public static function invalidate_site( Scope $scope, ?int $erased_user = null ): bool|\WP_Error { if ( 0 === $scope->site_id ) { return self::error( 'forbidden', 'Site privacy invalidation denied.', 403 ); } return self::invalidate( $scope, $erased_user ); } /** * Invalidate private exports only for a network settings scope. * * @param Scope $scope Operation scope whose current permissions are checked. * @param int|null $erased_user Optional positive user identifier whose explicit job references must be cleared. * * @return bool|\WP_Error Cleanup completion, or an authority or storage error. */ public static function invalidate_network( Scope $scope, ?int $erased_user = null ): bool|\WP_Error { if ( 0 !== $scope->site_id ) { return self::error( 'forbidden', 'Network privacy invalidation denied.', 403 ); } return self::invalidate( $scope, $erased_user ); } /** * Advance privacy generation before cancelling jobs and clearing identity references. * * @param Scope $scope Operation scope whose current permissions are checked. * @param int|null $erased_user Optional positive user identifier whose explicit job references must be cleared. * * @return bool|\WP_Error Whether bounded cleanup completed, or an authority, generation or storage error. */ private static function invalidate( Scope $scope, ?int $erased_user ): bool|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ if ( null !== $erased_user && $erased_user < 1 ) { return self::error( 'identity', 'Invalid erased user ID.' ); } $allowed = static fn() => $scope->valid_for( self::settings_cap( $scope->site_id ) ) && ( null === $erased_user || $scope->valid_for( 0 === $scope->site_id ? 'logged_ai_purge_network_data' : 'logged_ai_purge_data' ) ); if ( ! $allowed() ) { return self::error( 'forbidden', 'Privacy invalidation or identity purge denied.', 403 ); } if ( 0 === $scope->site_id ) { $changed = NetworkPrivacy::advance_generation( $scope ); if ( is_wp_error( $changed ) ) { return $changed; } } else { add_option( self::GENERATION_OPTION, 0, '', false ); if ( ! $allowed() ) { return self::error( 'forbidden', 'Privacy invalidation authority changed.', 403 ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Atomically advance the stored privacy generation; cached read-modify-write could lose revocation. $changed = $wpdb->query( $wpdb->prepare( 'UPDATE %i SET option_value=CAST(option_value AS UNSIGNED)+1 WHERE option_name=%s', $wpdb->options, self::GENERATION_OPTION ) ); if ( 1 !== $changed ) { return self::error( 'privacy', 'Export privacy invalidation failed; erasure must not proceed.', 503 ); } wp_cache_delete( self::GENERATION_OPTION, 'options' ); } if ( ! $allowed() ) { return self::error( 'forbidden', 'Privacy invalidation authority changed.', 403 ); } $table = self::tables()['jobs']; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Persist scoped export cancellation and remove stored personal filters, cursors and bindings. Fixed jobs table; network/site %d; all state transitions, nulled fields and cursor resets are literal SQL. $ok = $wpdb->query( $wpdb->prepare( 'UPDATE %i SET state=\'cancelled\',reason=\'privacy_changed\',manifest=NULL,sha256=NULL,purged_at=NULL,filters=\'{}\',personal_subject=NULL,baseline_decision_cursor=0,baseline_history_cursor=0,incident_case_cursor=0,report_search_cursor=0,report_schedule_cursor=0,report_job_cursor=0,advanced_cursor=\'\',advanced_binding=\'\',advanced_done=0,request_binding=\'\',request_valid_until=NULL WHERE network_id=%d AND site_id=%d AND state IN (\'queued\',\'writing\',\'ready\')', $table, $scope->network_id, $scope->site_id ) ); if ( false === $ok ) { return self::error( 'storage', 'Exports are inaccessible, but privacy cleanup needs a retry.', 503 ); } if ( null !== $erased_user ) { if ( ! $allowed() ) { return self::error( 'forbidden', 'Identity purge authority changed.', 403 ); } // Filter JSON can itself include actor_id/subject_id. All cancelled // site jobs lose filters; erase matching explicit identity columns. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Erase persisted scoped export identity references; cache invalidation is not personal-data erasure. Fixed jobs table; erased user appears in two %d CASE comparisons and network/site in %d predicates; all field names and clearing expressions are fixed. $ok = $wpdb->query( $wpdb->prepare( 'UPDATE %i SET filters=\'{}\',baseline_decision_cursor=0,baseline_history_cursor=0,incident_case_cursor=0,report_search_cursor=0,report_schedule_cursor=0,report_job_cursor=0,advanced_cursor=\'\',advanced_binding=\'\',advanced_done=0,request_binding=\'\',request_valid_until=NULL,personal_subject=CASE WHEN personal_subject=%d THEN NULL ELSE personal_subject END,principal=CASE WHEN principal=%d THEN 0 ELSE principal END WHERE network_id=%d AND site_id=%d', $table, $erased_user, $erased_user, $scope->network_id, $scope->site_id ) ); if ( false === $ok ) { return self::error( 'storage', 'Export identity cleanup needs a retry.', 503 ); } } return self::cleanup( 0 === $scope->site_id ); } /** * Reuse or create an authorized personal export and return its review-page reference. * * @param Scope $scope Operation scope whose current permissions are checked. * @param int $user Positive subject identifier for the authorized personal export. * * @return array|\WP_Error Job identifier and administration link that grant no export access, or an error. */ public static function personal_reference( Scope $scope, int $user ): array|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ if ( ! $scope->valid_for_all( array( self::export_cap( $scope->site_id ), 'manage_privacy_options', self::settings_cap( $scope->site_id ) ) ) || $user < 1 ) { return self::error( 'forbidden', 'Personal export access denied.', 403 ); } $table = self::tables()['jobs']; $generation = self::generation( 0 === $scope->site_id ); if ( is_wp_error( $generation ) ) { return $generation; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Find only a current-generation personal job; cached selection could reuse revoked output. Fixed jobs table; scope/owner/subject/generation/context version are %d, current expiry cutoff %s; JSON/live-state filters and LIMIT 1 are literal. $existing = $wpdb->get_var( $wpdb->prepare( 'SELECT id FROM %i WHERE network_id=%d AND site_id=%d AND principal=%d AND personal_subject=%d AND generation=%d AND personal_context_version=%d AND format=\'json\' AND state IN (\'queued\',\'writing\',\'ready\') AND expires_at>%s ORDER BY created_at DESC LIMIT 1', $table, $scope->network_id, $scope->site_id, get_current_user_id(), $user, $generation, $scope->site_id > 0 ? 8 : 0, Store::now() ) ); if ( '' !== $wpdb->last_error ) { return self::error( 'storage', 'Personal export lookup is unavailable.', 503 ); } $job = $existing ? self::status( $scope, $existing ) : self::create( $scope, 'json', array(), $user ); if ( is_wp_error( $job ) ) { return $job; } // Revalidation can cancel a previously ready job after its privacy context changes. if ( $existing && ! in_array( $job['state'], array( 'queued', 'writing', 'ready' ), true ) ) { $job = self::create( $scope, 'json', array(), $user ); if ( is_wp_error( $job ) ) { return $job; } } return array( 'job_id' => $job['id'], 'url' => add_query_arg( array( 'page' => 0 === $scope->site_id ? 'logged-ai-network-exports' : 'logged-ai-exports', 'job' => $job['id'], ), 0 === $scope->site_id ? Scope::network_admin_url( 'admin.php' ) : admin_url( 'admin.php' ) ), 'message' => 'The authorized administrator can review and download this private export. This reference grants no access and contains no activity records. Export access expires in 24 hours.', ); } /** * Run a bounded queued-job quantum and cleanup for the selected scope kind. * * @param bool $network Whether to select current-network-origin jobs instead of current-site jobs. * * @return void */ public static function cron( bool $network = false ): void { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $table = self::tables()['jobs']; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Select current bounded export work; cached queue pages could strand or replay jobs. Fixed jobs table, network/site %d, literal queued/writing states and oldest-first LIMIT 3. $jobs = $wpdb->get_col( $wpdb->prepare( 'SELECT id FROM %i WHERE network_id=%d AND site_id=%d AND state IN (\'queued\',\'writing\') ORDER BY created_at LIMIT 3', $table, Scope::network(), $network ? 0 : get_current_blog_id() ) ); /** * Validate queued export IDs before running the three-job quantum from the untyped wpdb adapter. * * @var mixed $jobs */ $result = '' !== $wpdb->last_error || ! is_array( $jobs ) ? self::error( 'storage', 'Export queue is unavailable.', 503 ) : array( 'quantum_returned' => true ); if ( ! is_wp_error( $result ) ) { foreach ( $jobs as $id ) { $job = self::run_work( $id, 10, $network ); if ( is_wp_error( $job ) ) { $result = $job; } } $clean = self::cleanup( $network ); if ( is_wp_error( $clean ) ) { $result = $clean; } elseif ( ! is_wp_error( $result ) ) { $result['cleanup_pending'] = ! $clean; } } if ( class_exists( Health::class ) ) { Health::record( $network ? 'network_exports' : 'exports', $result ); } } /** * Adapt export cleanup completion into native worker-health reporting. * * Keep direct cleanup false=pending semantics separate from native action health. * * @return void */ public static function cleanup_cron(): void { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $db = $wpdb; $origin = Derived::native_context( $db ); try { $result = self::cleanup(); } catch ( \Throwable $error ) { $result = new \WP_Error( 'worker', 'Export cleanup was interrupted.' ); } $health = is_wp_error( $result ) ? $result : array( 'cleanup_pending' => ! $result ); if ( Derived::native_context( $db ) === $origin && class_exists( Health::class, false ) ) { Health::record( 'exports', $health ); } } /** * Remove bounded expired private chunks, membership and old operational history. * * @param bool $network_scope Whether cleanup selects current-network-origin jobs instead of site jobs. * * @return bool|\WP_Error True when cleanup is complete, false when more work remains, or a storage error. */ public static function cleanup( bool $network_scope = false ): bool|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $tables = self::tables(); $network = Scope::network(); $site = $network_scope ? 0 : get_current_blog_id(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Select actual expired or terminal jobs for bounded private-data cleanup. Fixed jobs table, network/site %d and current UTC %s, literal purge/terminal predicates and LIMIT 10. $jobs = $wpdb->get_results( $wpdb->prepare( 'SELECT id,storage_version FROM %i WHERE network_id=%d AND site_id=%d AND purged_at IS NULL AND (expires_at<=%s OR state IN (\'cancelled\',\'failed\',\'expired\')) ORDER BY created_at LIMIT 10', $tables['jobs'], $network, $site, Store::now() ), ARRAY_A ); if ( '' !== $wpdb->last_error || ! is_array( $jobs ) ) { return self::error( 'storage', 'Private export cleanup state is unavailable.', 503 ); } foreach ( $jobs as $job ) { if ( ! in_array( (string) $job['storage_version'], array( '0', '1' ), true ) ) { return self::error( 'storage_version', 'This saved export storage version is unsupported.', 503 ); } $lock = 'lai_export_' . substr( hash( 'sha256', DB_NAME . $tables['jobs'] . $job['id'] ), 0, 40 ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Acquire the exact job mutex before removing ciphertext; cached results cannot exclude its writer. if ( '1' !== (string) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s,0)', $lock ) ) ) { continue; } try { $removed = ExportStorage::remove_chunks( $job['id'] ); if ( true === $removed && 0 === (int) $job['storage_version'] ) { $removed = ExportStorage::remove_job( $job['id'] ); } if ( is_wp_error( $removed ) ) { return $removed; } if ( $removed ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Delete actual membership only after private directory removal; cache clearing is insufficient. if ( false === $wpdb->delete( $tables['items'], array( 'job_id' => $job['id'] ) ) ) { return self::error( 'storage', 'Private export membership cleanup needs a retry.', 503 ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Persist cleanup receipt and clear manifest/digest after owned private files are removed. Fixed jobs table; purge timestamp and job UUID %s; expired state and cleared fields are literal. if ( false === $wpdb->query( $wpdb->prepare( 'UPDATE %i SET state=\'expired\',manifest=NULL,sha256=NULL,purged_at=%s WHERE id=%s', $tables['jobs'], Store::now(), $job['id'] ) ) ) { return self::error( 'storage', 'Private export cleanup receipt needs a retry.', 503 ); } } } finally { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Release the cleanup job mutex in finally; cached results cannot release it. $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock ) ); } } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Delete bounded old purged job history from authoritative custom storage. Fixed jobs table; network/site %d and 30-day cutoff %s; purged-only predicate and LIMIT 100 are fixed. if ( false === $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE network_id=%d AND site_id=%d AND purged_at IS NOT NULL AND created_at<%s LIMIT 100', $tables['jobs'], $network, $site, gmdate('Y-m-d H:i:s', time() - 30 * DAY_IN_SECONDS) ) ) ) { return self::error( 'storage', 'Export history cleanup needs a retry.', 503 ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Count actual remaining private cleanup work; cached totals cannot prove erasure completion. Fixed jobs table; network/site %d and current UTC %s; same unpurged expiry/terminal selection as cleanup admission. $remaining = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE network_id=%d AND site_id=%d AND purged_at IS NULL AND (expires_at<=%s OR state IN (\'cancelled\',\'failed\',\'expired\'))', $tables['jobs'], $network, $site, Store::now() ) ); if ( null === $remaining || '' !== $wpdb->last_error ) { return self::error( 'storage', 'Remaining export cleanup could not be checked.', 503 ); } if ( (int) $remaining > 0 ) { self::schedule( $network_scope ); } return 0 === (int) $remaining; } /** * Remove validated owned ciphertext entries during explicit data-removing uninstall. * * Explicit owned-data uninstall only. Never removes a configured ancestor. * * @return bool|\WP_Error True after removal, or an error preserving unexpected entries for review. */ public static function uninstall_owned_files(): bool|\WP_Error { return ExportStorage::uninstall(); } /** * Handle a site export-creation form through the common scoped adapter. * * @return void */ public static function handle_create(): void { self::request_create( false ); } /** * Handle a network export-creation form through the common scoped adapter. * * @return void */ public static function handle_network_create(): void { self::request_create( true ); } /** * Check form method, scope and nonce before creating an export job. * * @param bool $network Whether to select current-network-origin jobs instead of current-site jobs. * * @return void */ private static function request_create( bool $network ): void { $request_input = AdminInput::form(); $scope = $network ? Scope::current_network( 'logged_ai_export_network_events' ) : Scope::current( 'logged_ai_export_events' ); $prefix = $network ? 'logged_ai_network_export_' : 'logged_ai_export_'; $page_url = $network ? Scope::network_admin_url( 'admin.php?page=logged-ai-network-exports' ) : admin_url( 'admin.php?page=logged-ai-exports' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Only the exact native POST method is accepted before the scoped creation nonce and export admission checks. if ( is_wp_error( $scope ) || 'POST' !== AdminInput::method() ) { wp_die( esc_html__( 'Export access denied.', 'logged-ai' ), '', array( 'response' => 403 ) ); } check_admin_referer( $prefix . 'job' ); if ( array_diff( array_keys( $request_input ), array( 'action', '_wpnonce', '_wp_http_referer', 'format', 'filters' ) ) || ( array_key_exists( 'filters', $request_input ) && ! is_array( $request_input['filters'] ) ) ) { wp_die( esc_html__( 'The saved export request is invalid. Reload Exports and choose the selection again.', 'logged-ai' ), '', array( 'response' => 400 ) ); } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- create passes these unslashed fields through Store::filters, which validates the finite typed filter contract before any export job is written. $filters = isset( $request_input['filters'] ) && is_array( $request_input['filters'] ) ? $request_input['filters'] : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- create accepts only exact csv or json format values before export admission; unknown strings remain invalid. $format = isset( $request_input['format'] ) && is_string( $request_input['format'] ) ? $request_input['format'] : ''; $job = self::create( $scope, $format, $filters ); if ( is_wp_error( $job ) ) { wp_die( esc_html( PresentationTranslations::text( $job->get_error_message() ) ), '', array( 'response' => 400 ) ); } wp_safe_redirect( add_query_arg( 'job', $job['id'], $page_url ) ); exit; } /** * Handle a site export-cancellation form through the common scoped adapter. * * @return void */ public static function handle_cancel(): void { self::request_cancel( false ); } /** * Handle a network export-cancellation form through the common scoped adapter. * * @return void */ public static function handle_network_cancel(): void { self::request_cancel( true ); } /** * Validate the scoped job form and nonce before invoking owned cancellation. * * @param bool $network Whether to select current-network-origin jobs instead of current-site jobs. * * @return void */ private static function request_cancel( bool $network ): void { $request_input = AdminInput::form(); $scope = $network ? Scope::current_network( 'logged_ai_export_network_events' ) : Scope::current( 'logged_ai_export_events' ); $prefix = $network ? 'logged_ai_network_export_' : 'logged_ai_export_'; $page_url = $network ? Scope::network_admin_url( 'admin.php?page=logged-ai-network-exports' ) : admin_url( 'admin.php?page=logged-ai-exports' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- The immediately following uuid grammar check rejects noncanonical job IDs before the job-bound nonce and owned cancellation. $id = isset( $request_input['job'] ) && is_string( $request_input['job'] ) ? $request_input['job'] : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Require the exact native POST method and canonical UUID before the job-bound nonce and owned cancellation. if ( is_wp_error( $scope ) || 'POST' !== AdminInput::method() || ! self::uuid( $id ) ) { wp_die( esc_html__( 'Export access denied.', 'logged-ai' ), '', array( 'response' => 403 ) ); } check_admin_referer( $prefix . 'cancel_' . $id ); $result = self::cancel( $scope, $id ); if ( is_wp_error( $result ) ) { wp_die( esc_html( PresentationTranslations::text( $result->get_error_message() ) ), '', array( 'response' => 403 ) ); } wp_safe_redirect( $page_url ); exit; } /** * Handle a site export download through the common scoped adapter. * * @return void */ public static function handle_download(): void { self::request_download( false ); } /** * Handle a network export download through the common scoped adapter. * * @return void */ public static function handle_network_download(): void { self::request_download( true ); } /** * Check current job access and nonce before streaming private attachment bytes. * * @param bool $network Whether to select current-network-origin jobs instead of current-site jobs. * * @return void */ private static function request_download( bool $network ): void { $request_input = AdminInput::query(); $scope = $network ? Scope::current_network( 'logged_ai_export_network_events' ) : Scope::current( 'logged_ai_export_events' ); $prefix = $network ? 'logged_ai_network_export_' : 'logged_ai_export_'; $page_url = $network ? Scope::network_admin_url( 'admin.php?page=logged-ai-network-exports' ) : admin_url( 'admin.php?page=logged-ai-exports' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- The uuid grammar check rejects noncanonical job IDs before the job-bound nonce, current scoped status and private stream checks. $id = isset( $request_input['job'] ) && is_string( $request_input['job'] ) ? $request_input['job'] : ''; $manifest = isset( $request_input['manifest'] ) && '1' === $request_input['manifest']; if ( is_wp_error( $scope ) || ! self::uuid( $id ) ) { wp_die( esc_html__( 'Export download denied.', 'logged-ai' ), '', array( 'response' => 403 ) ); } check_admin_referer( $prefix . 'download_' . $id ); $status = self::status( $scope, $id ); if ( is_wp_error( $status ) || 'ready' !== $status['state'] ) { wp_die( esc_html__( 'Export is unavailable.', 'logged-ai' ), '', array( 'response' => 403 ) ); } nocache_headers(); header( 'Cache-Control: private, no-store' ); header( 'X-Content-Type-Options: nosniff' ); header( 'Content-Type: ' . ( $manifest || 'json' === $status['format'] ? 'application/json' : 'text/csv' ) . '; charset=UTF-8' ); header( 'Content-Disposition: attachment; filename="logged-ai-' . $id . ( $manifest ? '-manifest.json' : '.' . $status['format'] ) . '"' ); $emitted = false; $result = self::stream( $scope, $id, static function ( string $bytes ) use ( &$emitted ) { if ( '' !== $bytes ) { $emitted = true; } // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Authorized CSV/JSON attachment bytes are encoded by stream(); HTML escaping would corrupt the download. Attachment and nosniff headers are set above. echo $bytes; }, $manifest ); if ( is_wp_error( $result ) && ! headers_sent() ) { status_header( 409 ); // An output buffer can keep headers unsent after attachment bytes were emitted. // Never append an HTML error to an export that has already started. if ( ! $emitted && ! headers_sent() ) { header_remove( 'Content-Disposition' ); header( 'Content-Type: text/html; charset=UTF-8' ); wp_die( esc_html__( 'The saved export could not be downloaded safely. Reload Exports and prepare a new export. If this happens again, check System status and share the diagnostic reason with support.', 'logged-ai' ), esc_html__( 'Download unavailable', 'logged-ai' ), array( 'response' => 409, 'back_link' => true ) ); } } exit; } /** * Explain operational status without confusing cleanup with a job's outcome. * * Cleanup uses expired internally for all removed payloads. Preserve the * recorded cancellation/failure reason in the owner-facing explanation. * * @param array $job Authorized owner-visible job status. * @return array Translated status label and next-step explanation. */ private static function display_status( array $job ): array { $reason = $job['reason']; if ( 'owner_cancelled' === $reason ) { return array( __( 'Cancelled', 'logged-ai' ), __( 'You cancelled this export. Its download is no longer available. Prepare a new export if you still need the selection.', 'logged-ai' ) ); } if ( in_array( $reason, array( 'authorization_changed', 'advanced_privacy_changed', 'request_privacy_changed', 'privacy_changed', 'privacy_generation_changed', 'cancelled_during_membership', 'cancelled_during_write', 'cancelled_during_finish' ), true ) ) { return array( __( 'Cancelled', 'logged-ai' ), __( 'This export was stopped because its access, privacy or saved state changed. Review your current selection and permissions, then prepare a new export.', 'logged-ai' ) ); } if ( in_array( $reason, array( 'row_limit', 'byte_limit' ), true ) ) { return array( __( 'Not created', 'logged-ai' ), __( 'The selection exceeded the export size limit. Choose a narrower date range and prepare a new export.', 'logged-ai' ) ); } if ( 'storage_capacity' === $reason ) { return array( __( 'Not created', 'logged-ai' ), __( 'Saved-export storage reached its capacity. Remove older exports, then prepare a new export.', 'logged-ai' ) ); } if ( in_array( $reason, array( 'audit_failed', 'membership_failed', 'personal_context_failed', 'invalid_data', 'worker_failed', 'storage_failed', 'integrity_failed' ), true ) || 'failed' === $job['state'] ) { return array( __( 'Unavailable', 'logged-ai' ), __( 'The export could not be prepared or verified safely. Check System status, then prepare a new export. If this happens again, share the diagnostic reason with support.', 'logged-ai' ) ); } $states = array( 'queued' => array( __( 'Queued', 'logged-ai' ), __( 'Waiting for WordPress background processing. Reload this page to check progress; delayed background tasks can delay the export.', 'logged-ai' ) ), 'writing' => array( __( 'Preparing', 'logged-ai' ), __( 'The export is being prepared. Reload this page to check progress.', 'logged-ai' ) ), 'ready' => array( __( 'Ready', 'logged-ai' ), __( 'The export is ready to download until its expiry or a change to access or privacy.', 'logged-ai' ) ), 'cancelled' => array( __( 'Cancelled', 'logged-ai' ), __( 'This export is no longer available. Prepare a new export if you still need the selection.', 'logged-ai' ) ), 'expired' => array( __( 'Expired', 'logged-ai' ), __( 'The saved download has expired. Prepare a new export to use the current retained evidence.', 'logged-ai' ) ), ); return $states[ $job['state'] ] ?? array( __( 'Unavailable', 'logged-ai' ), __( 'Reload this page and check System status before preparing a new export.', 'logged-ai' ) ); } /** * Render the current-site export administration page. * * @return void */ public static function page(): void { self::render_page( false ); } /** * Render the current-network export administration page. * * @return void */ public static function page_network(): void { self::render_page( true ); } /** * Render the authorized owner's export form and bounded recent job statuses. * * @param bool $network Whether to select current-network-origin jobs instead of current-site jobs. * * @return void */ private static function render_page( bool $network ): void { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $scope = $network ? Scope::current_network( 'logged_ai_export_network_events' ) : Scope::current( 'logged_ai_export_events' ); $prefix = $network ? 'logged_ai_network_export_' : 'logged_ai_export_'; $page_url = $network ? Scope::network_admin_url( 'admin.php?page=logged-ai-network-exports' ) : admin_url( 'admin.php?page=logged-ai-exports' ); if ( is_wp_error( $scope ) ) { wp_die( esc_html__( 'Export access denied.', 'logged-ai' ), '', array( 'response' => 403 ) ); } try { $time = TimeDisplay::site(); } catch ( \Throwable $error ) { wp_die( esc_html__( 'The display timezone context changed. Reload this page.', 'logged-ai' ), '', array( 'response' => 403 ) ); } $readiness = self::readiness_message(); $direct_action = $network ? 'logged_ai_network_direct_export' : 'logged_ai_direct_export'; nocache_headers(); echo '

' . esc_html( $network ? __( 'Network exports', 'logged-ai' ) : __( 'Exports', 'logged-ai' ) ) . '

'; /* translators: %s: current display timezone name. */ echo '

' . esc_html( sprintf( __( 'Display timezone: %s. Export contents and date filters use UTC.', 'logged-ai' ), $time->zone() ) ) . '

'; if ( $network ) { echo '

' . esc_html__( 'Only network-origin events in the current network are included. Site timelines and other networks are separate.', 'logged-ai' ) . '

'; } echo '

' . esc_html__( 'Download now', 'logged-ai' ) . '

'; echo '
'; wp_nonce_field( $direct_action ); echo '
'; /* translators: 1: maximum number of direct-export records, 2: maximum direct-export size in mebibytes. */ echo '

' . esc_html( sprintf( __( 'CSV for spreadsheets; JSON for structured data. Maximum: %1$s records and %2$s MiB.', 'logged-ai' ), number_format_i18n( DirectExports::MAX_ROWS ), number_format_i18n( DirectExports::MAX_BYTES / 1048576 ) ) ) . '

'; echo '

' . esc_html__( 'Dates use UTC. Start is included from 00:00; end stops before 00:00 and is not included. Leave either date blank for no limit on that side.', 'logged-ai' ) . '

'; echo '
' . esc_html__( 'About direct downloads', 'logged-ai' ) . '

' . esc_html__( 'Logged.ai does not save a copy on the server. You do not need to enable saved exports to use this option. Keep downloaded files secure; they cannot be recalled.', 'logged-ai' ) . '

' . esc_html__( 'If the selection exceeds either limit, nothing is downloaded. Narrow the dates, or prepare a large export when encryption support is available.', 'logged-ai' ) . '

' . esc_html__( 'The end date stops before 00:00 UTC. To include all of 31 October, choose 1 November as the end date.', 'logged-ai' ) . '

'; if ( ! $network ) { DesignPro::invitation( 'report', 'reports', __( 'Need this review every week? Explore scheduled reports.', 'logged-ai' ) ); } echo '

' . esc_html__( 'Prepare large export', 'logged-ai' ) . '

'; if ( '' !== $readiness ) { echo '

' . esc_html__( 'Background export setup needed', 'logged-ai' ) . '

' . esc_html( $readiness ) . '

' . esc_html__( 'Activity logging and Download now remain available. Existing export jobs stay listed below.', 'logged-ai' ) . '

'; } else { echo '
'; wp_nonce_field( $prefix . 'job' ); echo '
'; } /* translators: 1: maximum background-export rows, 2: maximum background-export size in mebibytes. */ echo '

' . esc_html( sprintf( __( 'CSV for spreadsheets; JSON for structured data. Maximum: %1$s rows and %2$s MiB.', 'logged-ai' ), number_format_i18n( self::MAX_ROWS ), number_format_i18n( self::MAX_BYTES / 1048576 ) ) ) . '

'; echo '

' . esc_html__( 'Dates use UTC. Start is included from 00:00; end stops before 00:00 and is not included. Leave either date blank for no limit on that side.', 'logged-ai' ) . '

'; echo '

' . esc_html__( 'Download the finished export here. Saved exports expire 24 hours after you request them. WordPress background scheduling may be delayed.', 'logged-ai' ) . '

'; echo '
' . esc_html__( 'How saved exports work', 'logged-ai' ) . '

' . esc_html__( 'Saved exports are encrypted in the database; no export files are written to your website directories. Keep downloaded files secure; they cannot be recalled.', 'logged-ai' ) . '

' . esc_html__( 'Each export has a summary (manifest) with the selection, row count, generation time and a data checksum for integrity checks. JSON includes it; CSV has a separate summary download.', 'logged-ai' ) . '

' . esc_html__( 'The end date stops before 00:00 UTC. To include all of 31 October, choose 1 November as the end date.', 'logged-ai' ) . '

'; if ( '' === $readiness ) { echo '

' . esc_html__( 'Saved exports require PHP Sodium encryption support. The required functions are available on this server.', 'logged-ai' ) . '

'; } echo '
'; echo '

' . esc_html__( 'Saved background exports', 'logged-ai' ) . '

'; $table = self::tables()['jobs']; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- List current owner-bound jobs; cached rows could retain invalidated private export state. Fixed jobs table; network/site/current principal %d; newest-first LIMIT 25. Each ID subsequently passes current status checks. $rows = $wpdb->get_col( $wpdb->prepare( 'SELECT id FROM %i WHERE network_id=%d AND site_id=%d AND principal=%d ORDER BY created_at DESC LIMIT 25', $table, $scope->network_id, $scope->site_id, get_current_user_id() ) ); if ( '' !== $wpdb->last_error ) { echo '

' . esc_html__( 'The saved export list is unavailable. Please try again.', 'logged-ai' ) . '

'; } elseif ( ! $rows ) { echo '

' . esc_html__( 'No saved background exports to show. Files from Download now are not kept in this list.', 'logged-ai' ) . '

'; } foreach ( $rows as $id ) { $job = self::status( $scope, $id ); if ( is_wp_error( $job ) ) { continue; } $display_status = self::display_status( $job ); /* translators: 1: job status, 2: number of rows written, 3: job status explanation. */ echo '

' . esc_html( strtoupper( $job['format'] ) . ' · ' . $time->text( $job['created_at'] ) ) . '

' . esc_html( sprintf( __( 'Status: %1$s. Rows written: %2$s. %3$s', 'logged-ai' ), $display_status[0], number_format_i18n( $job['row_count'] ), $display_status[1] ) ) . '

'; if ( '' !== $job['reason'] ) { echo '
' . esc_html__( 'Diagnostic details', 'logged-ai' ) . '

' . esc_html__( 'Recorded reason:', 'logged-ai' ) . ' ' . esc_html( $job['reason'] ) . '

'; } if ( 'ready' === $job['state'] ) { $url = wp_nonce_url( add_query_arg( array( 'action' => $prefix . 'download', 'job' => $id, ), admin_url( 'admin-post.php' ) ), $prefix . 'download_' . $id ); echo '

' . esc_html__( 'Download export', 'logged-ai' ) . ' ' . esc_html__( 'Download export summary (manifest)', 'logged-ai' ) . '

'; } if ( in_array( $job['state'], array( 'queued', 'writing', 'ready' ), true ) ) { echo '
'; wp_nonce_field( $prefix . 'cancel_' . $id ); echo '
'; } echo '
'; } echo '
'; } /** * Dispatch site export CLI arguments through the scoped command adapter. * * @param array $args Positional CLI arguments supplied by the command adapter. * @param array $assoc Named CLI arguments supplied by the command adapter. * * @return void */ public static function cli( array $args, array $assoc ): void { self::run_cli( $args, $assoc, false ); } /** * Dispatch network export CLI arguments through the scoped command adapter. * * @param array $args Positional CLI arguments supplied by the command adapter. * @param array $assoc Named CLI arguments supplied by the command adapter. * * @return void */ public static function cli_network( array $args, array $assoc ): void { self::run_cli( $args, $assoc, true ); } /** * Validate scoped CLI arguments and ownership before export operations or previews. * * @param array $args Positional CLI arguments supplied by the command adapter. * @param array $assoc Named CLI arguments supplied by the command adapter. * @param bool $network Whether to select current-network-origin jobs instead of current-site jobs. * * @return void */ private static function run_cli( array $args, array $assoc, bool $network ): void { $scope = $network ? Scope::current_network( 'logged_ai_export_network_events' ) : Scope::current( 'logged_ai_export_events' ); if ( is_wp_error( $scope ) ) { \WP_CLI::error( __( 'Use an authorized --user and explicit --url for exports.', 'logged-ai' ) ); } $action = $args[0] ?? ''; if ( 'create' === $action && ! array_diff( array_keys( $assoc ), array_merge( array( 'format' ), Store::filter_keys() ) ) ) { $format = $assoc['format'] ?? 'json'; unset( $assoc['format'] ); $result = self::create( $scope, $format, $assoc ); } elseif ( 'work' === $action && isset( $args[1] ) && ! array_diff( array_keys( $assoc ), array( 'batches' ) ) ) { // The internal worker reloads the stored principal for cron. A CLI // caller must first prove ownership before receiving its result. $access = self::status( $scope, $args[1] ); $result = is_wp_error( $access ) ? $access : self::run_work( $args[1], isset( $assoc['batches'] ) ? (int) $assoc['batches'] : 10, $network ); } elseif ( 'status' === $action && isset( $args[1] ) && ! $assoc ) { $result = self::status( $scope, $args[1] ); } elseif ( 'cancel' === $action && 2 === count( $args ) && isset( $args[1] ) && ( array( 'yes' => true ) === $assoc || array( 'dry-run' => true ) === $assoc ) ) { $result = isset( $assoc['dry-run'] ) ? self::cancel_preview( $scope, $args[1] ) : self::cancel( $scope, $args[1] ); } else { \WP_CLI::error( __( 'Use exports create --format=json|csv [filters], status JOB, work JOB [--batches=10], or cancel JOB with exactly one of --dry-run or --yes. Include --user and --url.', 'logged-ai' ) ); } if ( is_wp_error( $result ) ) { \WP_CLI::error( PresentationTranslations::text( $result->get_error_message() ) ); } \WP_CLI::line( wp_json_encode( $result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) ); } }