Changeset 3649865
- Timestamp:
- 08/16/2026 03:05:48 PM (2 months ago)
- Location:
- ai-image-disclosure-labels
- Files:
-
- 4 deleted
- 8 edited
- 1 copied
-
tags/3.0.1 (copied) (copied from ai-image-disclosure-labels/trunk )
-
tags/3.0.1/RELEASE_AUDIT_3.0.0.md (deleted)
-
tags/3.0.1/RELEASE_NOTES_3.0.0.md (deleted)
-
tags/3.0.1/ai-image-disclosure-labels.php (modified) (2 diffs)
-
tags/3.0.1/includes/class-gdaiidl-ai-analysis.php (modified) (18 diffs)
-
tags/3.0.1/includes/class-gdaiidl-plugin.php (modified) (1 diff)
-
tags/3.0.1/readme.txt (modified) (5 diffs)
-
trunk/RELEASE_AUDIT_3.0.0.md (deleted)
-
trunk/RELEASE_NOTES_3.0.0.md (deleted)
-
trunk/ai-image-disclosure-labels.php (modified) (2 diffs)
-
trunk/includes/class-gdaiidl-ai-analysis.php (modified) (18 diffs)
-
trunk/includes/class-gdaiidl-plugin.php (modified) (1 diff)
-
trunk/readme.txt (modified) (5 diffs)
Legend:
- Unmodified
- Added
- Removed
-
ai-image-disclosure-labels/tags/3.0.1/ai-image-disclosure-labels.php
r3649807 r3649865 4 4 * Plugin URI: https://github.com/gerald-drissner/ai-image-disclosure-labels 5 5 * 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. 06 * Version: 3.0.1 7 7 * Requires at least: 6.7 8 8 * Requires PHP: 7.4 … … 16 16 defined( 'ABSPATH' ) || exit; 17 17 18 define( 'GDAIIDL_VERSION', '3.0. 0' );18 define( 'GDAIIDL_VERSION', '3.0.1' ); 19 19 define( 'GDAIIDL_FILE', __FILE__ ); 20 20 define( 'GDAIIDL_DIR', plugin_dir_path( __FILE__ ) ); -
ai-image-disclosure-labels/tags/3.0.1/includes/class-gdaiidl-ai-analysis.php
r3649807 r3649865 557 557 <div> 558 558 <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 & 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 & 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> 566 560 </div> 567 561 </details> … … 604 598 <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> 605 599 </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> 607 601 <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> 608 602 <pre class="gdaiidl-ai-contract">{ … … 704 698 <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 ); ?>> 705 699 <?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; ?> 707 708 </label> 708 709 </div> … … 994 995 $count = absint( wp_unslash( $_GET['gdaiidl_analysis_processed'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended 995 996 $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 } 997 1004 echo '<div class="notice notice-success is-dismissible"><p>' . esc_html( $message ) . '</p></div>'; 998 1005 } … … 1021 1028 */ 1022 1029 public function ajax_fetch_models() { 1023 $ this->check_ajax_admin();1030 $request = $this->ajax_request(); 1024 1031 $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'], 1029 1036 ); 1030 1037 $result = $this->fetch_models( $context, true ); … … 1043 1050 */ 1044 1051 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']; 1048 1055 $providers = $this->providers(); 1049 1056 if ( ! isset( $providers[ $provider ] ) || ( 'wordpress_ai_client' !== $provider && '' === trim( $model ) ) ) { … … 1055 1062 'provider' => $provider, 1056 1063 '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'], 1061 1068 ), 1062 1069 $this->settings() … … 1122 1129 /** AJAX prepare job. */ 1123 1130 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']; 1126 1133 if ( ! in_array( $scope, array( 'all', 'unclassified' ), true ) ) { 1127 1134 wp_send_json_error( array( 'message' => __( 'Invalid analysis scope.', 'ai-image-disclosure-labels' ) ), 400 ); … … 1139 1146 /** AJAX start job. */ 1140 1147 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']; 1143 1150 if ( ! in_array( $scope, array( 'all', 'unclassified' ), true ) || ! $this->is_configured() ) { 1144 1151 wp_send_json_error( array( 'message' => __( 'Analysis is not fully configured.', 'ai-image-disclosure-labels' ) ), 400 ); … … 1153 1160 /** AJAX status. */ 1154 1161 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']; 1157 1164 wp_send_json_success( array( 'html' => $this->job_status_html( $job_id ), 'job' => $this->get_job( $job_id ) ) ); 1158 1165 } … … 1160 1167 /** AJAX cancel. */ 1161 1168 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']; 1164 1171 $jobs = $this->jobs(); 1165 1172 if ( isset( $jobs[ $job_id ] ) && in_array( $jobs[ $job_id ]['status'], array( 'queued', 'running' ), true ) ) { … … 1172 1179 1173 1180 /** 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() { 1179 1189 check_ajax_referer( 'gdaiidl_ai_admin', 'nonce' ); 1190 1180 1191 if ( ! current_user_can( $this->analysis_capability() ) ) { 1181 1192 wp_send_json_error( array( 'message' => __( 'You do not have permission to manage AI analysis.', 'ai-image-disclosure-labels' ) ), 403 ); 1182 1193 } 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 ); 1183 1215 } 1184 1216 … … 1445 1477 private function count_scope_images( $scope ) { 1446 1478 global $wpdb; 1479 $image_mime_like = $wpdb->esc_like( 'image/' ) . '%'; 1480 1447 1481 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 ); 1453 1510 } 1454 1511 … … 1463 1520 private function query_scope_ids_after( $scope, $after_id, $limit ) { 1464 1521 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 1467 1526 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 ); 1470 1545 } 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 ) ); 1475 1564 } 1476 1565 … … 1571 1660 $saved = $editor->save( $tmp, 'image/jpeg' ); 1572 1661 if ( is_wp_error( $saved ) || empty( $saved['path'] ) || ! is_readable( $saved['path'] ) ) { 1573 @unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged1662 wp_delete_file( $tmp ); 1574 1663 return is_wp_error( $saved ) ? $saved : new WP_Error( 'gdaiidl_temp_failed', __( 'Could not write the temporary analysis image.', 'ai-image-disclosure-labels' ) ); 1575 1664 } … … 1577 1666 $temp_size = @filesize( $saved['path'] ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- temporary file can disappear; handled below. 1578 1667 if ( false === $temp_size || $temp_size <= 0 || $temp_size > $max_bytes ) { 1579 @unlink( $saved['path'] ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged1668 wp_delete_file( $saved['path'] ); 1580 1669 if ( $saved['path'] !== $tmp && file_exists( $tmp ) ) { 1581 @unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged1670 wp_delete_file( $tmp ); 1582 1671 } 1583 1672 return new WP_Error( 'gdaiidl_temp_size', __( 'The temporary analysis image is unexpectedly large or unreadable.', 'ai-image-disclosure-labels' ) ); 1584 1673 } 1585 1674 $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.Discouraged1675 wp_delete_file( $saved['path'] ); 1587 1676 if ( $saved['path'] !== $tmp && file_exists( $tmp ) ) { 1588 @unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged1677 wp_delete_file( $tmp ); 1589 1678 } 1590 1679 if ( false === $data || '' === $data || strlen( $data ) > $max_bytes ) { … … 1651 1740 } 1652 1741 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 } 1654 1746 } 1655 1747 $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 ) : '' ); 1657 1750 } 1658 1751 … … 2417 2510 $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' ) ); 2418 2511 $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>'; 2420 2520 $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>'; 2423 2523 if ( ! empty( $job['last_error'] ) ) { $html .= '<p class="description gdaiidl-ai-error">' . esc_html( $job['last_error'] ) . '</p>'; } 2424 2524 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 1028 1028 1029 1029 $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 ) { 1046 1034 return $where . ' AND 1=0'; 1047 1035 } 1048 1036 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 ); 1050 1052 } 1051 1053 -
ai-image-disclosure-labels/tags/3.0.1/readme.txt
r3649807 r3649865 6 6 Tested up to: 7.1 7 7 Requires PHP: 7.4 8 Stable tag: 3.0. 08 Stable tag: 3.0.1 9 9 License: GPLv2 or later 10 10 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 33 33 * 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. 34 34 * 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. 36 36 * 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. 37 37 * Provider-independent custom HTTPS endpoint contract for Cloudflare Workers, AI Gateway, dedicated detectors and other future services. … … 133 133 = Can the plugin automatically detect AI-generated images? = 134 134 135 Version 3.0. 0can 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.135 Version 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. 136 136 The 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. 137 137 … … 158 158 = How are AI videos labelled? = 159 159 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.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 the 3.0.x series. 161 161 162 162 = Does AI analysis send my images to another service? = … … 219 219 220 220 == 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. 221 231 222 232 = 3.0.0 = -
ai-image-disclosure-labels/trunk/ai-image-disclosure-labels.php
r3649807 r3649865 4 4 * Plugin URI: https://github.com/gerald-drissner/ai-image-disclosure-labels 5 5 * 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. 06 * Version: 3.0.1 7 7 * Requires at least: 6.7 8 8 * Requires PHP: 7.4 … … 16 16 defined( 'ABSPATH' ) || exit; 17 17 18 define( 'GDAIIDL_VERSION', '3.0. 0' );18 define( 'GDAIIDL_VERSION', '3.0.1' ); 19 19 define( 'GDAIIDL_FILE', __FILE__ ); 20 20 define( 'GDAIIDL_DIR', plugin_dir_path( __FILE__ ) ); -
ai-image-disclosure-labels/trunk/includes/class-gdaiidl-ai-analysis.php
r3649807 r3649865 557 557 <div> 558 558 <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 & 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 & 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> 566 560 </div> 567 561 </details> … … 604 598 <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> 605 599 </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> 607 601 <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> 608 602 <pre class="gdaiidl-ai-contract">{ … … 704 698 <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 ); ?>> 705 699 <?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; ?> 707 708 </label> 708 709 </div> … … 994 995 $count = absint( wp_unslash( $_GET['gdaiidl_analysis_processed'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended 995 996 $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 } 997 1004 echo '<div class="notice notice-success is-dismissible"><p>' . esc_html( $message ) . '</p></div>'; 998 1005 } … … 1021 1028 */ 1022 1029 public function ajax_fetch_models() { 1023 $ this->check_ajax_admin();1030 $request = $this->ajax_request(); 1024 1031 $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'], 1029 1036 ); 1030 1037 $result = $this->fetch_models( $context, true ); … … 1043 1050 */ 1044 1051 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']; 1048 1055 $providers = $this->providers(); 1049 1056 if ( ! isset( $providers[ $provider ] ) || ( 'wordpress_ai_client' !== $provider && '' === trim( $model ) ) ) { … … 1055 1062 'provider' => $provider, 1056 1063 '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'], 1061 1068 ), 1062 1069 $this->settings() … … 1122 1129 /** AJAX prepare job. */ 1123 1130 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']; 1126 1133 if ( ! in_array( $scope, array( 'all', 'unclassified' ), true ) ) { 1127 1134 wp_send_json_error( array( 'message' => __( 'Invalid analysis scope.', 'ai-image-disclosure-labels' ) ), 400 ); … … 1139 1146 /** AJAX start job. */ 1140 1147 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']; 1143 1150 if ( ! in_array( $scope, array( 'all', 'unclassified' ), true ) || ! $this->is_configured() ) { 1144 1151 wp_send_json_error( array( 'message' => __( 'Analysis is not fully configured.', 'ai-image-disclosure-labels' ) ), 400 ); … … 1153 1160 /** AJAX status. */ 1154 1161 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']; 1157 1164 wp_send_json_success( array( 'html' => $this->job_status_html( $job_id ), 'job' => $this->get_job( $job_id ) ) ); 1158 1165 } … … 1160 1167 /** AJAX cancel. */ 1161 1168 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']; 1164 1171 $jobs = $this->jobs(); 1165 1172 if ( isset( $jobs[ $job_id ] ) && in_array( $jobs[ $job_id ]['status'], array( 'queued', 'running' ), true ) ) { … … 1172 1179 1173 1180 /** 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() { 1179 1189 check_ajax_referer( 'gdaiidl_ai_admin', 'nonce' ); 1190 1180 1191 if ( ! current_user_can( $this->analysis_capability() ) ) { 1181 1192 wp_send_json_error( array( 'message' => __( 'You do not have permission to manage AI analysis.', 'ai-image-disclosure-labels' ) ), 403 ); 1182 1193 } 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 ); 1183 1215 } 1184 1216 … … 1445 1477 private function count_scope_images( $scope ) { 1446 1478 global $wpdb; 1479 $image_mime_like = $wpdb->esc_like( 'image/' ) . '%'; 1480 1447 1481 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 ); 1453 1510 } 1454 1511 … … 1463 1520 private function query_scope_ids_after( $scope, $after_id, $limit ) { 1464 1521 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 1467 1526 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 ); 1470 1545 } 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 ) ); 1475 1564 } 1476 1565 … … 1571 1660 $saved = $editor->save( $tmp, 'image/jpeg' ); 1572 1661 if ( is_wp_error( $saved ) || empty( $saved['path'] ) || ! is_readable( $saved['path'] ) ) { 1573 @unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged1662 wp_delete_file( $tmp ); 1574 1663 return is_wp_error( $saved ) ? $saved : new WP_Error( 'gdaiidl_temp_failed', __( 'Could not write the temporary analysis image.', 'ai-image-disclosure-labels' ) ); 1575 1664 } … … 1577 1666 $temp_size = @filesize( $saved['path'] ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- temporary file can disappear; handled below. 1578 1667 if ( false === $temp_size || $temp_size <= 0 || $temp_size > $max_bytes ) { 1579 @unlink( $saved['path'] ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged1668 wp_delete_file( $saved['path'] ); 1580 1669 if ( $saved['path'] !== $tmp && file_exists( $tmp ) ) { 1581 @unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged1670 wp_delete_file( $tmp ); 1582 1671 } 1583 1672 return new WP_Error( 'gdaiidl_temp_size', __( 'The temporary analysis image is unexpectedly large or unreadable.', 'ai-image-disclosure-labels' ) ); 1584 1673 } 1585 1674 $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.Discouraged1675 wp_delete_file( $saved['path'] ); 1587 1676 if ( $saved['path'] !== $tmp && file_exists( $tmp ) ) { 1588 @unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged1677 wp_delete_file( $tmp ); 1589 1678 } 1590 1679 if ( false === $data || '' === $data || strlen( $data ) > $max_bytes ) { … … 1651 1740 } 1652 1741 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 } 1654 1746 } 1655 1747 $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 ) : '' ); 1657 1750 } 1658 1751 … … 2417 2510 $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' ) ); 2418 2511 $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>'; 2420 2520 $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>'; 2423 2523 if ( ! empty( $job['last_error'] ) ) { $html .= '<p class="description gdaiidl-ai-error">' . esc_html( $job['last_error'] ) . '</p>'; } 2424 2524 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 1028 1028 1029 1029 $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 ) { 1046 1034 return $where . ' AND 1=0'; 1047 1035 } 1048 1036 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 ); 1050 1052 } 1051 1053 -
ai-image-disclosure-labels/trunk/readme.txt
r3649807 r3649865 6 6 Tested up to: 7.1 7 7 Requires PHP: 7.4 8 Stable tag: 3.0. 08 Stable tag: 3.0.1 9 9 License: GPLv2 or later 10 10 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 33 33 * 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. 34 34 * 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. 36 36 * 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. 37 37 * Provider-independent custom HTTPS endpoint contract for Cloudflare Workers, AI Gateway, dedicated detectors and other future services. … … 133 133 = Can the plugin automatically detect AI-generated images? = 134 134 135 Version 3.0. 0can 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.135 Version 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. 136 136 The 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. 137 137 … … 158 158 = How are AI videos labelled? = 159 159 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.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 the 3.0.x series. 161 161 162 162 = Does AI analysis send my images to another service? = … … 219 219 220 220 == 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. 221 231 222 232 = 3.0.0 =
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](https://plugins.trac.wordpress.org/chrome/site/your_project_logo.png)