Attachments mid-deletion; their meta removal is not a human "clear". */ protected static $deleting = array(); /** @var array|null Memoized registry data for the current request. */ protected $data_cache = null; /** * Register the detection REST route and keep the cached scan fresh. * * The library scan is cached for twelve hours, so any change to an image's flag must * invalidate that cache; otherwise a just-disclosed image keeps showing as undisclosed. * * @since 1.2.4 * * @return void */ public function hooks() { add_action( 'rest_api_init', array( $this, 'rest' ) ); add_action( 'added_post_meta', array( $this, 'on_flag_set' ), 10, 3 ); add_action( 'updated_post_meta', array( $this, 'on_flag_set' ), 10, 3 ); add_action( 'deleted_post_meta', array( $this, 'on_flag_cleared' ), 10, 3 ); add_action( 'delete_attachment', array( $this, 'note_deletion' ) ); add_action( 'add_attachment', array( $this, 'on_upload' ) ); } /** * A new attachment was uploaded: drop the cached library scan so a freshly uploaded image that * already carries AI provenance surfaces without waiting for the twelve-hour cache to expire. * * @since 1.2.6 * * @param int $post_id The new attachment id; unused. * @return void */ public function on_upload( $post_id ) { delete_transient( self::CACHE ); } /** * Record an attachment entering deletion, so clearing its flag is not treated as a human choice. * * @since 1.2.4 * * @param int $post_id The attachment being deleted. * @return void */ public function note_deletion( $post_id ) { self::$deleting[ (int) $post_id ] = true; } /** * A flag was set: drop the cached scan and lift any prior "left unflagged" decision. * * @since 1.2.4 * * @param int $meta_id The affected meta row id; unused. * @param int $post_id The attachment id. * @param string $meta_key The meta key that changed. * @return void */ public function on_flag_set( $meta_id, $post_id, $meta_key ) { if ( AICL_Plugin::META_KEY !== $meta_key ) { return; } delete_transient( self::CACHE ); delete_post_meta( (int) $post_id, self::REVIEWED ); } /** * A flag was cleared: drop the cached scan and, if a human did it, record the decision so the * scan stops surfacing an image the owner deliberately chose to leave unflagged. * * @since 1.2.4 * * @param int[] $meta_ids The deleted meta row ids; unused. * @param int $post_id The attachment id. * @param string $meta_key The meta key that was removed. * @return void */ public function on_flag_cleared( $meta_ids, $post_id, $meta_key ) { if ( AICL_Plugin::META_KEY !== $meta_key ) { return; } delete_transient( self::CACHE ); if ( ! isset( self::$deleting[ (int) $post_id ] ) && 'attachment' === get_post_type( $post_id ) ) { update_post_meta( (int) $post_id, self::REVIEWED, time() ); } } /** * Register the /detect route (admin-only, read-only). * * @since 1.2.4 * * @return void */ public function rest() { register_rest_route( 'aim-transparency/v1', '/detect', array( 'methods' => 'GET', 'permission_callback' => function () { return current_user_can( 'manage_options' ); }, 'callback' => array( $this, 'rest_detect' ), ) ); } /** * Return the full detection result for the dashboard. * * @since 1.2.4 * * @param WP_REST_Request $request The REST request; a truthy `fresh` param bypasses the cache. * @return WP_REST_Response The detected plugins and the undisclosed-image scan. */ public function rest_detect( $request ) { $fresh = (bool) $request->get_param( 'fresh' ); return rest_ensure_response( array( 'systems' => $this->systems( $fresh ), 'undisclosed' => $this->undisclosed( $fresh ), ) ); } /** * Load the curated tools data (plugins + generator signatures), filterable. * * @since 1.2.4 * * @return array{plugins:array,generator_signatures:string[]} The tools data. */ protected function data() { if ( null !== $this->data_cache ) { return $this->data_cache; } $file = AICL_DIR . 'data/ai-registry.json'; if ( ! is_readable( $file ) ) { $file = AICL_DIR . 'data/ai-image-tools.json'; } $data = array( 'systems' => array(), 'generator_signatures' => array() ); if ( is_readable( $file ) ) { $decoded = wp_json_file_decode( $file, array( 'associative' => true ) ); if ( is_array( $decoded ) ) { $data = array_merge( $data, $decoded ); } } // Back-compat: the legacy file keyed capable tools under `plugins`; map them into the systems shape. if ( empty( $data['systems'] ) && ! empty( $data['plugins'] ) ) { foreach ( (array) $data['plugins'] as $legacy ) { $slug = isset( $legacy['slug'] ) ? (string) $legacy['slug'] : ''; if ( '' === $slug ) { continue; } $data['systems'][] = array( 'id' => $slug, 'name' => isset( $legacy['name'] ) ? (string) $legacy['name'] : $slug, 'category' => 'image', 'article' => '50(2)', 'makes_media' => true, 'signals' => array( 'plugins' => array( $slug ) ), ); } } /** * Filter the AI systems registry (each entry: id, name, category, article, makes_media, signals). * * @since 1.2.6 * * @param array $systems The curated AI systems list. */ $data['systems'] = (array) apply_filters( 'aicl_ai_systems', $data['systems'] ); $this->data_cache = $data; return $data; } /** * Detect AI systems the site is running, by two local signals: active plugins from the * registry, and AI widgets whose script signatures appear in the site's own front page. * * Reports only presence ("this AI tool is on your site, so its output may need disclosure"); * it never asserts that a specific image or message is AI. * * @since 1.2.6 * * @param bool $fresh Refetch the front page instead of using the cached copy. * @return array Detected systems. */ public function systems( $fresh = false ) { $registry = $this->data()['systems']; $active = (array) get_option( 'active_plugins', array() ); $by_slug = array(); foreach ( $active as $path ) { $by_slug[ strtok( (string) $path, '/' ) ] = (string) $path; } $html = $this->frontend_html( $fresh ); $installed = null; // Loaded lazily only when a plugin signal matches, to skip the header disk-scan otherwise. $found = array(); foreach ( $registry as $sys ) { $signals = isset( $sys['signals'] ) && is_array( $sys['signals'] ) ? $sys['signals'] : array(); $how = array(); $name = isset( $sys['name'] ) ? (string) $sys['name'] : ''; foreach ( (array) ( isset( $signals['plugins'] ) ? $signals['plugins'] : array() ) as $slug ) { if ( isset( $by_slug[ (string) $slug ] ) ) { $how['plugin'] = true; if ( null === $installed ) { if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $installed = get_plugins(); } $file = $by_slug[ (string) $slug ]; if ( isset( $installed[ $file ]['Name'] ) ) { $name = $this->clean_name( (string) $installed[ $file ]['Name'] ); } break; } } if ( '' !== $html ) { foreach ( (array) ( isset( $signals['scripts'] ) ? $signals['scripts'] : array() ) as $needle ) { if ( '' !== $needle && false !== stripos( $html, (string) $needle ) ) { $how['frontend'] = true; break; } } } if ( empty( $how ) ) { continue; } $found[] = array( 'id' => isset( $sys['id'] ) ? (string) $sys['id'] : '', 'name' => '' !== $name ? $name : ( isset( $sys['id'] ) ? (string) $sys['id'] : '' ), 'category' => isset( $sys['category'] ) ? (string) $sys['category'] : 'other', 'article' => isset( $sys['article'] ) ? (string) $sys['article'] : '', 'makes_media' => ! empty( $sys['makes_media'] ), 'how' => array_keys( $how ), ); } return $found; } /** * Fetch and cache the site's OWN front page, so widget signatures can be scanned without a * network hit on every dashboard load. This is a loopback request to home_url(), not a * third-party service. * * @since 1.2.6 * * @param bool $fresh Bypass the six-hour cache and refetch. * @return string The front-page HTML, or an empty string when the fetch fails. */ protected function frontend_html( $fresh = false ) { if ( ! $fresh ) { $cached = get_transient( self::HTML_CACHE ); if ( is_string( $cached ) ) { return $cached; } } $resp = wp_remote_get( home_url( '/' ), array( 'timeout' => 5, 'redirection' => 2, 'sslverify' => false, 'user-agent' => 'AIM-Transparency detector (self-scan)', ) ); $html = ( ! is_wp_error( $resp ) && 200 === (int) wp_remote_retrieve_response_code( $resp ) ) ? (string) wp_remote_retrieve_body( $resp ) : ''; // Cache a real page for the full window, but retry a failed/empty fetch within minutes. $ttl = ( '' !== $html ) ? 6 * HOUR_IN_SECONDS : 5 * MINUTE_IN_SECONDS; set_transient( self::HTML_CACHE, $html, $ttl ); return $html; } /** * Reduce a WordPress plugin's display name to the recognizable product name. * * Strips a trailing marketing tagline (after a dash, colon, pipe or parenthesis) so the card * shows what the owner sees in their plugin list, without the "Chat. Create. Automate." fluff. * * @since 1.2.4 * * @param string $name The raw plugin header name. * @return string The trimmed core name. */ protected function clean_name( $name ) { $name = trim( wp_strip_all_tags( $name ) ); $parts = preg_split( '/\s+[-\x{2013}\x{2014}]\s+|\s*[:|(]/u', $name ); return ( is_array( $parts ) && '' !== trim( (string) $parts[0] ) ) ? trim( (string) $parts[0] ) : $name; } /** * Count unflagged images whose files read as AI-generated, with a small sample. * * Reads each candidate's head bytes and looks for a declared DigitalSourceType or a known * generator name. Result is capped and cached for twelve hours. * * @since 1.2.4 * * @param bool $fresh Recompute even when a cached result exists. * @return array{count:int,sample:int[],scanned:int} The undisclosed-image summary. */ public function undisclosed( $fresh = false ) { if ( ! $fresh ) { $cached = get_transient( self::CACHE ); if ( is_array( $cached ) ) { return $cached; } } $ids = $this->candidates(); $signatures = array_map( 'strtolower', array_filter( (array) $this->data()['generator_signatures'], 'is_string' ) ); $count = 0; $sample = array(); foreach ( $ids as $id ) { if ( $this->reads_as_ai( (int) $id, $signatures ) ) { $count++; if ( count( $sample ) < 12 ) { $sample[] = (int) $id; } } } $result = array( 'count' => $count, 'sample' => $sample, 'scanned' => count( $ids ), ); set_transient( self::CACHE, $result, 12 * HOUR_IN_SECONDS ); return $result; } /** * Query unflagged image attachments, newest first. * * @since 1.2.4 * * @return int[] Attachment IDs. */ protected function candidates() { global $wpdb; $ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- multi-join over posts + postmeta not expressible via WP_Query; the caller (undisclosed()) caches the result in a 12-hour transient. $wpdb->prepare( "SELECT p.ID FROM {$wpdb->posts} p LEFT JOIN {$wpdb->postmeta} m ON m.post_id = p.ID AND m.meta_key = %s LEFT JOIN {$wpdb->postmeta} r ON r.post_id = p.ID AND r.meta_key IN ( %s, %s ) WHERE p.post_type = 'attachment' AND p.post_status = 'inherit' AND p.post_mime_type LIKE %s AND m.post_id IS NULL AND r.post_id IS NULL ORDER BY p.ID DESC LIMIT %d", AICL_Plugin::META_KEY, self::REVIEWED, '_aimpro_scanned', $wpdb->esc_like( 'image/' ) . '%', self::SAMPLE ) ); return array_map( 'intval', $ids ); } /** * Determine whether an attachment's file reads as AI-generated. * * @since 1.2.4 * * @param int $id The attachment ID. * @param string[] $signatures Lowercase generator-name needles. * @return bool True when the file declares AI provenance or names a known generator. */ protected function reads_as_ai( $id, $signatures ) { $file = get_attached_file( $id ); if ( ! $file || ! is_readable( $file ) ) { return false; } $head = (string) @file_get_contents( $file, false, null, 0, self::HEAD ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPress.PHP.NoSilencedErrors.Discouraged -- reading raw image head bytes. if ( '' === $head ) { return false; } if ( preg_match( '#digitalsourcetype/(trainedAlgorithmicMedia|compositeWithTrainedAlgorithmicMedia)#i', $head ) ) { return true; } $haystack = strtolower( $head ); foreach ( $signatures as $needle ) { if ( '' !== $needle && false !== strpos( $haystack, $needle ) ) { return true; } } return false; } }