tag. */
esc_html__( '%s requires BuddyPress or BuddyBoss Platform to be installed and active.', 'buddypress-activity-link-preview' ),
'' . esc_html__( 'Activity Link Preview For BuddyPress', 'buddypress-activity-link-preview' ) . ''
);
?>
admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'bp_activity_link_preview_nonce' ),
'buddyboss_link_preview_active' => $buddyboss_link_preview_active,
'i18n' => array(
'cancelPreview' => __( 'Cancel Preview', 'buddypress-activity-link-preview' ),
'cancelPreviewImage' => __( 'Cancel Preview Image', 'buddypress-activity-link-preview' ),
'previousImage' => __( 'Previous image', 'buddypress-activity-link-preview' ),
'nextImage' => __( 'Next image', 'buddypress-activity-link-preview' ),
/* translators: 1: current image number. 2: total number of images. */
'imageCount' => __( 'Image %1$s of %2$s', 'buddypress-activity-link-preview' ),
),
)
);
}
/**
* Remove BuddyBoss Platform's link preview filter when our plugin is active.
* BuddyBoss adds bp_activity_link_preview at priority 20 which causes duplicate previews.
*
* @since 1.7.1
*/
function bp_activity_link_preview_disable_buddyboss_preview() {
// Check if BuddyBoss Platform is active.
if ( defined( 'BP_PLATFORM_VERSION' ) && function_exists( 'bp_activity_link_preview' ) ) {
remove_filter( 'bp_get_activity_content_body', 'bp_activity_link_preview', 20 );
}
}
/** Bp_activity_parse_url_preview */
function bp_activity_parse_url_preview() {
// Check if user is logged in first.
if ( ! is_user_logged_in() ) {
wp_send_json_error( array( 'message' => __( 'You must be logged in to perform this action.', 'buddypress-activity-link-preview' ) ) );
}
// Verify nonce - required for security.
$nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : '';
if ( empty( $nonce ) || ! wp_verify_nonce( $nonce, 'bp_activity_link_preview_nonce' ) ) {
wp_send_json_error( array( 'message' => __( 'Security check failed.', 'buddypress-activity-link-preview' ) ) );
}
// Get URL and comment ID (if it's a comment).
$url = ! empty( $_POST['url'] ) ? filter_var( wp_unslash( $_POST['url'] ), FILTER_VALIDATE_URL ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- URL is validated with FILTER_VALIDATE_URL.
$comment_id = ! empty( $_POST['comment_id'] ) ? sanitize_text_field( wp_unslash( $_POST['comment_id'] ) ) : '';
// Check if URL is validated.
if ( ! filter_var( $url, FILTER_VALIDATE_URL ) ) {
wp_send_json( array( 'error' => __( 'The URL you entered is not valid.', 'buddypress-activity-link-preview' ) ) );
}
// Parse URL to get host.
$parsed_url = wp_parse_url( $url );
$host = isset( $parsed_url['host'] ) ? $parsed_url['host'] : '';
// Block requests to private/internal IP ranges and localhost.
if ( bp_activity_link_preview_is_blocked_host( $host ) ) {
wp_send_json( array( 'error' => __( 'This URL cannot be previewed for security reasons.', 'buddypress-activity-link-preview' ) ) );
}
$parse_url_data = bp_activity_link_parse_url( $url );
// If empty data then send error.
if ( empty( $parse_url_data ) ) {
wp_send_json( array( 'error' => __( 'Sorry! Preview is not available right now. Please try again later.', 'buddypress-activity-link-preview' ) ) );
}
// Add comment_id to response if it exists.
if ( ! empty( $comment_id ) ) {
$parse_url_data['comment_id'] = $comment_id;
}
// Apply filter to allow modification of parsed data.
$parse_url_data = apply_filters( 'bp_activity_parse_url_preview', $parse_url_data, $url );
// send json success.
wp_send_json( $parse_url_data );
}
/**
* Determine whether a host points at a private, loopback, or reserved address.
*
* Centralizes the SSRF host guard so it can be applied both to the original
* input URL in the AJAX handler and to any redirect-resolved URL during
* short-URL resolution. Returns true when the host should be BLOCKED.
*
* @since 1.7.4
* @param string $host The host portion of a URL (no scheme/path).
* @return bool True if the host must be blocked for security reasons.
*/
function bp_activity_link_preview_is_blocked_host( $host ) {
if ( empty( $host ) ) {
return true;
}
if ( filter_var( $host, FILTER_VALIDATE_IP ) &&
( false === filter_var( $host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) )
) {
return true;
}
if ( '127.0.0.1' === $host || 'localhost' === $host ) {
return true;
}
// Single-label hosts ("https://www/", intranet names) are never a public site.
if ( false === strpos( $host, '.' ) && ! filter_var( $host, FILTER_VALIDATE_IP ) ) {
return true;
}
if ( preg_match( '/^(10\.|172\.(1[6-9]|2[0-9]|3[0-1])\.|192\.168\.)/', $host ) ) {
return true;
}
return false;
}
/**
* Parse a URL and return preview data.
*
* @param string $url The URL to parse.
* @param bool $cached_only When true, never performs a remote HTTP request:
* only the transient cache and the internal-URL
* (local DB) fast path are consulted. Used by
* render-time code so page views never block on
* external fetches.
* @return array Parsed URL data (empty array when unavailable).
*/
function bp_activity_link_parse_url( $url, $cached_only = false ) {
$parse_url_data = wp_parse_url( $url, PHP_URL_HOST );
$original_url = $url;
if ( ! $cached_only && in_array( $parse_url_data, apply_filters( 'bp_activity_link_parse_url_shorten_url_provider', array( 'bit.ly', 'snip.ly', 'rb.gy', 'tinyurl.com', 'tiny.one', 'rotf.lol', 'b.link', '4ubr.short.gy', '' ) ), true ) ) {
$response = wp_safe_remote_get(
$url,
array(
'stream' => true,
'headers' => array(
'user-agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:71.0) Gecko/20100101 Firefox/71.0',
),
),
);
if ( ! is_wp_error( $response ) && ! empty( $response['http_response']->get_response_object()->url ) && $response['http_response']->get_response_object()->url !== $url ) {
$new_url = $response['http_response']->get_response_object()->url;
if ( filter_var( $new_url, FILTER_VALIDATE_URL ) ) {
$url = $new_url;
}
}
if ( $original_url === $url ) {
// Fallback: resolve the short URL through the WordPress HTTP API so the
// request honours WP_HTTP_BLOCK_EXTERNAL and the configured transports.
// Follow a single redirect and read the resolved Location.
$head_response = wp_safe_remote_head(
$url,
array(
'redirection' => 1,
'timeout' => 5,
'headers' => array(
'user-agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:71.0) Gecko/20100101 Firefox/71.0',
),
)
);
if ( ! is_wp_error( $head_response ) ) {
$new_url = wp_remote_retrieve_header( $head_response, 'location' );
if ( ! empty( $new_url ) && filter_var( $new_url, FILTER_VALIDATE_URL ) ) {
// Re-validate the redirect-resolved host against the same SSRF
// guard used on the original input URL. A malicious or compromised
// short-URL provider could redirect to a private/loopback address;
// if so, abort resolution and keep the original URL.
$resolved_host = wp_parse_url( $new_url, PHP_URL_HOST );
if ( ! bp_activity_link_preview_is_blocked_host( $resolved_host ) ) {
$url = $new_url;
}
}
}
}
}
$cache_key = 'bp_oembed_' . md5( maybe_serialize( $url ) );
$parsed_url_data = get_transient( $cache_key );
// Negative cache hit: a recent parse failed - do not retry on every call.
if ( 'bpalp_failed' === $parsed_url_data ) {
return apply_filters( 'bp_activity_link_parse_url', array() );
}
if ( ! empty( $parsed_url_data ) ) {
return $parsed_url_data;
}
$parsed_url_data = array();
if ( strstr( $url, site_url() ) && ( strstr( $url, 'download_document_file' ) || strstr( $url, 'download_media_file' ) || strstr( $url, 'download_video_file' ) ) ) {
return array();
}
if ( $cached_only ) {
// Internal URLs resolve from the local database (no HTTP), so they are
// still allowed in cached-only mode; anything else returns empty rather
// than fetching a remote page synchronously during render.
if ( bp_is_same_site_url( $url ) ) {
$internal_data = bp_activity_link_parse_internal_url( $url );
if ( ! empty( $internal_data ) ) {
set_transient( $cache_key, $internal_data, DAY_IN_SECONDS );
return apply_filters( 'bp_activity_link_parse_url', $internal_data );
}
}
return apply_filters( 'bp_activity_link_parse_url', array() );
}
if ( ! function_exists( '_wp_oembed_get_object' ) ) {
require ABSPATH . WPINC . '/class-oembed.php';
}
// Distinguishes a transient fetch failure (timeout, blocked, provider
// momentarily down) from a genuine empty result (a real page with nothing
// to preview). Only genuine empties are negative-cached; a transient
// failure must be retried on the next request rather than blackholing a
// good URL for the whole negative-cache window. See the write below.
$transient_failure = false;
$embed_code = '';
$oembed_obj = _wp_oembed_get_object();
$discover = apply_filters( 'bp_oembed_discover_support', false, $url );
$is_oembed = $oembed_obj->get_data( $url, array( 'discover' => $discover ) );
if ( $is_oembed ) {
$embed_code = wp_oembed_get( $url, array( 'discover' => $discover ) );
// The URL IS a known oEmbed provider (YouTube, Vimeo, Twitter...) but
// the embed came back empty. That is the provider being slow or
// unreachable this once, not a URL with nothing to show - so it must
// not be negative-cached. This is the exact YouTube case: one hiccup
// would otherwise fall through to a server-side scrape of youtube.com,
// which many hosts get a consent wall for, cache that as "empty", and
// leave YouTube previews dead for 15 minutes even though a retry works.
if ( empty( $embed_code ) ) {
$transient_failure = true;
}
}
// Fetch the oembed code for URL.
if ( ! empty( $embed_code ) ) {
$parsed_url_data['title'] = ' ';
$parsed_url_data['description'] = $embed_code;
$parsed_url_data['images'] = '';
$parsed_url_data['error'] = '';
$parsed_url_data['wp_embed'] = true;
} else {
// Try to parse internal URLs directly without HTTP request (avoids self-request performance issues).
if ( bp_is_same_site_url( $url ) ) {
$internal_data = bp_activity_link_parse_internal_url( $url );
if ( ! empty( $internal_data ) ) {
$parsed_url_data = $internal_data;
// Cache the result. $internal_data was already checked above, so no
// second emptiness test is needed here.
set_transient( $cache_key, $parsed_url_data, DAY_IN_SECONDS );
return apply_filters( 'bp_activity_link_parse_url', $parsed_url_data );
}
}
$args = array(
'user-agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:71.0) Gecko/20100101 Firefox/71.0',
'timeout' => 15,
);
if ( bp_is_same_site_url( $url ) ) {
// BuddyBoss Platform compatibility - add JWT token if available.
if ( function_exists( 'bp_enable_private_network' ) && function_exists( 'bb_create_jwt' ) ) {
if ( ! bp_enable_private_network() ) {
// Add the custom header with the JWT token.
$args['headers'] = array(
'bb-preview-token' => bb_create_jwt(
array(
'url' => $url,
'iat' => time(),
'exp' => time() + 120, // Token validity 2 minutes.
)
),
);
}
}
$args['sslverify'] = false;
}
// safely get URL and response body.
$response = wp_safe_remote_get( $url, $args );
$body = wp_remote_retrieve_body( $response );
// A WP_Error means the request itself never completed - timeout, DNS
// failure, connection refused, or WP_HTTP_BLOCK_EXTERNAL. That is
// transient (or config), not a page with nothing to preview, so it must
// be retried rather than negative-cached.
if ( is_wp_error( $response ) ) {
$transient_failure = true;
}
// if response is not empty.
if ( ! is_wp_error( $body ) && ! empty( $body ) ) {
// Load HTML to DOM Object.
$dom = new DOMDocument();
// Suppress warnings for malformed HTML and handle encoding properly.
libxml_use_internal_errors( true );
$dom->loadHTML( '' . $body, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD );
libxml_clear_errors();
$meta_tags = array();
$images = array();
$description = '';
$title = '';
$xpath = new DOMXPath( $dom );
$query = '//*/meta[starts-with(@property, \'og:\')]';
$metas_query = $xpath->query( $query );
foreach ( $metas_query as $meta ) {
// DOMXPath::query() is typed as returning DOMNode, which has no
// getAttribute(). The expression selects meta elements, so this is
// always a DOMElement in practice - guard so a malformed document
// can never reach an undefined method.
if ( ! $meta instanceof DOMElement ) {
continue;
}
$property = $meta->getAttribute( 'property' );
$content = $meta->getAttribute( 'content' );
$meta_tags[] = array( $property, $content );
}
foreach ( $meta_tags as $tag ) {
if ( 'og:title' === $tag[0] ) {
$title = $tag[1];
}
if ( 'og:description' === $tag[0] || 'description' === strtolower( $tag[0] ) ) {
// DOMDocument already decodes attribute entities once; decoding again
// would turn a double-encoded payload on a remote page into live markup.
$description = $tag[1];
}
if ( 'og:image' === $tag[0] ) {
$images[] = $tag[1];
}
}
// Parse DOM to get Title.
if ( empty( $title ) ) {
// getElementsByTagName() always returns a DOMNodeList, so only its
// length is worth testing.
$nodes = $dom->getElementsByTagName( 'title' );
$title = $nodes->length > 0 ? $nodes->item( 0 )->nodeValue : '';
}
// Parse DOM to get Meta Description.
if ( empty( $description ) ) {
$metas = $dom->getElementsByTagName( 'meta' );
for ( $i = 0; $i < $metas->length; $i++ ) {
$meta = $metas->item( $i );
if ( 'description' === $meta->getAttribute( 'name' ) ) {
$description = $meta->getAttribute( 'content' );
break;
}
}
}
// Parse DOM to get Images.
$image_elements = $dom->getElementsByTagName( 'img' );
for ( $i = 0; $i < $image_elements->length; $i++ ) {
$image = $image_elements->item( $i );
$src = $image->getAttribute( 'src' );
if ( filter_var( $src, FILTER_VALIDATE_URL ) ) {
$images[] = $src;
}
}
if ( ! empty( $description ) && '' === trim( $title ) ) {
$title = $description;
}
if ( ! empty( $title ) && '' === trim( $description ) ) {
$description = $title;
}
if ( ! empty( $title ) ) {
$parsed_url_data['title'] = $title;
}
if ( ! empty( $description ) ) {
$parsed_url_data['description'] = $description;
}
if ( ! empty( $images ) ) {
$parsed_url_data['images'] = $images;
}
if ( ! empty( $title ) || ! empty( $description ) || ! empty( $images ) ) {
$parsed_url_data['error'] = '';
}
}
}
if ( ! empty( $parsed_url_data ) ) {
// set the transient.
set_transient( $cache_key, $parsed_url_data, DAY_IN_SECONDS );
} elseif ( ! $transient_failure ) {
// Negative cache a GENUINE empty result - a page that was fetched
// successfully but has nothing to preview - so it is not re-scraped on
// every request. A transient_failure (timeout, blocked, or an oEmbed
// provider that came back empty this once) is deliberately NOT cached:
// caching it would leave a good URL dead for the whole window with no
// feedback and no way to clear it, which is exactly the "YouTube
// previews stopped working" report. Those retry on the next view.
//
// The window is filterable so a site hitting genuinely slow pages can
// tune it without editing the plugin.
$negative_ttl = (int) apply_filters( 'bp_activity_link_preview_negative_cache_ttl', 15 * MINUTE_IN_SECONDS, $url );
if ( $negative_ttl > 0 ) {
set_transient( $cache_key, 'bpalp_failed', $negative_ttl );
}
}
return apply_filters( 'bp_activity_link_parse_url', $parsed_url_data );
}
/**
* Check if the requested URL is from same site.
*
* @param string $url The URL to check.
* @return bool True if the URL is from the same site.
*/
function bp_is_same_site_url( $url ) {
$parsed_url = wp_parse_url( $url );
$home_url = wp_parse_url( home_url( '/' ) );
if ( ! empty( $parsed_url['host'] ) && ! empty( $parsed_url['scheme'] ) ) {
return ( strtolower( $parsed_url['host'] ) === strtolower( $home_url['host'] ) ) && ( $parsed_url['scheme'] === $home_url['scheme'] );
}
return false;
}
/**
* Parse internal BuddyPress/BuddyBoss URLs directly without HTTP requests.
* This avoids self-requests that cause performance issues on live sites.
*
* @param string $url The internal URL to parse.
* @return array|false Preview data array or false if not an internal BP page.
*/
function bp_activity_link_parse_internal_url( $url ) {
if ( ! bp_is_same_site_url( $url ) ) {
return false;
}
$parsed_url = wp_parse_url( $url );
$path = isset( $parsed_url['path'] ) ? trim( $parsed_url['path'], '/' ) : '';
if ( empty( $path ) ) {
return false;
}
// Remove the site path if it's in the URL (for subdirectory installs).
$site_path = trim( wp_parse_url( home_url( '/' ), PHP_URL_PATH ), '/' );
if ( ! empty( $site_path ) && 0 === strpos( $path, $site_path ) ) {
$path = trim( substr( $path, strlen( $site_path ) ), '/' );
}
$path_parts = explode( '/', $path );
// Check for BuddyPress member profile: /members/username/ or /user/username/.
// explode() always yields at least one element, so $path_parts[0] is guaranteed.
if ( in_array( $path_parts[0], array( 'members', 'user' ), true ) && ! empty( $path_parts[1] ) ) {
$username = sanitize_user( $path_parts[1] );
$user = get_user_by( 'slug', $username );
if ( ! $user && is_numeric( $username ) ) {
$user = get_user_by( 'id', $username );
}
if ( $user ) {
// Get user data.
$display_name = $user->display_name;
// Try to get BuddyPress/XProfile data.
if ( function_exists( 'bp_get_profile_field_data' ) ) {
$args = array(
'field' => 'About',
'user_id' => $user->ID,
);
$about = bp_get_profile_field_data( $args );
if ( empty( $about ) ) {
$args['field'] = 'Description';
$about = bp_get_profile_field_data( $args );
}
if ( empty( $about ) ) {
$about = get_user_meta( $user->ID, 'description', true );
}
} else {
$about = get_user_meta( $user->ID, 'description', true );
}
// Get user avatar URL.
$avatar_url = '';
if ( function_exists( 'bp_core_fetch_avatar' ) ) {
$avatar_url = bp_core_fetch_avatar(
array(
'item_id' => $user->ID,
'object' => 'user',
'type' => 'full',
'html' => false,
)
);
}
if ( empty( $avatar_url ) ) {
$avatar_url = get_avatar_url( $user->ID, array( 'size' => 150 ) );
}
return array(
'title' => $display_name,
/* translators: %s: member display name. */
'description' => ! empty( $about ) ? wp_trim_words( $about, 30, '...' ) : sprintf( __( 'View %s\'s profile', 'buddypress-activity-link-preview' ), $display_name ),
'images' => ! empty( $avatar_url ) ? array( $avatar_url ) : array(),
'error' => '',
);
}
}
// Check for BuddyPress groups: /groups/group-name/.
if ( 'groups' === $path_parts[0] && ! empty( $path_parts[1] ) ) {
$group_slug = sanitize_text_field( $path_parts[1] );
if ( function_exists( 'groups_get_groups' ) ) {
$groups = groups_get_groups(
array(
'slug' => $group_slug,
'per_page' => 1,
)
);
if ( ! empty( $groups['groups'] ) ) {
$group = $groups['groups'][0];
// Get group avatar.
$avatar_url = '';
if ( function_exists( 'bp_core_fetch_avatar' ) ) {
$avatar_url = bp_core_fetch_avatar(
array(
'item_id' => $group->id,
'object' => 'group',
'type' => 'full',
'html' => false,
'avatar_dir' => 'group-avatars',
)
);
}
// Get group description.
$description = '';
if ( ! empty( $group->description ) ) {
$description = wp_trim_words( $group->description, 30, '...' );
}
return array(
'title' => $group->name,
/* translators: %s: group name. */
'description' => ! empty( $description ) ? $description : sprintf( __( 'View the %s group', 'buddypress-activity-link-preview' ), $group->name ),
'images' => ! empty( $avatar_url ) ? array( $avatar_url ) : array(),
'error' => '',
);
}
}
}
return false;
}
/**
* Save link preview data into activity meta
*
* @param BP_Activity_Activity $activity Activity object.
*/
function bp_activity_link_preview_save_link_data( $activity ) {
// phpcs:disable WordPress.Security.NonceVerification.Missing -- Nonce verification is handled by BuddyPress before bp_activity_after_save hook is fired.
// Handle main activity posts.
if ( isset( $_POST['link_url'] ) && isset( $_POST['link_title'] ) && isset( $_POST['link_description'] ) && isset( $_POST['link_image'] ) ) {
$link_url = ! empty( $_POST['link_url'] ) ? sanitize_text_field( wp_unslash( $_POST['link_url'] ) ) : '';
$link_title = ! empty( $_POST['link_title'] ) ? sanitize_text_field( wp_unslash( $_POST['link_title'] ) ) : '';
$link_description = ! empty( $_POST['link_description'] ) ? sanitize_text_field( wp_unslash( $_POST['link_description'] ) ) : '';
$link_image = ! empty( $_POST['link_image'] ) ? sanitize_text_field( wp_unslash( $_POST['link_image'] ) ) : '';
$link_wp_embed = ! empty( $_POST['link_wp_embed'] );
$link_preview_data['url'] = $link_url;
if ( false !== strpos( $link_preview_data['url'], 'www.reddit.com' ) ) {
return;
}
if ( ! empty( $link_image ) ) {
$link_preview_data['image_url'] = $link_image;
}
if ( ! empty( $link_title ) ) {
$link_preview_data['title'] = $link_title;
}
if ( ! empty( $link_description ) ) {
$link_preview_data['description'] = $link_description;
}
// Persist oEmbed videos (YouTube, Vimeo, etc.). The composer flags these
// with link_wp_embed; the embed HTML is regenerated server-side from the
// URL (wp_oembed_get, whitelisted providers only) so the saved activity
// renders the player without a live external fetch at render time.
if ( $link_wp_embed && ! empty( $link_url ) ) {
$embed_html = wp_oembed_get( $link_url );
if ( ! empty( $embed_html ) ) {
$link_preview_data['wp_embed'] = true;
$link_preview_data['embed_html'] = $embed_html;
}
}
bp_activity_update_meta( $activity->id, '_bp_activity_link_preview_data', $link_preview_data );
}
// Handle comment link previews (only if enabled).
if ( apply_filters( 'bp_activity_link_preview_enable_comments', true ) && 'activity_comment' === $activity->type && isset( $_POST['comment_link_url'] ) && isset( $_POST['comment_link_title'] ) && isset( $_POST['comment_link_description'] ) && isset( $_POST['comment_link_image'] ) ) {
$comment_link_url = ! empty( $_POST['comment_link_url'] ) ? sanitize_text_field( wp_unslash( $_POST['comment_link_url'] ) ) : '';
$comment_link_title = ! empty( $_POST['comment_link_title'] ) ? sanitize_text_field( wp_unslash( $_POST['comment_link_title'] ) ) : '';
$comment_link_description = ! empty( $_POST['comment_link_description'] ) ? sanitize_text_field( wp_unslash( $_POST['comment_link_description'] ) ) : '';
$comment_link_image = ! empty( $_POST['comment_link_image'] ) ? sanitize_text_field( wp_unslash( $_POST['comment_link_image'] ) ) : '';
$comment_link_wp_embed = ! empty( $_POST['comment_link_wp_embed'] );
$comment_link_preview_data['url'] = $comment_link_url;
if ( false !== strpos( $comment_link_preview_data['url'], 'www.reddit.com' ) ) {
return;
}
if ( ! empty( $comment_link_image ) ) {
$comment_link_preview_data['image_url'] = $comment_link_image;
}
if ( ! empty( $comment_link_title ) ) {
$comment_link_preview_data['title'] = $comment_link_title;
}
if ( ! empty( $comment_link_description ) ) {
$comment_link_preview_data['description'] = $comment_link_description;
}
// Persist oEmbed videos in comments (see main-activity handling above).
if ( $comment_link_wp_embed && ! empty( $comment_link_url ) ) {
$comment_embed_html = wp_oembed_get( $comment_link_url );
if ( ! empty( $comment_embed_html ) ) {
$comment_link_preview_data['wp_embed'] = true;
$comment_link_preview_data['embed_html'] = $comment_embed_html;
}
}
bp_activity_update_meta( $activity->id, '_bp_activity_comment_link_preview_data', $comment_link_preview_data );
} elseif ( apply_filters( 'bp_activity_link_preview_enable_comments', true ) && 'activity_comment' === $activity->type && ! empty( $activity->content ) ) {
// Fallback: comment has no saved preview data but its content has URLs, so extract and save (only if enabled).
$urls = bp_activity_link_preview_extract_urls_from_content( $activity->content, false );
if ( ! empty( $urls ) ) {
$url = $urls[0]; // Use first URL found.
$parsed_data = bp_activity_link_parse_url( $url );
// Check if it's a social media URL that uses native embeds (Twitter, Facebook).
$is_social_media = bp_activity_link_preview_is_social_media_url( $url );
// Save preview data if we have title/description OR if it's a social media URL.
if ( ! empty( $parsed_data ) && ( ! empty( $parsed_data['title'] ) || ! empty( $parsed_data['description'] ) || $is_social_media ) ) {
// Scraped remote content is untrusted - sanitize before storing in activity meta.
$comment_link_preview_data = array(
'url' => esc_url_raw( $url ),
'title' => ! empty( $parsed_data['title'] ) ? sanitize_text_field( wp_strip_all_tags( $parsed_data['title'] ) ) : '',
'description' => ! empty( $parsed_data['description'] ) ? sanitize_text_field( wp_strip_all_tags( $parsed_data['description'] ) ) : '',
'image_url' => ! empty( $parsed_data['images'] ) ? esc_url_raw( $parsed_data['images'][0] ) : '',
);
if ( false === strpos( $url, 'www.reddit.com' ) ) {
bp_activity_update_meta( $activity->id, '_bp_activity_comment_link_preview_data', $comment_link_preview_data );
}
}
}
}
// phpcs:enable WordPress.Security.NonceVerification.Missing
}
/**
* Extract URLs from content
*
* @param string $content The content to extract URLs from.
* @param bool $exclude_internal Whether to exclude same-site URLs (default: true).
* @return array Array of URLs found in content.
*/
function bp_activity_link_preview_extract_urls_from_content( $content, $exclude_internal = true ) {
// Strip HTML tags so that URLs only in href attributes (e.g. @mention profile links
// converted to