--- a/visual-link-preview/includes/admin/class-vlp-assets.php
+++ b/visual-link-preview/includes/admin/class-vlp-assets.php
@@ -68,6 +68,7 @@
'post_types' => $post_types,
'settings_link' => admin_url( 'options-general.php?page=bv_settings_vlp' ),
'microlink_api_key' => VLP_Settings::get( 'microlink_api_key' ),
+ 'url_providers' => VLP_Url_Provider_Manager::get_available_providers(),
));
}
@@ -86,6 +87,7 @@
'templates' => VLP_Template_Manager::get_templates(),
'edit_link' => admin_url( 'post.php?action=edit&post='),
'settings_link' => admin_url( 'options-general.php?page=bv_settings_vlp' ),
+ 'url_providers' => VLP_Url_Provider_Manager::get_available_providers(),
));
}
}
--- a/visual-link-preview/includes/admin/modal/class-vlp-modal.php
+++ b/visual-link-preview/includes/admin/modal/class-vlp-modal.php
@@ -30,6 +30,7 @@
add_action( 'wp_ajax_vlp_search_posts', array( __CLASS__, 'ajax_search_posts' ) );
add_action( 'wp_ajax_vlp_save_image', array( __CLASS__, 'ajax_save_image' ) );
add_action( 'wp_ajax_vlp_get_post_content', array( __CLASS__, 'ajax_get_post_content' ) );
+ add_action( 'wp_ajax_vlp_get_url_content', array( __CLASS__, 'ajax_get_url_content' ) );
}
/**
@@ -66,6 +67,11 @@
$posts = $query->posts;
foreach ( $posts as $post ) {
+ // Only include posts the user has permission to read.
+ if ( ! current_user_can( 'read_post', $post->ID ) ) {
+ continue;
+ }
+
$post_type = get_post_type_object( get_post_type( $post ) );
if ( $post_type ) {
@@ -94,6 +100,15 @@
$url = isset( $_POST['url'] ) ? esc_url( wp_unslash( $_POST['url'] ) ) : ''; // Input var okay.
$url = str_replace( array( "n", "t", "r" ), '', $url );
+ // Validate URL to prevent SSRF attacks - only allow http/https protocols.
+ $parsed_url = wp_parse_url( $url );
+ if ( ! $parsed_url || ! isset( $parsed_url['scheme'] ) || ! in_array( $parsed_url['scheme'], array( 'http', 'https' ), true ) ) {
+ wp_send_json_error( array(
+ 'message' => __( 'Invalid URL provided.', 'visual-link-preview' ),
+ ) );
+ wp_die();
+ }
+
$image_url = media_sideload_image( $url, null, null, 'src' );
$image_id = attachment_url_to_postid( $image_url );
@@ -122,6 +137,13 @@
$post_id = isset( $_POST['id'] ) ? intval( wp_unslash( $_POST['id'] ) ) : false; // Input var okay.
$post = get_post( $post_id );
+ // Check if user has permission to read this specific post.
+ // This prevents access to private, password-protected, or draft posts the user shouldn't see.
+ if ( $post && ! current_user_can( 'read_post', $post_id ) ) {
+ wp_send_json_error( array() );
+ wp_die();
+ }
+
if ( $post ) {
// Title.
$content['title'] = $post->post_title;
@@ -150,6 +172,57 @@
}
}
+ wp_die();
+ }
+
+ /**
+ * Get content from URL.
+ *
+ * @since 2.3.0
+ */
+ public static function ajax_get_url_content() {
+ if ( check_ajax_referer( 'vlp', 'security', false ) && current_user_can( 'edit_posts' ) ) {
+ $url = isset( $_POST['url'] ) ? esc_url_raw( wp_unslash( $_POST['url'] ) ) : ''; // Input var okay.
+ $provider = isset( $_POST['provider'] ) ? sanitize_text_field( wp_unslash( $_POST['provider'] ) ) : ''; // Input var okay.
+
+ // Validate URL to prevent SSRF attacks - only allow http/https protocols.
+ $parsed_url = wp_parse_url( $url );
+ if ( ! $parsed_url || ! isset( $parsed_url['scheme'] ) || ! in_array( $parsed_url['scheme'], array( 'http', 'https' ), true ) ) {
+ wp_send_json_error( array(
+ 'message' => __( 'Invalid URL provided.', 'visual-link-preview' ),
+ ) );
+ wp_die();
+ }
+
+ // Get metadata using provider manager.
+ $provider_id = ! empty( $provider ) ? $provider : null;
+ $result = VLP_Url_Provider_Manager::get_metadata( $url, $provider_id );
+
+ if ( is_wp_error( $result ) ) {
+ wp_send_json_error( array(
+ 'message' => $result->get_error_message(),
+ ) );
+ } else {
+ // Map to expected format.
+ $content = array();
+ if ( isset( $result['title'] ) ) {
+ $content['title'] = $result['title'];
+ }
+ if ( isset( $result['summary'] ) ) {
+ $content['summary'] = $result['summary'];
+ }
+ if ( isset( $result['image_url'] ) ) {
+ $content['image_id'] = -1;
+ $content['image_url'] = $result['image_url'];
+ }
+
+ wp_send_json_success( array(
+ 'data' => $content,
+ 'provider_used' => isset( $result['provider_used'] ) ? $result['provider_used'] : '',
+ ) );
+ }
+ }
+
wp_die();
}
}
--- a/visual-link-preview/includes/admin/providers/class-vlp-url-provider-linkpreview.php
+++ b/visual-link-preview/includes/admin/providers/class-vlp-url-provider-linkpreview.php
@@ -0,0 +1,117 @@
+<?php
+/**
+ * LinkPreview.net API URL metadata provider.
+ *
+ * @link http://bootstrapped.ventures
+ * @since 2.3.0
+ *
+ * @package Visual_Link_Preview
+ * @subpackage Visual_Link_Preview/includes/admin/providers
+ */
+
+/**
+ * LinkPreview.net API URL metadata provider.
+ *
+ * @since 2.3.0
+ * @package Visual_Link_Preview
+ * @subpackage Visual_Link_Preview/includes/admin/providers
+ * @author Brecht Vandersmissen <brecht@bootstrapped.ventures>
+ */
+class VLP_Url_Provider_LinkPreview extends VLP_Url_Provider {
+
+ /**
+ * Get provider ID.
+ *
+ * @since 2.3.0
+ * @return string Provider ID.
+ */
+ public function get_id() {
+ return 'linkpreview';
+ }
+
+ /**
+ * Get provider name.
+ *
+ * @since 2.3.0
+ * @return string Provider name.
+ */
+ public function get_name() {
+ return __( 'LinkPreview', 'visual-link-preview' );
+ }
+
+ /**
+ * Check if provider is available.
+ *
+ * @since 2.3.0
+ * @return bool True if provider is available.
+ */
+ public function is_available() {
+ $api_key = VLP_Settings::get( 'linkpreview_api_key' );
+ return ! empty( $api_key );
+ }
+
+ /**
+ * Get metadata for a URL.
+ *
+ * @since 2.3.0
+ * @param string $url URL to fetch metadata for.
+ * @return array|WP_Error Normalized metadata array or WP_Error on failure.
+ */
+ public function get_metadata( $url ) {
+ if ( ! $this->is_valid_url( $url ) ) {
+ return new WP_Error( 'invalid_url', __( 'Invalid URL provided.', 'visual-link-preview' ) );
+ }
+
+ $api_key = VLP_Settings::get( 'linkpreview_api_key' );
+ if ( empty( $api_key ) ) {
+ return new WP_Error( 'no_api_key', __( 'LinkPreview API key not configured.', 'visual-link-preview' ) );
+ }
+
+ $endpoint = 'https://api.linkpreview.net';
+
+ $body = wp_json_encode( array(
+ 'key' => $api_key,
+ 'q' => $url,
+ ) );
+
+ $response = wp_remote_post( $endpoint, array(
+ 'timeout' => 10,
+ 'headers' => array(
+ 'Content-Type' => 'application/json',
+ ),
+ 'body' => $body,
+ 'sslverify' => true,
+ ) );
+
+ if ( is_wp_error( $response ) ) {
+ return $response;
+ }
+
+ $response_code = wp_remote_retrieve_response_code( $response );
+ if ( 200 !== $response_code ) {
+ return new WP_Error( 'http_error', sprintf( __( 'HTTP error: %d', 'visual-link-preview' ), $response_code ) );
+ }
+
+ $response_body = wp_remote_retrieve_body( $response );
+ $data = json_decode( $response_body, true );
+
+ if ( ! is_array( $data ) || isset( $data['error'] ) ) {
+ $error_message = isset( $data['error'] ) ? sanitize_text_field( $data['error'] ) : __( 'LinkPreview API returned an error.', 'visual-link-preview' );
+ return new WP_Error( 'api_error', $error_message );
+ }
+
+ // Map LinkPreview response format.
+ $metadata = array();
+ if ( isset( $data['title'] ) ) {
+ $metadata['title'] = $data['title'];
+ }
+ if ( isset( $data['description'] ) ) {
+ $metadata['summary'] = $data['description'];
+ }
+ if ( isset( $data['image'] ) ) {
+ $metadata['image_url'] = $data['image'];
+ }
+
+ return $this->normalize_response( $metadata, $url );
+ }
+}
--- a/visual-link-preview/includes/admin/providers/class-vlp-url-provider-manager.php
+++ b/visual-link-preview/includes/admin/providers/class-vlp-url-provider-manager.php
@@ -0,0 +1,224 @@
+<?php
+/**
+ * Manager for URL metadata providers.
+ *
+ * @link http://bootstrapped.ventures
+ * @since 2.3.0
+ *
+ * @package Visual_Link_Preview
+ * @subpackage Visual_Link_Preview/includes/admin/providers
+ */
+
+/**
+ * Manager for URL metadata providers.
+ *
+ * @since 2.3.0
+ * @package Visual_Link_Preview
+ * @subpackage Visual_Link_Preview/includes/admin/providers
+ * @author Brecht Vandersmissen <brecht@bootstrapped.ventures>
+ */
+class VLP_Url_Provider_Manager {
+
+ /**
+ * Registered providers.
+ *
+ * @since 2.3.0
+ * @var array
+ */
+ private static $providers = array();
+
+ /**
+ * Whether providers have been initialized.
+ *
+ * @since 2.3.0
+ * @var bool
+ */
+ private static $initialized = false;
+
+ /**
+ * Initialize the provider system.
+ *
+ * @since 2.3.0
+ */
+ public static function init() {
+ // Prevent re-initialization.
+ if ( self::$initialized ) {
+ return;
+ }
+
+ // Load provider classes.
+ require_once( VLP_DIR . 'includes/admin/providers/class-vlp-url-provider.php' );
+ require_once( VLP_DIR . 'includes/admin/providers/class-vlp-url-provider-php.php' );
+ require_once( VLP_DIR . 'includes/admin/providers/class-vlp-url-provider-microlink.php' );
+ require_once( VLP_DIR . 'includes/admin/providers/class-vlp-url-provider-linkpreview.php' );
+
+ // Register default providers.
+ self::register_provider( new VLP_Url_Provider_PHP() );
+ self::register_provider( new VLP_Url_Provider_Microlink() );
+ self::register_provider( new VLP_Url_Provider_LinkPreview() );
+
+ // Allow filtering providers.
+ self::$providers = apply_filters( 'vlp_url_providers', self::$providers );
+
+ self::$initialized = true;
+ }
+
+ /**
+ * Register a provider.
+ *
+ * @since 2.3.0
+ * @param VLP_Url_Provider $provider Provider instance.
+ */
+ public static function register_provider( $provider ) {
+ if ( $provider instanceof VLP_Url_Provider ) {
+ self::$providers[ $provider->get_id() ] = $provider;
+ }
+ }
+
+ /**
+ * Get metadata for a URL using providers with fallback.
+ *
+ * @since 2.3.0
+ * @param string $url URL to fetch metadata for.
+ * @param string $provider_id Optional. Specific provider ID to use. If not provided, tries providers in order.
+ * @return array|WP_Error Normalized metadata array with 'provider_used' key, or WP_Error on failure.
+ */
+ public static function get_metadata( $url, $provider_id = null ) {
+ // Ensure providers are initialized.
+ if ( empty( self::$providers ) ) {
+ self::init();
+ }
+ // Validate URL.
+ $parsed_url = wp_parse_url( $url );
+ if ( ! $parsed_url || ! isset( $parsed_url['scheme'] ) || ! in_array( $parsed_url['scheme'], array( 'http', 'https' ), true ) ) {
+ return new WP_Error( 'invalid_url', __( 'Invalid URL provided.', 'visual-link-preview' ) );
+ }
+
+ // If specific provider requested, try only that provider (no fallback).
+ if ( ! empty( $provider_id ) ) {
+ if ( ! isset( self::$providers[ $provider_id ] ) ) {
+ return new WP_Error( 'invalid_provider', __( 'Invalid provider specified.', 'visual-link-preview' ) );
+ }
+
+ $provider = self::$providers[ $provider_id ];
+ if ( ! $provider->is_available() ) {
+ return new WP_Error( 'provider_unavailable', sprintf( __( 'Provider "%s" is not available.', 'visual-link-preview' ), $provider->get_name() ) );
+ }
+
+ $result = $provider->get_metadata( $url );
+ if ( is_wp_error( $result ) ) {
+ // When manually selecting a provider, return the error directly without fallback.
+ // Enhance error message to indicate which provider failed.
+ $error_message = $result->get_error_message();
+ $provider_name = $provider->get_name();
+ return new WP_Error(
+ $result->get_error_code(),
+ sprintf( __( '%s: %s', 'visual-link-preview' ), $provider_name, $error_message ),
+ $result->get_error_data()
+ );
+ }
+
+ $result['provider_used'] = $provider_id;
+ return $result;
+ }
+
+ // Get provider order from settings.
+ $provider_order = self::get_provider_order();
+
+ $last_error = null;
+
+ // Try providers in order until one succeeds.
+ foreach ( $provider_order as $pid ) {
+ if ( ! isset( self::$providers[ $pid ] ) ) {
+ continue;
+ }
+
+ $provider = self::$providers[ $pid ];
+ if ( ! $provider->is_available() ) {
+ continue;
+ }
+
+ $result = $provider->get_metadata( $url );
+ if ( is_wp_error( $result ) ) {
+ $last_error = $result;
+ continue;
+ }
+
+ // Success! Add provider ID to result.
+ $result['provider_used'] = $pid;
+ return $result;
+ }
+
+ // All providers failed.
+ if ( $last_error ) {
+ return $last_error;
+ }
+
+ return new WP_Error( 'no_providers', __( 'No available providers to fetch metadata.', 'visual-link-preview' ) );
+ }
+
+ /**
+ * Get provider order from settings.
+ *
+ * @since 2.3.0
+ * @return array Array of provider IDs in order.
+ */
+ private static function get_provider_order() {
+ // Ensure providers are initialized before parsing order.
+ if ( empty( self::$providers ) ) {
+ self::init();
+ }
+
+ $order_setting = VLP_Settings::get( 'url_provider_order' );
+ $default_order = array( 'php', 'microlink', 'linkpreview' );
+
+ if ( empty( $order_setting ) ) {
+ return $default_order;
+ }
+
+ // Parse textarea value (one provider per line).
+ $lines = explode( "n", $order_setting );
+ $order = array();
+
+ foreach ( $lines as $line ) {
+ $provider_id = trim( $line );
+ // Sanitize provider ID to prevent injection.
+ $provider_id = sanitize_key( $provider_id );
+ if ( ! empty( $provider_id ) && isset( self::$providers[ $provider_id ] ) ) {
+ $order[] = $provider_id;
+ }
+ }
+
+ // If no valid providers found, use default.
+ if ( empty( $order ) ) {
+ return $default_order;
+ }
+
+ return $order;
+ }
+
+ /**
+ * Get list of available providers.
+ *
+ * @since 2.3.0
+ * @return array Array of provider info arrays with 'id', 'name', and 'available' keys.
+ */
+ public static function get_available_providers() {
+ // Ensure providers are initialized.
+ if ( empty( self::$providers ) ) {
+ self::init();
+ }
+
+ $providers = array();
+
+ foreach ( self::$providers as $provider_id => $provider ) {
+ $providers[] = array(
+ 'id' => $provider_id,
+ 'name' => $provider->get_name(),
+ 'available' => $provider->is_available(),
+ );
+ }
+
+ return $providers;
+ }
+}
--- a/visual-link-preview/includes/admin/providers/class-vlp-url-provider-microlink.php
+++ b/visual-link-preview/includes/admin/providers/class-vlp-url-provider-microlink.php
@@ -0,0 +1,110 @@
+<?php
+/**
+ * Microlink API URL metadata provider.
+ *
+ * @link http://bootstrapped.ventures
+ * @since 2.3.0
+ *
+ * @package Visual_Link_Preview
+ * @subpackage Visual_Link_Preview/includes/admin/providers
+ */
+
+/**
+ * Microlink API URL metadata provider.
+ *
+ * @since 2.3.0
+ * @package Visual_Link_Preview
+ * @subpackage Visual_Link_Preview/includes/admin/providers
+ * @author Brecht Vandersmissen <brecht@bootstrapped.ventures>
+ */
+class VLP_Url_Provider_Microlink extends VLP_Url_Provider {
+
+ /**
+ * Get provider ID.
+ *
+ * @since 2.3.0
+ * @return string Provider ID.
+ */
+ public function get_id() {
+ return 'microlink';
+ }
+
+ /**
+ * Get provider name.
+ *
+ * @since 2.3.0
+ * @return string Provider name.
+ */
+ public function get_name() {
+ return __( 'Microlink', 'visual-link-preview' );
+ }
+
+ /**
+ * Check if provider is available.
+ *
+ * @since 2.3.0
+ * @return bool True if provider is available.
+ */
+ public function is_available() {
+ return true; // Microlink free tier is always available.
+ }
+
+ /**
+ * Get metadata for a URL.
+ *
+ * @since 2.3.0
+ * @param string $url URL to fetch metadata for.
+ * @return array|WP_Error Normalized metadata array or WP_Error on failure.
+ */
+ public function get_metadata( $url ) {
+ if ( ! $this->is_valid_url( $url ) ) {
+ return new WP_Error( 'invalid_url', __( 'Invalid URL provided.', 'visual-link-preview' ) );
+ }
+
+ $api_key = VLP_Settings::get( 'microlink_api_key' );
+ $endpoint = empty( $api_key ) ? 'https://api.microlink.io' : 'https://pro.microlink.io';
+
+ $request_url = add_query_arg( 'url', urlencode( $url ), $endpoint );
+
+ $headers = array();
+ if ( ! empty( $api_key ) ) {
+ $headers['x-api-key'] = $api_key;
+ }
+
+ $response = wp_remote_get( $request_url, array(
+ 'timeout' => 10,
+ 'headers' => $headers,
+ 'sslverify' => true,
+ ) );
+
+ if ( is_wp_error( $response ) ) {
+ return $response;
+ }
+
+ $response_code = wp_remote_retrieve_response_code( $response );
+ if ( 200 !== $response_code ) {
+ return new WP_Error( 'http_error', sprintf( __( 'HTTP error: %d', 'visual-link-preview' ), $response_code ) );
+ }
+
+ $body = wp_remote_retrieve_body( $response );
+ $data = json_decode( $body, true );
+
+ if ( ! is_array( $data ) || 'success' !== $data['status'] ) {
+ return new WP_Error( 'api_error', __( 'Microlink API returned an error.', 'visual-link-preview' ) );
+ }
+
+ // Map Microlink response format.
+ $metadata = array();
+ if ( isset( $data['data']['title'] ) ) {
+ $metadata['title'] = $data['data']['title'];
+ }
+ if ( isset( $data['data']['description'] ) ) {
+ $metadata['summary'] = $data['data']['description'];
+ }
+ if ( isset( $data['data']['image'] ) && is_array( $data['data']['image'] ) && isset( $data['data']['image']['url'] ) ) {
+ $metadata['image_url'] = $data['data']['image']['url'];
+ }
+
+ return $this->normalize_response( $metadata, $url );
+ }
+}
--- a/visual-link-preview/includes/admin/providers/class-vlp-url-provider-php.php
+++ b/visual-link-preview/includes/admin/providers/class-vlp-url-provider-php.php
@@ -0,0 +1,160 @@
+<?php
+/**
+ * PHP-based self-hosted URL metadata provider.
+ *
+ * @link http://bootstrapped.ventures
+ * @since 2.3.0
+ *
+ * @package Visual_Link_Preview
+ * @subpackage Visual_Link_Preview/includes/admin/providers
+ */
+
+/**
+ * PHP-based self-hosted URL metadata provider.
+ *
+ * @since 2.3.0
+ * @package Visual_Link_Preview
+ * @subpackage Visual_Link_Preview/includes/admin/providers
+ * @author Brecht Vandersmissen <brecht@bootstrapped.ventures>
+ */
+class VLP_Url_Provider_PHP extends VLP_Url_Provider {
+
+ /**
+ * Get provider ID.
+ *
+ * @since 2.3.0
+ * @return string Provider ID.
+ */
+ public function get_id() {
+ return 'php';
+ }
+
+ /**
+ * Get provider name.
+ *
+ * @since 2.3.0
+ * @return string Provider name.
+ */
+ public function get_name() {
+ return __( 'Self-hosted (PHP)', 'visual-link-preview' );
+ }
+
+ /**
+ * Check if provider is available.
+ *
+ * @since 2.3.0
+ * @return bool True if provider is available.
+ */
+ public function is_available() {
+ return class_exists( 'DOMDocument' );
+ }
+
+ /**
+ * Get metadata for a URL.
+ *
+ * @since 2.3.0
+ * @param string $url URL to fetch metadata for.
+ * @return array|WP_Error Normalized metadata array or WP_Error on failure.
+ */
+ public function get_metadata( $url ) {
+ if ( ! $this->is_valid_url( $url ) ) {
+ return new WP_Error( 'invalid_url', __( 'Invalid URL provided.', 'visual-link-preview' ) );
+ }
+
+ // Fetch HTML content.
+ $response = wp_remote_get( $url, array(
+ 'timeout' => 10,
+ 'user-agent' => 'Mozilla/5.0 (compatible; Visual Link Preview; +https://bootstrapped.ventures)',
+ 'sslverify' => true,
+ ) );
+
+ if ( is_wp_error( $response ) ) {
+ return $response;
+ }
+
+ $response_code = wp_remote_retrieve_response_code( $response );
+ if ( 200 !== $response_code ) {
+ return new WP_Error( 'http_error', sprintf( __( 'HTTP error: %d', 'visual-link-preview' ), $response_code ) );
+ }
+
+ $body = wp_remote_retrieve_body( $response );
+ if ( empty( $body ) ) {
+ return new WP_Error( 'empty_response', __( 'Empty response from URL.', 'visual-link-preview' ) );
+ }
+
+ // Parse HTML.
+ $metadata = $this->parse_html( $body, $url );
+ return $this->normalize_response( $metadata, $url );
+ }
+
+ /**
+ * Parse HTML to extract metadata.
+ *
+ * @since 2.3.0
+ * @param string $html HTML content.
+ * @param string $url Original URL.
+ * @return array Extracted metadata.
+ */
+ private function parse_html( $html, $url ) {
+ $metadata = array();
+
+ // Suppress warnings for malformed HTML.
+ libxml_use_internal_errors( true );
+
+ $dom = new DOMDocument();
+ @$dom->loadHTML( mb_convert_encoding( $html, 'HTML-ENTITIES', 'UTF-8' ) );
+
+ $xpath = new DOMXPath( $dom );
+
+ // Open Graph tags.
+ $og_title = $xpath->query( '//meta[@property="og:title"]/@content' );
+ if ( $og_title->length > 0 ) {
+ $metadata['title'] = $og_title->item( 0 )->nodeValue;
+ }
+
+ $og_description = $xpath->query( '//meta[@property="og:description"]/@content' );
+ if ( $og_description->length > 0 ) {
+ $metadata['summary'] = $og_description->item( 0 )->nodeValue;
+ }
+
+ $og_image = $xpath->query( '//meta[@property="og:image"]/@content' );
+ if ( $og_image->length > 0 ) {
+ $metadata['image_url'] = $og_image->item( 0 )->nodeValue;
+ }
+
+ // Twitter Card tags (fallback for image).
+ if ( empty( $metadata['image_url'] ) ) {
+ $twitter_image = $xpath->query( '//meta[@name="twitter:image"]/@content' );
+ if ( $twitter_image->length > 0 ) {
+ $metadata['image_url'] = $twitter_image->item( 0 )->nodeValue;
+ }
+ }
+
+ // Meta tags (fallback).
+ if ( empty( $metadata['title'] ) ) {
+ $meta_title = $xpath->query( '//title' );
+ if ( $meta_title->length > 0 ) {
+ $metadata['title'] = $meta_title->item( 0 )->nodeValue;
+ }
+ }
+
+ if ( empty( $metadata['summary'] ) ) {
+ $meta_description = $xpath->query( '//meta[@name="description"]/@content' );
+ if ( $meta_description->length > 0 ) {
+ $metadata['summary'] = $meta_description->item( 0 )->nodeValue;
+ }
+ }
+
+ // H1 fallback for title.
+ if ( empty( $metadata['title'] ) ) {
+ $h1 = $xpath->query( '//h1' );
+ if ( $h1->length > 0 ) {
+ $metadata['title'] = $h1->item( 0 )->nodeValue;
+ }
+ }
+
+ libxml_clear_errors();
+
+ return $metadata;
+ }
+}
--- a/visual-link-preview/includes/admin/providers/class-vlp-url-provider.php
+++ b/visual-link-preview/includes/admin/providers/class-vlp-url-provider.php
@@ -0,0 +1,161 @@
+<?php
+/**
+ * Base class for URL metadata providers.
+ *
+ * @link http://bootstrapped.ventures
+ * @since 2.3.0
+ *
+ * @package Visual_Link_Preview
+ * @subpackage Visual_Link_Preview/includes/admin/providers
+ */
+
+/**
+ * Base class for URL metadata providers.
+ *
+ * @since 2.3.0
+ * @package Visual_Link_Preview
+ * @subpackage Visual_Link_Preview/includes/admin/providers
+ * @author Brecht Vandersmissen <brecht@bootstrapped.ventures>
+ */
+abstract class VLP_Url_Provider {
+
+ /**
+ * Get metadata for a URL.
+ *
+ * @since 2.3.0
+ * @param string $url URL to fetch metadata for.
+ * @return array|WP_Error Normalized metadata array or WP_Error on failure.
+ */
+ abstract public function get_metadata( $url );
+
+ /**
+ * Check if provider is available.
+ *
+ * @since 2.3.0
+ * @return bool True if provider is available.
+ */
+ abstract public function is_available();
+
+ /**
+ * Get provider name.
+ *
+ * @since 2.3.0
+ * @return string Provider name.
+ */
+ abstract public function get_name();
+
+ /**
+ * Get provider ID.
+ *
+ * @since 2.3.0
+ * @return string Provider ID.
+ */
+ abstract public function get_id();
+
+ /**
+ * Normalize metadata response.
+ *
+ * @since 2.3.0
+ * @param array $data Raw metadata data.
+ * @param string $url Original URL.
+ * @return array Normalized metadata array.
+ */
+ protected function normalize_response( $data, $url ) {
+ $normalized = array(
+ 'title' => '',
+ 'summary' => '',
+ 'image_url' => '',
+ );
+
+ // Title.
+ if ( isset( $data['title'] ) && ! empty( $data['title'] ) ) {
+ $normalized['title'] = sanitize_text_field( $data['title'] );
+ }
+
+ // Summary/Description.
+ if ( isset( $data['summary'] ) && ! empty( $data['summary'] ) ) {
+ $normalized['summary'] = sanitize_textarea_field( $data['summary'] );
+ } elseif ( isset( $data['description'] ) && ! empty( $data['description'] ) ) {
+ $normalized['summary'] = sanitize_textarea_field( $data['description'] );
+ }
+
+ // Image URL.
+ if ( isset( $data['image_url'] ) && ! empty( $data['image_url'] ) ) {
+ $normalized['image_url'] = esc_url_raw( $data['image_url'] );
+ } elseif ( isset( $data['image'] ) && is_array( $data['image'] ) && isset( $data['image']['url'] ) ) {
+ $normalized['image_url'] = esc_url_raw( $data['image']['url'] );
+ } elseif ( isset( $data['image'] ) && is_string( $data['image'] ) ) {
+ $normalized['image_url'] = esc_url_raw( $data['image'] );
+ }
+
+ // Resolve relative image URLs to absolute URLs.
+ if ( ! empty( $normalized['image_url'] ) ) {
+ $normalized['image_url'] = $this->resolve_image_url( $normalized['image_url'], $url );
+ }
+
+ return $normalized;
+ }
+
+ /**
+ * Resolve relative image URL to absolute URL.
+ *
+ * @since 2.3.0
+ * @param string $image_url Image URL (may be relative).
+ * @param string $base_url Base URL to resolve against.
+ * @return string Absolute image URL.
+ */
+ protected function resolve_image_url( $image_url, $base_url ) {
+ // Already absolute.
+ if ( preg_match( '/^https?:///', $image_url ) ) {
+ return $image_url;
+ }
+
+ // Parse base URL.
+ $parsed_base = wp_parse_url( $base_url );
+ if ( ! $parsed_base || ! isset( $parsed_base['scheme'] ) || ! isset( $parsed_base['host'] ) ) {
+ return $image_url;
+ }
+
+ $base_scheme = $parsed_base['scheme'];
+ $base_host = $parsed_base['host'];
+ $base_path = isset( $parsed_base['path'] ) ? $parsed_base['path'] : '/';
+
+ // Protocol-relative URL.
+ if ( preg_match( '/^///', $image_url ) ) {
+ return $base_scheme . ':' . $image_url;
+ }
+
+ // Absolute path.
+ if ( preg_match( '/^//', $image_url ) ) {
+ return $base_scheme . '://' . $base_host . $image_url;
+ }
+
+ // Relative path.
+ $base_dir = dirname( $base_path );
+ if ( '/' === $base_dir ) {
+ $base_dir = '';
+ }
+ return $base_scheme . '://' . $base_host . $base_dir . '/' . $image_url;
+ }
+
+ /**
+ * Validate URL.
+ *
+ * @since 2.3.0
+ * @param string $url URL to validate.
+ * @return bool True if valid URL.
+ */
+ protected function is_valid_url( $url ) {
+ $parsed_url = wp_parse_url( $url );
+ if ( ! $parsed_url || ! isset( $parsed_url['scheme'] ) || ! isset( $parsed_url['host'] ) ) {
+ return false;
+ }
+
+ // Only allow http/https protocols.
+ if ( ! in_array( $parsed_url['scheme'], array( 'http', 'https' ), true ) ) {
+ return false;
+ }
+
+ return true;
+ }
+}
--- a/visual-link-preview/includes/class-visual-link-preview.php
+++ b/visual-link-preview/includes/class-visual-link-preview.php
@@ -31,7 +31,7 @@
* @since 1.0.0
*/
private function define_constants() {
- define( 'VLP_VERSION', '2.2.9' );
+ define( 'VLP_VERSION', '2.3.0' );
define( 'VLP_DIR', plugin_dir_path( dirname( __FILE__ ) ) );
define( 'VLP_URL', plugin_dir_url( dirname( __FILE__ ) ) );
}
@@ -63,6 +63,7 @@
require_once( VLP_DIR . 'includes/public/class-vlp-dynamic-template-block.php' );
require_once( VLP_DIR . 'includes/public/class-vlp-dynamic-template-layout.php' );
require_once( VLP_DIR . 'includes/public/class-vlp-dynamic-template.php' );
+ require_once( VLP_DIR . 'includes/public/class-vlp-compatibility.php' );
require_once( VLP_DIR . 'includes/public/class-vlp-link.php' );
require_once( VLP_DIR . 'includes/public/class-vlp-shortcode.php' );
require_once( VLP_DIR . 'includes/public/class-vlp-template-editor.php' );
@@ -87,6 +88,10 @@
if ( is_admin() ) {
require_once( VLP_DIR . 'includes/admin/class-vlp-assets.php' );
+ // Providers.
+ require_once( VLP_DIR . 'includes/admin/providers/class-vlp-url-provider-manager.php' );
+ VLP_Url_Provider_Manager::init();
+
// Modal.
require_once( VLP_DIR . 'includes/admin/modal/class-vlp-button.php' );
require_once( VLP_DIR . 'includes/admin/modal/class-vlp-modal.php' );
--- a/visual-link-preview/includes/public/api/class-vlp-api-block.php
+++ b/visual-link-preview/includes/public/api/class-vlp-api-block.php
@@ -95,6 +95,11 @@
$query_posts = $query->posts;
foreach( $query_posts as $post ) {
+ // Only include posts the user has permission to read.
+ if ( ! current_user_can( 'read_post', $post->ID ) ) {
+ continue;
+ }
+
$post_type = get_post_type_object( $post->post_type );
$posts[] = array(
--- a/visual-link-preview/includes/public/class-vlp-compatibility.php
+++ b/visual-link-preview/includes/public/class-vlp-compatibility.php
@@ -0,0 +1,106 @@
+<?php
+/**
+ * Handle compatibility with other plugins/themes.
+ *
+ * @link http://bootstrapped.ventures
+ * @since 2.3.0
+ *
+ * @package Visual_Link_Preview
+ * @subpackage Visual_Link_Preview/includes/public
+ */
+
+/**
+ * Handle compatibility with other plugins/themes.
+ *
+ * @since 2.3.0
+ * @package Visual_Link_Preview
+ * @subpackage Visual_Link_Preview/includes/public
+ * @author Brecht Vandersmissen <brecht@bootstrapped.ventures>
+ */
+class VLP_Compatibility {
+
+ /**
+ * Register actions and filters.
+ *
+ * @since 2.3.0
+ */
+ public static function init() {
+ // Elementor.
+ add_action( 'elementor/editor/before_enqueue_scripts', array( __CLASS__, 'elementor_assets' ) );
+ add_action( 'elementor/controls/register', array( __CLASS__, 'elementor_controls' ) );
+ add_action( 'elementor/preview/enqueue_styles', array( __CLASS__, 'elementor_styles' ) );
+ add_action( 'elementor/widgets/register', array( __CLASS__, 'elementor_widgets' ) );
+ add_action( 'elementor/elements/categories_registered', array( __CLASS__, 'elementor_categories' ) );
+ }
+
+ /**
+ * Enqueue Elementor editor assets.
+ *
+ * @since 2.3.0
+ */
+ public static function elementor_assets() {
+ if ( class_exists( 'VLP_Modal' ) ) {
+ VLP_Modal::add_modal_content();
+ }
+
+ if ( class_exists( 'VLP_Assets' ) ) {
+ VLP_Assets::enqueue();
+ }
+
+ // Ensure modal styles and icons are available in the Elementor editor.
+ wp_enqueue_style( 'vlp-admin', VLP_URL . 'dist/admin.css', array(), VLP_VERSION, 'all' );
+
+ wp_enqueue_script( 'vlp-elementor', VLP_URL . 'assets/js/other/elementor.js', array( 'jquery', 'vlp-admin' ), VLP_VERSION, true );
+ }
+
+ /**
+ * Register Elementor controls.
+ *
+ * @since 2.3.0
+ * @param ElementorControls_Manager $controls_manager Elementor controls manager.
+ */
+ public static function elementor_controls( $controls_manager ) {
+ include( VLP_DIR . 'templates/elementor/control.php' );
+ $controls_manager->register( new VLP_Elementor_Control() );
+ }
+
+ /**
+ * Enqueue Elementor preview styles.
+ *
+ * @since 2.3.0
+ */
+ public static function elementor_styles() {
+ if ( class_exists( 'VLP_Shortcode' ) ) {
+ VLP_Shortcode::enqueue();
+ }
+ }
+
+ /**
+ * Register Elementor widgets.
+ *
+ * @since 2.3.0
+ * @param ElementorWidgets_Manager $widgets_manager Elementor widgets manager.
+ */
+ public static function elementor_widgets( $widgets_manager ) {
+ include( VLP_DIR . 'templates/elementor/widget-link.php' );
+ $widgets_manager->register( new VLP_Elementor_Link_Widget() );
+ }
+
+ /**
+ * Add custom widget categories to Elementor.
+ *
+ * @since 2.3.0
+ * @param ElementorElements_Manager $elements_manager Elementor elements manager.
+ */
+ public static function elementor_categories( $elements_manager ) {
+ $elements_manager->add_category(
+ 'visual-link-preview',
+ array(
+ 'title' => __( 'Visual Link Preview', 'visual-link-preview' ),
+ 'icon' => 'fa fa-plug',
+ )
+ );
+ }
+}
+
+VLP_Compatibility::init();
--- a/visual-link-preview/includes/public/class-vlp-shortcode.php
+++ b/visual-link-preview/includes/public/class-vlp-shortcode.php
@@ -81,6 +81,10 @@
'type' => 'string',
'default' => '',
),
+ 'provider_used' => array(
+ 'type' => 'string',
+ 'default' => '',
+ ),
'template' => array(
'type' => 'string',
'default' => 'use_default_from_settings',
--- a/visual-link-preview/templates/elementor/control.php
+++ b/visual-link-preview/templates/elementor/control.php
@@ -0,0 +1,58 @@
+<?php
+/**
+ * Elementor control for Visual Link Preview.
+ *
+ * @link http://bootstrapped.ventures
+ * @since 2.3.0
+ *
+ * @package Visual_Link_Preview
+ * @subpackage Visual_Link_Preview/templates/elementor
+ */
+
+/**
+ * Elementor control for Visual Link Preview.
+ *
+ * @since 2.3.0
+ */
+class VLP_Elementor_Control extends ElementorBase_Data_Control {
+
+ /**
+ * Get control type.
+ *
+ * @since 2.3.0
+ */
+ public function get_type() {
+ return 'vlp-link-builder';
+ }
+
+ /**
+ * Enqueue control assets.
+ *
+ * @since 2.3.0
+ */
+ public function enqueue() {
+ wp_enqueue_script( 'vlp-elementor-control', VLP_URL . 'templates/elementor/control.min.js', array( 'jquery' ), VLP_VERSION, true );
+ }
+
+ /**
+ * Get default value.
+ *
+ * @since 2.3.0
+ */
+ public function get_default_value() {
+ return '';
+ }
+
+ /**
+ * Output control template.
+ *
+ * @since 2.3.0
+ */
+ public function content_template() {
+ ?>
+ <div class="vlp-elementor-control-placeholder">
+ <?php esc_html_e( 'Use the buttons above to create or edit a visual link preview.', 'visual-link-preview' ); ?>
+ </div>
+ <?php
+ }
+}
--- a/visual-link-preview/templates/elementor/widget-link.php
+++ b/visual-link-preview/templates/elementor/widget-link.php
@@ -0,0 +1,173 @@
+<?php
+/**
+ * Elementor VLP Link Widget.
+ *
+ * Elementor widget for inserting Visual Link Preview links.
+ *
+ * @since 2.3.0
+ */
+class VLP_Elementor_Link_Widget extends ElementorWidget_Base {
+
+ /**
+ * Get widget name.
+ *
+ * @since 2.3.0
+ */
+ public function get_name() {
+ return 'vlp-link-preview';
+ }
+
+ /**
+ * Get widget title.
+ *
+ * @since 2.3.0
+ */
+ public function get_title() {
+ return __( 'Visual Link Preview', 'visual-link-preview' );
+ }
+
+ /**
+ * Get widget icon.
+ *
+ * @since 2.3.0
+ */
+ public function get_icon() {
+ return 'eicon-link';
+ }
+
+ /**
+ * Get widget categories.
+ *
+ * @since 2.3.0
+ */
+ public function get_categories() {
+ return array( 'visual-link-preview' );
+ }
+
+ /**
+ * Get widget keywords.
+ *
+ * @since 2.3.0
+ */
+ public function get_keywords() {
+ return array( 'vlp', 'visual', 'link', 'preview' );
+ }
+
+ /**
+ * Register widget controls.
+ *
+ * @since 2.3.0
+ */
+ protected function register_controls() {
+ $this->start_controls_section(
+ 'content_section',
+ array(
+ 'label' => __( 'Visual Link Preview', 'visual-link-preview' ),
+ 'tab' => ElementorControls_Manager::TAB_CONTENT,
+ )
+ );
+
+ $this->add_control(
+ 'vlp_create',
+ array(
+ 'type' => ElementorControls_Manager::BUTTON,
+ 'text' => __( 'Create visual link', 'visual-link-preview' ),
+ 'event' => 'vlp:link:create',
+ 'conditions' => array(
+ 'terms' => array(
+ array(
+ 'name' => 'vlp_encoded',
+ 'operator' => '==',
+ 'value' => '',
+ ),
+ ),
+ ),
+ )
+ );
+
+ $this->add_control(
+ 'vlp_edit',
+ array(
+ 'type' => ElementorControls_Manager::BUTTON,
+ 'text' => __( 'Edit visual link', 'visual-link-preview' ),
+ 'event' => 'vlp:link:edit',
+ 'conditions' => array(
+ 'terms' => array(
+ array(
+ 'name' => 'vlp_encoded',
+ 'operator' => '!=',
+ 'value' => '',
+ ),
+ ),
+ ),
+ )
+ );
+
+ $this->add_control(
+ 'vlp_encoded',
+ array(
+ 'type' => ElementorControls_Manager::HIDDEN,
+ 'default' => '',
+ )
+ );
+
+ $this->add_control(
+ 'vlp_link_builder',
+ array(
+ 'type' => 'vlp-link-builder',
+ )
+ );
+
+ $this->add_control(
+ 'vlp_unset',
+ array(
+ 'type' => ElementorControls_Manager::BUTTON,
+ 'text' => __( 'Unset visual link', 'visual-link-preview' ),
+ 'event' => 'vlp:link:unset',
+ 'conditions' => array(
+ 'terms' => array(
+ array(
+ 'name' => 'vlp_encoded',
+ 'operator' => '!=',
+ 'value' => '',
+ ),
+ ),
+ ),
+ )
+ );
+
+ $this->end_controls_section();
+ }
+
+ /**
+ * Render widget output on the frontend.
+ *
+ * @since 2.3.0
+ */
+ protected function render() {
+ $output = '';
+ $encoded = trim( $this->get_settings_for_display( 'vlp_encoded' ) );
+
+ if ( ElementorPlugin::$instance->editor->is_edit_mode() ) {
+ if ( '' !== $encoded ) {
+ $link = new VLP_Link( $encoded );
+
+ $template = VLP_Template_Manager::get_template_by_slug( $link->template() );
+ if ( $template ) {
+ $output .= '<style type="text/css">' . VLP_Template_Manager::get_template_css( $template ) . VLP_Template_Style::get_css() . '</style>';
+ }
+
+ $output .= $link->output();
+ } else {
+ $output = '<div style="font-family: monospace;font-style:italic;cursor:pointer;"><' . esc_html__( 'Click and select a Visual Link Preview in the sidebar.', 'visual-link-preview' ) . '></div>';
+ }
+ } else {
+ if ( '' !== $encoded ) {
+ $link = new VLP_Link( $encoded );
+ $output = $link->output();
+ }
+ }
+
+ echo $output;
+ }
+}
--- a/visual-link-preview/templates/settings/settings.php
+++ b/visual-link-preview/templates/settings/settings.php
@@ -239,11 +239,23 @@
),
),
array(
- 'id' => 'advanced',
- 'name' => __( 'Advanced', 'visual-link-preview' ),
- 'icon' => 'cog',
+ 'id' => 'providers',
+ 'name' => __( 'Metadata Providers', 'visual-link-preview' ),
+ 'description' => __( 'Metadata providers are used to fetch metadata for external URLs, such as title, summary, and image.', 'visual-link-preview' ),
+ 'icon' => 'link',
'settings' => array(
array(
+ 'id' => 'url_provider_order',
+ 'name' => __( 'URL Provider Order', 'visual-link-preview' ),
+ 'description' => sprintf(
+ __( 'One provider ID per line (%s). Order matters - providers are tried top to bottom.', 'visual-link-preview' ),
+ 'php, microlink, linkpreview'
+ ),
+ 'type' => 'textarea',
+ 'default' => "phpnmicrolinknlinkpreview",
+ 'rows' => 3,
+ ),
+ array(
'id' => 'microlink_api_key',
'name' => __( 'Microlink API Key', 'visual-link-preview' ),
'description' => __( 'Optionally add your microlink.io API key. Leave blank to use the free plan (limited number of requests per month).', 'visual-link-preview' ),
@@ -252,6 +264,21 @@
'default' => '',
),
array(
+ 'id' => 'linkpreview_api_key',
+ 'name' => __( 'LinkPreview API Key', 'visual-link-preview' ),
+ 'description' => __( 'Optionally add your linkpreview.net API key.', 'visual-link-preview' ),
+ 'documentation' => 'https://www.linkpreview.net',
+ 'type' => 'text',
+ 'default' => '',
+ ),
+ ),
+ ),
+ array(
+ 'id' => 'advanced',
+ 'name' => __( 'Advanced', 'visual-link-preview' ),
+ 'icon' => 'cog',
+ 'settings' => array(
+ array(
'id' => 'rss_feed_output',
'name' => __( 'RSS Feed Output', 'visual-link-preview' ),
'description' => __( 'Choose how visual links should be rendered inside RSS feeds.', 'visual-link-preview' ),
--- a/visual-link-preview/visual-link-preview.php
+++ b/visual-link-preview/visual-link-preview.php
@@ -15,7 +15,7 @@
* Plugin Name: Visual Link Preview
* Plugin URI: http://bootstrapped.ventures/visual-link-preview/
* Description: Display a fully customizable visual link preview for any internal or external link.
- * Version: 2.2.9
+ * Version: 2.3.0
* Author: Bootstrapped Ventures
* Author URI: http://bootstrapped.ventures/
* License: GPL-2.0+