settings(); $sources = array( 'openverse' => 'Openverse' ); $labels = array( 'Openverse' => '' ); if ( $s->get( 'pexels_key' ) ) { $sources['pexels'] = 'Pexels'; $labels['Pexels'] = ''; } if ( $s->get( 'unsplash_key' ) ) { $sources['unsplash'] = 'Unsplash'; $labels['Unsplash'] = ''; } $r->group( 'images', array( 'label' => 'Stock images', 'description' => 'Search free images (and Pexels videos when a Pexels key is set) and add them to the media library with alt text and credit, then use them in pages, products or as featured images.', 'detected' => true, 'plugins' => $labels, 'notes' => isset( $sources['pexels'] ) || isset( $sources['unsplash'] ) ? '' : 'Add a free Pexels or Unsplash API key in Settings for photo-quality results.', ) ); $r->add( array( 'name' => 'images_search', 'group' => 'images', 'title' => 'Search free images', 'description' => 'Search stock photos by keywords. Returns preview URLs, size, license and credit. Prefer landscape for heroes and banners, square for products. Pick images, then call images_add.', 'cap' => 'upload_files', 'input' => array( 'required' => array( 'query' ), 'properties' => array( 'query' => array( 'type' => 'string', 'minLength' => 2, 'maxLength' => 100 ), 'orientation' => array( 'type' => 'string', 'enum' => array( 'any', 'landscape', 'portrait', 'square' ), 'default' => 'any' ), 'source' => array( 'type' => 'string', 'enum' => array_keys( $sources ) ), 'limit' => array( 'type' => 'integer', 'minimum' => 1, 'maximum' => 20, 'default' => 8 ), ), ), 'run' => static function ( array $a ) use ( $sources ) { $source = $a['source'] ?? ( isset( $sources['pexels'] ) ? 'pexels' : ( isset( $sources['unsplash'] ) ? 'unsplash' : 'openverse' ) ); $res = self::search( $source, $a['query'], $a['orientation'], $a['limit'] ); if ( is_wp_error( $res ) && 'openverse' !== $source ) { $res = self::search( 'openverse', $a['query'], $a['orientation'], $a['limit'] ); $source = 'openverse'; } return is_wp_error( $res ) ? $res : array( 'source' => $source, 'results' => $res ); }, ) ); $r->add( array( 'name' => 'icons_add_svg', 'group' => 'images', 'title' => 'Add a custom SVG icon', 'description' => 'Upload a custom SVG icon (your own markup) to the media library after removing anything unsafe (scripts, events, external links). Use it in Elementor icon widgets as {"value":{"id":ID,"url":"URL"},"library":"svg"} or as an image. Prefer the builder icon library (Font Awesome) when a suitable icon exists. Never use emoji as icons.', 'write' => true, 'cap' => 'upload_files', 'input' => array( 'required' => array( 'svg', 'name' ), 'properties' => array( 'svg' => array( 'type' => 'string', 'maxLength' => 200000 ), 'name' => array( 'type' => 'string', 'maxLength' => 80 ), ), ), 'run' => static function ( array $a ) { $svg = self::sanitize_svg( (string) $a['svg'] ); if ( is_wp_error( $svg ) ) { return $svg; } $file = sanitize_file_name( $a['name'] ) . '.svg'; $mime = static function ( $m ) { $m['svg'] = 'image/svg+xml'; return $m; }; add_filter( 'upload_mimes', $mime ); $check = static function ( $data, $f, $filename ) { return '.svg' === strtolower( substr( (string) $filename, -4 ) ) ? array( 'ext' => 'svg', 'type' => 'image/svg+xml', 'proper_filename' => false ) : $data; }; add_filter( 'wp_check_filetype_and_ext', $check, 10, 3 ); $up = wp_upload_bits( $file, null, $svg ); remove_filter( 'upload_mimes', $mime ); remove_filter( 'wp_check_filetype_and_ext', $check, 10 ); if ( ! empty( $up['error'] ) ) { return new \WP_Error( 'upload_failed', (string) $up['error'] ); } $id = wp_insert_attachment( array( 'post_mime_type' => 'image/svg+xml', 'post_title' => sanitize_text_field( $a['name'] ), 'post_status' => 'inherit' ), $up['file'] ); if ( is_wp_error( $id ) ) { return $id; } \HandyMCP\Tools\History::created_post( (int) $id ); return array( 'id' => $id, 'url' => $up['url'], 'elementor_icon' => array( 'value' => array( 'id' => $id, 'url' => $up['url'] ), 'library' => 'svg' ), 'message' => 'SVG icon added.' ); }, ) ); $r->add( array( 'name' => 'images_add', 'group' => 'images', 'title' => 'Add images to the media library', 'description' => 'Download one or more image URLs (from images_search or any public URL) into the media library with alt text, title and credit caption. Optionally set the first one as featured image of a post/product. Returns attachment IDs and URLs for use in builder layouts.', 'write' => true, 'cap' => 'upload_files', 'input' => array( 'required' => array( 'images' ), 'properties' => array( 'images' => array( 'type' => 'array', 'minItems' => 1, 'maxItems' => 12, 'items' => array( 'type' => 'object', 'required' => array( 'url' ), 'properties' => array( 'url' => array( 'type' => 'string' ), 'alt_text' => array( 'type' => 'string' ), 'title' => array( 'type' => 'string' ), 'credit' => array( 'type' => 'string', 'description' => 'e.g. "Photo by Jane on Pexels" or the CC attribution.' ), 'download_location' => array( 'type' => 'string', 'maxLength' => 500, 'description' => 'Pass download_location from an Unsplash result unchanged. Unsplash requires it to count the download.' ), 'alt' => array( 'type' => 'string', 'description' => 'Same as alt_text (the name images_search uses).' ), ), // Results from images_search can be passed as they are: their extra fields (preview, size, license, page) are ignored. 'additionalProperties' => true, ), ), 'post_id' => array( 'type' => 'integer', 'minimum' => 1, 'description' => 'Attach to this post.' ), 'set_featured' => array( 'type' => 'boolean', 'default' => false ), ), ), 'run' => static function ( array $a ) { $out = array(); $errors = array(); foreach ( $a['images'] as $i => $img ) { if ( ! isset( $img['alt_text'] ) && isset( $img['alt'] ) ) { $img['alt_text'] = (string) $img['alt']; } $res = WordPress::upload_media( array( 'url' => $img['url'], // Readable, SEO-friendly file name from the alt text or title, for every stock source. 'filename' => self::file_slug( (string) ( $img['alt_text'] ?? ( $img['title'] ?? '' ) ) ), 'title' => $img['title'] ?? ( $img['alt_text'] ?? null ), 'alt_text' => $img['alt_text'] ?? '', 'caption' => $img['credit'] ?? '', 'post_id' => $a['post_id'] ?? 0, ) ); if ( is_wp_error( $res ) ) { $errors[ $img['url'] ] = $res->get_error_message(); continue; } $out[] = $res; self::unsplash_track( (string) ( $img['download_location'] ?? '' ) ); if ( empty( $featured_done ) && $a['set_featured'] && ! empty( $a['post_id'] ) && current_user_can( 'edit_post', $a['post_id'] ) ) { set_post_thumbnail( $a['post_id'], $res['id'] ); $featured_done = true; } } return array( 'added' => $out, 'errors' => $errors, 'message' => count( $out ) . ' image(s) added.' ); }, ) ); if ( $s->get( 'pexels_key' ) ) { self::register_videos( $r ); } } /** * Stock videos from Pexels (needs the free Pexels key): search, then add to the media library. * * @param Registry $r Registry. */ private static function register_videos( Registry $r ): void { $r->add( array( 'name' => 'videos_search', 'group' => 'images', 'title' => 'Search free stock videos', 'description' => 'Search free stock videos on Pexels (needs the Pexels key in HandyMCP Settings > Photos). Returns MP4 file links in HD and SD, a poster image, length and credit. Use the url directly in a video block or widget, or call videos_add to copy it into the media library.', 'cap' => 'upload_files', 'input' => array( 'required' => array( 'query' ), 'properties' => array( 'query' => array( 'type' => 'string', 'minLength' => 2, 'maxLength' => 100 ), 'orientation' => array( 'type' => 'string', 'enum' => array( 'any', 'landscape', 'portrait', 'square' ), 'default' => 'any' ), 'max_seconds' => array( 'type' => 'integer', 'minimum' => 3, 'maximum' => 600, 'description' => 'Only clips up to this length.' ), 'limit' => array( 'type' => 'integer', 'minimum' => 1, 'maximum' => 15, 'default' => 6 ), ), ), 'run' => static function ( array $a ) { $key = (string) Plugin::instance()->settings()->get( 'pexels_key' ); if ( '' === $key ) { return new \WP_Error( 'no_key', 'Video search needs a free Pexels API key. Add it under HandyMCP > Settings > Photos.' ); } $args = array( 'query' => rawurlencode( $a['query'] ), 'per_page' => $a['limit'] ?? 6 ); if ( ! empty( $a['orientation'] ) && 'any' !== $a['orientation'] ) { $args['orientation'] = $a['orientation']; } $json = self::json( wp_remote_get( add_query_arg( $args, 'https://api.pexels.com/videos/search' ), array( 'timeout' => 15, 'headers' => array( 'Authorization' => $key ) ) ) ); if ( is_wp_error( $json ) ) { return $json; } $out = array(); foreach ( (array) ( $json['videos'] ?? array() ) as $v ) { if ( ! empty( $a['max_seconds'] ) && (int) $v['duration'] > (int) $a['max_seconds'] ) { continue; } $files = array(); foreach ( (array) ( $v['video_files'] ?? array() ) as $f ) { if ( 'video/mp4' !== ( $f['file_type'] ?? '' ) || empty( $f['link'] ) ) { continue; } $q = (string) ( $f['quality'] ?? '' ); if ( ! isset( $files[ $q ] ) || (int) $f['width'] > (int) $files[ $q ]['width'] && (int) $f['width'] <= 1920 ) { $files[ $q ] = array( 'url' => $f['link'], 'width' => (int) $f['width'], 'height' => (int) $f['height'] ); } } if ( ! $files ) { continue; } $best = $files['hd'] ?? ( $files['sd'] ?? reset( $files ) ); $out[] = array( 'url' => $best['url'], 'sd_url' => $files['sd']['url'] ?? $best['url'], 'width' => $best['width'], 'height' => $best['height'], 'seconds' => (int) $v['duration'], 'poster' => (string) ( $v['image'] ?? '' ), 'credit' => 'Video by ' . ( $v['user']['name'] ?? 'unknown' ) . ' on Pexels', 'license' => 'Pexels License (free to use)', 'page' => (string) ( $v['url'] ?? '' ), ); } return array( 'source' => 'pexels', 'results' => $out, 'tip' => 'For background videos pick a short clip (under 20 seconds) and use sd_url on mobile to keep pages fast.' ); }, ) ); $r->add( array( 'name' => 'videos_add', 'group' => 'images', 'title' => 'Add a stock video to the media library', 'description' => 'Copy an MP4 from videos_search (Pexels) into the media library with a title and credit caption, so the site does not depend on Pexels. Max 100 MB. Returns the attachment ID and URL for video blocks and widgets.', 'write' => true, 'cap' => 'upload_files', 'input' => array( 'required' => array( 'url' ), 'properties' => array( 'url' => array( 'type' => 'string', 'maxLength' => 2000 ), 'title' => array( 'type' => 'string', 'maxLength' => 200 ), 'credit' => array( 'type' => 'string', 'maxLength' => 300 ), 'post_id' => array( 'type' => 'integer', 'minimum' => 1, 'description' => 'Attach to this post.' ), ), ), 'run' => static function ( array $a ) { $host = (string) wp_parse_url( $a['url'], PHP_URL_HOST ); if ( 'https' !== wp_parse_url( $a['url'], PHP_URL_SCHEME ) || ! preg_match( '/(^|\.)pexels\.com$/', $host ) ) { return new \WP_Error( 'bad_url', 'Only video links from videos_search (Pexels) can be added. To use another video, upload it in Media or paste its YouTube or Vimeo link into the page.' ); } if ( ! empty( $a['post_id'] ) && ( ! get_post( (int) $a['post_id'] ) || ! current_user_can( 'edit_post', (int) $a['post_id'] ) ) ) { return new \WP_Error( 'forbidden', 'You cannot attach media to that post.' ); } require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/media.php'; require_once ABSPATH . 'wp-admin/includes/image.php'; // Every redirect hop must stay on https Pexels hosts; the file may not exceed 100 MB. $tmp = WordPress::safe_download( (string) $a['url'], 100 * MB_IN_BYTES, '/(^|\.)pexels\.com$/' ); if ( is_wp_error( $tmp ) ) { return 'too_large' === $tmp->get_error_code() ? new \WP_Error( 'too_large', 'That video is over 100 MB. Pick the sd_url or a shorter clip.' ) : $tmp; } $head = (string) file_get_contents( $tmp, false, null, 0, 12 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local temp file. if ( 12 > strlen( $head ) || 'ftyp' !== substr( $head, 4, 4 ) ) { wp_delete_file( $tmp ); return new \WP_Error( 'not_a_video', 'The link did not return an MP4 video.' ); } $title = sanitize_text_field( (string) ( $a['title'] ?? 'Stock video' ) ); $id = media_handle_sideload( array( 'name' => sanitize_file_name( $title ) . '.mp4', 'tmp_name' => $tmp ), (int) ( $a['post_id'] ?? 0 ), $title, array( 'post_excerpt' => sanitize_text_field( (string) ( $a['credit'] ?? '' ) ) ) ); if ( is_wp_error( $id ) ) { wp_delete_file( $tmp ); return $id; } return array( 'id' => (int) $id, 'url' => wp_get_attachment_url( $id ), 'title' => $title, 'message' => 'Video added to the media library.' ); }, ) ); } /** * Tell Unsplash a photo was downloaded, as its API guidelines require. * Only api.unsplash.com photo download links are called, with the site's own key. * * @param string $location download_location from an Unsplash search result. */ private static function unsplash_track( string $location ): void { $key = (string) Plugin::instance()->settings()->get( 'unsplash_key' ); if ( '' === $key || ! preg_match( '#^https://api\.unsplash\.com/photos/[A-Za-z0-9_-]+/download(\?[A-Za-z0-9=&_%.-]*)?$#', $location ) ) { return; } wp_safe_remote_get( $location, array( 'timeout' => 5, 'blocking' => false, 'headers' => array( 'Authorization' => 'Client-ID ' . $key ) ) ); } /** * Clean an SVG: only drawing elements and attributes, no scripts, events or external links. * * @param string $svg Markup. * @return string|\WP_Error */ public static function sanitize_svg( string $svg ) { if ( strlen( $svg ) > 200000 ) { return new \WP_Error( 'too_large', 'SVG is too large (max 200 KB).' ); } // UTF-8 only: a BOM, UTF-16 or other encodings could hide markup from the checks below. if ( "\xEF\xBB\xBF" === substr( $svg, 0, 3 ) || "\xFE\xFF" === substr( $svg, 0, 2 ) || "\xFF\xFE" === substr( $svg, 0, 2 ) || false !== strpos( $svg, "\0" ) || ! preg_match( '//u', $svg ) ) { return new \WP_Error( 'invalid_svg', 'SVG must be plain UTF-8 text.' ); } if ( preg_match( '/<\?xml[^>]*encoding\s*=\s*["\']?(?!utf-?8["\'\s?])/i', $svg ) ) { return new \WP_Error( 'invalid_svg', 'SVG must be plain UTF-8 text.' ); } $tags = array( 'svg', 'g', 'path', 'circle', 'ellipse', 'rect', 'line', 'polyline', 'polygon', 'defs', 'lineargradient', 'radialgradient', 'stop', 'title', 'desc', 'clippath', 'mask', 'symbol', 'use' ); $attrs = array( 'viewbox', 'width', 'height', 'xmlns', 'fill', 'fill-rule', 'fill-opacity', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-dasharray', 'stroke-opacity', 'opacity', 'd', 'cx', 'cy', 'r', 'rx', 'ry', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'points', 'transform', 'id', 'class', 'offset', 'stop-color', 'stop-opacity', 'gradientunits', 'gradienttransform', 'clip-path', 'clip-rule', 'mask', 'preserveaspectratio', 'href', 'xlink:href', 'aria-hidden', 'role', 'focusable' ); if ( preg_match( '/loadXML( trim( $svg ), LIBXML_NONET ); libxml_clear_errors(); libxml_use_internal_errors( $prev ); if ( ! $ok || null !== $dom->doctype || ! $dom->documentElement || 'svg' !== strtolower( $dom->documentElement->localName ) ) { return new \WP_Error( 'invalid_svg', 'That is not valid SVG markup.' ); } $svg_ns = 'http://www.w3.org/2000/svg'; $walk = static function ( \DOMNode $node ) use ( &$walk, $tags, $attrs, $svg_ns ) { foreach ( iterator_to_array( $node->childNodes ) as $child ) { if ( $child instanceof \DOMElement ) { $ns = (string) $child->namespaceURI; if ( ( '' !== $ns && $svg_ns !== $ns ) || ! in_array( strtolower( $child->localName ), $tags, true ) ) { $node->removeChild( $child ); continue; } $walk( $child ); } elseif ( XML_TEXT_NODE !== $child->nodeType ) { // Comments, processing instructions, CDATA sections and entity references. $node->removeChild( $child ); } } if ( $node instanceof \DOMElement ) { foreach ( iterator_to_array( $node->attributes ) as $at ) { $name = strtolower( $at->nodeName ); $val = (string) $at->nodeValue; if ( ! in_array( $name, $attrs, true ) || ( in_array( $name, array( 'href', 'xlink:href' ), true ) && 0 !== strpos( $val, '#' ) ) || preg_match( '/(javascript|vbscript|data:|expression\(|@import)/i', $val ) || ( preg_match( '/url\s*\(/i', $val ) && ! preg_match( '/^url\(\s*[\'"]?#[^)]*\)$/i', trim( $val ) ) ) ) { $node->removeAttributeNode( $at ); } } } }; $walk( $dom->documentElement ); return (string) $dom->saveXML( $dom->documentElement ); } /** * Query a source. * * @param string $source Source. * @param string $q Query. * @param string $orientation Orientation. * @param int $limit Limit. * @return array>|\WP_Error */ private static function search( string $source, string $q, string $orientation, int $limit ) { $s = Plugin::instance()->settings(); switch ( $source ) { case 'pexels': $url = add_query_arg( array( 'query' => rawurlencode( $q ), 'per_page' => $limit, 'orientation' => 'any' === $orientation ? null : $orientation ), 'https://api.pexels.com/v1/search' ); $resp = wp_remote_get( $url, array( 'timeout' => 15, 'headers' => array( 'Authorization' => (string) $s->get( 'pexels_key' ) ) ) ); $json = self::json( $resp ); if ( is_wp_error( $json ) ) { return $json; } return array_map( static function ( $p ) { return array( 'url' => $p['src']['large2x'] ?? $p['src']['original'], 'preview' => $p['src']['medium'], 'width' => $p['width'], 'height' => $p['height'], 'alt' => $p['alt'] ?? '', 'credit' => 'Photo by ' . $p['photographer'] . ' on Pexels', 'license' => 'Pexels License (free to use)', 'page' => $p['url'] ); }, (array) ( $json['photos'] ?? array() ) ); case 'unsplash': $url = add_query_arg( array( 'query' => rawurlencode( $q ), 'per_page' => $limit, 'orientation' => 'any' === $orientation ? null : ( 'square' === $orientation ? 'squarish' : $orientation ) ), 'https://api.unsplash.com/search/photos' ); $resp = wp_remote_get( $url, array( 'timeout' => 15, 'headers' => array( 'Authorization' => 'Client-ID ' . $s->get( 'unsplash_key' ) ) ) ); $json = self::json( $resp ); if ( is_wp_error( $json ) ) { return $json; } return array_map( static function ( $p ) { return array( 'url' => $p['urls']['regular'], 'preview' => $p['urls']['small'], 'width' => $p['width'], 'height' => $p['height'], 'alt' => (string) ( $p['alt_description'] ?? '' ), 'credit' => 'Photo by ' . $p['user']['name'] . ' on Unsplash', 'license' => 'Unsplash License (free to use)', 'page' => $p['links']['html'], 'download_location' => (string) ( $p['links']['download_location'] ?? '' ) ); }, (array) ( $json['results'] ?? array() ) ); default: $args = array( 'q' => rawurlencode( $q ), 'page_size' => $limit, 'license_type' => 'commercial,modification', 'mature' => 'false' ); if ( 'any' !== $orientation ) { $args['aspect_ratio'] = 'landscape' === $orientation ? 'wide' : ( 'portrait' === $orientation ? 'tall' : 'square' ); } $json = self::json( wp_remote_get( add_query_arg( $args, 'https://api.openverse.org/v1/images/' ), array( 'timeout' => 25 ) ) ); if ( is_wp_error( $json ) ) { return $json; } return array_map( static function ( $p ) { $credit = '"' . ( $p['title'] ?? 'Image' ) . '" by ' . ( $p['creator'] ?? 'unknown' ) . ', ' . strtoupper( (string) $p['license'] ) . ' ' . ( $p['license_version'] ?? '' ); return array( 'url' => $p['url'], 'preview' => $p['thumbnail'] ?? $p['url'], 'width' => $p['width'] ?? null, 'height' => $p['height'] ?? null, 'alt' => (string) ( $p['title'] ?? '' ), 'credit' => trim( $credit ), 'license' => $p['license_url'] ?? '', 'page' => $p['foreign_landing_url'] ?? '' ); }, (array) ( $json['results'] ?? array() ) ); } } /** * File name base from alt text or title: lowercase words, at most 60 characters. * * @param string $text Alt text or title. */ private static function file_slug( string $text ): ?string { $slug = sanitize_title( wp_strip_all_tags( $text ) ); if ( strlen( $slug ) > 60 ) { $slug = rtrim( substr( $slug, 0, 60 ), '-' ); $cut = strrpos( $slug, '-' ); $slug = $cut > 30 ? substr( $slug, 0, $cut ) : $slug; } return '' !== $slug ? $slug : null; } /** * Decode an HTTP JSON response. * * @param array|\WP_Error $resp Response. * @return array|\WP_Error */ private static function json( $resp ) { if ( is_wp_error( $resp ) ) { return $resp; } $code = (int) wp_remote_retrieve_response_code( $resp ); if ( $code >= 400 ) { return new \WP_Error( 'image_api', "Image service returned HTTP $code." ); } $json = json_decode( (string) wp_remote_retrieve_body( $resp ), true ); return is_array( $json ) ? $json : new \WP_Error( 'image_api', 'Unexpected response from the image service.' ); } }