'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 * * @return WP_REST_Response The detected plugins and the undisclosed-image scan. */ public function rest_detect() { return rest_ensure_response( array( 'plugins' => $this->plugins(), 'undisclosed' => $this->undisclosed(), ) ); } /** * 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() { $file = AICL_DIR . 'data/ai-image-tools.json'; $data = array( 'plugins' => array(), 'generator_signatures' => array() ); if ( is_readable( $file ) ) { $decoded = json_decode( (string) file_get_contents( $file ), true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- bundled read-only data file. if ( is_array( $decoded ) ) { $data = array_merge( $data, $decoded ); } } /** * Filter the list of AI-image-capable plugins ({slug,name} entries). * * @since 1.2.4 * * @param array $plugins The curated plugin list. */ $data['plugins'] = (array) apply_filters( 'aicl_ai_image_plugins', $data['plugins'] ); return $data; } /** * Detect active plugins that can generate AI images. * * Only reports capability ("this tool can make AI images"); it never asserts that any * specific image is AI. * * @since 1.2.4 * * @return array Matched active plugins. */ public function plugins() { $active = (array) get_option( 'active_plugins', array() ); $slugs = array(); foreach ( $active as $path ) { $slugs[ strtok( (string) $path, '/' ) ] = true; } $found = array(); foreach ( $this->data()['plugins'] as $tool ) { if ( isset( $tool['slug'], $slugs[ $tool['slug'] ] ) ) { $found[] = array( 'slug' => (string) $tool['slug'], 'name' => isset( $tool['name'] ) ? (string) $tool['name'] : (string) $tool['slug'], ); } } return $found; } /** * 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) $this->data()['generator_signatures'] ); $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( $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 WHERE p.post_type = 'attachment' AND p.post_status = 'inherit' AND p.post_mime_type LIKE 'image/%%' AND m.post_id IS NULL ORDER BY p.ID DESC LIMIT %d", AICL_Plugin::META_KEY, 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; } }