PluginProbe
ZIP AI – AI Website Builder & AI Agent / 0.0.13
ZIP AI – AI Website Builder & AI Agent v0.0.13
0.0.13 0.0.12 0.0.11 0.0.10 0.0.9 trunk 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8
zip-ai / inc / abilities / zipai / builder / import-media.php

import-media.php in ZIP AI – AI Website Builder & AI Agent 0.0.13, at inc/abilities/zipai/builder/import-media.php

324 lines 11.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Import Media — server-side ability used by the website builder in place of
4 * the three wp-cli calls (`wp media import …`, `wp post get …`,
5 * `wp post meta update _wp_attachment_image_alt …`) that are denylisted or
6 * unsupported by RunWpCli's `verify_command_security`.
7 *
8 * Sideloads a remote image into the WordPress media library via WP core's
9 * `media_sideload_image()` and `media_handle_sideload()` helpers. SSRF guards:
10 * https:// only, public IP only, must content-type as image, capped at 25 MB.
11 *
12 * @since 0.0.5
13 * @package zip-ai
14 */
15
16 namespace ZipAI\MCP\Classes\Abilities\Zipai\Builder;
17
18 use ZipAI\MCP\Classes\Abilities\Abstract_Ability;
19 use ZipAI\MCP\Classes\Core\Tool_Types;
20 use ZipAI\MCP\Classes\Core\Response;
21
22 defined( 'ABSPATH' ) || exit;
23
24 /**
25 * Ability: sideload a remote image URL into the media library.
26 */
27 class ImportMedia extends Abstract_Ability {
28
29 const MAX_BYTES = 26214400; // 25 MB.
30
31 /**
32 * Configures the ability's id, label, description and metadata.
33 *
34 * @return void
35 */
36 public function configure() {
37 $this->id = 'zipai/import-media';
38 $this->label = 'Import Media';
39 $this->description = 'Sideload a remote image URL (e.g. a stock photo from Unsplash or Pexels) into the WordPress media library and return its attachment { id, url }. Use this to bring an external image URL into the site before applying it to a block. For AI-generated images use core ai_generate_image instead.';
40 $this->capability = 'upload_files';
41 }
42
43 /**
44 * Returns the tool-type classification for this ability.
45 *
46 * @return string One of the Tool_Types constants.
47 */
48 public function get_tool_type() {
49 return Tool_Types::ACTION;
50 }
51
52 /**
53 * Returns the JSON Schema for this ability's input arguments.
54 *
55 * @return array<string,mixed> JSON Schema describing accepted arguments.
56 */
57 public function get_input_schema() {
58 return array(
59 'type' => 'object',
60 'required' => array( 'url' ),
61 'additionalProperties' => false,
62 'properties' => array(
63 'url' => array(
64 'type' => 'string',
65 'format' => 'uri',
66 'description' => 'Public https URL of the image. Internal/loopback IPs are refused.',
67 ),
68 'title' => array(
69 'type' => 'string',
70 'description' => 'Optional attachment title.',
71 'maxLength' => 250,
72 ),
73 'alt_text' => array(
74 'type' => 'string',
75 'description' => 'Optional alt text — stored in the `_wp_attachment_image_alt` meta.',
76 'maxLength' => 500,
77 ),
78 ),
79 );
80 }
81
82 /**
83 * Sideloads a remote image URL into the WordPress media library.
84 *
85 * @param array<string,mixed> $args Validated input arguments.
86 * @return array<string,mixed> Standardized success or error response.
87 */
88 public function execute( $args ) {
89 // Validate the RAW URL first. `esc_url_raw` silently rewrites things
90 // like `file://…` and `javascript:…` to '' which would otherwise
91 // land in the "url is required" branch with a misleading error.
92 $raw_url = isset( $args['url'] ) && is_string( $args['url'] ) ? $args['url'] : '';
93 $title = isset( $args['title'] ) && is_string( $args['title'] ) ? sanitize_text_field( $args['title'] ) : '';
94 $alt_text = isset( $args['alt_text'] ) && is_string( $args['alt_text'] ) ? sanitize_text_field( $args['alt_text'] ) : '';
95
96 if ( '' === $raw_url ) {
97 return Response::error( 'url is required.' );
98 }
99
100 $validation = $this->validate_remote_url( $raw_url );
101 if ( is_wp_error( $validation ) ) {
102 return Response::error( $validation->get_error_message() );
103 }
104 // Safe to esc_url_raw now — we've confirmed scheme/host/IP pass.
105 $url = esc_url_raw( $raw_url );
106 if ( '' === $url ) {
107 return Response::error( 'url is not a valid URL.' );
108 }
109
110 // WP core helpers used below.
111 if ( ! function_exists( 'media_sideload_image' ) ) {
112 require_once ABSPATH . 'wp-admin/includes/media.php';
113 }
114 if ( ! function_exists( 'wp_tempnam' ) ) {
115 require_once ABSPATH . 'wp-admin/includes/file.php';
116 }
117 if ( ! function_exists( 'wp_read_image_metadata' ) ) {
118 require_once ABSPATH . 'wp-admin/includes/image.php';
119 }
120
121 // 1) Stream to a temp file with the size cap enforced mid-download
122 // (download_url() has no streaming limit — it would write the full
123 // body to disk before any size check could run).
124 $tmp = wp_tempnam( $url );
125 if ( ! $tmp ) {
126 return Response::error( 'Could not create a temporary file.' );
127 }
128 // Cap + 1 so a transport that truncates at the limit (instead of
129 // erroring) still leaves the file measurably over MAX_BYTES.
130 $response = wp_safe_remote_get(
131 $url,
132 array(
133 'timeout' => 30,
134 'stream' => true,
135 'filename' => $tmp,
136 'limit_response_size' => self::MAX_BYTES + 1,
137 )
138 );
139 if ( is_wp_error( $response ) ) {
140 wp_delete_file( $tmp );
141 return Response::error( 'Download failed: ' . $response->get_error_message() );
142 }
143 $status = (int) wp_remote_retrieve_response_code( $response );
144 if ( 200 !== $status ) {
145 wp_delete_file( $tmp );
146 $reason = trim( wp_remote_retrieve_response_message( $response ) );
147 return Response::error( 'Download failed: ' . ( '' !== $reason ? $reason : "HTTP {$status}" ) );
148 }
149 if ( filesize( $tmp ) > self::MAX_BYTES ) {
150 wp_delete_file( $tmp );
151 return Response::error( sprintf( 'Remote file is larger than %d bytes.', self::MAX_BYTES ) );
152 }
153
154 // 2) Verify it's actually an image and resolve a sideload-safe filename.
155 $resolved = $this->resolve_image_file( $tmp, $url );
156 if ( is_wp_error( $resolved ) ) {
157 wp_delete_file( $tmp );
158 return Response::error( $resolved->get_error_message() );
159 }
160
161 // 3) Hand off to media_handle_sideload — creates the attachment, returns ID.
162 $file_array = array(
163 'name' => $resolved['name'],
164 'tmp_name' => $tmp,
165 );
166 $post_data = array();
167 if ( '' !== $title ) {
168 $post_data['post_title'] = $title;
169 }
170 $attachment_id = media_handle_sideload( $file_array, 0, '' !== $title ? $title : null, $post_data );
171 if ( is_wp_error( $attachment_id ) ) {
172 wp_delete_file( $tmp );
173 return Response::error( 'media_handle_sideload failed: ' . $attachment_id->get_error_message() );
174 }
175
176 // 4) Alt text.
177 if ( '' !== $alt_text ) {
178 update_post_meta( (int) $attachment_id, '_wp_attachment_image_alt', $alt_text );
179 }
180
181 $attachment_url = wp_get_attachment_url( (int) $attachment_id );
182 if ( ! $attachment_url ) {
183 // Roll back the just-created attachment so a URL-resolution failure
184 // doesn't leave an orphan in the media library.
185 wp_delete_attachment( (int) $attachment_id, true );
186 return Response::error( 'Image was imported but its URL could not be resolved.' );
187 }
188
189 return Response::success(
190 sprintf( 'Imported image into the media library (id %d).', (int) $attachment_id ),
191 array(
192 'id' => (int) $attachment_id,
193 'url' => $attachment_url,
194 'title' => $title,
195 'alt' => $alt_text,
196 'mime' => $resolved['type'],
197 )
198 );
199 }
200
201 /**
202 * Resolve the mime type and a sideload-safe filename for a downloaded file.
203 *
204 * The downloaded bytes are the authority: stock/CDN image URLs (Unsplash,
205 * dynamic image services) often have no extension in their path, and a URL
206 * extension can lie about the content. The URL filename is only a naming
207 * hint. Formats core cannot byte-sniff (SVG) fall back to the extension
208 * check, which also enforces the site's allowed mime types.
209 *
210 * @param string $tmp Path to the downloaded temp file.
211 * @param string $url Source URL — filename hint only, never trusted for type.
212 * @return array{type: string, name: string}|\WP_Error
213 */
214 public function resolve_image_file( string $tmp, string $url ) {
215 $url_path = wp_parse_url( $url, PHP_URL_PATH );
216 $path_name = is_string( $url_path ) ? basename( $url_path ) : '';
217
218 $type = (string) wp_get_image_mime( $tmp );
219 if ( '' !== $type ) {
220 // Parity with the extension path: enforce the site's allowed mime
221 // list here too, so a disallowed type fails with a precise error
222 // instead of media_handle_sideload's generic one.
223 if ( ! in_array( $type, get_allowed_mime_types(), true ) ) {
224 return new \WP_Error(
225 'disallowed_mime',
226 sprintf( 'Image type "%s" is not allowed on this site.', $type )
227 );
228 }
229 $ext = wp_get_default_extension_for_mime_type( $type );
230 if ( ! $ext ) {
231 return new \WP_Error(
232 'unsupported_image',
233 sprintf( 'Image type "%s" is not supported.', $type )
234 );
235 }
236 $base = pathinfo( $path_name, PATHINFO_FILENAME );
237 return array(
238 'type' => $type,
239 'name' => sanitize_file_name( ( '' !== $base ? $base : 'image' ) . '.' . $ext ),
240 );
241 }
242
243 $mime_check = wp_check_filetype_and_ext( $tmp, $path_name );
244 $ext_type = (string) $mime_check['type'];
245 if ( '' === $ext_type || 0 !== strpos( $ext_type, 'image/' ) ) {
246 return new \WP_Error(
247 'not_an_image',
248 sprintf( 'URL does not resolve to an image (detected: "%s").', $ext_type )
249 );
250 }
251 $proper_filename = (string) $mime_check['proper_filename'];
252 return array(
253 'type' => $ext_type,
254 'name' => '' !== $proper_filename ? $proper_filename : $path_name,
255 );
256 }
257
258 /**
259 * Reject URLs that wouldn't survive WP.org review. Returns true on success
260 * or a WP_Error on the first failed check.
261 *
262 * @param string $url Raw URL.
263 * @return true|\WP_Error
264 */
265 private function validate_remote_url( string $url ) {
266 if ( ! filter_var( $url, FILTER_VALIDATE_URL ) ) {
267 return new \WP_Error( 'invalid_url', 'url is not a valid URL.' );
268 }
269 $parts = wp_parse_url( $url );
270 if ( ! is_array( $parts ) || empty( $parts['scheme'] ) || empty( $parts['host'] ) ) {
271 return new \WP_Error( 'invalid_url', 'url is missing scheme or host.' );
272 }
273 // HTTPS only — http allows MITM substitution; data:/file:/javascript: are obvious SSRF vectors.
274 if ( 'https' !== strtolower( $parts['scheme'] ) ) {
275 return new \WP_Error( 'insecure_scheme', 'Only https:// URLs are accepted.' );
276 }
277
278 $host = strtolower( $parts['host'] );
279 // Strip bracketed IPv6 form (parse_url returns "[::1]") so it lines up
280 // with the literal IP we compare against.
281 if ( '[' === $host[0] && str_ends_with( $host, ']' ) ) {
282 $host = substr( $host, 1, -1 );
283 }
284 // Reject loopback hostnames outright (covers most local-dev SSRF).
285 $blocked_hosts = array( 'localhost', '0.0.0.0', '127.0.0.1', '::1' );
286 if ( in_array( $host, $blocked_hosts, true ) ) {
287 return new \WP_Error( 'blocked_host', 'Internal hostnames are not allowed.' );
288 }
289 // If host is an IP literal, refuse private + link-local + loopback ranges.
290 if ( filter_var( $host, FILTER_VALIDATE_IP ) ) {
291 if ( ! filter_var( $host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
292 return new \WP_Error( 'private_ip', 'Private/reserved IPs are not allowed.' );
293 }
294 }
295 return true;
296 }
297
298 /**
299 * Returns the JSON Schema for this ability's response.
300 *
301 * @return array<string,mixed> JSON Schema describing the response shape.
302 */
303 public function get_output_schema() {
304 return array(
305 'type' => 'object',
306 'required' => array( 'success' ),
307 'properties' => array(
308 'success' => array( 'type' => 'boolean' ),
309 'message' => array( 'type' => 'string' ),
310 'data' => array(
311 'type' => 'object',
312 'properties' => array(
313 'id' => array( 'type' => 'integer' ),
314 'url' => array( 'type' => 'string' ),
315 'title' => array( 'type' => 'string' ),
316 'alt' => array( 'type' => 'string' ),
317 'mime' => array( 'type' => 'string' ),
318 ),
319 ),
320 ),
321 );
322 }
323 }
324