*/ protected static $deleting = array(); /** * Memoized registry data for the current request. * * @var array|null */ 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. * * The scan outcome ships with the findings so the dashboard can tell a clean site from one whose * pages could not be read. Without it a blocked loopback renders as "no AI found", which is a * confident answer to a question that was never asked. * * @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' ); $systems = $this->systems( $fresh ); $read = $this->read_site(); return rest_ensure_response( array( 'systems' => $systems, 'undisclosed' => $this->undisclosed( $fresh ), 'scan' => array( 'ok' => (bool) $read['ok'], 'reason' => (string) $read['reason'], 'pages' => (int) $read['pages'], 'at' => (string) $read['at'], ), ) ); } /** * 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. * * Local mode answers from active plugins alone, with no HTTP at all, so the setup flow never opens * on a spinner or on a loopback failure. The full scan upgrades the result in the background. * * `staffing` records who actually replies: ai, human, mixed or unknown. Most detected chat tools are * live chat with an optional bot, and this is what stops the dashboard telling an owner to announce * an AI to visitors who are talking to a person. * * @since 1.2.6 * * @param bool $fresh Refetch the site's pages instead of using the cached copy. * @param bool $local_only Match active plugins only, making no HTTP request at all. * @return array Detected systems. */ public function systems( $fresh = false, $local_only = 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; } $read = $local_only ? array( 'js' => '', 'surface' => '', 'cookies' => array(), ) : $this->read_site( $fresh ); $js = isset( $read['js'] ) ? $read['js'] : ''; $surface = isset( $read['surface'] ) ? $read['surface'] : ''; $cookies = isset( $read['cookies'] ) ? $read['cookies'] : array(); $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 ( '' !== $surface || '' !== $js ) { foreach ( (array) ( isset( $signals['scripts'] ) ? $signals['scripts'] : array() ) as $needle ) { if ( '' !== $needle && false !== stripos( $surface, (string) $needle ) ) { $how['frontend'] = true; break; } } foreach ( (array) ( isset( $signals['globals'] ) ? $signals['globals'] : array() ) as $global ) { if ( self::has_global( $js, (string) $global ) ) { $how['global'] = true; break; } } } foreach ( (array) ( isset( $signals['cookies'] ) ? $signals['cookies'] : array() ) as $cookie ) { $prefix = strtolower( (string) $cookie ); if ( '' === $prefix ) { continue; } foreach ( $cookies as $set ) { if ( 0 === strpos( $set, $prefix ) ) { $how['cookie'] = true; break 2; } } } if ( empty( $how ) ) { continue; } $evidence = array_keys( $how ); $category = isset( $sys['category'] ) ? (string) $sys['category'] : 'other'; $found[] = array( 'id' => isset( $sys['id'] ) ? (string) $sys['id'] : '', 'name' => '' !== $name ? $name : ( isset( $sys['id'] ) ? (string) $sys['id'] : '' ), 'category' => $category, 'article' => self::article_for( $category ), 'confidence' => self::confidence_for( $evidence ), 'implication' => self::implication_for( $category ), 'staffing' => isset( $sys['staffing'] ) ? (string) $sys['staffing'] : 'unknown', 'makes_media' => ! empty( $sys['makes_media'] ), 'how' => $evidence, ); } return $found; } /** * URLs worth reading when looking for third-party widgets. * * The home page alone is the weakest possible sample: every script signal in the * registry is a chat or content widget, and those are routinely scoped to a * contact or about page and absent from the front page. Newest post and newest * page are cheap to resolve and cover the common placements. * * A shop gets its own request: chat and support widgets are routinely scoped to WooCommerce * templates, so a store front page can carry one the blog never shows. * * @since 1.3.0 * * @return string[] Absolute URLs on this site, home first. */ protected function scan_urls() { $urls = array( home_url( '/' ) ); $posts_page = (int) get_option( 'page_for_posts' ); if ( $posts_page > 0 ) { $urls[] = get_permalink( $posts_page ); } $types = array( 'post', 'page' ); if ( post_type_exists( 'product' ) ) { $shop = function_exists( 'wc_get_page_id' ) ? (int) wc_get_page_id( 'shop' ) : 0; if ( $shop > 0 ) { $urls[] = get_permalink( $shop ); } $types[] = 'product'; } foreach ( $types as $type ) { $recent = get_posts( array( 'post_type' => $type, 'post_status' => 'publish', 'posts_per_page' => 1, 'orderby' => 'modified', 'order' => 'DESC', 'fields' => 'ids', 'no_found_rows' => true, ) ); if ( ! empty( $recent[0] ) ) { $urls[] = get_permalink( (int) $recent[0] ); } } $urls = array_values( array_unique( array_filter( $urls ) ) ); /** * Filter the pages the detector reads when looking for AI widgets. * * Useful when a widget only appears somewhere the defaults do not reach, such as a * landing page, a checkout, or a members area. * * @since 2.0.0 * * @param string[] $urls Absolute URLs on this site. */ $urls = (array) apply_filters( 'aicl_scan_urls', $urls ); return array_slice( array_values( array_unique( array_filter( $urls ) ) ), 0, self::MAX_PAGES ); } /** * Read this site's own pages so widget signatures can be matched. * * Returns an outcome, never a bare string. A failed read and a genuinely clean * page are different findings, and collapsing them is how a tool ends up * reporting "all clear" about a page it never saw. Callers must be able to tell * the difference and say so. * * Certificates are verified because the response decides a compliance verdict. A local box with a * self-signed certificate is the one exception and has to opt in, rather than every production site * paying for it. * * A body under 512 bytes is treated as unread: that is a holding page, a login wall or an error, and * matching against it would produce a confident nothing. * * @since 1.3.0 * * @param bool $fresh Bypass the cache. * @return array{ok:bool,reason:string,js:string,surface:string,cookies:string[],pages:int,at:string} */ protected function read_site( $fresh = false ) { if ( ! $fresh ) { $cached = get_transient( self::HTML_CACHE ); if ( is_array( $cached ) && isset( $cached['ok'] ) ) { return $cached; } } $verify = ! in_array( wp_get_environment_type(), array( 'local', 'development' ), true ); $cookies = array(); $read = 0; $reason = ''; $js = ''; $surface = ''; foreach ( $this->scan_urls() as $url ) { $resp = $this->fetch_self( $url, $verify ); if ( is_wp_error( $resp ) ) { $reason = '' === $reason ? 'unreachable' : $reason; continue; } $code = (int) wp_remote_retrieve_response_code( $resp ); if ( 200 !== $code ) { $reason = '' === $reason ? ( 401 === $code || 403 === $code ? 'protected' : 'http_' . $code ) : $reason; continue; } $body = (string) wp_remote_retrieve_body( $resp ); if ( strlen( $body ) < 512 ) { $reason = '' === $reason ? 'empty' : $reason; continue; } if ( strlen( $body ) > self::MAX_BODY ) { $body = substr( $body, 0, self::MAX_BODY ); } $page_js = self::executable_js( $body ); $js .= "\n" . $page_js; $surface .= "\n" . self::loader_surface( $body, '' ); ++$read; foreach ( (array) wp_remote_retrieve_header( $resp, 'set-cookie' ) as $line ) { $cookies[] = strtolower( ltrim( (string) $line ) ); } if ( strlen( $js ) + strlen( $surface ) > self::MAX_HTML ) { break; } } $out = array( 'ok' => $read > 0, 'reason' => $read > 0 ? '' : ( '' !== $reason ? $reason : 'unreachable' ), 'js' => $js, 'surface' => $surface, 'cookies' => array_values( array_unique( $cookies ) ), 'pages' => $read, 'at' => gmdate( 'c' ), ); // Keep a good read for the full window; retry a failed one within minutes. set_transient( self::HTML_CACHE, $out, $out['ok'] ? 6 * HOUR_IN_SECONDS : 5 * MINUTE_IN_SECONDS ); return $out; } /** * Fetch one of the site's own pages, falling back to the server's own socket. * * A server cannot always reach itself by its public address: local installs (Docker, MAMP, Local) * publish on a port the web process does not listen on, firewalls and security plugins block * loopback requests, and an origin behind a proxy often cannot resolve its own public hostname. * WordPress ships a Site Health check for this because it is common, so treating it as simply * unreachable would hand a large number of perfectly healthy sites a permanent failure notice. * * The canonical URL is always tried first, since that is what a visitor gets. Only if it fails do * we retry the same path against the machine itself with a Host header naming the site, which * keeps WordPress serving the canonical page instead of redirecting. Certificate verification is * dropped for that retry alone: the request never leaves the machine, so there is nothing between * us and ourselves to intercept, and a loopback IP can never match the site's certificate. * * @since 1.3.1 * * @param string $url Absolute URL on this site. * @param bool $verify Whether to verify TLS on the public attempt. * @return array|WP_Error The response, or the last error when every attempt failed. */ protected function fetch_self( $url, $verify ) { $args = array( 'timeout' => 5, 'redirection' => 2, 'sslverify' => $verify, 'user-agent' => 'AIM-Transparency detector (self-scan)', ); $resp = wp_safe_remote_get( $url, $args ); if ( ! is_wp_error( $resp ) && 200 === (int) wp_remote_retrieve_response_code( $resp ) ) { return $resp; } $home = wp_parse_url( home_url( '/' ) ); $host = isset( $home['host'] ) ? $home['host'] : ''; if ( '' === $host ) { return $resp; } $header_host = $host . ( isset( $home['port'] ) ? ':' . (int) $home['port'] : '' ); $scheme = isset( $home['scheme'] ) ? $home['scheme'] : 'http'; $parts = wp_parse_url( $url ); $path = isset( $parts['path'] ) && '' !== $parts['path'] ? $parts['path'] : '/'; $suffix = $path . ( isset( $parts['query'] ) ? '?' . $parts['query'] : '' ); $args['sslverify'] = false; $args['headers'] = array( 'Host' => $header_host ); foreach ( $this->self_origins( $scheme ) as $origin ) { $alt = wp_remote_get( $origin . $suffix, $args ); if ( ! is_wp_error( $alt ) && 200 === (int) wp_remote_retrieve_response_code( $alt ) ) { return $alt; } } return $resp; } /** * Addresses this machine may answer on, most specific first. * * SERVER_ADDR / SERVER_PORT describe the socket actually serving the current request, so they are * the best guess available; the loopback addresses cover the case where those are absent (WP-CLI, * cron) or point somewhere unreachable. * * @since 1.3.1 * * @param string $scheme The site's scheme. * @return string[] Origins including scheme and port, without a trailing slash. */ protected function self_origins( $scheme ) { $origins = array(); $port = 'https' === $scheme ? 443 : 80; $addr = isset( $_SERVER['SERVER_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['SERVER_ADDR'] ) ) : ''; $sport = isset( $_SERVER['SERVER_PORT'] ) ? absint( wp_unslash( $_SERVER['SERVER_PORT'] ) ) : 0; if ( '' !== $addr && filter_var( $addr, FILTER_VALIDATE_IP ) ) { $bracket = false !== strpos( $addr, ':' ) ? '[' . $addr . ']' : $addr; if ( $sport > 0 ) { $origins[] = $scheme . '://' . $bracket . ':' . $sport; } $origins[] = $scheme . '://' . $bracket . ':' . $port; } if ( $sport > 0 ) { $origins[] = $scheme . '://127.0.0.1:' . $sport; } $origins[] = $scheme . '://127.0.0.1:' . $port; return array_values( array_unique( $origins ) ); } /** * The surface a widget loader can occupy: every URL attribute on the page, plus inline JavaScript. * * Script signatures are URL fragments like "widget.intercom.io". Matching them against the whole * document means an article that quotes a vendor's install snippet reads as that vendor running * here, and since the scan widened from the home page to posts and pages, a tutorial is exactly * the kind of page now being read. * * Attributes are read from real tags only. A tutorial that shows an install snippet writes it * escaped, which is text rather than a tag, so scanning the raw document for src= would read the * article as the widget. * * @since 1.3.0 * * @param string $raw Page HTML. * @param string $js Executable inline JavaScript already extracted from $raw. * @return string The concatenated loader surface. */ protected static function loader_surface( $raw, $js ) { $urls = array(); if ( preg_match_all( '#<[a-zA-Z][^>]*>#', $raw, $tags ) ) { foreach ( $tags[0] as $tag ) { if ( preg_match_all( '#\b(?:src|href|data-src)\s*=\s*["\']([^"\']+)#i', $tag, $a ) ) { foreach ( $a[1] as $url ) { $urls[] = $url; } } } } return implode( "\n", $urls ) . "\n" . $js; } /** * Concatenate only the executable-JavaScript blocks of a page. * * Ported from the site checker. Data blocks (application/json, ld+json, HTML * templates) are skipped so a competitor name sitting in a "Tidio vs Intercom" * nav blob is never read as that widget running. * * @since 1.3.0 * * @param string $raw Page HTML. * @return string The concatenated inline JavaScript. */ protected static function executable_js( $raw ) { if ( ! preg_match_all( '#]*)>([\s\S]*?)#i', $raw, $m, PREG_SET_ORDER ) ) { return ''; } $out = array(); foreach ( $m as $set ) { preg_match( '#(?:^|\s)type\s*=\s*["\']?([^"\'\s>]+)#i', $set[1], $t ); $type = isset( $t[1] ) ? strtolower( $t[1] ) : ''; if ( '' === $type || 'text/javascript' === $type || 'application/javascript' === $type || 'module' === $type || 'text/babel' === $type ) { $out[] = $set[2]; } } $js = implode( "\n", $out ); $js = preg_replace( '#/\*[\s\S]*?\*/#', ' ', $js ); $js = preg_replace( '#(^|[\r\n])\s*//[^\r\n]*#', '$1 ', $js ); return null === $js ? '' : $js; } /** * Whether a JS global appears in the source as a live code reference. * * Short globals like "zE", "gist" and "drift" would otherwise match inside "register" or * "adrift", so the character before must be a non-identifier. `$` is an identifier character * because several globals start with it ($crisp, $zopim, $zoho). * * The character after must additionally look like code: a global is being read or called, so * it is followed by one of . [ ( = ! ? : ; , ) | & { }, optionally after whitespace. Requiring * that is what separates "olark.identify(" from the word "drift" sitting in an English * sentence inside a string literal, which a plain word-boundary test happily matches. A * compliance plugin naming a chatbot the site does not run is worse than naming none. * * @since 1.3.0 * * @param string $haystack JavaScript source. * @param string $token Global identifier. * @return bool True when the token reads as a live reference to the global. */ protected static function has_global( $haystack, $token ) { if ( '' === $token || '' === $haystack ) { return false; } $before = '[^A-Za-z0-9_$"\'`]'; $after = '\s*[.\[(=!?:;,)|&{}]'; $re = '/(?:^|' . $before . ')' . preg_quote( $token, '/' ) . '(?:' . $after . '|$)/'; return 1 === preg_match( $re, $haystack ); } /* --- classifying a finding ------------------------------------------------ */ /** * How sure we are the system is really there. * * Seeing a script run on the front end is different from finding a folder on disk, and reporting * both with the same certainty is what makes a scanner easy to dismiss. * * @since 2.2.0 * * @param array $evidence The evidence keys recorded for this system. * @return string confirmed, likely, or possible. */ protected static function confidence_for( $evidence ) { $evidence = (array) $evidence; if ( in_array( 'frontend', $evidence, true ) || in_array( 'global', $evidence, true ) ) { return 'confirmed'; } if ( in_array( 'cookie', $evidence, true ) ) { return 'likely'; } return 'possible'; } /** * What, if anything, follows from the system being present. * * A chatbot reaches a visitor directly, so a disclosure duty plausibly attaches. An image or * text generator being installed says only that a capability exists: it does not establish that * anything was published with it, and publishing alone does not settle whether a duty applies. * Holding those apart is the difference between a finding and an accusation. * * @since 2.2.0 * * @param string $category The system's registry category. * @return string interactive_agent, capability_present, or none. */ protected static function implication_for( $category ) { if ( 'chatbot' === $category ) { return 'interactive_agent'; } if ( in_array( $category, array( 'image', 'content', 'audio', 'video', 'translation' ), true ) ) { return 'capability_present'; } return 'none'; } /** * The Article a finding can honestly be filed under. * * The registry files every generator under Article 50(2). That paragraph binds the provider of * the generative system, not the site running its output, so a site was being told it carried an * obligation that sits with its vendor. A generator on the site raises the question of 50(4), * and only for content that would pass as real; it does not settle it. * * @since 2.2.0 * * @param string $category The system's registry category. * @return string The Article reference, or an empty string when none applies. */ protected static function article_for( $category ) { if ( 'chatbot' === $category ) { return '50(1)'; } if ( in_array( $category, array( 'image', 'audio', 'video' ), true ) ) { return '50(4a)'; } if ( in_array( $category, array( 'content', 'translation' ), true ) ) { return '50(4b)'; } return ''; } /** * Reduces a page or site name to the part a person would recognise. * * Titles arrive carrying a tagline after a dash, colon or pipe. The leading segment is the * name; the rest is decoration that makes two records for the same site look like two sites. * * @since 1.3.1 * * @param string $name The raw name. * @return string The leading segment, or the original when splitting leaves nothing. */ 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. * * Each sampled row carries WHY it matched, because a screen that offers to mark these files has to * state the evidence per file rather than one generic sentence naming both possibilities. Without it * a row whose file literally declares trainedAlgorithmicMedia is presented in the same words as a row * that merely mentions a generator's name, and the owner is asked to tick on a claim we invented. * `sample` is kept alongside `rows` because the report screen reads bare ids and must not break. * * @since 1.2.4 * * @param bool $fresh Recompute even when a cached result exists. * @return array{count:int,sample:int[],rows:array,scanned:int} The undisclosed-image summary. */ public function undisclosed( $fresh = false ) { if ( ! $fresh ) { $cached = get_transient( self::CACHE ); if ( is_array( $cached ) && isset( $cached['rows'] ) ) { return $cached; } } $ids = $this->candidates(); $signatures = array_map( 'strtolower', array_filter( (array) $this->data()['generator_signatures'], 'is_string' ) ); $count = 0; $rows = array(); foreach ( $ids as $id ) { $reason = $this->ai_reason( (int) $id, $signatures ); if ( '' === $reason ) { continue; } ++$count; if ( count( $rows ) < 12 ) { $rows[] = array( 'id' => (int) $id, 'reason' => $reason, ); } } $result = array( 'count' => $count, 'sample' => wp_list_pluck( $rows, 'id' ), 'rows' => $rows, '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 ); } /** * Say why an attachment's file reads as AI-generated, or return an empty string when it does not. * * The reason is returned rather than a boolean so a caller can print the evidence next to the file * instead of asserting something about the picture. A declared DigitalSourceType is the file saying * so itself; a generator name in the header is far weaker, and the two must not be worded the same. * * @since 1.4.1 * * @param int $id The attachment ID. * @param string[] $signatures Lowercase generator-name needles. * @return string 'declared:', 'generator:', or '' when nothing was found. */ protected function ai_reason( $id, $signatures ) { $file = get_attached_file( $id ); if ( ! $file || ! is_readable( $file ) ) { return ''; } $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 ''; } $matched = array(); if ( preg_match( '#digitalsourcetype/(trainedAlgorithmicMedia|compositeWithTrainedAlgorithmicMedia)#i', $head, $matched ) ) { return 'declared:' . $matched[1]; } $haystack = strtolower( $head ); foreach ( $signatures as $needle ) { if ( '' !== $needle && false !== strpos( $haystack, $needle ) ) { return 'generator:' . $needle; } } return ''; } }