Plugin Directory

Changeset 3649865


Ignore:
Timestamp:
08/16/2026 03:05:48 PM (2 months ago)
Author:
geralddrissner
Message:

Update to version 3.0.1 from GitHub

Location:
ai-image-disclosure-labels
Files:
4 deleted
8 edited
1 copied

Legend:

Unmodified
Added
Removed
  • ai-image-disclosure-labels/tags/3.0.1/ai-image-disclosure-labels.php

    r3649807 r3649865  
    44 * Plugin URI:        https://github.com/gerald-drissner/ai-image-disclosure-labels
    55 * Description:       Adds visible AI disclosure labels for images and videos, Media Library classification, optional machine-readable source data, and optional AI-assisted image analysis.
    6  * Version:           3.0.0
     6 * Version:           3.0.1
    77 * Requires at least: 6.7
    88 * Requires PHP:      7.4
    … …  
    1616defined( 'ABSPATH' ) || exit;
    1717
    18 define( 'GDAIIDL_VERSION', '3.0.0' );
     18define( 'GDAIIDL_VERSION', '3.0.1' );
    1919define( 'GDAIIDL_FILE', __FILE__ );
    2020define( 'GDAIIDL_DIR', plugin_dir_path( __FILE__ ) );
  • ai-image-disclosure-labels/tags/3.0.1/includes/class-gdaiidl-ai-analysis.php

    r3649807 r3649865  
    557557                                <div>
    558558                                        <p><?php esc_html_e( 'AI analysis is optional and off by default. When it runs, a temporary resized image and a short prompt leave your WordPress server and are processed by the provider you selected. Images can contain personal or confidential information, so review the provider’s current terms, privacy/data-use rules and regional requirements before enabling analysis.', 'ai-image-disclosure-labels' ); ?></p>
    559                                         <ul>
    560                                                 <li><a href="https://openai.com/policies/" target="_blank" rel="noopener noreferrer">OpenAI terms &amp; policies</a></li>
    561                                                 <li><a href="https://ai.google.dev/gemini-api/terms" target="_blank" rel="noopener noreferrer">Google Gemini API terms</a></li>
    562                                                 <li><a href="https://privacy.anthropic.com/" target="_blank" rel="noopener noreferrer">Anthropic privacy &amp; commercial policy center</a></li>
    563                                                 <li><a href="https://www.cloudflare.com/policies/" target="_blank" rel="noopener noreferrer">Cloudflare policies</a></li>
    564                                         </ul>
    565                                         <p class="description"><?php esc_html_e( 'For an OpenAI-compatible or custom endpoint, you are responsible for reviewing and documenting that service’s own terms and privacy policy.', 'ai-image-disclosure-labels' ); ?></p>
     559                                        <p class="description"><?php esc_html_e( 'The WordPress.org readme lists the current service, terms and privacy-policy links for the built-in providers. For an OpenAI-compatible or custom endpoint, you are responsible for reviewing and documenting that service’s own terms and privacy policy.', 'ai-image-disclosure-labels' ); ?></p>
    566560                                </div>
    567561                        </details>
    … …  
    604598                                                <li><?php esc_html_e( 'Optionally support action="models" for dynamic model/policy discovery and return usage or cost_usd when your router knows the real request cost.', 'ai-image-disclosure-labels' ); ?></li>
    605599                                        </ol>
    606                                         <p class="description"><a href="https://developers.cloudflare.com/workers-ai/configuration/bindings/" target="_blank" rel="noopener noreferrer"><?php esc_html_e( 'Cloudflare: Workers AI bindings', 'ai-image-disclosure-labels' ); ?></a> · <a href="https://developers.cloudflare.com/workers/get-started/dashboard/" target="_blank" rel="noopener noreferrer"><?php esc_html_e( 'Cloudflare: create a Worker in the dashboard', 'ai-image-disclosure-labels' ); ?></a></p>
     600                                        <p class="description"><?php esc_html_e( 'Cloudflare’s current Workers AI and Worker setup documentation is listed in the WordPress.org readme together with the other external-service information.', 'ai-image-disclosure-labels' ); ?></p>
    607601                                        <p><?php esc_html_e( 'For analysis, the plugin POSTs JSON containing action="analyze", the configured model/policy, a short classification prompt and a resized base64 image. Return the fields below. resolved_model, usage, cost_usd, pricing, evidence and verified_provenance are optional.', 'ai-image-disclosure-labels' ); ?></p>
    608602                                        <pre class="gdaiidl-ai-contract">{
    … …  
    704698                                <input type="password" name="<?php echo esc_attr( self::SECRET_OPTION . '[' . $key . ']' ); ?>" value="" autocomplete="new-password" placeholder="<?php echo esc_attr( $from_const ? __( 'Supplied by wp-config.php', 'ai-image-disclosure-labels' ) : ( $stored ? __( 'Saved – leave blank to keep', 'ai-image-disclosure-labels' ) : __( 'Paste secret here', 'ai-image-disclosure-labels' ) ) ); ?>" <?php disabled( $from_const ); ?>>
    705699                                <?php if ( $stored && ! $from_const ) : ?><small><label><input type="checkbox" name="<?php echo esc_attr( self::SECRET_OPTION . '[clear_' . $key . ']' ); ?>" value="1"> <?php esc_html_e( 'Remove saved key on Save Changes', 'ai-image-disclosure-labels' ); ?></label></small><?php endif; ?>
    706                                 <?php if ( $from_const ) : ?><small><?php echo esc_html( sprintf( __( 'Using constant %s.', 'ai-image-disclosure-labels' ), $constant ) ); ?></small><?php endif; ?>
     700                                <?php if ( $from_const ) : ?>
     701                                        <small>
     702                                                <?php
     703                                                /* translators: %s: wp-config.php constant name. */
     704                                                echo esc_html( sprintf( __( 'Using constant %s.', 'ai-image-disclosure-labels' ), $constant ) );
     705                                                ?>
     706                                        </small>
     707                                <?php endif; ?>
    707708                        </label>
    708709                </div>
    … …  
    994995                $count = absint( wp_unslash( $_GET['gdaiidl_analysis_processed'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
    995996                $job = isset( $_GET['gdaiidl_analysis_job'] ) ? sanitize_key( wp_unslash( $_GET['gdaiidl_analysis_job'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
    996                 $message = $job ? sprintf( __( 'AI analysis queued for %d selected image(s). It will run in background batches.', 'ai-image-disclosure-labels' ), $count ) : sprintf( __( '%d selected image(s) processed.', 'ai-image-disclosure-labels' ), $count );
     997                if ( $job ) {
     998                        /* translators: %d: number of selected images queued for analysis. */
     999                        $message = sprintf( __( 'AI analysis queued for %d selected image(s). It will run in background batches.', 'ai-image-disclosure-labels' ), $count );
     1000                } else {
     1001                        /* translators: %d: number of selected images processed. */
     1002                        $message = sprintf( __( '%d selected image(s) processed.', 'ai-image-disclosure-labels' ), $count );
     1003                }
    9971004                echo '<div class="notice notice-success is-dismissible"><p>' . esc_html( $message ) . '</p></div>';
    9981005        }
    … …  
    10211028         */
    10221029        public function ajax_fetch_models() {
    1023                 $this->check_ajax_admin();
     1030                $request = $this->ajax_request();
    10241031                $context = array(
    1025                         'provider'                   => isset( $_POST['provider'] ) ? sanitize_key( wp_unslash( $_POST['provider'] ) ) : '',
    1026                         'cloudflare_account_id'      => isset( $_POST['cloudflare_account_id'] ) ? sanitize_text_field( wp_unslash( $_POST['cloudflare_account_id'] ) ) : '',
    1027                         'custom_endpoint'            => isset( $_POST['custom_endpoint'] ) ? $this->sanitize_https_endpoint( wp_unslash( $_POST['custom_endpoint'] ) ) : '',
    1028                         'compatible_models_endpoint' => isset( $_POST['compatible_models_endpoint'] ) ? $this->sanitize_https_endpoint( wp_unslash( $_POST['compatible_models_endpoint'] ) ) : '',
     1032                        'provider'                   => $request['provider'],
     1033                        'cloudflare_account_id'      => $request['cloudflare_account_id'],
     1034                        'custom_endpoint'            => $request['custom_endpoint'],
     1035                        'compatible_models_endpoint' => $request['compatible_models_endpoint'],
    10291036                );
    10301037                $result = $this->fetch_models( $context, true );
    … …  
    10431050         */
    10441051        public function ajax_test_model() {
    1045                 $this->check_ajax_admin();
    1046                 $provider = isset( $_POST['provider'] ) ? sanitize_key( wp_unslash( $_POST['provider'] ) ) : '';
    1047                 $model    = isset( $_POST['model'] ) ? substr( sanitize_text_field( wp_unslash( $_POST['model'] ) ), 0, 220 ) : '';
     1052                $request  = $this->ajax_request();
     1053                $provider = $request['provider'];
     1054                $model    = $request['model'];
    10481055                $providers = $this->providers();
    10491056                if ( ! isset( $providers[ $provider ] ) || ( 'wordpress_ai_client' !== $provider && '' === trim( $model ) ) ) {
    … …  
    10551062                                'provider'                   => $provider,
    10561063                                'model'                      => $model,
    1057                                 'cloudflare_account_id'      => isset( $_POST['cloudflare_account_id'] ) ? substr( sanitize_text_field( wp_unslash( $_POST['cloudflare_account_id'] ) ), 0, 120 ) : '',
    1058                                 'custom_endpoint'            => isset( $_POST['custom_endpoint'] ) ? $this->sanitize_https_endpoint( wp_unslash( $_POST['custom_endpoint'] ) ) : '',
    1059                                 'compatible_endpoint'        => isset( $_POST['compatible_endpoint'] ) ? $this->sanitize_https_endpoint( wp_unslash( $_POST['compatible_endpoint'] ) ) : '',
    1060                                 'compatible_models_endpoint' => isset( $_POST['compatible_models_endpoint'] ) ? $this->sanitize_https_endpoint( wp_unslash( $_POST['compatible_models_endpoint'] ) ) : '',
     1064                                'cloudflare_account_id'      => $request['cloudflare_account_id'],
     1065                                'custom_endpoint'            => $request['custom_endpoint'],
     1066                                'compatible_endpoint'        => $request['compatible_endpoint'],
     1067                                'compatible_models_endpoint' => $request['compatible_models_endpoint'],
    10611068                        ),
    10621069                        $this->settings()
    … …  
    11221129        /** AJAX prepare job. */
    11231130        public function ajax_prepare_job() {
    1124                 $this->check_ajax_admin();
    1125                 $scope = isset( $_POST['scope'] ) ? sanitize_key( wp_unslash( $_POST['scope'] ) ) : '';
     1131                $request = $this->ajax_request();
     1132                $scope = $request['scope'];
    11261133                if ( ! in_array( $scope, array( 'all', 'unclassified' ), true ) ) {
    11271134                        wp_send_json_error( array( 'message' => __( 'Invalid analysis scope.', 'ai-image-disclosure-labels' ) ), 400 );
    … …  
    11391146        /** AJAX start job. */
    11401147        public function ajax_start_job() {
    1141                 $this->check_ajax_admin();
    1142                 $scope = isset( $_POST['scope'] ) ? sanitize_key( wp_unslash( $_POST['scope'] ) ) : '';
     1148                $request = $this->ajax_request();
     1149                $scope = $request['scope'];
    11431150                if ( ! in_array( $scope, array( 'all', 'unclassified' ), true ) || ! $this->is_configured() ) {
    11441151                        wp_send_json_error( array( 'message' => __( 'Analysis is not fully configured.', 'ai-image-disclosure-labels' ) ), 400 );
    … …  
    11531160        /** AJAX status. */
    11541161        public function ajax_job_status() {
    1155                 $this->check_ajax_admin();
    1156                 $job_id = isset( $_POST['job_id'] ) ? sanitize_key( wp_unslash( $_POST['job_id'] ) ) : '';
     1162                $request = $this->ajax_request();
     1163                $job_id = $request['job_id'];
    11571164                wp_send_json_success( array( 'html' => $this->job_status_html( $job_id ), 'job' => $this->get_job( $job_id ) ) );
    11581165        }
    … …  
    11601167        /** AJAX cancel. */
    11611168        public function ajax_cancel_job() {
    1162                 $this->check_ajax_admin();
    1163                 $job_id = isset( $_POST['job_id'] ) ? sanitize_key( wp_unslash( $_POST['job_id'] ) ) : '';
     1169                $request = $this->ajax_request();
     1170                $job_id = $request['job_id'];
    11641171                $jobs = $this->jobs();
    11651172                if ( isset( $jobs[ $job_id ] ) && in_array( $jobs[ $job_id ]['status'], array( 'queued', 'running' ), true ) ) {
    … …  
    11721179
    11731180        /**
    1174          * Check AJAX auth.
    1175          *
    1176          * @return void
    1177          */
    1178         private function check_ajax_admin() {
     1181         * Authenticate and sanitize an AI-admin AJAX request.
     1182         *
     1183         * Keeping nonce verification and input access in the same method makes the
     1184         * security boundary explicit to both reviewers and static analysis.
     1185         *
     1186         * @return array<string,string> Sanitized request values.
     1187         */
     1188        private function ajax_request() {
    11791189                check_ajax_referer( 'gdaiidl_ai_admin', 'nonce' );
     1190
    11801191                if ( ! current_user_can( $this->analysis_capability() ) ) {
    11811192                        wp_send_json_error( array( 'message' => __( 'You do not have permission to manage AI analysis.', 'ai-image-disclosure-labels' ) ), 403 );
    11821193                }
     1194
     1195                $custom_endpoint = isset( $_POST['custom_endpoint'] )
     1196                        ? esc_url_raw( wp_unslash( $_POST['custom_endpoint'] ), array( 'https' ) )
     1197                        : '';
     1198                $compatible_endpoint = isset( $_POST['compatible_endpoint'] )
     1199                        ? esc_url_raw( wp_unslash( $_POST['compatible_endpoint'] ), array( 'https' ) )
     1200                        : '';
     1201                $compatible_models_endpoint = isset( $_POST['compatible_models_endpoint'] )
     1202                        ? esc_url_raw( wp_unslash( $_POST['compatible_models_endpoint'] ), array( 'https' ) )
     1203                        : '';
     1204
     1205                return array(
     1206                        'provider'                   => isset( $_POST['provider'] ) ? sanitize_key( wp_unslash( $_POST['provider'] ) ) : '',
     1207                        'model'                      => isset( $_POST['model'] ) ? substr( sanitize_text_field( wp_unslash( $_POST['model'] ) ), 0, 220 ) : '',
     1208                        'cloudflare_account_id'      => isset( $_POST['cloudflare_account_id'] ) ? substr( sanitize_text_field( wp_unslash( $_POST['cloudflare_account_id'] ) ), 0, 120 ) : '',
     1209                        'custom_endpoint'            => $this->sanitize_https_endpoint( $custom_endpoint ),
     1210                        'compatible_endpoint'        => $this->sanitize_https_endpoint( $compatible_endpoint ),
     1211                        'compatible_models_endpoint' => $this->sanitize_https_endpoint( $compatible_models_endpoint ),
     1212                        'scope'                      => isset( $_POST['scope'] ) ? sanitize_key( wp_unslash( $_POST['scope'] ) ) : '',
     1213                        'job_id'                     => isset( $_POST['job_id'] ) ? sanitize_key( wp_unslash( $_POST['job_id'] ) ) : '',
     1214                );
    11831215        }
    11841216
    … …  
    14451477        private function count_scope_images( $scope ) {
    14461478                global $wpdb;
     1479                $image_mime_like = $wpdb->esc_like( 'image/' ) . '%';
     1480
    14471481                if ( 'unclassified' === $scope ) {
    1448                         $sql = "SELECT COUNT(DISTINCT p.ID) FROM {$wpdb->posts} p LEFT JOIN {$wpdb->postmeta} pm ON (p.ID = pm.post_id AND pm.meta_key = %s) WHERE p.post_type = 'attachment' AND p.post_status <> 'trash' AND p.post_mime_type LIKE 'image/%%' AND (pm.meta_id IS NULL OR pm.meta_value = '' OR pm.meta_value NOT IN ('no-ai','generated','modified','edited','enhanced'))";
    1449                         return (int) $wpdb->get_var( $wpdb->prepare( $sql, GDAIIDL_Plugin::META_MEDIA_SOURCE_TYPE ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
    1450                 }
    1451                 $sql = "SELECT COUNT(ID) FROM {$wpdb->posts} WHERE post_type = 'attachment' AND post_status <> 'trash' AND post_mime_type LIKE 'image/%%'";
    1452                 return (int) $wpdb->get_var( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
     1482                        $query = $wpdb->prepare(
     1483                                "SELECT COUNT(DISTINCT p.ID)
     1484                                FROM %i p
     1485                                LEFT JOIN %i pm ON (p.ID = pm.post_id AND pm.meta_key = %s)
     1486                                WHERE p.post_type = 'attachment'
     1487                                AND p.post_status <> 'trash'
     1488                                AND p.post_mime_type LIKE %s
     1489                                AND (pm.meta_id IS NULL OR pm.meta_value = '' OR pm.meta_value NOT IN ('no-ai','generated','modified','edited','enhanced'))",
     1490                                $wpdb->posts,
     1491                                $wpdb->postmeta,
     1492                                GDAIIDL_Plugin::META_MEDIA_SOURCE_TYPE,
     1493                                $image_mime_like
     1494                        );
     1495                        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared -- Query was prepared immediately above; admin-only background-job count is intentionally uncached.
     1496                        return (int) $wpdb->get_var( $query );
     1497                }
     1498
     1499                $query = $wpdb->prepare(
     1500                        "SELECT COUNT(ID)
     1501                        FROM %i
     1502                        WHERE post_type = 'attachment'
     1503                        AND post_status <> 'trash'
     1504                        AND post_mime_type LIKE %s",
     1505                        $wpdb->posts,
     1506                        'image/%'
     1507                );
     1508                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared -- Query was prepared immediately above; admin-only background-job count is intentionally uncached.
     1509                return (int) $wpdb->get_var( $query );
    14531510        }
    14541511
    … …  
    14631520        private function query_scope_ids_after( $scope, $after_id, $limit ) {
    14641521                global $wpdb;
    1465                 $limit = max( 1, min( 50, (int) $limit ) );
    1466                 $after_id = max( 0, (int) $after_id );
     1522                $limit           = max( 1, min( 50, (int) $limit ) );
     1523                $after_id        = max( 0, (int) $after_id );
     1524                $image_mime_like = $wpdb->esc_like( 'image/' ) . '%';
     1525
    14671526                if ( 'unclassified' === $scope ) {
    1468                         $sql = "SELECT DISTINCT p.ID FROM {$wpdb->posts} p LEFT JOIN {$wpdb->postmeta} pm ON (p.ID = pm.post_id AND pm.meta_key = %s) WHERE p.post_type = 'attachment' AND p.post_status <> 'trash' AND p.post_mime_type LIKE 'image/%%' AND p.ID > %d AND (pm.meta_id IS NULL OR pm.meta_value = '' OR pm.meta_value NOT IN ('no-ai','generated','modified','edited','enhanced')) ORDER BY p.ID ASC LIMIT %d";
    1469                         $prepared = $wpdb->prepare( $sql, GDAIIDL_Plugin::META_MEDIA_SOURCE_TYPE, $after_id, $limit );
     1527                        $prepared = $wpdb->prepare(
     1528                                "SELECT DISTINCT p.ID
     1529                                FROM %i p
     1530                                LEFT JOIN %i pm ON (p.ID = pm.post_id AND pm.meta_key = %s)
     1531                                WHERE p.post_type = 'attachment'
     1532                                AND p.post_status <> 'trash'
     1533                                AND p.post_mime_type LIKE %s
     1534                                AND p.ID > %d
     1535                                AND (pm.meta_id IS NULL OR pm.meta_value = '' OR pm.meta_value NOT IN ('no-ai','generated','modified','edited','enhanced'))
     1536                                ORDER BY p.ID ASC
     1537                                LIMIT %d",
     1538                                $wpdb->posts,
     1539                                $wpdb->postmeta,
     1540                                GDAIIDL_Plugin::META_MEDIA_SOURCE_TYPE,
     1541                                $image_mime_like,
     1542                                $after_id,
     1543                                $limit
     1544                        );
    14701545                } else {
    1471                         $sql = "SELECT ID FROM {$wpdb->posts} WHERE post_type = 'attachment' AND post_status <> 'trash' AND post_mime_type LIKE 'image/%%' AND ID > %d ORDER BY ID ASC LIMIT %d";
    1472                         $prepared = $wpdb->prepare( $sql, $after_id, $limit );
    1473                 }
    1474                 return array_map( 'absint', (array) $wpdb->get_col( $prepared ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
     1546                        $prepared = $wpdb->prepare(
     1547                                "SELECT ID
     1548                                FROM %i
     1549                                WHERE post_type = 'attachment'
     1550                                AND post_status <> 'trash'
     1551                                AND post_mime_type LIKE %s
     1552                                AND ID > %d
     1553                                ORDER BY ID ASC
     1554                                LIMIT %d",
     1555                                $wpdb->posts,
     1556                                $image_mime_like,
     1557                                $after_id,
     1558                                $limit
     1559                        );
     1560                }
     1561
     1562                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared -- Query was prepared immediately above; cursor-based background-job query is intentionally uncached.
     1563                return array_map( 'absint', (array) $wpdb->get_col( $prepared ) );
    14751564        }
    14761565
    … …  
    15711660                $saved = $editor->save( $tmp, 'image/jpeg' );
    15721661                if ( is_wp_error( $saved ) || empty( $saved['path'] ) || ! is_readable( $saved['path'] ) ) {
    1573                         @unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
     1662                        wp_delete_file( $tmp );
    15741663                        return is_wp_error( $saved ) ? $saved : new WP_Error( 'gdaiidl_temp_failed', __( 'Could not write the temporary analysis image.', 'ai-image-disclosure-labels' ) );
    15751664                }
    … …  
    15771666                $temp_size = @filesize( $saved['path'] ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- temporary file can disappear; handled below.
    15781667                if ( false === $temp_size || $temp_size <= 0 || $temp_size > $max_bytes ) {
    1579                         @unlink( $saved['path'] ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
     1668                        wp_delete_file( $saved['path'] );
    15801669                        if ( $saved['path'] !== $tmp && file_exists( $tmp ) ) {
    1581                                 @unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
     1670                                wp_delete_file( $tmp );
    15821671                        }
    15831672                        return new WP_Error( 'gdaiidl_temp_size', __( 'The temporary analysis image is unexpectedly large or unreadable.', 'ai-image-disclosure-labels' ) );
    15841673                }
    15851674                $data = file_get_contents( $saved['path'] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- bounded temporary file.
    1586                 @unlink( $saved['path'] ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
     1675                wp_delete_file( $saved['path'] );
    15871676                if ( $saved['path'] !== $tmp && file_exists( $tmp ) ) {
    1588                         @unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
     1677                        wp_delete_file( $tmp );
    15891678                }
    15901679                if ( false === $data || '' === $data || strlen( $data ) > $max_bytes ) {
    … …  
    16511740                }
    16521741                foreach ( $candidates as $candidate ) {
    1653                         if ( is_string( $candidate ) && '' !== trim( $candidate ) ) { return sprintf( __( 'Provider error %1$d: %2$s', 'ai-image-disclosure-labels' ), $code, sanitize_text_field( $candidate ) ); }
     1742                        if ( is_string( $candidate ) && '' !== trim( $candidate ) ) {
     1743                                /* translators: 1: HTTP/status code, 2: provider error message. */
     1744                                return sprintf( __( 'Provider error %1$d: %2$s', 'ai-image-disclosure-labels' ), $code, sanitize_text_field( $candidate ) );
     1745                        }
    16541746                }
    16551747                $raw = trim( wp_strip_all_tags( (string) $raw ) );
    1656                 return sprintf( __( 'Provider returned HTTP %d.%s', 'ai-image-disclosure-labels' ), $code, '' !== $raw ? ' ' . substr( $raw, 0, 300 ) : '' );
     1748                /* translators: 1: HTTP status code, 2: optional provider response excerpt including its leading space. */
     1749                return sprintf( __( 'Provider returned HTTP %1$d.%2$s', 'ai-image-disclosure-labels' ), $code, '' !== $raw ? ' ' . substr( $raw, 0, 300 ) : '' );
    16571750        }
    16581751
    … …  
    24172510                $status_labels = array( 'queued' => __( 'Queued', 'ai-image-disclosure-labels' ), 'running' => __( 'Running', 'ai-image-disclosure-labels' ), 'completed' => __( 'Completed', 'ai-image-disclosure-labels' ), 'cancelled' => __( 'Cancelled', 'ai-image-disclosure-labels' ), 'stopped_cost' => __( 'Stopped at cost limit', 'ai-image-disclosure-labels' ), 'stopped_config' => __( 'Stopped because settings changed', 'ai-image-disclosure-labels' ) );
    24182511                $status = isset( $status_labels[ $job['status'] ] ) ? $status_labels[ $job['status'] ] : $job['status'];
    2419                 $html = '<div class="gdaiidl-ai-job" data-job-id="' . esc_attr( $job_id ) . '"><p><strong>' . esc_html( $status ) . '</strong> · ' . esc_html( sprintf( __( '%1$d / %2$d images (%3$d%%)', 'ai-image-disclosure-labels' ), $processed, (int) $job['total'], $percent ) ) . '</p>';
     2512                /* translators: 1: processed image count, 2: total image count, 3: completion percentage. */
     2513                $progress_text = sprintf( __( '%1$d / %2$d images (%3$d%%)', 'ai-image-disclosure-labels' ), $processed, (int) $job['total'], $percent );
     2514                /* translators: 1: AI-generated count, 2: AI-modified count, 3: likely non-AI count, 4: uncertain count, 5: failed count. */
     2515                $result_text = sprintf( __( 'Generated: %1$d · Modified: %2$d · Likely non-AI: %3$d · Uncertain: %4$d · Failed: %5$d', 'ai-image-disclosure-labels' ), (int) $job['generated'], (int) $job['modified'], (int) $job['non_ai'], (int) $job['uncertain'], (int) $job['failed'] );
     2516                /* translators: 1: known or estimated US-dollar cost, 2: number of requests for which cost was unavailable. */
     2517                $cost_text = sprintf( __( 'Known/estimated cost recorded by the plugin: $%1$.6f · Requests with unknown cost: %2$d', 'ai-image-disclosure-labels' ), (float) $job['known_cost'], (int) $job['unknown_cost_count'] );
     2518
     2519                $html = '<div class="gdaiidl-ai-job" data-job-id="' . esc_attr( $job_id ) . '"><p><strong>' . esc_html( $status ) . '</strong> · ' . esc_html( $progress_text ) . '</p>';
    24202520                $html .= '<div class="gdaiidl-ai-progress"><span style="width:' . esc_attr( $percent ) . '%"></span></div>';
    2421                 $html .= '<p class="description">' . esc_html( sprintf( __( 'Generated: %1$d · Modified: %2$d · Likely non-AI: %3$d · Uncertain: %4$d · Failed: %5$d', 'ai-image-disclosure-labels' ), (int) $job['generated'], (int) $job['modified'], (int) $job['non_ai'], (int) $job['uncertain'], (int) $job['failed'] ) ) . '</p>';
    2422                 $html .= '<p class="description">' . esc_html( sprintf( __( 'Known/estimated cost recorded by the plugin: $%1$.6f · Requests with unknown cost: %2$d', 'ai-image-disclosure-labels' ), (float) $job['known_cost'], (int) $job['unknown_cost_count'] ) ) . '</p>';
     2521                $html .= '<p class="description">' . esc_html( $result_text ) . '</p>';
     2522                $html .= '<p class="description">' . esc_html( $cost_text ) . '</p>';
    24232523                if ( ! empty( $job['last_error'] ) ) { $html .= '<p class="description gdaiidl-ai-error">' . esc_html( $job['last_error'] ) . '</p>'; }
    24242524                if ( in_array( $job['status'], array( 'queued', 'running' ), true ) ) { $html .= '<p><button type="button" class="button-link-delete gdaiidl-ai-cancel-job" data-job-id="' . esc_attr( $job_id ) . '">' . esc_html__( 'Cancel job', 'ai-image-disclosure-labels' ) . '</button></p>'; }
  • ai-image-disclosure-labels/tags/3.0.1/includes/class-gdaiidl-plugin.php

    r3649807 r3649865  
    10281028
    10291029                $settings = $this->settings();
    1030                 $clauses  = array();
    1031                 $values   = array();
    1032 
    1033                 if ( ! empty( $settings['enable_images'] ) ) {
    1034                         $clauses[] = '%i.post_mime_type LIKE %s';
    1035                         $values[]  = $wpdb->posts;
    1036                         $values[]  = $wpdb->esc_like( 'image/' ) . '%';
    1037                 }
    1038 
    1039                 if ( ! empty( $settings['enable_videos'] ) ) {
    1040                         $clauses[] = '%i.post_mime_type LIKE %s';
    1041                         $values[]  = $wpdb->posts;
    1042                         $values[]  = $wpdb->esc_like( 'video/' ) . '%';
    1043                 }
    1044 
    1045                 if ( empty( $clauses ) ) {
     1030                $images_enabled = ! empty( $settings['enable_images'] );
     1031                $videos_enabled = ! empty( $settings['enable_videos'] );
     1032
     1033                if ( ! $images_enabled && ! $videos_enabled ) {
    10461034                        return $where . ' AND 1=0';
    10471035                }
    10481036
    1049                 return $where . $wpdb->prepare( ' AND ( ' . implode( ' OR ', $clauses ) . ' )', ...$values );
     1037                if ( $images_enabled && $videos_enabled ) {
     1038                        return $where . $wpdb->prepare(
     1039                                ' AND ( %i.post_mime_type LIKE %s OR %i.post_mime_type LIKE %s )',
     1040                                $wpdb->posts,
     1041                                $wpdb->esc_like( 'image/' ) . '%',
     1042                                $wpdb->posts,
     1043                                $wpdb->esc_like( 'video/' ) . '%'
     1044                        );
     1045                }
     1046
     1047                return $where . $wpdb->prepare(
     1048                        ' AND %i.post_mime_type LIKE %s',
     1049                        $wpdb->posts,
     1050                        $wpdb->esc_like( $images_enabled ? 'image/' : 'video/' ) . '%'
     1051                );
    10501052        }
    10511053
  • ai-image-disclosure-labels/tags/3.0.1/readme.txt

    r3649807 r3649865  
    66Tested up to: 7.1
    77Requires PHP: 7.4
    8 Stable tag: 3.0.0
     8Stable tag: 3.0.1
    99License: GPLv2 or later
    1010License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    3333* Media Library list tools: an AI status column, status filter and grouped bulk actions make it possible to classify many images and videos without opening them individually.
    3434* Optional automatic visible disclosures for AI-classified Media Library images and videos; this remains off by default.
    35 * Optional AI-assisted image analysis through the WordPress 7.0+ AI Client / configured Connectors when available, or directly through OpenAI, Google Gemini, Anthropic Claude, Cloudflare Workers AI, an OpenAI-compatible HTTPS endpoint, or a custom HTTPS analysis endpoint. Automated analysis remains image-only in 3.0.0; videos are classified manually.
     35* Optional AI-assisted image analysis through the WordPress 7.0+ AI Client / configured Connectors when available, or directly through OpenAI, Google Gemini, Anthropic Claude, Cloudflare Workers AI, an OpenAI-compatible HTTPS endpoint, or a custom HTTPS analysis endpoint. Automated analysis remains image-only in the 3.0.x series; videos are classified manually.
    3636* Dynamic model discovery where the provider exposes a Models API or, in WordPress AI Client mode, through the configured AI provider registry; model names are never hard-coded and manual model/policy names remain supported.
    3737* Provider-independent custom HTTPS endpoint contract for Cloudflare Workers, AI Gateway, dedicated detectors and other future services.
    … …  
    133133= Can the plugin automatically detect AI-generated images? =
    134134
    135 Version 3.0.0 can optionally ask a configured external vision-capable AI service to assess an image and store a suggestion such as "likely AI-generated", "likely AI-modified", "likely non-AI" or "uncertain". This is probabilistic analysis, not forensic proof. General vision models can be wrong; an absent watermark or a "likely non-AI" answer does not prove that no AI was used.
     135Version 3.0.x can optionally ask a configured external vision-capable AI service to assess an image and store a suggestion such as "likely AI-generated", "likely AI-modified", "likely non-AI" or "uncertain". This is probabilistic analysis, not forensic proof. General vision models can be wrong; an absent watermark or a "likely non-AI" answer does not prove that no AI was used.
    136136The built-in direct-provider adapters perform visual analysis; they do not claim to cryptographically verify C2PA, SynthID or proprietary provider watermarks. If you operate a custom endpoint that performs genuine provenance/watermark verification, it can return `verified_provenance=true` and evidence labels. Automatic application of that stronger signal is a separate opt-in setting.
    137137
    … …  
    158158= How are AI videos labelled? =
    159159
    160 For the core WordPress Video block, enable the AI disclosure in the block sidebar or classify the uploaded video in the Media Library and enable automatic Media Library labels. The disclosure appears immediately below the video, outside the playback surface. This avoids covering native controls and works with captions because it is inserted before the figure caption. Optional machine-readable output uses a Schema.org `VideoObject`. AI-assisted frame/video analysis is not included in 3.0.0.
     160For the core WordPress Video block, enable the AI disclosure in the block sidebar or classify the uploaded video in the Media Library and enable automatic Media Library labels. The disclosure appears immediately below the video, outside the playback surface. This avoids covering native controls and works with captions because it is inserted before the figure caption. Optional machine-readable output uses a Schema.org `VideoObject`. AI-assisted frame/video analysis is not included in the 3.0.x series.
    161161
    162162= Does AI analysis send my images to another service? =
    … …  
    219219
    220220== Changelog ==
     221
     222= 3.0.1 =
     223* Release-hardening update following the official WordPress Plugin Check run for 3.0.0.
     224* Consolidated AI-admin AJAX input handling so nonce verification, capability checks and sanitization are explicit in the same request boundary.
     225* Replaced temporary-file `unlink()` calls with WordPress `wp_delete_file()`.
     226* Reworked the Media Library MIME SQL filter to use fixed prepared query shapes instead of dynamically assembled placeholders.
     227* Hardened background-job SQL preparation and documented the narrow, admin-only direct-query exceptions used for cursor-based library scans.
     228* Added missing translator comments and ordered placeholders in translatable strings.
     229* Removed remote documentation links from PHP-rendered admin markup; provider terms/privacy/service links remain clearly documented in `readme.txt`.
     230* Updated the distribution ignore rules so GitHub-only release notes and audit documents are never included in the WordPress.org production ZIP.
    221231
    222232= 3.0.0 =
  • ai-image-disclosure-labels/trunk/ai-image-disclosure-labels.php

    r3649807 r3649865  
    44 * Plugin URI:        https://github.com/gerald-drissner/ai-image-disclosure-labels
    55 * Description:       Adds visible AI disclosure labels for images and videos, Media Library classification, optional machine-readable source data, and optional AI-assisted image analysis.
    6  * Version:           3.0.0
     6 * Version:           3.0.1
    77 * Requires at least: 6.7
    88 * Requires PHP:      7.4
    … …  
    1616defined( 'ABSPATH' ) || exit;
    1717
    18 define( 'GDAIIDL_VERSION', '3.0.0' );
     18define( 'GDAIIDL_VERSION', '3.0.1' );
    1919define( 'GDAIIDL_FILE', __FILE__ );
    2020define( 'GDAIIDL_DIR', plugin_dir_path( __FILE__ ) );
  • ai-image-disclosure-labels/trunk/includes/class-gdaiidl-ai-analysis.php

    r3649807 r3649865  
    557557                                <div>
    558558                                        <p><?php esc_html_e( 'AI analysis is optional and off by default. When it runs, a temporary resized image and a short prompt leave your WordPress server and are processed by the provider you selected. Images can contain personal or confidential information, so review the provider’s current terms, privacy/data-use rules and regional requirements before enabling analysis.', 'ai-image-disclosure-labels' ); ?></p>
    559                                         <ul>
    560                                                 <li><a href="https://openai.com/policies/" target="_blank" rel="noopener noreferrer">OpenAI terms &amp; policies</a></li>
    561                                                 <li><a href="https://ai.google.dev/gemini-api/terms" target="_blank" rel="noopener noreferrer">Google Gemini API terms</a></li>
    562                                                 <li><a href="https://privacy.anthropic.com/" target="_blank" rel="noopener noreferrer">Anthropic privacy &amp; commercial policy center</a></li>
    563                                                 <li><a href="https://www.cloudflare.com/policies/" target="_blank" rel="noopener noreferrer">Cloudflare policies</a></li>
    564                                         </ul>
    565                                         <p class="description"><?php esc_html_e( 'For an OpenAI-compatible or custom endpoint, you are responsible for reviewing and documenting that service’s own terms and privacy policy.', 'ai-image-disclosure-labels' ); ?></p>
     559                                        <p class="description"><?php esc_html_e( 'The WordPress.org readme lists the current service, terms and privacy-policy links for the built-in providers. For an OpenAI-compatible or custom endpoint, you are responsible for reviewing and documenting that service’s own terms and privacy policy.', 'ai-image-disclosure-labels' ); ?></p>
    566560                                </div>
    567561                        </details>
    … …  
    604598                                                <li><?php esc_html_e( 'Optionally support action="models" for dynamic model/policy discovery and return usage or cost_usd when your router knows the real request cost.', 'ai-image-disclosure-labels' ); ?></li>
    605599                                        </ol>
    606                                         <p class="description"><a href="https://developers.cloudflare.com/workers-ai/configuration/bindings/" target="_blank" rel="noopener noreferrer"><?php esc_html_e( 'Cloudflare: Workers AI bindings', 'ai-image-disclosure-labels' ); ?></a> · <a href="https://developers.cloudflare.com/workers/get-started/dashboard/" target="_blank" rel="noopener noreferrer"><?php esc_html_e( 'Cloudflare: create a Worker in the dashboard', 'ai-image-disclosure-labels' ); ?></a></p>
     600                                        <p class="description"><?php esc_html_e( 'Cloudflare’s current Workers AI and Worker setup documentation is listed in the WordPress.org readme together with the other external-service information.', 'ai-image-disclosure-labels' ); ?></p>
    607601                                        <p><?php esc_html_e( 'For analysis, the plugin POSTs JSON containing action="analyze", the configured model/policy, a short classification prompt and a resized base64 image. Return the fields below. resolved_model, usage, cost_usd, pricing, evidence and verified_provenance are optional.', 'ai-image-disclosure-labels' ); ?></p>
    608602                                        <pre class="gdaiidl-ai-contract">{
    … …  
    704698                                <input type="password" name="<?php echo esc_attr( self::SECRET_OPTION . '[' . $key . ']' ); ?>" value="" autocomplete="new-password" placeholder="<?php echo esc_attr( $from_const ? __( 'Supplied by wp-config.php', 'ai-image-disclosure-labels' ) : ( $stored ? __( 'Saved – leave blank to keep', 'ai-image-disclosure-labels' ) : __( 'Paste secret here', 'ai-image-disclosure-labels' ) ) ); ?>" <?php disabled( $from_const ); ?>>
    705699                                <?php if ( $stored && ! $from_const ) : ?><small><label><input type="checkbox" name="<?php echo esc_attr( self::SECRET_OPTION . '[clear_' . $key . ']' ); ?>" value="1"> <?php esc_html_e( 'Remove saved key on Save Changes', 'ai-image-disclosure-labels' ); ?></label></small><?php endif; ?>
    706                                 <?php if ( $from_const ) : ?><small><?php echo esc_html( sprintf( __( 'Using constant %s.', 'ai-image-disclosure-labels' ), $constant ) ); ?></small><?php endif; ?>
     700                                <?php if ( $from_const ) : ?>
     701                                        <small>
     702                                                <?php
     703                                                /* translators: %s: wp-config.php constant name. */
     704                                                echo esc_html( sprintf( __( 'Using constant %s.', 'ai-image-disclosure-labels' ), $constant ) );
     705                                                ?>
     706                                        </small>
     707                                <?php endif; ?>
    707708                        </label>
    708709                </div>
    … …  
    994995                $count = absint( wp_unslash( $_GET['gdaiidl_analysis_processed'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
    995996                $job = isset( $_GET['gdaiidl_analysis_job'] ) ? sanitize_key( wp_unslash( $_GET['gdaiidl_analysis_job'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
    996                 $message = $job ? sprintf( __( 'AI analysis queued for %d selected image(s). It will run in background batches.', 'ai-image-disclosure-labels' ), $count ) : sprintf( __( '%d selected image(s) processed.', 'ai-image-disclosure-labels' ), $count );
     997                if ( $job ) {
     998                        /* translators: %d: number of selected images queued for analysis. */
     999                        $message = sprintf( __( 'AI analysis queued for %d selected image(s). It will run in background batches.', 'ai-image-disclosure-labels' ), $count );
     1000                } else {
     1001                        /* translators: %d: number of selected images processed. */
     1002                        $message = sprintf( __( '%d selected image(s) processed.', 'ai-image-disclosure-labels' ), $count );
     1003                }
    9971004                echo '<div class="notice notice-success is-dismissible"><p>' . esc_html( $message ) . '</p></div>';
    9981005        }
    … …  
    10211028         */
    10221029        public function ajax_fetch_models() {
    1023                 $this->check_ajax_admin();
     1030                $request = $this->ajax_request();
    10241031                $context = array(
    1025                         'provider'                   => isset( $_POST['provider'] ) ? sanitize_key( wp_unslash( $_POST['provider'] ) ) : '',
    1026                         'cloudflare_account_id'      => isset( $_POST['cloudflare_account_id'] ) ? sanitize_text_field( wp_unslash( $_POST['cloudflare_account_id'] ) ) : '',
    1027                         'custom_endpoint'            => isset( $_POST['custom_endpoint'] ) ? $this->sanitize_https_endpoint( wp_unslash( $_POST['custom_endpoint'] ) ) : '',
    1028                         'compatible_models_endpoint' => isset( $_POST['compatible_models_endpoint'] ) ? $this->sanitize_https_endpoint( wp_unslash( $_POST['compatible_models_endpoint'] ) ) : '',
     1032                        'provider'                   => $request['provider'],
     1033                        'cloudflare_account_id'      => $request['cloudflare_account_id'],
     1034                        'custom_endpoint'            => $request['custom_endpoint'],
     1035                        'compatible_models_endpoint' => $request['compatible_models_endpoint'],
    10291036                );
    10301037                $result = $this->fetch_models( $context, true );
    … …  
    10431050         */
    10441051        public function ajax_test_model() {
    1045                 $this->check_ajax_admin();
    1046                 $provider = isset( $_POST['provider'] ) ? sanitize_key( wp_unslash( $_POST['provider'] ) ) : '';
    1047                 $model    = isset( $_POST['model'] ) ? substr( sanitize_text_field( wp_unslash( $_POST['model'] ) ), 0, 220 ) : '';
     1052                $request  = $this->ajax_request();
     1053                $provider = $request['provider'];
     1054                $model    = $request['model'];
    10481055                $providers = $this->providers();
    10491056                if ( ! isset( $providers[ $provider ] ) || ( 'wordpress_ai_client' !== $provider && '' === trim( $model ) ) ) {
    … …  
    10551062                                'provider'                   => $provider,
    10561063                                'model'                      => $model,
    1057                                 'cloudflare_account_id'      => isset( $_POST['cloudflare_account_id'] ) ? substr( sanitize_text_field( wp_unslash( $_POST['cloudflare_account_id'] ) ), 0, 120 ) : '',
    1058                                 'custom_endpoint'            => isset( $_POST['custom_endpoint'] ) ? $this->sanitize_https_endpoint( wp_unslash( $_POST['custom_endpoint'] ) ) : '',
    1059                                 'compatible_endpoint'        => isset( $_POST['compatible_endpoint'] ) ? $this->sanitize_https_endpoint( wp_unslash( $_POST['compatible_endpoint'] ) ) : '',
    1060                                 'compatible_models_endpoint' => isset( $_POST['compatible_models_endpoint'] ) ? $this->sanitize_https_endpoint( wp_unslash( $_POST['compatible_models_endpoint'] ) ) : '',
     1064                                'cloudflare_account_id'      => $request['cloudflare_account_id'],
     1065                                'custom_endpoint'            => $request['custom_endpoint'],
     1066                                'compatible_endpoint'        => $request['compatible_endpoint'],
     1067                                'compatible_models_endpoint' => $request['compatible_models_endpoint'],
    10611068                        ),
    10621069                        $this->settings()
    … …  
    11221129        /** AJAX prepare job. */
    11231130        public function ajax_prepare_job() {
    1124                 $this->check_ajax_admin();
    1125                 $scope = isset( $_POST['scope'] ) ? sanitize_key( wp_unslash( $_POST['scope'] ) ) : '';
     1131                $request = $this->ajax_request();
     1132                $scope = $request['scope'];
    11261133                if ( ! in_array( $scope, array( 'all', 'unclassified' ), true ) ) {
    11271134                        wp_send_json_error( array( 'message' => __( 'Invalid analysis scope.', 'ai-image-disclosure-labels' ) ), 400 );
    … …  
    11391146        /** AJAX start job. */
    11401147        public function ajax_start_job() {
    1141                 $this->check_ajax_admin();
    1142                 $scope = isset( $_POST['scope'] ) ? sanitize_key( wp_unslash( $_POST['scope'] ) ) : '';
     1148                $request = $this->ajax_request();
     1149                $scope = $request['scope'];
    11431150                if ( ! in_array( $scope, array( 'all', 'unclassified' ), true ) || ! $this->is_configured() ) {
    11441151                        wp_send_json_error( array( 'message' => __( 'Analysis is not fully configured.', 'ai-image-disclosure-labels' ) ), 400 );
    … …  
    11531160        /** AJAX status. */
    11541161        public function ajax_job_status() {
    1155                 $this->check_ajax_admin();
    1156                 $job_id = isset( $_POST['job_id'] ) ? sanitize_key( wp_unslash( $_POST['job_id'] ) ) : '';
     1162                $request = $this->ajax_request();
     1163                $job_id = $request['job_id'];
    11571164                wp_send_json_success( array( 'html' => $this->job_status_html( $job_id ), 'job' => $this->get_job( $job_id ) ) );
    11581165        }
    … …  
    11601167        /** AJAX cancel. */
    11611168        public function ajax_cancel_job() {
    1162                 $this->check_ajax_admin();
    1163                 $job_id = isset( $_POST['job_id'] ) ? sanitize_key( wp_unslash( $_POST['job_id'] ) ) : '';
     1169                $request = $this->ajax_request();
     1170                $job_id = $request['job_id'];
    11641171                $jobs = $this->jobs();
    11651172                if ( isset( $jobs[ $job_id ] ) && in_array( $jobs[ $job_id ]['status'], array( 'queued', 'running' ), true ) ) {
    … …  
    11721179
    11731180        /**
    1174          * Check AJAX auth.
    1175          *
    1176          * @return void
    1177          */
    1178         private function check_ajax_admin() {
     1181         * Authenticate and sanitize an AI-admin AJAX request.
     1182         *
     1183         * Keeping nonce verification and input access in the same method makes the
     1184         * security boundary explicit to both reviewers and static analysis.
     1185         *
     1186         * @return array<string,string> Sanitized request values.
     1187         */
     1188        private function ajax_request() {
    11791189                check_ajax_referer( 'gdaiidl_ai_admin', 'nonce' );
     1190
    11801191                if ( ! current_user_can( $this->analysis_capability() ) ) {
    11811192                        wp_send_json_error( array( 'message' => __( 'You do not have permission to manage AI analysis.', 'ai-image-disclosure-labels' ) ), 403 );
    11821193                }
     1194
     1195                $custom_endpoint = isset( $_POST['custom_endpoint'] )
     1196                        ? esc_url_raw( wp_unslash( $_POST['custom_endpoint'] ), array( 'https' ) )
     1197                        : '';
     1198                $compatible_endpoint = isset( $_POST['compatible_endpoint'] )
     1199                        ? esc_url_raw( wp_unslash( $_POST['compatible_endpoint'] ), array( 'https' ) )
     1200                        : '';
     1201                $compatible_models_endpoint = isset( $_POST['compatible_models_endpoint'] )
     1202                        ? esc_url_raw( wp_unslash( $_POST['compatible_models_endpoint'] ), array( 'https' ) )
     1203                        : '';
     1204
     1205                return array(
     1206                        'provider'                   => isset( $_POST['provider'] ) ? sanitize_key( wp_unslash( $_POST['provider'] ) ) : '',
     1207                        'model'                      => isset( $_POST['model'] ) ? substr( sanitize_text_field( wp_unslash( $_POST['model'] ) ), 0, 220 ) : '',
     1208                        'cloudflare_account_id'      => isset( $_POST['cloudflare_account_id'] ) ? substr( sanitize_text_field( wp_unslash( $_POST['cloudflare_account_id'] ) ), 0, 120 ) : '',
     1209                        'custom_endpoint'            => $this->sanitize_https_endpoint( $custom_endpoint ),
     1210                        'compatible_endpoint'        => $this->sanitize_https_endpoint( $compatible_endpoint ),
     1211                        'compatible_models_endpoint' => $this->sanitize_https_endpoint( $compatible_models_endpoint ),
     1212                        'scope'                      => isset( $_POST['scope'] ) ? sanitize_key( wp_unslash( $_POST['scope'] ) ) : '',
     1213                        'job_id'                     => isset( $_POST['job_id'] ) ? sanitize_key( wp_unslash( $_POST['job_id'] ) ) : '',
     1214                );
    11831215        }
    11841216
    … …  
    14451477        private function count_scope_images( $scope ) {
    14461478                global $wpdb;
     1479                $image_mime_like = $wpdb->esc_like( 'image/' ) . '%';
     1480
    14471481                if ( 'unclassified' === $scope ) {
    1448                         $sql = "SELECT COUNT(DISTINCT p.ID) FROM {$wpdb->posts} p LEFT JOIN {$wpdb->postmeta} pm ON (p.ID = pm.post_id AND pm.meta_key = %s) WHERE p.post_type = 'attachment' AND p.post_status <> 'trash' AND p.post_mime_type LIKE 'image/%%' AND (pm.meta_id IS NULL OR pm.meta_value = '' OR pm.meta_value NOT IN ('no-ai','generated','modified','edited','enhanced'))";
    1449                         return (int) $wpdb->get_var( $wpdb->prepare( $sql, GDAIIDL_Plugin::META_MEDIA_SOURCE_TYPE ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
    1450                 }
    1451                 $sql = "SELECT COUNT(ID) FROM {$wpdb->posts} WHERE post_type = 'attachment' AND post_status <> 'trash' AND post_mime_type LIKE 'image/%%'";
    1452                 return (int) $wpdb->get_var( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
     1482                        $query = $wpdb->prepare(
     1483                                "SELECT COUNT(DISTINCT p.ID)
     1484                                FROM %i p
     1485                                LEFT JOIN %i pm ON (p.ID = pm.post_id AND pm.meta_key = %s)
     1486                                WHERE p.post_type = 'attachment'
     1487                                AND p.post_status <> 'trash'
     1488                                AND p.post_mime_type LIKE %s
     1489                                AND (pm.meta_id IS NULL OR pm.meta_value = '' OR pm.meta_value NOT IN ('no-ai','generated','modified','edited','enhanced'))",
     1490                                $wpdb->posts,
     1491                                $wpdb->postmeta,
     1492                                GDAIIDL_Plugin::META_MEDIA_SOURCE_TYPE,
     1493                                $image_mime_like
     1494                        );
     1495                        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared -- Query was prepared immediately above; admin-only background-job count is intentionally uncached.
     1496                        return (int) $wpdb->get_var( $query );
     1497                }
     1498
     1499                $query = $wpdb->prepare(
     1500                        "SELECT COUNT(ID)
     1501                        FROM %i
     1502                        WHERE post_type = 'attachment'
     1503                        AND post_status <> 'trash'
     1504                        AND post_mime_type LIKE %s",
     1505                        $wpdb->posts,
     1506                        'image/%'
     1507                );
     1508                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared -- Query was prepared immediately above; admin-only background-job count is intentionally uncached.
     1509                return (int) $wpdb->get_var( $query );
    14531510        }
    14541511
    … …  
    14631520        private function query_scope_ids_after( $scope, $after_id, $limit ) {
    14641521                global $wpdb;
    1465                 $limit = max( 1, min( 50, (int) $limit ) );
    1466                 $after_id = max( 0, (int) $after_id );
     1522                $limit           = max( 1, min( 50, (int) $limit ) );
     1523                $after_id        = max( 0, (int) $after_id );
     1524                $image_mime_like = $wpdb->esc_like( 'image/' ) . '%';
     1525
    14671526                if ( 'unclassified' === $scope ) {
    1468                         $sql = "SELECT DISTINCT p.ID FROM {$wpdb->posts} p LEFT JOIN {$wpdb->postmeta} pm ON (p.ID = pm.post_id AND pm.meta_key = %s) WHERE p.post_type = 'attachment' AND p.post_status <> 'trash' AND p.post_mime_type LIKE 'image/%%' AND p.ID > %d AND (pm.meta_id IS NULL OR pm.meta_value = '' OR pm.meta_value NOT IN ('no-ai','generated','modified','edited','enhanced')) ORDER BY p.ID ASC LIMIT %d";
    1469                         $prepared = $wpdb->prepare( $sql, GDAIIDL_Plugin::META_MEDIA_SOURCE_TYPE, $after_id, $limit );
     1527                        $prepared = $wpdb->prepare(
     1528                                "SELECT DISTINCT p.ID
     1529                                FROM %i p
     1530                                LEFT JOIN %i pm ON (p.ID = pm.post_id AND pm.meta_key = %s)
     1531                                WHERE p.post_type = 'attachment'
     1532                                AND p.post_status <> 'trash'
     1533                                AND p.post_mime_type LIKE %s
     1534                                AND p.ID > %d
     1535                                AND (pm.meta_id IS NULL OR pm.meta_value = '' OR pm.meta_value NOT IN ('no-ai','generated','modified','edited','enhanced'))
     1536                                ORDER BY p.ID ASC
     1537                                LIMIT %d",
     1538                                $wpdb->posts,
     1539                                $wpdb->postmeta,
     1540                                GDAIIDL_Plugin::META_MEDIA_SOURCE_TYPE,
     1541                                $image_mime_like,
     1542                                $after_id,
     1543                                $limit
     1544                        );
    14701545                } else {
    1471                         $sql = "SELECT ID FROM {$wpdb->posts} WHERE post_type = 'attachment' AND post_status <> 'trash' AND post_mime_type LIKE 'image/%%' AND ID > %d ORDER BY ID ASC LIMIT %d";
    1472                         $prepared = $wpdb->prepare( $sql, $after_id, $limit );
    1473                 }
    1474                 return array_map( 'absint', (array) $wpdb->get_col( $prepared ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
     1546                        $prepared = $wpdb->prepare(
     1547                                "SELECT ID
     1548                                FROM %i
     1549                                WHERE post_type = 'attachment'
     1550                                AND post_status <> 'trash'
     1551                                AND post_mime_type LIKE %s
     1552                                AND ID > %d
     1553                                ORDER BY ID ASC
     1554                                LIMIT %d",
     1555                                $wpdb->posts,
     1556                                $image_mime_like,
     1557                                $after_id,
     1558                                $limit
     1559                        );
     1560                }
     1561
     1562                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared -- Query was prepared immediately above; cursor-based background-job query is intentionally uncached.
     1563                return array_map( 'absint', (array) $wpdb->get_col( $prepared ) );
    14751564        }
    14761565
    … …  
    15711660                $saved = $editor->save( $tmp, 'image/jpeg' );
    15721661                if ( is_wp_error( $saved ) || empty( $saved['path'] ) || ! is_readable( $saved['path'] ) ) {
    1573                         @unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
     1662                        wp_delete_file( $tmp );
    15741663                        return is_wp_error( $saved ) ? $saved : new WP_Error( 'gdaiidl_temp_failed', __( 'Could not write the temporary analysis image.', 'ai-image-disclosure-labels' ) );
    15751664                }
    … …  
    15771666                $temp_size = @filesize( $saved['path'] ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- temporary file can disappear; handled below.
    15781667                if ( false === $temp_size || $temp_size <= 0 || $temp_size > $max_bytes ) {
    1579                         @unlink( $saved['path'] ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
     1668                        wp_delete_file( $saved['path'] );
    15801669                        if ( $saved['path'] !== $tmp && file_exists( $tmp ) ) {
    1581                                 @unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
     1670                                wp_delete_file( $tmp );
    15821671                        }
    15831672                        return new WP_Error( 'gdaiidl_temp_size', __( 'The temporary analysis image is unexpectedly large or unreadable.', 'ai-image-disclosure-labels' ) );
    15841673                }
    15851674                $data = file_get_contents( $saved['path'] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- bounded temporary file.
    1586                 @unlink( $saved['path'] ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
     1675                wp_delete_file( $saved['path'] );
    15871676                if ( $saved['path'] !== $tmp && file_exists( $tmp ) ) {
    1588                         @unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
     1677                        wp_delete_file( $tmp );
    15891678                }
    15901679                if ( false === $data || '' === $data || strlen( $data ) > $max_bytes ) {
    … …  
    16511740                }
    16521741                foreach ( $candidates as $candidate ) {
    1653                         if ( is_string( $candidate ) && '' !== trim( $candidate ) ) { return sprintf( __( 'Provider error %1$d: %2$s', 'ai-image-disclosure-labels' ), $code, sanitize_text_field( $candidate ) ); }
     1742                        if ( is_string( $candidate ) && '' !== trim( $candidate ) ) {
     1743                                /* translators: 1: HTTP/status code, 2: provider error message. */
     1744                                return sprintf( __( 'Provider error %1$d: %2$s', 'ai-image-disclosure-labels' ), $code, sanitize_text_field( $candidate ) );
     1745                        }
    16541746                }
    16551747                $raw = trim( wp_strip_all_tags( (string) $raw ) );
    1656                 return sprintf( __( 'Provider returned HTTP %d.%s', 'ai-image-disclosure-labels' ), $code, '' !== $raw ? ' ' . substr( $raw, 0, 300 ) : '' );
     1748                /* translators: 1: HTTP status code, 2: optional provider response excerpt including its leading space. */
     1749                return sprintf( __( 'Provider returned HTTP %1$d.%2$s', 'ai-image-disclosure-labels' ), $code, '' !== $raw ? ' ' . substr( $raw, 0, 300 ) : '' );
    16571750        }
    16581751
    … …  
    24172510                $status_labels = array( 'queued' => __( 'Queued', 'ai-image-disclosure-labels' ), 'running' => __( 'Running', 'ai-image-disclosure-labels' ), 'completed' => __( 'Completed', 'ai-image-disclosure-labels' ), 'cancelled' => __( 'Cancelled', 'ai-image-disclosure-labels' ), 'stopped_cost' => __( 'Stopped at cost limit', 'ai-image-disclosure-labels' ), 'stopped_config' => __( 'Stopped because settings changed', 'ai-image-disclosure-labels' ) );
    24182511                $status = isset( $status_labels[ $job['status'] ] ) ? $status_labels[ $job['status'] ] : $job['status'];
    2419                 $html = '<div class="gdaiidl-ai-job" data-job-id="' . esc_attr( $job_id ) . '"><p><strong>' . esc_html( $status ) . '</strong> · ' . esc_html( sprintf( __( '%1$d / %2$d images (%3$d%%)', 'ai-image-disclosure-labels' ), $processed, (int) $job['total'], $percent ) ) . '</p>';
     2512                /* translators: 1: processed image count, 2: total image count, 3: completion percentage. */
     2513                $progress_text = sprintf( __( '%1$d / %2$d images (%3$d%%)', 'ai-image-disclosure-labels' ), $processed, (int) $job['total'], $percent );
     2514                /* translators: 1: AI-generated count, 2: AI-modified count, 3: likely non-AI count, 4: uncertain count, 5: failed count. */
     2515                $result_text = sprintf( __( 'Generated: %1$d · Modified: %2$d · Likely non-AI: %3$d · Uncertain: %4$d · Failed: %5$d', 'ai-image-disclosure-labels' ), (int) $job['generated'], (int) $job['modified'], (int) $job['non_ai'], (int) $job['uncertain'], (int) $job['failed'] );
     2516                /* translators: 1: known or estimated US-dollar cost, 2: number of requests for which cost was unavailable. */
     2517                $cost_text = sprintf( __( 'Known/estimated cost recorded by the plugin: $%1$.6f · Requests with unknown cost: %2$d', 'ai-image-disclosure-labels' ), (float) $job['known_cost'], (int) $job['unknown_cost_count'] );
     2518
     2519                $html = '<div class="gdaiidl-ai-job" data-job-id="' . esc_attr( $job_id ) . '"><p><strong>' . esc_html( $status ) . '</strong> · ' . esc_html( $progress_text ) . '</p>';
    24202520                $html .= '<div class="gdaiidl-ai-progress"><span style="width:' . esc_attr( $percent ) . '%"></span></div>';
    2421                 $html .= '<p class="description">' . esc_html( sprintf( __( 'Generated: %1$d · Modified: %2$d · Likely non-AI: %3$d · Uncertain: %4$d · Failed: %5$d', 'ai-image-disclosure-labels' ), (int) $job['generated'], (int) $job['modified'], (int) $job['non_ai'], (int) $job['uncertain'], (int) $job['failed'] ) ) . '</p>';
    2422                 $html .= '<p class="description">' . esc_html( sprintf( __( 'Known/estimated cost recorded by the plugin: $%1$.6f · Requests with unknown cost: %2$d', 'ai-image-disclosure-labels' ), (float) $job['known_cost'], (int) $job['unknown_cost_count'] ) ) . '</p>';
     2521                $html .= '<p class="description">' . esc_html( $result_text ) . '</p>';
     2522                $html .= '<p class="description">' . esc_html( $cost_text ) . '</p>';
    24232523                if ( ! empty( $job['last_error'] ) ) { $html .= '<p class="description gdaiidl-ai-error">' . esc_html( $job['last_error'] ) . '</p>'; }
    24242524                if ( in_array( $job['status'], array( 'queued', 'running' ), true ) ) { $html .= '<p><button type="button" class="button-link-delete gdaiidl-ai-cancel-job" data-job-id="' . esc_attr( $job_id ) . '">' . esc_html__( 'Cancel job', 'ai-image-disclosure-labels' ) . '</button></p>'; }
  • ai-image-disclosure-labels/trunk/includes/class-gdaiidl-plugin.php

    r3649807 r3649865  
    10281028
    10291029                $settings = $this->settings();
    1030                 $clauses  = array();
    1031                 $values   = array();
    1032 
    1033                 if ( ! empty( $settings['enable_images'] ) ) {
    1034                         $clauses[] = '%i.post_mime_type LIKE %s';
    1035                         $values[]  = $wpdb->posts;
    1036                         $values[]  = $wpdb->esc_like( 'image/' ) . '%';
    1037                 }
    1038 
    1039                 if ( ! empty( $settings['enable_videos'] ) ) {
    1040                         $clauses[] = '%i.post_mime_type LIKE %s';
    1041                         $values[]  = $wpdb->posts;
    1042                         $values[]  = $wpdb->esc_like( 'video/' ) . '%';
    1043                 }
    1044 
    1045                 if ( empty( $clauses ) ) {
     1030                $images_enabled = ! empty( $settings['enable_images'] );
     1031                $videos_enabled = ! empty( $settings['enable_videos'] );
     1032
     1033                if ( ! $images_enabled && ! $videos_enabled ) {
    10461034                        return $where . ' AND 1=0';
    10471035                }
    10481036
    1049                 return $where . $wpdb->prepare( ' AND ( ' . implode( ' OR ', $clauses ) . ' )', ...$values );
     1037                if ( $images_enabled && $videos_enabled ) {
     1038                        return $where . $wpdb->prepare(
     1039                                ' AND ( %i.post_mime_type LIKE %s OR %i.post_mime_type LIKE %s )',
     1040                                $wpdb->posts,
     1041                                $wpdb->esc_like( 'image/' ) . '%',
     1042                                $wpdb->posts,
     1043                                $wpdb->esc_like( 'video/' ) . '%'
     1044                        );
     1045                }
     1046
     1047                return $where . $wpdb->prepare(
     1048                        ' AND %i.post_mime_type LIKE %s',
     1049                        $wpdb->posts,
     1050                        $wpdb->esc_like( $images_enabled ? 'image/' : 'video/' ) . '%'
     1051                );
    10501052        }
    10511053
  • ai-image-disclosure-labels/trunk/readme.txt

    r3649807 r3649865  
    66Tested up to: 7.1
    77Requires PHP: 7.4
    8 Stable tag: 3.0.0
     8Stable tag: 3.0.1
    99License: GPLv2 or later
    1010License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    3333* Media Library list tools: an AI status column, status filter and grouped bulk actions make it possible to classify many images and videos without opening them individually.
    3434* Optional automatic visible disclosures for AI-classified Media Library images and videos; this remains off by default.
    35 * Optional AI-assisted image analysis through the WordPress 7.0+ AI Client / configured Connectors when available, or directly through OpenAI, Google Gemini, Anthropic Claude, Cloudflare Workers AI, an OpenAI-compatible HTTPS endpoint, or a custom HTTPS analysis endpoint. Automated analysis remains image-only in 3.0.0; videos are classified manually.
     35* Optional AI-assisted image analysis through the WordPress 7.0+ AI Client / configured Connectors when available, or directly through OpenAI, Google Gemini, Anthropic Claude, Cloudflare Workers AI, an OpenAI-compatible HTTPS endpoint, or a custom HTTPS analysis endpoint. Automated analysis remains image-only in the 3.0.x series; videos are classified manually.
    3636* Dynamic model discovery where the provider exposes a Models API or, in WordPress AI Client mode, through the configured AI provider registry; model names are never hard-coded and manual model/policy names remain supported.
    3737* Provider-independent custom HTTPS endpoint contract for Cloudflare Workers, AI Gateway, dedicated detectors and other future services.
    … …  
    133133= Can the plugin automatically detect AI-generated images? =
    134134
    135 Version 3.0.0 can optionally ask a configured external vision-capable AI service to assess an image and store a suggestion such as "likely AI-generated", "likely AI-modified", "likely non-AI" or "uncertain". This is probabilistic analysis, not forensic proof. General vision models can be wrong; an absent watermark or a "likely non-AI" answer does not prove that no AI was used.
     135Version 3.0.x can optionally ask a configured external vision-capable AI service to assess an image and store a suggestion such as "likely AI-generated", "likely AI-modified", "likely non-AI" or "uncertain". This is probabilistic analysis, not forensic proof. General vision models can be wrong; an absent watermark or a "likely non-AI" answer does not prove that no AI was used.
    136136The built-in direct-provider adapters perform visual analysis; they do not claim to cryptographically verify C2PA, SynthID or proprietary provider watermarks. If you operate a custom endpoint that performs genuine provenance/watermark verification, it can return `verified_provenance=true` and evidence labels. Automatic application of that stronger signal is a separate opt-in setting.
    137137
    … …  
    158158= How are AI videos labelled? =
    159159
    160 For the core WordPress Video block, enable the AI disclosure in the block sidebar or classify the uploaded video in the Media Library and enable automatic Media Library labels. The disclosure appears immediately below the video, outside the playback surface. This avoids covering native controls and works with captions because it is inserted before the figure caption. Optional machine-readable output uses a Schema.org `VideoObject`. AI-assisted frame/video analysis is not included in 3.0.0.
     160For the core WordPress Video block, enable the AI disclosure in the block sidebar or classify the uploaded video in the Media Library and enable automatic Media Library labels. The disclosure appears immediately below the video, outside the playback surface. This avoids covering native controls and works with captions because it is inserted before the figure caption. Optional machine-readable output uses a Schema.org `VideoObject`. AI-assisted frame/video analysis is not included in the 3.0.x series.
    161161
    162162= Does AI analysis send my images to another service? =
    … …  
    219219
    220220== Changelog ==
     221
     222= 3.0.1 =
     223* Release-hardening update following the official WordPress Plugin Check run for 3.0.0.
     224* Consolidated AI-admin AJAX input handling so nonce verification, capability checks and sanitization are explicit in the same request boundary.
     225* Replaced temporary-file `unlink()` calls with WordPress `wp_delete_file()`.
     226* Reworked the Media Library MIME SQL filter to use fixed prepared query shapes instead of dynamically assembled placeholders.
     227* Hardened background-job SQL preparation and documented the narrow, admin-only direct-query exceptions used for cursor-based library scans.
     228* Added missing translator comments and ordered placeholders in translatable strings.
     229* Removed remote documentation links from PHP-rendered admin markup; provider terms/privacy/service links remain clearly documented in `readme.txt`.
     230* Updated the distribution ignore rules so GitHub-only release notes and audit documents are never included in the WordPress.org production ZIP.
    221231
    222232= 3.0.0 =
Note: See TracChangeset for help on using the changeset viewer.