__( 'Activity Log', '404ops-activity-log' ), self::MENU_SLUG, static fn(): string => __( 'Audit trail of logins, content, plugin, user and settings events, with retention and CSV export.', '404ops-activity-log' ), 'dashicons-clock', self::CAPABILITY ); add_action( 'admin_menu', array( $this, 'add_menu' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'assets' ) ); add_action( 'admin_init', array( $this, 'handle_export' ) ); add_action( 'admin_init', array( $this, 'save_settings' ) ); add_action( 'admin_notices', array( $this, 'ai_migration_notice' ) ); // Location and device (#7): its own form on the Settings tab, and // its two log columns in Screen Options, where each viewer can hide // them. Core's own column toggles (common.js, the hidden-columns // AJAX action, per-user meta) do the rest. ContextSettings::register(); add_filter( 'manage_' . self::hook_suffix() . '_columns', array( __CLASS__, 'hideable_columns' ) ); } /** * The log columns Screen Options lists, so they can be hidden. * * @param mixed $columns What earlier listeners returned. * @return array */ public static function hideable_columns( $columns ): array { return array_merge( is_array( $columns ) ? $columns : array(), array( 'location' => __( 'Location', '404ops-activity-log' ), 'device' => __( 'Device', '404ops-activity-log' ), ) ); } /** * The log columns the current viewer has hidden in Screen Options. * * @return array */ private static function hidden_columns(): array { $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; return $screen ? array_map( 'strval', get_hidden_columns( $screen ) ) : array(); } /** * Enqueue the 404ops UI kit on our screen only. * * Bound to `admin_enqueue_scripts` unconditionally, so this method runs * on EVERY wp-admin request -- the Dashboard, the post list, Settings, * screens belonging to other people's plugins, so a fatal here is a white * screen across the whole of wp-admin, raised by a plugin the admin may * not even be looking at. */ public function assets( string $hook ): void { if ( self::hook_suffix() === $hook ) { Ui::enqueue(); ImportTab::enqueue(); } } /** * This screen's `admin_enqueue_scripts` hook suffix, as `Menu::hook()` * derives it: `404ops_page_404ops-activity-log`. * * Derived, never hardcoded: this page is a submenu of the shared parent, so * the old 'toplevel_page_*' suffix stopped matching and the kit's * stylesheet would silently stop loading on this screen. The integration * suite pins the kit's answer against core's own * `get_plugin_page_hookname()`. * * The call is unguarded: this plugin bundles its own namespace-prefixed copy * of the kit (vendor-prefixed/, ui-kit 0.6.0), so the class is always there. * Nothing probes for it, and nothing falls back to another copy's. */ private static function hook_suffix(): string { return Menu::hook( self::MENU_SLUG ); } /** * Add this plugin's page under the shared 404ops parent menu. */ public function add_menu(): void { add_submenu_page( Menu::parent( '404ops-activity-log' ), __( 'Activity Log', '404ops-activity-log' ), // The label drops the "404ops" prefix now it is nested -- the // parent already says it. __( 'Activity Log', '404ops-activity-log' ), self::CAPABILITY, self::MENU_SLUG, array( $this, 'render' ) ); } /** * Handle CSV export. Free tier — CsvExport::download() does its own * capability check; the nonce check below happens first so an * unauthenticated/forged request never reaches it. */ public function handle_export(): void { // phpcs:disable WordPress.Security.NonceVerification.Missing if ( ! isset( $_GET['ops_activity_log_export'] ) || ! current_user_can( 'manage_options' ) ) { return; } // Verify nonce. if ( ! isset( $_GET['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'ops_activity_log_export' ) ) { wp_die( 'Security check failed.' ); } CsvExport::download( self::export_filters( $_GET ) ); // phpcs:enable } /** * The export's filters, read from its request: the same ones the log * tab shows, carried on its Export CSV link. * * @param array $request Request parameters ($_GET). * @return array */ public static function export_filters( array $request ): array { $args = array(); if ( ! empty( $request['user_id'] ) ) { $args['user_id'] = (int) $request['user_id']; } foreach ( array( 'event_type' => 'event_type', 'date_from' => 'date_from', 'date_to' => 'date_to', 'log_search' => 'search', 'country' => 'country', 'device' => 'device_class', ) as $param => $filter ) { if ( ! empty( $request[ $param ] ) && is_string( $request[ $param ] ) ) { $args[ $filter ] = sanitize_text_field( wp_unslash( $request[ $param ] ) ); } } return $args; } /** * Save settings. */ public function save_settings(): void { // phpcs:disable WordPress.Security.NonceVerification.Missing if ( ! isset( $_POST['ops_activity_log_save_settings'] ) || ! current_user_can( 'manage_options' ) ) { return; } // Verify nonce. if ( ! isset( $_POST['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ), 'ops_activity_log_settings' ) ) { wp_die( 'Security check failed.' ); } $retention = isset( $_POST['ops_activity_log_retention_days'] ) ? sanitize_text_field( wp_unslash( $_POST['ops_activity_log_retention_days'] ) ) : null; if ( ActivityLog::has_valid_retention_days( $retention ) ) { update_option( 'ops_activity_log_retention_days', (int) $retention ); } update_option( 'ops_activity_log_retention_unlimited', isset( $_POST['ops_activity_log_retention_unlimited'] ) ); // Unchecked checkboxes are absent from $_POST entirely -- presence // (regardless of value) means "checked". update_option( EventLogger::OPTION_STORE_FULL_IPS, isset( $_POST['ops_activity_log_store_full_ips'] ) ); // The high-risk email alerts decided for #3: off unless opted in. update_option( Alerts::OPTION_ENABLED, isset( $_POST['ops_activity_log_alerts_enabled'] ) ); if ( isset( $_POST['ops_activity_log_ai_settings_present'] ) ) { update_option( AiSummary::OPTION_AI_ENABLED, isset( $_POST['ops_activity_log_ai_enabled'] ) ? '1' : '0' ); } // Only one of the offered sources is ever stored, never a header name // a forged request supplies. $ip_source = isset( $_POST['ops_activity_log_ip_source'] ) ? sanitize_text_field( wp_unslash( $_POST['ops_activity_log_ip_source'] ) ) : ''; if ( isset( EventLogger::IP_SOURCES[ $ip_source ] ) ) { update_option( EventLogger::OPTION_IP_SOURCE, $ip_source ); } // The exclusion lists post together behind a marker, so a form // without them leaves them alone. Unticked roles are absent from // $_POST, so absence with the marker means none. if ( isset( $_POST['ops_activity_log_exclusions'] ) ) { $roles = isset( $_POST['ops_activity_log_excluded_roles'] ) ? array_map( 'sanitize_key', (array) wp_unslash( $_POST['ops_activity_log_excluded_roles'] ) ) : array(); update_option( Exclusions::OPTION_ROLES, Exclusions::sanitize_roles( $roles ) ); $typed = isset( $_POST['ops_activity_log_excluded_users'] ) ? sanitize_textarea_field( wp_unslash( $_POST['ops_activity_log_excluded_users'] ) ) : ''; update_option( Exclusions::OPTION_USERS, Exclusions::ids_from_logins( $typed ) ); } // phpcs:enable } /** Explain the provider migration to sites that used the previous connection. */ public function ai_migration_notice(): void { if ( ! current_user_can( 'manage_options' ) || ! AiSummary::needs_migration_notice() ) { return; } $url = add_query_arg( array( 'page' => '404ops-activity-log', 'tab' => 'settings', ), admin_url( 'admin.php' ) ); echo '

' . esc_html__( 'Activity Log now uses WordPress AI providers. Its AI summaries are paused until you configure a provider and enable AI again. Save Activity Log settings to acknowledge this change.', '404ops-activity-log' ) . ' ' . esc_html__( 'Open Activity Log settings', '404ops-activity-log' ) . '

'; } /** * Render the page. */ public function render(): void { if ( ! current_user_can( 'manage_options' ) ) { return; } // phpcs:disable WordPress.Security.NonceVerification.Recommended $tab = isset( $_GET['tab'] ) ? sanitize_text_field( wp_unslash( $_GET['tab'] ) ) : 'log'; $tabs = self::tabs(); if ( ! isset( $tabs[ $tab ] ) ) { $tab = 'log'; } echo '
'; Render::header( __( 'Activity Log', '404ops-activity-log' ), __( 'Monitor site security and changes', '404ops-activity-log' ) ); // Render tabs. echo ''; if ( 'settings' === $tab ) { $this->render_settings_tab(); } elseif ( 'log' === $tab ) { $this->render_log_tab(); } elseif ( 'sessions' === $tab ) { SessionsTab::render(); } elseif ( 'import' === $tab ) { ImportTab::render(); } elseif ( 'integrity' === $tab ) { IntegrityTab::render(); } elseif ( 'chain' === $tab ) { ChainTab::render(); } else { /** * Render the body of a tab added through * `ops404_activity_log_settings_tabs`. The matching half of that * filter: free never names the class that owns the tab, and a * slug free does not know about lands here rather than silently * falling back to the log. * * @param string $tab The tab slug being rendered. */ do_action( 'ops404_activity_log_render_settings_tab', $tab ); } echo '
'; } /** * This screen's tabs, `slug => label`, in display order. * * Public so the Pro add-on's own tab can look up what already exists * before appending, and so a test can read the same list the nav renders. * * @return array */ public static function tabs(): array { $tabs = array( 'log' => __( 'Activity Log', '404ops-activity-log' ), 'settings' => __( 'Settings', '404ops-activity-log' ), 'sessions' => __( 'Sessions', '404ops-activity-log' ), 'import' => __( 'Import', '404ops-activity-log' ), ); // File integrity monitoring lives on a single site or a network's // main site only: the files are the network's. if ( \ActivityLog404\Integrity\IntegrityActions::available() ) { $tabs['integrity'] = __( 'Integrity', '404ops-activity-log' ); } // Each site has its own log and so its own chain. $tabs['chain'] = __( 'Log integrity', '404ops-activity-log' ); // 'sessions' (#6), 'import' (#21), 'integrity' and 'chain' are free: see // AGENTS.md's product invariants. // // Lets the Pro add-on inject its own tabs (Forwarding, Retention, // Licence) without free code ever naming a Pro class; `render()` // above carries the matching render-dispatch seam. Free itself adds // nothing through this filter. Shape is `[ slug => label ]`, // appended after free's own tabs. // // No `@param` doc tag on the call (PHPStan reads a doc-comment // immediately before an apply_filters() as the hook's signature and, // with `array` declared, would narrow $filtered to // match -- making the is_array() guard below provably dead from its // perspective, though a real third-party listener can return // anything at runtime, which is what the guard defends against). $filtered = apply_filters( 'ops404_activity_log_settings_tabs', $tabs ); return is_array( $filtered ) ? $filtered : $tabs; } /** * The screen's filters as URL query arguments, without the empty ones. * * @param array $filters Filters from render_log_tab(). * @return array */ private static function filter_query_args( array $filters ): array { $names = array( 'user_id' => 'user_id', 'event_type' => 'event_type', 'date_from' => 'date_from', 'date_to' => 'date_to', 'search' => 'log_search', 'country' => 'country', 'device' => 'device', ); $args = array(); foreach ( $names as $filter => $name ) { if ( isset( $filters[ $filter ] ) && '' !== (string) $filters[ $filter ] ) { $args[ $name ] = (string) $filters[ $filter ]; } } return $args; } /** * The screen's query filters without the empty ones, as the * `ops404_activity_log_after_log_filters` seam hands them over. * * @param array $query_filters The query's filters, keyed by EventQuery names. * @return array */ public static function current_filters( array $query_filters ): array { $current = array(); foreach ( $query_filters as $key => $value ) { if ( null === $value || '' === $value || 0 === $value || ! is_scalar( $value ) ) { continue; } $current[ (string) $key ] = is_int( $value ) ? $value : (string) $value; } return $current; } /** * Render the activity log tab. */ private function render_log_tab(): void { // phpcs:disable WordPress.Security.NonceVerification.Recommended $page = isset( $_GET['paged'] ) ? (int) $_GET['paged'] : 1; $user_id = isset( $_GET['user_id'] ) ? (int) $_GET['user_id'] : null; $event_type = isset( $_GET['event_type'] ) ? sanitize_text_field( wp_unslash( $_GET['event_type'] ) ) : null; $date_from = isset( $_GET['date_from'] ) ? sanitize_text_field( wp_unslash( $_GET['date_from'] ) ) : null; $date_to = isset( $_GET['date_to'] ) ? sanitize_text_field( wp_unslash( $_GET['date_to'] ) ) : null; $search = isset( $_GET['log_search'] ) ? sanitize_text_field( wp_unslash( $_GET['log_search'] ) ) : null; $country = isset( $_GET['country'] ) ? strtoupper( sanitize_key( wp_unslash( $_GET['country'] ) ) ) : null; $device = isset( $_GET['device'] ) ? sanitize_key( wp_unslash( $_GET['device'] ) ) : null; // phpcs:enable // ActivityLog validates both again; these keep an invalid value out // of the links this screen builds. $country = null !== $country && 1 === preg_match( '/^[A-Z]{2}$/', $country ) ? $country : null; $device = null !== $device && in_array( $device, UserAgent::CLASSES, true ) ? $device : null; // The screen's filters, used for the page, the count, pagination // links and the export link alike. $filters = array( 'user_id' => $user_id, 'event_type' => $event_type, 'date_from' => $date_from, 'date_to' => $date_to, 'search' => $search, 'country' => $country, 'device' => $device, ); $per_page = 25; // The screen names the device filter `device`; the query calls it // `device_class`, as the column is. $query_filters = array_merge( $filters, array( 'device_class' => $device ) ); unset( $query_filters['device'] ); $events = ActivityLog::get_events( array_merge( $query_filters, array( 'per_page' => $per_page, 'page' => $page, ) ) ); $total = ActivityLog::count_events( $query_filters ); $event_types = ActivityLog::get_event_types(); echo '
'; // Filter form. echo '
'; echo ''; // Free-text search. echo '
'; echo ''; echo ''; echo '

' . esc_html__( 'Matches the summary, email address, IP address and event details.', '404ops-activity-log' ) . '

'; echo '
'; echo '
'; // User filter. echo '
'; echo ''; echo ''; echo '
'; // Event type filter. echo '
'; echo ''; echo ''; echo '
'; // Country filter (#7): the countries the log actually holds. echo '
'; echo ''; echo ''; echo '
'; // Device class filter (#7). echo '
'; echo ''; echo ''; echo '
'; // Date from filter. echo '
'; echo ''; echo ''; echo '
'; // Date to filter. echo '
'; echo ''; echo ''; echo '
'; echo '
'; submit_button( __( 'Filter', '404ops-activity-log' ), 'primary', 'ops_activity_log_filter', false ); // Export CSV (free tier) with the same filters as this view. The link // used to carry none of them, so every export held the whole log. echo ' ' . esc_html__( 'Export CSV', '404ops-activity-log' ) . ''; echo '
'; echo '
'; /** * After the log tab's filter form, with the filters the screen is * showing. The Pro add-on's saved views render here; free adds * nothing. A public seam rather than the add-on reading `$_GET` * itself: the filters arrive already validated by this screen (an * invalid country or device class is dropped before the query, as * it is from every link the screen builds), keyed by * `Api\EventQuery`'s names, empty ones omitted. * * @param array $current user_id, event_type, date_from, date_to, search, country, device_class. */ do_action( 'ops404_activity_log_after_log_filters', self::current_filters( $query_filters ) ); // Events table. The two context columns carry core's column classes, // so Screen Options can hide them per viewer. $hidden = self::hidden_columns(); $hide_location = in_array( 'location', $hidden, true ) ? ' hidden' : ''; $hide_device = in_array( 'device', $hidden, true ) ? ' hidden' : ''; if ( ! empty( $events ) ) { echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; foreach ( $events as $event ) { $event_label = $event_types[ $event['event_type'] ] ?? ucfirst( str_replace( '_', ' ', (string) $event['event_type'] ) ); $event_ts = strtotime( (string) $event['timestamp'] ); $event_time = false !== $event_ts ? (string) wp_date( 'Y-m-d H:i:s', $event_ts ) : (string) $event['timestamp']; $user_email = (string) ( $event['user_email'] ?? '' ); echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; } echo ''; echo '
' . esc_html__( 'Timestamp', '404ops-activity-log' ) . '' . esc_html__( 'User', '404ops-activity-log' ) . '' . esc_html__( 'IP Address', '404ops-activity-log' ) . '' . esc_html__( 'Location', '404ops-activity-log' ) . '' . esc_html__( 'Device', '404ops-activity-log' ) . '' . esc_html__( 'Event', '404ops-activity-log' ) . '' . esc_html__( 'Summary', '404ops-activity-log' ) . '' . esc_html__( 'Details', '404ops-activity-log' ) . '
' . esc_html( $event_time ) . '' . esc_html( '' !== $user_email ? $user_email : 'Guest' ) . '' . esc_html( $event['ip_address'] ?? 'N/A' ) . '' . wp_kses_post( self::location_cell( $event ) ) . '' . wp_kses_post( self::device_cell( $event ) ) . '' . esc_html( $event_label ) . '' . esc_html( $event['summary'] ) . '' . wp_kses_post( self::render_details( $event ) ) . '
'; // Pagination. $total_pages = (int) ceil( $total / $per_page ); if ( $total_pages > 1 ) { echo '
'; $query_args = array_merge( array( 'page' => '404ops-activity-log' ), self::filter_query_args( $filters ) ); for ( $i = 1; $i <= $total_pages; $i++ ) { $query_args['paged'] = $i; $link = add_query_arg( $query_args, admin_url( 'admin.php' ) ); $class = $i === $page ? 'class="button button-primary"' : 'class="button"'; echo '' . esc_html( (string) $i ) . ' '; } echo '
'; } } else { Render::empty_state( __( 'No events found', '404ops-activity-log' ), __( 'Activity will appear here as site changes occur.', '404ops-activity-log' ) ); } } /** * The device classes, labelled for people. * * @return array */ public static function device_class_labels(): array { return array( 'desktop' => __( 'Desktop', '404ops-activity-log' ), 'mobile' => __( 'Mobile', '404ops-activity-log' ), 'tablet' => __( 'Tablet', '404ops-activity-log' ), 'bot' => __( 'Bot', '404ops-activity-log' ), ); } /** * A row's location as text: "NZ, Auckland", "NZ", or ''. * * @param array $event One ActivityLog::get_events() row. */ public static function location_text( array $event ): string { $country = isset( $event['country'] ) && is_string( $event['country'] ) ? $event['country'] : ''; $city = isset( $event['city'] ) && is_string( $event['city'] ) ? $event['city'] : ''; return implode( ', ', array_filter( array( $country, $city ), static fn( string $part ): bool => '' !== $part ) ); } /** * A row's device as text: "Mobile · Safari 18 · iOS 18", or ''. * * @param array $event One ActivityLog::get_events() row. */ public static function device_text( array $event ): string { $class = isset( $event['device_class'] ) && is_string( $event['device_class'] ) ? $event['device_class'] : ''; if ( '' === $class ) { return ''; } $labels = self::device_class_labels(); $parts = array( $labels[ $class ] ?? $class ); foreach ( array( 'browser', 'os' ) as $key ) { if ( isset( $event[ $key ] ) && is_string( $event[ $key ] ) && '' !== $event[ $key ] ) { $parts[] = $event[ $key ]; } } return implode( ' · ', $parts ); } /** * The login flags (#7, Context\LoginSignal) a row's details carry. * * @param array $event One ActivityLog::get_events() row. * @return array */ private static function login_flags( array $event ): array { $details = isset( $event['details'] ) && is_string( $event['details'] ) ? json_decode( $event['details'], true ) : null; if ( ! is_array( $details ) ) { return array(); } return array( 'new_country' => true === ( $details['new_country'] ?? null ), 'new_device' => true === ( $details['new_device'] ?? null ), 'impossible_travel' => true === ( $details['impossible_travel'] ?? null ), ); } /** * The Location cell, escaped: the location, "New country" when the * login was the user's first from it, and "Impossible travel" when it * could not have been reached from the user's previous location in time. * * @param array $event One ActivityLog::get_events() row. */ public static function location_cell( array $event ): string { $text = self::location_text( $event ); $html = '' !== $text ? esc_html( $text ) : '—'; if ( self::login_flags( $event )['new_country'] ?? false ) { $html .= ' ' . esc_html__( 'New country', '404ops-activity-log' ) . ''; } if ( self::login_flags( $event )['impossible_travel'] ?? false ) { $html .= ' ' . esc_html__( 'Impossible travel', '404ops-activity-log' ) . ''; } return $html; } /** * The Device cell, escaped, with "New device" on a login from a device * the user had not used recently. * * @param array $event One ActivityLog::get_events() row. */ public static function device_cell( array $event ): string { $text = self::device_text( $event ); $html = '' !== $text ? esc_html( $text ) : '—'; if ( self::login_flags( $event )['new_device'] ?? false ) { $html .= ' ' . esc_html__( 'New device', '404ops-activity-log' ) . ''; } return $html; } /** * The "Details" toggle for one log row: a native `
` element * (no inline script) expanding an escaped key/value view of the event's * details JSON. Empty string when the row has no details. * * Public, and independent of this screen's own manage_options gate, so * a test can drive the revision-link capability check in * render_revision_id() below (`current_user_can( 'edit_post', ... )`, * which answers differently for an editor and a subscriber) without * needing to satisfy render()'s own capability check first. * * Every value this method or the private helpers below it output is * escaped with esc_html() (keys, scalar values, and the "N more" / * "N items" summary text) or esc_url()/esc_html() together (the * revision link). Callers also filter the completed HTML at output. * * @param array $event One ActivityLog::get_events() row. */ public static function render_details( array $event ): string { $details = isset( $event['details'] ) && is_string( $event['details'] ) && '' !== $event['details'] ? json_decode( $event['details'], true ) : null; $details = is_array( $details ) ? $details : array(); // The request context (#7) leads the toggle, so it is there even // with the Location and Device columns hidden. $context = array_filter( array( __( 'Location', '404ops-activity-log' ) => self::location_text( $event ), __( 'Device', '404ops-activity-log' ) => self::device_text( $event ), ), static fn( string $text ): bool => '' !== $text ); if ( array() === $details && array() === $context ) { return ''; } $object_id = isset( $event['object_id'] ) && '' !== $event['object_id'] ? (int) $event['object_id'] : null; $context_html = ''; foreach ( $context as $label => $text ) { $context_html .= '
  • ' . esc_html( (string) $label ) . ': ' . esc_html( $text ) . '
  • '; } return '
    ' . esc_html__( 'Details', '404ops-activity-log' ) . '' . ( '' !== $context_html ? '
      ' . $context_html . '
    ' : '' ) . ( array() !== $details ? self::render_details_list( $details, 1, $object_id ) : '' ) . '
    '; } /** * One level of a details value as an escaped `
      `: scalars as * "key: value" list items, arrays recursed into a nested `
        ` up to * DETAILS_MAX_DEPTH levels, and only the first DETAILS_MAX_ENTRIES * entries of any one level listed -- the rest summarised as "…and N * more" rather than rendered, so neither an oversized nor a deeply * nested details blob can grow this screen without bound. * * @param array $value The array to render at this level. * @param int $depth 1-based nesting depth of $value itself. * @param int|null $object_id The event's object_id, for the revision link. */ private static function render_details_list( array $value, int $depth, ?int $object_id ): string { $entries = array_slice( $value, 0, self::DETAILS_MAX_ENTRIES, true ); $overflow = count( $value ) - count( $entries ); $html = '
          '; foreach ( $entries as $key => $item ) { $html .= '
        • ' . self::render_details_item( (string) $key, $item, $depth, $object_id ) . '
        • '; } if ( $overflow > 0 ) { $html .= '
        • ' . esc_html( sprintf( /* translators: %d: number of additional entries not shown. */ _n( '…and %d more', '…and %d more', $overflow, '404ops-activity-log' ), $overflow ) ) . '
        • '; } $html .= '
        '; return $html; } /** * One key => value entry: the revision-id special case (see * render_revision_id()), a nested list, or an escaped scalar. * * @param mixed $item The value stored under $key. */ private static function render_details_item( string $key, $item, int $depth, ?int $object_id ): string { if ( 'revision_id' === $key && ( is_int( $item ) || ( is_string( $item ) && ctype_digit( $item ) ) ) ) { return esc_html( $key ) . ': ' . self::render_revision_id( (int) $item, $object_id ); } if ( is_array( $item ) ) { if ( $depth >= self::DETAILS_MAX_DEPTH ) { return esc_html( $key ) . ': ' . esc_html( sprintf( /* translators: %d: number of nested entries not shown. */ _n( '%d item', '%d items', count( $item ), '404ops-activity-log' ), count( $item ) ) ); } return esc_html( $key ) . ': ' . self::render_details_list( $item, $depth + 1, $object_id ); } return esc_html( $key ) . ': ' . esc_html( self::scalar_as_text( $item ) ); } /** * @param mixed $item A details leaf value. */ private static function scalar_as_text( $item ): string { if ( is_bool( $item ) ) { return $item ? 'true' : 'false'; } return null === $item ? '' : (string) $item; } /** * The revision id, linked to core's compare screen when the current * user can edit the post it belongs to -- otherwise plain text, per * issue #2 ("otherwise no link"). Independent of whatever capability * gates this screen itself: an editor and a subscriber get different * answers here even though today only an administrator ever reaches * this through render()'s own manage_options check. * * @param int $revision_id The revision's post id. * @param int|null $object_id The updated post's id. */ private static function render_revision_id( int $revision_id, ?int $object_id ): string { if ( null !== $object_id && current_user_can( 'edit_post', $object_id ) ) { return '' . esc_html( (string) $revision_id ) . ''; } return esc_html( (string) $revision_id ); } /** * Render the settings tab. */ private function render_settings_tab(): void { $retention = (int) get_option( 'ops_activity_log_retention_days', 90 ); $retention_unlimited = (bool) get_option( 'ops_activity_log_retention_unlimited', false ); $store_full_ips = (bool) get_option( EventLogger::OPTION_STORE_FULL_IPS, false ); echo '
        '; wp_nonce_field( 'ops_activity_log_settings' ); echo '
        '; echo ''; echo ''; echo '

        ' . esc_html__( 'Off by default. When enabled, WordPress chooses a compatible provider from those configured in Settings > Connectors; it may change as your providers change. Daily activity counts and anomaly descriptions are sent to that provider. If the Pro weekly AI summary is enabled, its redacted figures, flags and change summaries are also sent.', '404ops-activity-log' ) . '

        '; echo '

        ' . esc_html__( 'Open WordPress Connectors', '404ops-activity-log' ) . '

        '; echo '
        '; echo '
        '; echo ''; // The ceiling is read, not hardcoded: save_settings() validates // against ActivityLog::max_retention_days(), so a hardcoded ceiling here // would leave the Pro add-on's raised ceiling accepted by the // validator and refused by the browser. echo ''; echo '

        ' . esc_html__( 'Events older than this many days will be automatically deleted.', '404ops-activity-log' ) . '

        '; echo '

        '; echo '

        ' . esc_html__( 'When enabled, automatic retention cleanup is paused and imported history is kept regardless of age.', '404ops-activity-log' ) . '

        '; echo '
        '; echo '
        '; echo ''; echo '

        ' . esc_html__( 'Off by default: IP addresses are stored truncated (IPv4 last octet, IPv6 last 80 bits removed). Enable to store full IP addresses instead.', '404ops-activity-log' ) . '

        '; echo '
        '; $alerts_enabled = Alerts::is_enabled(); echo '
        '; echo ''; echo '

        ' . esc_html__( 'Off by default. Sends an email the moment a new administrator is created, a role is raised to administrator, a plugin is activated, the theme is switched, or a burst of failed logins is recorded. At most one email of each kind every 15 minutes.', '404ops-activity-log' ) . '

        '; echo '
        '; $ip_source = EventLogger::ip_source(); $ip_sources = array( 'remote_addr' => __( 'Direct connection (REMOTE_ADDR)', '404ops-activity-log' ), 'x_forwarded_for' => __( 'X-Forwarded-For header (proxy or load balancer)', '404ops-activity-log' ), 'x_real_ip' => __( 'X-Real-IP header (nginx proxy)', '404ops-activity-log' ), 'cf_connecting_ip' => __( 'CF-Connecting-IP header (Cloudflare)', '404ops-activity-log' ), ); echo '
        '; echo ''; echo ''; echo '

        ' . esc_html__( 'Visitors can send any of these headers themselves. Choose a header only if a proxy or CDN in front of this site sets it; otherwise the logged address can be forged. If the header is missing, the direct connection is used.', '404ops-activity-log' ) . '

        '; echo '
        '; $excluded_roles = Exclusions::roles(); echo '
        '; echo '
        '; echo '' . esc_html__( 'Roles never logged', '404ops-activity-log' ) . ''; foreach ( wp_roles()->get_names() as $slug => $name ) { echo ''; } echo '
        '; echo '
        '; echo '
        '; echo ''; echo ''; echo '

        ' . esc_html__( 'One username per line. A name that matches no user is dropped when you save. Failed logins and events with no signed-in user (WP-CLI, cron) are always recorded, and any change to these two lists is always logged, whoever makes it.', '404ops-activity-log' ) . '

        '; echo ''; echo '
        '; submit_button( __( 'Save Settings', '404ops-activity-log' ), 'primary', 'ops_activity_log_save_settings', true ); echo '
        '; // A second form, not more fields in this one: it posts a file, and // its own marker keeps it from being read as this form's save. ContextSettings::render(); } }