prefix . 'aatb_log'; } public static function init(): void { add_action( 'admin_init', [ __CLASS__, 'maybe_install' ] ); add_action( 'aatb_log_prune', [ __CLASS__, 'prune' ] ); add_action( 'admin_menu', [ __CLASS__, 'menu' ], 20 ); add_action( 'admin_post_aatb_log_csv', [ __CLASS__, 'export_csv' ] ); add_action( 'admin_post_aatb_log_settings', [ __CLASS__, 'save_settings' ] ); add_action( 'update_option_' . AATB_SETTINGS_SLUG, [ __CLASS__, 'settings_changed' ], 10, 2 ); add_action( 'wp_login', [ __CLASS__, 'on_login' ], 10, 2 ); add_action( 'wp_logout', [ __CLASS__, 'on_logout' ] ); add_action( 'wp_login_failed', [ __CLASS__, 'on_login_failed' ] ); } /* ……………………… storage ……………………… */ public static function install(): void { global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $table = self::table(); $charset = $wpdb->get_charset_collate(); dbDelta( "CREATE TABLE {$table} ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, created_at datetime NOT NULL, user_id bigint(20) unsigned NOT NULL DEFAULT 0, user_login varchar(60) NOT NULL DEFAULT '', action varchar(32) NOT NULL DEFAULT '', attachment_id bigint(20) unsigned NOT NULL DEFAULT 0, detail varchar(255) NOT NULL DEFAULT '', ip varchar(45) NOT NULL DEFAULT '', PRIMARY KEY (id), KEY created_at (created_at), KEY action (action) ) {$charset};" ); update_option( self::OPTION_DB, self::DB_VERSION, false ); if ( ! wp_next_scheduled( 'aatb_log_prune' ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'aatb_log_prune' ); } } public static function maybe_install(): void { if ( get_option( self::OPTION_DB ) !== self::DB_VERSION ) self::install(); } public static function deactivate(): void { wp_clear_scheduled_hook( 'aatb_log_prune' ); } /** Adds one entry. $detail is short free text (mode, tier, result), never a secret. */ public static function add( string $action, int $attachment_id = 0, string $detail = '', $user = null ): void { global $wpdb; if ( get_option( self::OPTION_DB ) !== self::DB_VERSION ) return; // table not created yet if ( ! $user instanceof WP_User ) $user = wp_get_current_user(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- the plugin's own log table, one insert per event. $wpdb->insert( self::table(), [ 'created_at' => current_time( 'mysql', true ), 'user_id' => (int) $user->ID, 'user_login' => substr( (string) $user->user_login, 0, 60 ), 'action' => substr( $action, 0, 32 ), 'attachment_id' => max( 0, $attachment_id ), // Characters, not bytes: a byte cut could split a letter and the insert would fail. 'detail' => mb_substr( $detail, 0, 255 ), 'ip' => self::ip(), ], [ '%s', '%d', '%s', '%s', '%d', '%s', '%s' ] ); } /** * Adds the alt text that was replaced to a detail ("… · Previous: …"), cut to fit the * 255-character column. An empty previous text leaves the detail as it is. */ public static function previous( string $detail, string $prev ): string { $prev = trim( $prev ); if ( '' === $prev ) return $detail; $head = ( '' === $detail ? '' : $detail . ' · ' ) . 'Previous: '; $room = 255 - mb_strlen( $head ); if ( mb_strlen( $prev ) > $room ) $prev = mb_substr( $prev, 0, max( 0, $room - 1 ) ) . '…'; return $head . $prev; } private static function ip(): string { $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; /** * Filters the IP address stored in the activity log. A site behind a trusted proxy can * return the visitor's address here. */ $ip = (string) apply_filters( 'aatb_log_ip', $ip ); return filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : ''; } private static function opt( string $key, $default ) { $o = get_option( AATB_SETTINGS_SLUG, [] ); return is_array( $o ) && array_key_exists( $key, $o ) ? $o[ $key ] : $default; } private static function days(): int { return min( 3650, max( 30, (int) self::opt( 'log_days', 365 ) ) ); } public static function prune(): void { global $wpdb; $cut = gmdate( 'Y-m-d H:i:s', time() - self::days() * DAY_IN_SECONDS ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the plugin's own log table. $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}aatb_log WHERE created_at < %s", $cut ) ); } /* ……………………… events ……………………… */ public static function settings_changed( $old, $new ): void { $old = is_array( $old ) ? $old : []; $new = is_array( $new ) ? $new : []; $changes = []; foreach ( [ 'site_token' => 'site key', 'openai_key' => 'OpenAI key' ] as $k => $label ) { if ( ( $old[ $k ] ?? '' ) !== ( $new[ $k ] ?? '' ) ) $changes[] = $label . ' ' . ( '' === ( $new[ $k ] ?? '' ) ? 'removed' : 'changed' ); } if ( ( $old['model_choice'] ?? '' ) !== ( $new['model_choice'] ?? '' ) ) { $changes[] = 'quality tier: ' . self::tier( (string) ( $old['model_choice'] ?? '' ) ) . ' to ' . self::tier( (string) ( $new['model_choice'] ?? '' ) ); } if ( ( $old['lang_choice'] ?? '' ) !== ( $new['lang_choice'] ?? '' ) ) { $changes[] = 'language: ' . ( $old['lang_choice'] ?? '' ) . ' to ' . ( $new['lang_choice'] ?? '' ); } if ( ( $old['custom_prompt'] ?? '' ) !== ( $new['custom_prompt'] ?? '' ) ) $changes[] = 'custom directives changed'; foreach ( [ 'auto_generate' => 'auto-generate on upload', 'log_signins' => 'sign-in logging' ] as $k => $label ) { if ( ! empty( $old[ $k ] ) !== ! empty( $new[ $k ] ) ) $changes[] = $label . ' ' . ( empty( $new[ $k ] ) ? 'off' : 'on' ); } if ( (int) ( $old['log_days'] ?? 365 ) !== (int) ( $new['log_days'] ?? 365 ) ) $changes[] = 'log kept for ' . (int) ( $new['log_days'] ?? 365 ) . ' days'; if ( $changes ) self::add( 'settings', 0, implode( '; ', $changes ) ); } public static function on_login( $login, $user ): void { if ( self::opt( 'log_signins', '' ) ) self::add( 'login', 0, '', $user ); } public static function on_logout( $user_id = 0 ): void { if ( ! self::opt( 'log_signins', '' ) ) return; $user = get_userdata( (int) $user_id ); self::add( 'logout', 0, '', $user ?: null ); } public static function on_login_failed( $username ): void { if ( ! self::opt( 'log_signins', '' ) ) return; self::add( 'login_failed', 0, 'username: ' . sanitize_user( (string) $username ), new WP_User( 0 ) ); } public static function tier( string $model ): string { return function_exists( 'aatb_model_tier' ) ? ucfirst( aatb_model_tier( $model ) ) : $model; } /* ……………………… admin screen ……………………… */ public static function labels(): array { return [ 'generate' => __( 'Generated alt text', 'ai-alt-text-builder' ), 'regenerate' => __( 'Regenerated alt text', 'ai-alt-text-builder' ), 'generate_failed' => __( 'Generation failed', 'ai-alt-text-builder' ), 'auto_generate' => __( 'Generated on upload', 'ai-alt-text-builder' ), 'bulk_start' => __( 'Bulk job started', 'ai-alt-text-builder' ), 'bulk_item' => __( 'Bulk job: image', 'ai-alt-text-builder' ), 'bulk_finish' => __( 'Bulk job ended', 'ai-alt-text-builder' ), 'settings' => __( 'Settings changed', 'ai-alt-text-builder' ), 'login' => __( 'Signed in', 'ai-alt-text-builder' ), 'logout' => __( 'Signed out', 'ai-alt-text-builder' ), 'login_failed' => __( 'Failed sign-in', 'ai-alt-text-builder' ), ]; } public static function menu(): void { add_submenu_page( 'ai-alt-text-builder', __( 'Activity log', 'ai-alt-text-builder' ), __( 'Activity log', 'ai-alt-text-builder' ), 'manage_options', self::PAGE, [ __CLASS__, 'render' ] ); } /** Rows for the screen and the CSV, newest first, optionally one action type. */ private static function rows( string $action, int $limit, int $offset ): array { global $wpdb; if ( '' !== $action ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the plugin's own log table. return (array) $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}aatb_log WHERE action = %s ORDER BY id DESC LIMIT %d OFFSET %d", $action, $limit, $offset ), ARRAY_A ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the plugin's own log table. return (array) $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}aatb_log ORDER BY id DESC LIMIT %d OFFSET %d", $limit, $offset ), ARRAY_A ); } private static function count( string $action ): int { global $wpdb; if ( '' !== $action ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the plugin's own log table. return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}aatb_log WHERE action = %s", $action ) ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the plugin's own log table. return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}aatb_log" ); } private static function requested_action(): string { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter on an admin screen. $a = isset( $_GET['log_action'] ) ? sanitize_key( wp_unslash( $_GET['log_action'] ) ) : ''; return array_key_exists( $a, self::labels() ) ? $a : ''; } public static function render(): void { if ( ! current_user_can( 'manage_options' ) ) return; self::maybe_install(); $labels = self::labels(); $action = self::requested_action(); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only pagination on an admin screen. $paged = max( 1, isset( $_GET['paged'] ) ? (int) $_GET['paged'] : 1 ); $total = self::count( $action ); $pages = max( 1, (int) ceil( $total / self::PER_PAGE ) ); $paged = min( $paged, $pages ); $rows = self::rows( $action, self::PER_PAGE, ( $paged - 1 ) * self::PER_PAGE ); $base = admin_url( 'admin.php?page=' . self::PAGE ); ?>
| ' . esc_html( get_the_title( $aid ) ?: '#' . $aid ) . ''; } elseif ( $aid ) { echo esc_html( '#' . $aid ); } ?> | |||||