403 ) ); } ); if ( defined( 'WP_CLI' ) && WP_CLI ) { \WP_CLI::add_command( 'logged-ai events', array( self::class, 'cli' ) ); } } /** * Register scoped read-only activity listing and detail REST routes. * * @return void */ public static function routes(): void { register_rest_route( 'logged-ai/v1', '/events', array( 'methods' => 'GET', 'permission_callback' => array( self::class, 'permission' ), 'callback' => static function ( \WP_REST_Request $request ) { $scope = Scope::current(); if ( is_wp_error( $scope ) ) { return $scope; } $params = $request->get_query_params(); $cursor = $params['cursor'] ?? ''; unset( $params['cursor'], $params['rest_route'], $params['_locale'], $params['_wpnonce'] ); if ( ! is_string( $cursor ) ) { return new \WP_Error( 'logged_ai_cursor', 'Invalid cursor.', array( 'status' => 400 ) ); } $result = Store::listing( $scope, $params, $cursor ); return self::response( $result ); }, ) ); register_rest_route( 'logged-ai/v1', '/events/(?P[0-9]+)', array( 'methods' => 'GET', 'permission_callback' => array( self::class, 'permission' ), 'callback' => static function ( \WP_REST_Request $request ) { $scope = Scope::current(); return is_wp_error( $scope ) ? $scope : self::response( Store::detail( $scope, (string) $request['id'] ) ); }, ) ); } /** * Resolve the current activity-read scope for REST permission checks. * * @return bool|\WP_Error True for an authorized current scope, or its access error. */ public static function permission(): bool|\WP_Error { $scope = Scope::current(); return is_wp_error( $scope ) ? $scope : true; } /** * Wrap successful activity data with private no-store REST response headers. * * @param array|\WP_Error $result Validated activity data or a structured operation error. * * @return \WP_REST_Response|\WP_Error Private REST response, or the original error. */ private static function response( array|\WP_Error $result ): \WP_REST_Response|\WP_Error { if ( is_wp_error( $result ) ) { return $result; } $response = new \WP_REST_Response( $result ); $response->header( 'Cache-Control', 'private, no-store, max-age=0' ); return $response; } /** * Remove reserved transport keys and normalize the unambiguous action filter. * * @param array $input Supplied structured operation fields to validate without lossy normalization. * @param array $reserved Transport keys removed before validating activity filters. * * @return array|\WP_Error Validated activity filters, or an ambiguous/unsupported-filter error. */ public static function input_filters( array $input, array $reserved ): array|\WP_Error { foreach ( $reserved as $key ) { unset( $input[ $key ] ); } if ( isset( $input['action_type'] ) ) { if ( isset( $input['action'] ) ) { return new \WP_Error( 'logged_ai_filter', 'Ambiguous action filter.', array( 'status' => 400 ) ); } $input['action'] = $input['action_type']; unset( $input['action_type'] ); } return Store::filters( $input ); } /** * Map the action filter to its non-conflicting administration query key. * * @param array $filters Supported normalized activity filter values. * * @return array Filters using action_type for the administration URL. */ public static function url_filters( array $filters ): array { if ( isset( $filters['action'] ) ) { $filters['action_type'] = $filters['action']; unset( $filters['action'] ); } return $filters; } /** * Render an escaped activity error with a clear-filter recovery link. * * @param \WP_Error $error Structured activity error whose message is escaped for the page. * * @return void */ private static function error( \WP_Error $error ): void { echo ( '' ); echo '

' . esc_html__( 'Clear filters and return to activity', 'logged-ai' ) . '

'; } /** * Buffer the activity view and revalidate its source reads before emitting the sealed output. * * @return void */ public static function page(): void { global $wpdb; $caller = $wpdb; $origin = Derived::native_context( $caller ); $scope = Scope::current(); if ( is_wp_error( $scope ) ) { wp_die( esc_html__( 'Evidence access denied.', 'logged-ai' ), '', array( 'response' => 403 ) ); } $reads = array(); DesignActivity::reset_profiles(); ContentTarget::reset( $scope ); $level = ob_get_level(); // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- The native output-buffer callback must accept and discard this parameter so unsealed evidence is never flushed. ob_start( static fn( string $chunk ): string => '' ); try { self::render_page( $scope, $reads ); $html = ob_get_clean(); } finally { while ( ob_get_level() > $level ) { ob_end_clean(); } } $valid = is_string( $html ) && strlen( $html ) <= 2097152 && Derived::native_context( $caller ) === $origin; $valid = $valid && DesignActivity::profiles_valid(); $valid = $valid && Store::render_groups( $scope, $reads, null, static function () use ( $scope ): bool { return ContentTarget::valid() && true === apply_filters( 'logged_ai_activity_view_actions_valid', true, $scope ); } ); if ( ! $reads ) { $valid = $valid && $scope->valid_for_all( array( 'logged_ai_view_events' ) ); } if ( ! $valid || Derived::native_context( $caller ) !== $origin ) { wp_die( esc_html__( 'Evidence or access changed while rendering. Reload this view.', 'logged-ai' ), '', array( 'response' => 403 ) ); } // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Every dynamic fragment was context-escaped in render_page; only the sealed buffer is emitted, without invoking another output callback. echo $html; // Escaped while buffered; no native output callbacks after the seal. } /** * Render scoped activity details, filters, result pages and private export controls. * * @param Scope $scope Current operation scope whose capabilities are rechecked. * @param array $reads Source result groups collected by reference for final output revalidation. * * @return void */ private static function render_page( Scope $scope, array &$reads ): void { $request_query = \LoggedAI\AdminInput::query(); try { $time = TimeDisplay::site(); } catch ( \Throwable $error ) { wp_die( esc_html__( 'Site timezone context changed. Reload this view.', 'logged-ai' ), '', array( 'response' => 403 ) ); } nocache_headers(); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only activity navigation uses validated filters, exact IDs and bounded cursors under current scope. $input = $request_query; $filters = self::input_filters( $input, array( 'page', 'event', 'event_uid', 'cursor' ) ); echo ( '
' . '
' . '

' . esc_html__( 'Logged.ai · Activity', 'logged-ai' ) . '

' . '

' . ( esc_html__( 'Know who changed what.', 'logged-ai' ) ) . '

' . '

' . ( esc_html__( 'A clear timeline of your people, their changes, and the evidence behind them.', 'logged-ai' ) ) . '

' . '
' ); if ( defined( 'LOGGED_AI_SYNTHETIC_TEST_ENVIRONMENT' ) && true === LOGGED_AI_SYNTHETIC_TEST_ENVIRONMENT ) { echo '

' . esc_html__( 'Synthetic fixture environment: these are controlled test actions.', 'logged-ai' ) . '

'; } /* translators: 1: Authorized WordPress site ID. 2: WordPress network ID. 3: Selected timezone. */ echo '

' . esc_html( sprintf( __( 'Site #%1$d · Network #%2$d · Times shown in %3$s', 'logged-ai' ), $scope->site_id, $scope->network_id, $time->zone() ) ) . '

'; if ( class_exists( Health::class ) ) { Health::banner( $scope ); } echo ( '
' . '' . esc_html__( 'Collection coverage & privacy', 'logged-ai' ) . '' . '

' . ( esc_html__( 'Logged.ai records supported user, role, content and configuration changes through WordPress APIs. Profile values and content bodies are redacted. Coverage varies by event type; see the coverage guide. Direct database edits and changes while disabled are not observed. Collection runs locally and does not require an external service.', 'logged-ai' ) ) . '

' . '

' . ( esc_html__( 'Administrative collectors do not collect IP addresses. Privacy & retention controls default to 90 days of administrative events and run in bounded batches. Records are preserved on deactivation; uninstall deletes them only when explicitly configured.', 'logged-ai' ) ) . '

' . esc_html__( 'Read collection and coverage limits', 'logged-ai' ) . ' ' . esc_html__( '(opens in a new tab)', 'logged-ai' ) . '

' . '
' ); if ( ! class_exists( Health::class ) && array() !== get_option( 'logged_ai_collection_health', array() ) ) { echo '

' . esc_html__( 'Collection has reported degradation. Evidence may be incomplete. Review System status or contact support before relying on the activity history.', 'logged-ai' ) . '

'; } if ( isset( $input['event_uid'] ) && ( ! isset( $input['event'] ) || ! is_string( $input['event_uid'] ) || ! preg_match( '/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/D', $input['event_uid'] ) ) ) { $filters = new \WP_Error( 'logged_ai_missing', __( 'This recorded event is unavailable or no longer retained.', 'logged-ai' ) ); } if ( is_wp_error( $filters ) ) { self::error( $filters ); echo '
'; return; } $cursor = $input['cursor'] ?? ''; if ( ! is_string( $cursor ) || strlen( $cursor ) > 1024 ) { self::error( new \WP_Error( 'logged_ai_cursor', 'Invalid cursor.' ) ); echo ''; return; } $list_url = add_query_arg( array_merge( array( 'page' => 'logged-ai' ), self::url_filters( $filters ), isset( $input['cursor'] ) && is_string( $input['cursor'] ) ? array( 'cursor' => $input['cursor'] ) : array() ), admin_url( 'admin.php' ) ); if ( isset( $input['event'] ) ) { $event_id = is_string( $input['event'] ) && 1 === preg_match( '/\A[1-9][0-9]{0,19}\z/D', $input['event'] ) ? $input['event'] : ''; $event = '' !== $event_id ? Store::detail( $scope, $event_id ) : new \WP_Error( 'logged_ai_missing', 'Event unavailable.' ); if ( ! is_wp_error( $event ) && isset( $input['event_uid'] ) && $input['event_uid'] !== ( $event['uid'] ?? null ) ) { $event = new \WP_Error( 'logged_ai_missing', __( 'This recorded event is unavailable or no longer retained.', 'logged-ai' ) ); } $back_url = $list_url . ( '' !== $event_id ? '#event-' . $event_id : '' ); echo '

' . esc_html__( 'Back to filtered activity', 'logged-ai' ) . '

'; if ( is_wp_error( $event ) ) { self::error( $event ); } else { $reads[] = array( 'value' => $event, 'detail' => true, ); self::inspector( $event, $time ); DesignPro::invitation( 'investigate', 'activity', __( 'Keep related evidence, notes and decisions together in a case.', 'logged-ai' ) ); } echo ''; return; } self::filter_form( $filters ); $cursor = $input['cursor'] ?? ''; if ( ! is_string( $cursor ) ) { self::error( new \WP_Error( 'logged_ai_cursor', 'Invalid cursor.' ) ); echo ''; return; } $result = Store::listing( $scope, $filters, $cursor, 50, true ); if ( is_wp_error( $result ) ) { self::error( $result ); echo ''; return; } $reads[] = array( 'value' => $result, 'detail' => false, 'payload' => true, ); /** Render optional edition actions for this validated view; no action is executed. */ do_action( 'logged_ai_activity_view_actions', $scope, $filters ); DesignActivity::results( $result['events'], $time, $list_url ); echo ( '
' . '' . esc_html__( 'Explore the patterns in these records', 'logged-ai' ) . '' ); DesignActivity::chart( $result['events'], 'activity' ); echo '
'; if ( $result['next_cursor'] ) { echo ( '

' . sprintf( /* translators: 1: Value or formatting element shown in this message. 2: Value or formatting element shown in this message. */ esc_html__( '%1$sOlder changes%2$s', 'logged-ai' ), '', '' ) . '

' ); } if ( current_user_can( 'logged_ai_export_events' ) ) { echo ( '
' . '' . esc_html__( 'Export this selection', 'logged-ai' ) . '' . '

' . ( esc_html__( 'Exports include this site’s matching records, up to 500. For larger sets, use a background export when encryption support is available. Dates use a UTC start-inclusive, end-exclusive range. Immediate downloads stream privately and are not saved on the web server.', 'logged-ai' ) ) . '

' . '
' . '' ); wp_nonce_field( 'logged_ai_export', '_wpnonce', false ); foreach ( $filters as $key => $value ) { echo ''; } echo '
'; self::background_export_form( $filters ); echo '
'; } echo ''; } /** * Render nonce-bound background export controls for the selected scope kind. * * @param array $filters Supported normalized activity filter values. * @param bool $network Whether the form targets the network export handler. * * @return void */ public static function background_export_form( array $filters, bool $network = false ): void { $readiness = Exports::readiness_message(); if ( '' !== $readiness ) { $url = $network ? Scope::network_admin_url( 'admin.php?page=logged-ai-network-exports' ) : admin_url( 'admin.php?page=logged-ai-exports' ); echo '

' . esc_html( $readiness ) . '

'; if ( ! $network ) { echo '

' . esc_html__( 'For this Activity selection, use Download CSV or Download JSON above.', 'logged-ai' ) . '

'; } echo '

' . esc_html__( 'Exports starts a new selection. Choose its date range there; the filters on this page are not carried over.', 'logged-ai' ) . '

' . esc_html__( 'Open Exports for an immediate download', 'logged-ai' ) . '

'; return; } $action = $network ? 'logged_ai_network_export_job' : 'logged_ai_export_job'; echo '
'; wp_nonce_field( $action ); foreach ( $filters as $key => $value ) { echo ''; } echo '

' . esc_html__( 'A background export freezes matching membership when its worker starts. It supports up to 1,000,000 rows and 256 MiB; encrypted saved exports expire after 24 hours.', 'logged-ai' ) . '

'; } /** * Format the retained actor identifier or the supported system/unknown label. * * @param array $event Retained decoded activity event projected for the authorized viewer. * * @return string Readable retained actor label without looking up current account data. */ private static function actor( array $event ): string { return null !== $event['actor_id'] ? sprintf( /* translators: %s: recorded user identifier. */ __( 'User #%s', 'logged-ai' ), $event['actor_id'] ) : ( 'system' === $event['actor_kind'] ? __( 'WordPress cron', 'logged-ai' ) : __( 'Unknown actor', 'logged-ai' ) ); } /** * Render the supported activity filters and their scope and search limitations. * * @param array $filters Supported normalized activity filter values. * @param bool $network Whether the form targets the separate network activity page. * * @return void */ public static function filter_form( array $filters, bool $network = false ): void { $page = $network ? 'logged-ai-network' : 'logged-ai'; $fields = array( 'q' => 'Search event types', 'actor_id' => 'Acting user ID', 'subject_id' => 'Affected user ID', 'object_id' => 'Object ID', 'request_id' => 'Request correlation ID', 'from' => 'From (UTC, inclusive)', 'to' => 'Until (UTC, exclusive)' ); $labels = array( 'code' => 'Recorded action', 'category' => 'Category', 'action' => 'Action type', 'object_type' => 'Object type', 'severity' => 'Severity', 'channel' => 'Execution channel', 'outcome' => 'Outcome', 'actor_kind' => 'Actor kind' ); $basic = array( 'q', 'category' ); $active = array_filter( $filters, static fn( $value ) => '' !== (string) $value ); $advanced = array_diff_key( $active, array_fill_keys( $basic, true ) ); echo ( '
' . '' . '
' . '' ); $choices = Store::filter_choices(); echo ( '' . '' . sprintf( /* translators: 1: Value or formatting element shown in this message. 2: Value or formatting element shown in this message. */ esc_html__( '%1$sReset%2$s', 'logged-ai' ), '', '' ) . '
' . '
' . '' . sprintf( /* translators: 1: Value or formatting element shown in this message. */ esc_html__( 'Refine by person, time or source%1$s', 'logged-ai' ), esc_html( $advanced ? sprintf( /* translators: 1: Number of active advanced filters. */ __( ' · %1$s active', 'logged-ai' ), count( $advanced ) ) : '' ) ) . '' . '
' ); foreach ( $fields as $key => $label ) { if ( 'q' === $key ) { continue; } echo ''; } foreach ( $choices as $key => $values ) { if ( 'category' === $key ) { continue; } echo ( ''; } echo ( '

' . esc_html__( 'Dates use UTC. The start date is included from 00:00; the end date stops before 00:00 and is not included. To include all of 31 October, choose 1 November as the end date. Leave a date blank for no limit on that side.', 'logged-ai' ) . '

' . '' . '
' . '' . esc_html__( 'Search scope and limitations', 'logged-ai' ) . '' . '

' . esc_html__( 'Up to four search terms match the registered action catalog. Administrative events do not retain IP addresses. This view follows your authorized WordPress site; network activity has a separate view.', 'logged-ai' ) . '

' . '
' . '
' . '

' . esc_html__( 'Search matches event types, not private content. Filters change this view and its exports, never collection.', 'logged-ai' ) . '

' . '
' ); } /** * Render retained activity facts, minimized field differences and source limitations. * * @param array $event Retained decoded activity event projected for the authorized viewer. * @param TimeDisplay $time Selected timezone formatter used consistently across the retained event view. * * @return void */ private static function inspector( array $event, TimeDisplay $time ): void { $title = Design::label( $event['code'] ); echo ( '
' . '

' . ( esc_html( $title ) ) . '

' . '
' . '
' . sprintf( /* translators: 1: Value or formatting element shown in this message. 2: Value or formatting element shown in this message. 3: Value or formatting element shown in this message. */ esc_html__( '%1$sActing user · current profile%2$s%3$s', 'logged-ai' ), '', '', wp_kses( DesignActivity::person( $event ), Design::allowed_markup() ) ) . '
' ); if ( ! empty( $event['subject_id'] ) ) { echo ( '
' . sprintf( /* translators: 1: Value or formatting element shown in this message. 2: Value or formatting element shown in this message. 3: Value or formatting element shown in this message. */ esc_html__( '%1$sAffected user · current profile%2$s%3$s', 'logged-ai' ), '', '', wp_kses( DesignActivity::person( $event, true ), Design::allowed_markup() ) ) . '
' ); } echo '
'; $facts = array( __( 'Actor', 'logged-ai' ) => self::actor( $event ) . ' (' . $event['actor_kind'] . ')', __( 'Affected object', 'logged-ai' ) => $event['subject_id'] ? 'User #' . $event['subject_id'] : $event['object_type'] . ' #' . $event['object_id'], __( 'Outcome', 'logged-ai' ) => $event['outcome'], __( 'Occurred (selected timezone)', 'logged-ai' ) => $time->text( $event['occurred_at'] ), __( 'Ingested (selected timezone)', 'logged-ai' ) => $time->text( $event['ingested_at'] ), __( 'Observed via', 'logged-ai' ) => $event['detail']['observed_via'] ?? __( 'Unavailable', 'logged-ai' ), __( 'Evidence strength', 'logged-ai' ) => $event['detail']['confidence'] ?? __( 'Unavailable', 'logged-ai' ), __( 'Source', 'logged-ai' ) => $event['detail']['source'] ?? __( 'Not recorded by earlier collector', 'logged-ai' ), __( 'Execution channel', 'logged-ai' ) => $event['channel'], __( 'Request correlation ID', 'logged-ai' ) => $event['request_id'], ); $core = array_slice( $facts, 0, 4, true ); $core[ __( 'Actor', 'logged-ai' ) ] = DesignActivity::actor( $event ); $core[ __( 'Affected object', 'logged-ai' ) ] = DesignActivity::object( $event ); $core[ __( 'Outcome', 'logged-ai' ) ] = DesignActivity::outcome( $event['outcome'] ); $core[ __( 'Occurred (selected timezone)', 'logged-ai' ) ] = Design::time_label( $time, $event['occurred_at'] ) . ' (' . $time->zone() . ')'; foreach ( $core as $label => $value ) { echo '
' . esc_html( PresentationTranslations::text( $label ) ) . '
' . esc_html( $value ) . '
'; } echo '
'; DesignActivity::content_target( $event, true ); echo ( '
' . '' . esc_html__( 'Source details & exact timestamps', 'logged-ai' ) . '' . '
' . '
' . esc_html__( 'Event reference', 'logged-ai' ) . '
' . '
' . ( esc_html( '#' . $event['id'] . ' / ' . $event['code'] ) ) . '
' ); foreach ( array_slice( $facts, 3, null, true ) as $label => $value ) { echo '
' . esc_html( PresentationTranslations::text( $label ) ) . '
' . esc_html( $value ) . '
'; } echo '

' . esc_html__( 'Activity from this request', 'logged-ai' ) . '

' . esc_html__( 'Captured field differences', 'logged-ai' ) . '

'; foreach ( $event['detail']['diffs'] ?? array() as $diff ) { echo ''; } if ( empty( $event['detail']['diffs'] ) ) { echo ( '' . '' . '' ); } $explanation = isset( $event['detail']['extension'] ) ? __( 'This is an assertion from installed extension code, not independent verification that its operation succeeded. Actor identity is captured by Logged.ai. Values follow the registered minimized schema. Caller-owned transaction rollback also rolls back this record.', 'logged-ai' ) : __( 'After is the value written by this successful WordPress operation. Before is an observed snapshot just before the write; another worker may change it between snapshot and write. This is not a serializable history or proof of malicious intent. Caller-owned transaction rollback also rolls back this record. Direct database edits are outside this collector.', 'logged-ai' ); if ( empty( $event['detail']['diffs'] ) && ! isset( $event['detail']['extension'] ) ) { $explanation = 'No before-and-after values were retained for this event. The captured outcome describes the observed operation. Unsupported changes and direct database edits may be absent.'; } if ( ! isset( $event['detail']['extension'] ) && 'stored_change' !== $event['outcome'] ) { $explanation = 'This records the captured observation and outcome. Missing field differences do not establish that no change occurred. Direct database edits and unsupported operations are outside this collector.'; } echo ( '' . '
' . esc_html__( 'Field', 'logged-ai' ) . '' . esc_html__( 'Before', 'logged-ai' ) . '' . esc_html__( 'After', 'logged-ai' ) . '
' . esc_html( DesignActivity::content_field( $diff['field'] ) ) . '' . esc_html( self::diff_value( $diff, 'before' ) ) . '' . esc_html( self::diff_value( $diff, 'after' ) ) . '
' . esc_html__( 'No field differences were captured for this event. Source details show the retained observation.', 'logged-ai' ) . '
' . '
' . '

' . esc_html__( 'Recorded observations are not proof of intent or a complete history.', 'logged-ai' ) . '

' . '
' . '' . esc_html__( 'How to interpret this evidence', 'logged-ai' ) . '' . '

' . ( esc_html( PresentationTranslations::text( $explanation ) ) ) . '

' . '
' . '
' . '' . ( esc_html__( 'Minimized JSON evidence', 'logged-ai' ) ) . '' . '
' . ( esc_html( wp_json_encode( $event, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) ) ) . '
' . '
' . '
' ); } /** * Format one minimized field-difference side with explicit redaction and absence labels. * * @param array $diff One retained minimized field difference. * @param string $side Selected before or after field-difference key. * * @return string Readable difference value or its redacted, unavailable or empty marker. */ private static function diff_value( array $diff, string $side ): string { if ( 'redacted' === ( $diff['availability'] ?? '' ) ) { return __( '(Changed; value redacted)', 'logged-ai' ); } if ( null === ( $diff[ $side ] ?? null ) ) { return __( '(Unavailable)', 'logged-ai' ); } $value = $diff[ $side ]; if ( is_array( $value ) ) { $text = implode( ', ', $value ); return $text ? $text : __( '(Empty)', 'logged-ai' ); } if ( is_bool( $value ) ) { return $value ? 'true' : 'false'; } return (string) $value; } /** * Remove selected control bytes and quote supported spreadsheet-expression prefixes. * * @param mixed $value Retained export cell value converted to its CSV string representation. * * @return string String prepared for CSV output with dangerous recognized prefixes quoted. */ public static function csv_cell( mixed $value ): string { $text = (string) $value; // Whitespace/control bytes can conceal spreadsheet expression prefixes. $text = str_replace( array( "\x00", "\x1b" ), '', $text ); if ( preg_match( '/\A[\x00-\x20\x7f\x{00A0}\x{2000}-\x{200F}\x{2028}-\x{202F}\x{2060}\x{FEFF}]*[=+@-]/u', $text ) ) { $text = "'" . $text; } return $text; } /** * Build one authorized activity export only when its complete selection fits 500 rows. * * @param Scope $scope Current operation scope whose capabilities are rechecked. * @param array $filters Supported normalized activity filter values. * * @return array|\WP_Error Scoped event records and checksum manifest, or a permission, limit or listing error. */ public static function export_data( Scope $scope, array $filters ): array|\WP_Error { $prepared = DirectExports::prepare( $scope, 'json', $filters ); if ( is_wp_error( $prepared ) ) { return $prepared; } if ( $prepared['row_count'] > 500 ) { return new \WP_Error( 'logged_ai_export_limit', __( 'More than 500 records match. Narrow the filters or open Exports for a larger download.', 'logged-ai' ), array( 'status' => 409 ) ); } // The legacy CSV layout expands role arrays into columns. Reject malformed // retained role detail before emitting headers or any attachment bytes. foreach ( $prepared['events'] as $event ) { $diffs = $event['detail']['diffs'] ?? array(); if ( ! is_array( $diffs ) ) { return new \WP_Error( 'logged_ai_export_data', __( 'Stored role detail could not be exported safely.', 'logged-ai' ), array( 'status' => 503 ) ); } foreach ( $diffs as $diff ) { if ( ! is_array( $diff ) || 'roles' !== ( $diff['field'] ?? null ) ) { continue; } foreach ( array( 'before', 'after' ) as $field ) { if ( ! isset( $diff[ $field ] ) || ! is_array( $diff[ $field ] ) || array_filter( $diff[ $field ], static fn( $role ): bool => ! is_string( $role ) ) ) { return new \WP_Error( 'logged_ai_export_data', __( 'Stored role detail could not be exported safely.', 'logged-ai' ), array( 'status' => 503 ) ); } } } } $canonical = wp_json_encode( $prepared['events'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); if ( ! is_string( $canonical ) ) { return new \WP_Error( 'logged_ai_export_data', __( 'The export could not be encoded.', 'logged-ai' ), array( 'status' => 503 ) ); } return array( 'manifest' => array( 'schema_version' => 1, 'plugin_version' => LOGGED_AI_VERSION, 'scope' => $prepared['manifest']['scope'], 'filter' => $filters, 'generated_at_utc' => $prepared['manifest']['generated_at_utc'], 'timezone' => 'UTC', 'row_count' => $prepared['row_count'], 'events_sha256' => hash( 'sha256', $canonical ), 'membership' => $prepared['manifest']['membership'], ), 'events' => $prepared['events'], ); } /** * Stream a nonce-bound scoped JSON or CSV activity download with private response headers. * * @return void */ public static function export(): void { $request_form = \LoggedAI\AdminInput::form(); $scope = Scope::current( 'logged_ai_export_events' ); if ( is_wp_error( $scope ) || 'POST' !== \LoggedAI\AdminInput::method() ) { wp_die( esc_html__( 'Access denied.', 'logged-ai' ), '', array( 'response' => 403 ) ); } check_admin_referer( 'logged_ai_export' ); $input = $request_form; if ( array_key_exists( 'filters', $input ) ) { $unknown = array_diff( array_keys( $input ), array( 'action', 'format', '_wpnonce', '_wp_http_referer', 'filters' ) ); $filters = $unknown || ! is_array( $input['filters'] ) ? new \WP_Error( 'logged_ai_filter', 'Ambiguous or invalid export filters.', array( 'status' => 400 ) ) : Store::filters( $input['filters'] ); } else { $filters = self::input_filters( $input, array( 'action', 'format', '_wpnonce', '_wp_http_referer' ) ); } $format = $input['format'] ?? ''; if ( ! in_array( $format, array( 'csv', 'json' ), true ) ) { wp_die( esc_html__( 'Invalid export format.', 'logged-ai' ), '', array( 'response' => 400 ) ); } // Run WordPress header filters before the final committed export authorization. nocache_headers(); $data = is_wp_error( $filters ) ? $filters : self::export_data( $scope, $filters ); if ( is_wp_error( $data ) ) { wp_die( esc_html( PresentationTranslations::text( $data->get_error_message() ) ), '', array( 'response' => (int) ( $data->get_error_data()['status'] ?? 400 ) ) ); } if ( headers_sent() ) { wp_die( esc_html__( 'The download could not start because this request already produced output.', 'logged-ai' ), '', array( 'response' => 503 ) ); } header( 'Cache-Control: private, no-store, max-age=0' ); header( 'X-Content-Type-Options: nosniff' ); header( 'Content-Disposition: attachment; filename="logged-ai-events.' . $format . '"' ); header( 'X-LoggedAI-Events-SHA256: ' . $data['manifest']['events_sha256'] ); if ( 'json' === $format ) { header( 'Content-Type: application/json; charset=utf-8' ); echo wp_json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); } else { header( 'Content-Type: text/csv; charset=utf-8' ); $stream = fopen( 'php://output', 'wb' ); fputcsv( $stream, array( 'id', 'occurred_at_utc', 'actor_kind', 'actor_id', 'subject_id', 'code', 'outcome', 'roles_before', 'roles_after', 'site_id', 'network_id', 'minimized_detail_json' ), ',', '"', '' ); foreach ( $data['events'] as $event ) { $diff = array_values( array_filter( $event['detail']['diffs'] ?? array(), static fn( $item ) => is_array( $item ) && 'roles' === ( $item['field'] ?? null ) ) )[0] ?? array( 'before' => array(), 'after' => array(), ); $row = array( $event['id'], $event['occurred_at'], $event['actor_kind'], $event['actor_id'], $event['subject_id'], $event['code'], $event['outcome'], implode( '|', $diff['before'] ), implode( '|', $diff['after'] ), $event['site_id'], $event['network_id'], wp_json_encode( $event['detail'] ?? array(), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) ); fputcsv( $stream, array_map( array( self::class, 'csv_cell' ), $row ), ',', '"', '' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Close the php://output CSV stream; no filesystem transport is involved. fclose( $stream ); } exit; } /** * Dispatch explicit scoped activity listing or bounded JSON export commands. * * OS operator surface, requires explicit --user to bind the scoped capability. * * @param array $args Positional WP-CLI command arguments. * @param array $assoc Named WP-CLI operation flags and filters. * * @return void */ public static function cli( array $args, array $assoc ): void { $operation = $args[0] ?? 'list'; if ( ! in_array( $operation, array( 'list', 'export' ), true ) ) { \WP_CLI::error( __( 'Use events list or events export. Include --user= and --url=.', 'logged-ai' ) ); } $scope = Scope::current( 'export' === $operation ? 'logged_ai_export_events' : 'logged_ai_view_events' ); if ( is_wp_error( $scope ) ) { \WP_CLI::error( PresentationTranslations::text( $scope->get_error_message() ) ); } $data = 'export' === $operation ? self::export_data( $scope, $assoc ) : Store::listing( $scope, $assoc ); if ( is_wp_error( $data ) ) { \WP_CLI::error( PresentationTranslations::text( $data->get_error_message() ) ); } \WP_CLI::line( wp_json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) ); } }