Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/wp-social-reviews/app/Hooks/Handlers/ActivationHandler.php
+++ b/wp-social-reviews/app/Hooks/Handlers/ActivationHandler.php
@@ -4,17 +4,73 @@
use WPSocialReviewsDatabaseDBMigrator;
use WPSocialReviewsDatabaseDBSeeder;
+use WPSocialReviewsAppServicesPlatformsFeedsCacheHandler;
class ActivationHandler
{
public function handle($network_wide = false)
{
DBMigrator::run($network_wide);
+ $this->purgeNonPublicYoutubeCaches($network_wide);
update_option('_wp_social_ninja_version', WPSOCIALREVIEWS_VERSION, 'no');
(new ActivateCronEvent())->activate();
$this->setPluginInstallTime();
}
+ /**
+ * One-time purge of pre-4.3.1 YouTube feed caches.
+ *
+ * The public-only privacy filter (YoutubeFeed::getPublishVideos) runs at fetch
+ * time, so caches written by 4.3.0 can still contain private/unlisted videos and
+ * would keep serving them until they expired. Clear the youtube cache rows once so
+ * the next fetch repopulates them through the filter. Runs after DBMigrator::run()
+ * so the wpsr_caches table exists, and is guarded by a non-autoloaded option whose
+ * flag is only set after a successful clear, so a failure retries on the next run.
+ *
+ * On network activation the caches, tables and the completion option are all
+ * per-site, so mirror DBMigrator::run() and purge each site in its own context.
+ *
+ * @param bool $network_wide
+ *
+ * @return void
+ * @since 4.3.1
+ */
+ public function purgeNonPublicYoutubeCaches($network_wide = false)
+ {
+ if ($network_wide && function_exists('get_sites') && function_exists('get_current_network_id')) {
+ $site_ids = get_sites(array(
+ 'fields' => 'ids',
+ 'network_id' => get_current_network_id(),
+ ));
+
+ foreach ($site_ids as $site_id) {
+ switch_to_blog($site_id);
+ $this->purgeYoutubeCacheForCurrentSite();
+ restore_current_blog();
+ }
+
+ return;
+ }
+
+ $this->purgeYoutubeCacheForCurrentSite();
+ }
+
+ /**
+ * Purge the current site's YouTube feed caches once, guarded by a per-site option.
+ *
+ * @return void
+ * @since 4.3.1
+ */
+ protected function purgeYoutubeCacheForCurrentSite()
+ {
+ if (get_option('wpsr_youtube_privacy_cache_purged')) {
+ return;
+ }
+
+ (new CacheHandler('youtube'))->clearCache();
+ update_option('wpsr_youtube_privacy_cache_purged', true, 'no');
+ }
+
public function setPluginInstallTime()
{
$statuses = get_option( 'wpsr_statuses', []);
--- a/wp-social-reviews/app/Hooks/Handlers/ShortcodeHandler.php
+++ b/wp-social-reviews/app/Hooks/Handlers/ShortcodeHandler.php
@@ -71,6 +71,13 @@
return __('Provided platform name is not valid.', 'wp-social-reviews');
}
+ // Same visibility policy as the AJAX renderers: never render a draft, private,
+ // password-protected, or wrong-post-type template on a public page. Users who
+ // can edit the template still get admin previews via the edit_post fallback.
+ if (!$this->isPublicRenderableTemplate($templateId)) {
+ return '';
+ }
+
$this->platform = $platform;
$this->productId = $product_id;
@@ -93,6 +100,55 @@
}
+ /**
+ * Check whether a template id may be rendered on a public (unauthenticated) request.
+ *
+ * Template configs live in post meta, so the public AJAX renderers would otherwise
+ * accept any post id — including unpublished/draft/private templates and posts
+ * belonging to other plugin post types that share the `_wpsr_template_config`
+ * meta key (notifications, chat widgets). The `wpsr-ajax-nonce` used by those
+ * endpoints is printed for logged-out visitors, so it authenticates nothing here.
+ *
+ * Users who can edit the template keep access, so admin-side previews still work.
+ *
+ * @param int $wpsr_template_id
+ *
+ * @return bool
+ * @since 4.3.1
+ */
+ public function isPublicRenderableTemplate($wpsr_template_id)
+ {
+ $wpsr_template_id = absint($wpsr_template_id);
+
+ if (empty($wpsr_template_id)) {
+ return false;
+ }
+
+ $wpsr_template_post = get_post($wpsr_template_id);
+
+ if (empty($wpsr_template_post)) {
+ return false;
+ }
+
+ $wpsr_allowed_post_types = apply_filters(
+ 'wpsocialreviews/public_renderable_template_post_types',
+ ['wp_social_reviews']
+ );
+
+ if (!in_array($wpsr_template_post->post_type, $wpsr_allowed_post_types, true)) {
+ return false;
+ }
+
+ // A published but password-protected template must still satisfy the post
+ // password challenge; anonymous AJAX callers never do, so fall through to the
+ // edit_post capability check (admin previews) rather than exposing its feed.
+ if ($wpsr_template_post->post_status === 'publish' && !post_password_required($wpsr_template_post)) {
+ return true;
+ }
+
+ return current_user_can('edit_post', $wpsr_template_id);
+ }
+
public function templateMeta($templateId, $platform)
{
$this->platform = $platform;
@@ -1262,6 +1318,13 @@
}
$templateId = absint(Arr::get($_REQUEST, 'template_id'));
+
+ if (!$this->isPublicRenderableTemplate($templateId)) {
+ wp_send_json_error([
+ 'message' => __('Invalid template.', 'wp-social-reviews')
+ ], 403);
+ }
+
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading AJAX parameters for pagination, not processing sensitive form data
$platform = sanitize_text_field(Arr::get($_REQUEST, 'platform'));
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading AJAX parameters for pagination, not processing sensitive form data
@@ -1305,6 +1368,12 @@
}
$templateId = absint($args['id']);
+
+ // Same visibility policy as the primary shortcode / AJAX renderers.
+ if (!$this->isPublicRenderableTemplate($templateId)) {
+ return '';
+ }
+
$feed_meta = get_post_meta($templateId, '_wpsr_template_config', true);
$decodedMeta = json_decode($feed_meta, true);
$feed_settings = Arr::get($decodedMeta, 'social_wall_settings', array());
--- a/wp-social-reviews/app/Hooks/Handlers/UninstallHandler.php
+++ b/wp-social-reviews/app/Hooks/Handlers/UninstallHandler.php
@@ -6,8 +6,32 @@
class UninstallHandler
{
public $isTableDelete = true;
+
+ /**
+ * Uninstall entry point. Removes all plugin data unless the user opted to keep it.
+ *
+ * The "preserve plugin data" guard lives here rather than in deleteAllPlatformsData()
+ * because that method serves two callers with opposite intent: this one, where the
+ * user's stored preference must win, and SettingsController::deleteAllData(), where
+ * the user has explicitly clicked "Delete All Data" and the preference must be
+ * ignored. Guarding the shared method would turn that button into a silent no-op,
+ * since preserve_plugin_data defaults to 'true'.
+ *
+ * The preference is read straight from the wpsr_global_settings option rather than
+ * through the GlobalSettings service, to keep the uninstall path free of service
+ * instantiation. Defaults to preserving, so an unreadable or missing option can
+ * never cause data loss.
+ *
+ * @return void
+ */
public function handle()
{
+ $settings = get_option('wpsr_global_settings', []);
+ $advanceSettings = Arr::get($settings, 'global_settings.advance_settings', []);
+ if (Arr::get($advanceSettings, 'preserve_plugin_data', 'true') === 'true') {
+ return;
+ }
+
$this->deleteAllPlatformsData($this->isTableDelete);
}
@@ -17,11 +41,6 @@
return;
}
- $advanceSettings = get_option('advance_settings');
- if (Arr::get($advanceSettings, 'preserve_plugin_data') === 'true') {
- return;
- }
-
$manager = new PlatformManager();
$reviewsPlatforms = $manager->reviewsPlatforms();
--- a/wp-social-reviews/app/Hooks/Handlers/YoutubeTemplateHandler.php
+++ b/wp-social-reviews/app/Hooks/Handlers/YoutubeTemplateHandler.php
@@ -124,6 +124,10 @@
$app = App::getInstance();
$shortcodeHandler = new ShortcodeHandler();
+ if (!$shortcodeHandler->isPublicRenderableTemplate($templateId)) {
+ wp_send_json_error(['message' => __('Invalid template.', 'wp-social-reviews')], 403);
+ }
+
$template_meta = $shortcodeHandler->templateMeta($templateId, 'youtube');
$feeds = (new YoutubeFeed())->getTemplateMeta($template_meta, $templateId);
$settings = $shortcodeHandler->formatFeedSettings($feeds);
--- a/wp-social-reviews/app/Http/Controllers/Platforms/Chats/MetaController.php
+++ b/wp-social-reviews/app/Http/Controllers/Platforms/Chats/MetaController.php
@@ -8,15 +8,49 @@
class MetaController extends Controller
{
+ /**
+ * The only post type these endpoints may read from or write to.
+ *
+ * absint() casts the ID but does not validate what it points at. Downstream,
+ * SocialChat::updateSettings() runs a raw `menu_order` UPDATE and an
+ * update_post_meta() on whatever ID it is handed, and neither performs a
+ * capability check of its own — so without this guard a low-privilege
+ * wpsn_* capability holder could write to any post on the site.
+ */
+ const CHAT_POST_TYPE = 'wpsr_social_chats';
+
+ /**
+ * @param int $postId
+ * @return bool Whether the ID resolves to a chat widget.
+ */
+ private function isChatWidget($postId)
+ {
+ return $postId && get_post_type($postId) === self::CHAT_POST_TYPE;
+ }
+
public function index(Request $request, $postId)
{
$postId = absint($postId);
+
+ if (!$this->isChatWidget($postId)) {
+ return $this->sendError([
+ 'message' => __('Chat widget not found.', 'wp-social-reviews')
+ ], 404);
+ }
+
do_action('wpsocialreviews/get_chat_settings', $postId);
}
public function update(Request $request, $postId)
{
$postId = absint($postId);
+
+ if (!$this->isChatWidget($postId)) {
+ return $this->sendError([
+ 'message' => __('Chat widget not found.', 'wp-social-reviews')
+ ], 404);
+ }
+
$settings = json_decode($request->get('args'), true);
$settings = wp_unslash($settings);
$settings = $this->sanitizeChatSettings($settings);
@@ -26,6 +60,13 @@
public function delete(Request $request, $postId)
{
$postId = absint($postId);
+
+ if (!$this->isChatWidget($postId)) {
+ return $this->sendError([
+ 'message' => __('Chat widget not found.', 'wp-social-reviews')
+ ], 404);
+ }
+
do_action('wpsocialreviews/delete_chat_settings', $postId);
}
--- a/wp-social-reviews/app/Http/Controllers/Platforms/Reviews/MetaController.php
+++ b/wp-social-reviews/app/Http/Controllers/Platforms/Reviews/MetaController.php
@@ -196,9 +196,45 @@
]);
}
+ /**
+ * Post types this controller is allowed to read from and write to.
+ *
+ * Without this guard the meta endpoints accept any post ID, and neither
+ * wp_update_post() nor update_post_meta() performs a capability check of
+ * its own, so a low-privilege capability holder could overwrite arbitrary
+ * pages, posts or products.
+ */
+ const ALLOWED_POST_TYPES = ['wp_social_reviews', 'wpsr_reviews_notify'];
+
+ /**
+ * @param int $templateId
+ * @return WP_Post|null The template post, or null if it is not one of ours.
+ */
+ private function getTemplatePost($templateId)
+ {
+ if (!$templateId) {
+ return null;
+ }
+
+ $templateDetails = get_post($templateId);
+
+ if (!$templateDetails || !in_array($templateDetails->post_type, self::ALLOWED_POST_TYPES, true)) {
+ return null;
+ }
+
+ return $templateDetails;
+ }
+
public function update(Request $request, $templateId)
{
$templateId = absint($templateId);
+
+ if (!$this->getTemplatePost($templateId)) {
+ return $this->sendError([
+ 'message' => __('Template not found.', 'wp-social-reviews')
+ ], 404);
+ }
+
$templateMeta = wp_unslash($request->get('template_meta'));
$templateMeta = json_decode($templateMeta, true);
$templateMeta = $this->sanitizeTemplateMeta($templateMeta);
@@ -240,7 +276,10 @@
$menuOrder = $formattedMeta['notification_settings']['notification_priority'];
$db = App::getInstance('db');
+ // post_type is re-asserted here: this bypasses wp_update_post(), so it
+ // must not rely on the caller's guard alone.
$db->table('posts')->where('ID', $templateId)
+ ->whereIn('post_type', self::ALLOWED_POST_TYPES)
->update([
'menu_order' => absint($menuOrder)
]);
@@ -410,8 +449,8 @@
public function loadMore(Request $request, $templateId)
{
$templateId = absint($templateId);
- $templateDetails = get_post($templateId);
- if (!$templateDetails || !in_array($templateDetails->post_type, ['wp_social_reviews', 'wpsr_reviews_notify'], true)) {
+
+ if (!$this->getTemplatePost($templateId)) {
return $this->sendError(['message' => __('Template not found.', 'wp-social-reviews')], 404);
}
--- a/wp-social-reviews/app/Services/Platforms/Chats/SocialChat.php
+++ b/wp-social-reviews/app/Services/Platforms/Chats/SocialChat.php
@@ -68,7 +68,10 @@
$menuOrder = $args['menu_order'];
unset($args['menu_order']);
$db = App::getInstance('db');
+ // post_type is re-asserted here: this is a raw UPDATE that bypasses the
+ // WordPress post API, so it must not rely on the caller's guard alone.
$db->table('posts')->where('ID', $postId)
+ ->where('post_type', 'wpsr_social_chats')
->update([
'menu_order' => absint($menuOrder)
]);
--- a/wp-social-reviews/app/Services/Platforms/Feeds/Common/FeedFilters.php
+++ b/wp-social-reviews/app/Services/Platforms/Feeds/Common/FeedFilters.php
@@ -79,10 +79,14 @@
$globalSettings = $imageHandlerObj->getGlobalSettings();
if(Arr::get($globalSettings, 'optimized_images') === 'true') {
$resizedImages = $imageHandlerObj->getResizeNeededImageLists($feeds, $feed_settings);
- $account_to_show = Arr::get($feed_settings, 'header_settings.account_to_show');
- $header['account_id'] = $account_to_show;
- $header['avatar'] = $imageHandlerObj->formattedData($header,'avatars');
- $header['covers'] = $imageHandlerObj->formattedData($header,'covers');
+ // Some feed types (e.g. youtube single video) return an empty string
+ // as header, so only decorate it when there is an actual header array.
+ if (is_array($header) && !empty($header)) {
+ $account_to_show = Arr::get($feed_settings, 'header_settings.account_to_show');
+ $header['account_id'] = $account_to_show;
+ $header['avatar'] = $imageHandlerObj->formattedData($header, 'avatars');
+ $header['covers'] = $imageHandlerObj->formattedData($header, 'covers');
+ }
}
}
--- a/wp-social-reviews/app/Services/Platforms/Feeds/Youtube/YoutubeFeed.php
+++ b/wp-social-reviews/app/Services/Platforms/Feeds/Youtube/YoutubeFeed.php
@@ -643,15 +643,21 @@
public function getPublishVideos($videoList)
{
$videos = Arr::get($videoList, 'items' , []);
- foreach($videos as $index => $video)
+ $publicVideos = [];
+ foreach($videos as $video)
{
- $videoStatus = Arr::get($video['status'], 'privacyStatus', '');
- if($videoStatus == 'unlisted')
+ // Allow only 'public' videos through. The feed is fetched with the site
+ // owner's OAuth token, so 'private' and 'unlisted' videos come back in the
+ // API response and must not be rendered on the front end.
+ $videoStatus = Arr::get($video, 'status.privacyStatus', '');
+ if($videoStatus === 'public')
{
- unset($videoList['items'][$index]);
+ $publicVideos[] = $video;
}
}
+ $videoList['items'] = $publicVideos;
+
return $videoList;
}
@@ -768,7 +774,10 @@
}
$videoLists = [];
- $parts = 'id,snippet';
+ // Request 'status' so getPublishVideos() can drop private/unlisted videos; the
+ // single-video feed is fetched with the site owner's token and would otherwise
+ // expose non-public videos configured by id on a published template.
+ $parts = 'id,snippet,status';
$parts = apply_filters('wpsocialreviews/youtube_api_parts', $parts, 'single_video');
$videoLists = $this->cacheHandler->getFeedCache($feedCacheName);
@@ -1075,6 +1084,11 @@
return ['error_message' => isset($wpsr_error_check_complete[1]) ? $wpsr_error_check_complete[1] : __('Problem in api key or access token setting!!', 'wp-social-reviews')];
} elseif (!$this->cacheHandler->getFeedCache($cacheName)) {
+ if ($feedType === 'single_video') {
+ // Fetched with the site owner's token, so private/unlisted videos come
+ // back in the response — filter to public-only before caching or returning.
+ $feedData = $this->getPublishVideos($feedData);
+ }
$this->cacheHandler->createCache($cacheName, $feedData);
return $feedData;
}
--- a/wp-social-reviews/app/Services/Platforms/ImageOptimizationHandler.php
+++ b/wp-social-reviews/app/Services/Platforms/ImageOptimizationHandler.php
@@ -54,6 +54,14 @@
$platform = isset($_REQUEST['platform']) ? sanitize_text_field(wp_unslash($_REQUEST['platform'])) : '';
$feed_type = isset($_REQUEST['feed_type']) ? sanitize_text_field(wp_unslash($_REQUEST['feed_type'])) : '';
+ // The nonce is localized for logged-out visitors, so it authenticates nothing.
+ // Gate on the template being publicly renderable before reading its meta, otherwise
+ // an anonymous request could fetch a draft/private feed with the site's token and
+ // materialize its private images into the uploads directory.
+ if (!(new WPSocialReviewsAppHooksHandlersShortcodeHandler())->isPublicRenderableTemplate($id)) {
+ wp_send_json_error(['message' => __('Invalid template.', 'wp-social-reviews')], 403);
+ }
+
if($id > 0 && $this->platform == $platform) {
$encodedMeta = get_post_meta($id, '_wpsr_template_config', true);
$decodedMeta = json_decode($encodedMeta, true);
--- a/wp-social-reviews/app/Services/Platforms/PlatformErrorManager.php
+++ b/wp-social-reviews/app/Services/Platforms/PlatformErrorManager.php
@@ -221,7 +221,7 @@
// translators: Please retain the placeholders (%s, %d, etc.) and ensure they are correctly used in context.
$return_message['error_message'] = sprintf( __( 'Account(%1$s): Error: Connected account for the user %2$s does not have permission to use this feed type.', 'wp-social-reviews' ), $userName, $userName );
// translators: Please retain the placeholders (%s, %d, etc.) and ensure they are correctly used in context.
- $return_message['admin_only'] = sprintf(__( 'Simply tap on the "Continue with Instagram/Facebook" button on the "%s Configuration" modal to reconnect your account and update its permissions.', 'wp-social-reviews' ), $platformWithType);
+ $return_message['admin_only'] = sprintf(__( 'Simply tap on the "Connect Instagram Account" or "Connect Facebook Account" button on the "%s Configuration" modal to reconnect your account and update its permissions.', 'wp-social-reviews' ), $platformWithType);
} elseif ( (int) Arr::get($errors, 'error.code') === 24 ){
$return_message['error_message'] = __( 'Error: Cannot retrieve posts for this hashtag.', 'wp-social-reviews' );
$return_message['admin_only'] = $errors['error']['error_user_msg'];
@@ -229,13 +229,13 @@
// translators: Please retain the placeholders (%s, %d, etc.) and ensure they are correctly used in context.
$return_message['error_message'] = sprintf( __( 'API error %s:', 'wp-social-reviews' ), $errors['error']['code'] ) . ' ' . str_replace( '"', '', $errors['error']['message']);
// translators: Please retain the placeholders (%s, %d, etc.) and ensure they are correctly used in context.
- $return_message['admin_only'] = sprintf(__( 'Simply tap on the "Continue with Instagram/Facebook" button on the "%s Configuration" modal to reconnect your account and update its permissions.', 'wp-social-reviews' ), $platformWithType);
+ $return_message['admin_only'] = sprintf(__( 'Simply tap on the "Connect Instagram Account" or "Connect Facebook Account" button on the "%s Configuration" modal to reconnect your account and update its permissions.', 'wp-social-reviews' ), $platformWithType);
} elseif ((int) Arr::get($errors, 'error.code') == 'invalid_grant' && str_contains($errors['error']['message'] ?? '', 'Refresh token is invalid or expired.')) {
// translators: Please retain the placeholders (%s, %d, etc.) and ensure they are correctly used in context.
$return_message['error_message'] = sprintf(__('API error %s:', 'wp-social-reviews'), $errors['error']['code']) . ' ' . str_replace('"', '', $errors['error']['message']);
$return_message['admin_only'] = sprintf(
// translators: Please retain the placeholders (%s, %d, etc.) and ensure they are correctly used in context.
- __('The connection to TikTok has expired or is no longer valid. This usually happens if the refresh token is outdated or the app’s permissions have been changed. To restore the connection, please tap on the "Continue with TikTok" button on the "%s Configuration" modal. This will guide you through reconnecting your account and updating its permissions.', 'wp-social-reviews'),
+ __('The connection to TikTok has expired or is no longer valid. This usually happens if the refresh token is outdated or the app’s permissions have been changed. To restore the connection, please tap on the "Connect TikTok Account" button on the "%s Configuration" modal. This will guide you through reconnecting your account and updating its permissions.', 'wp-social-reviews'),
$platformWithType
);
} else {
--- a/wp-social-reviews/app/Services/Platforms/ReviewImageOptimizationHandler.php
+++ b/wp-social-reviews/app/Services/Platforms/ReviewImageOptimizationHandler.php
@@ -43,6 +43,15 @@
$templateId = absint(Arr::get($_REQUEST, 'id', -1));
$resize_data = isset($_REQUEST['resize_data']) ? array_map('sanitize_text_field', (array) wp_unslash($_REQUEST['resize_data'])) : [];
$platforms = isset($_REQUEST['platforms']) ? array_map('sanitize_text_field', (array) wp_unslash($_REQUEST['platforms'])) : [];
+
+ // The nonce is localized for logged-out visitors, so it authenticates nothing.
+ // Gate on the template being publicly renderable before reading its meta, otherwise
+ // an anonymous request could fetch a draft/private template's reviews and
+ // materialize their images into the uploads directory.
+ if (!(new WPSocialReviewsAppHooksHandlersShortcodeHandler())->isPublicRenderableTemplate($templateId)) {
+ wp_send_json_error(['message' => __('Invalid template.', 'wp-social-reviews')], 403);
+ }
+
if ($templateId > 0) {
$templateMeta = get_post_meta($templateId, '_wpsr_template_config', true);
$formattedMeta = json_decode($templateMeta, true);
--- a/wp-social-reviews/app/Services/Platforms/Reviews/Airbnb.php
+++ b/wp-social-reviews/app/Services/Platforms/Reviews/Airbnb.php
@@ -540,7 +540,7 @@
'staysBookingMigrationEnabled' => false,
'translateUgc' => null,
'useNewSectionWrapperApi' => false,
- 'sectionIds' => ['POLICIES_DEFAULT', 'BOOK_IT_SIDEBAR', 'URGENCY_COMMITMENT_SIDEBAR', 'BOOK_IT_NAV', 'BOOK_IT_FLOATING_FOOTER', 'URGENCY_COMMITMENT', 'BOOK_IT_CALENDAR_SHEET', 'CANCELLATION_POLICY_PICKER_MODAL'],
+ 'sectionIds' => ['TITLE_DEFAULT', 'POLICIES_DEFAULT', 'BOOK_IT_SIDEBAR', 'URGENCY_COMMITMENT_SIDEBAR', 'BOOK_IT_NAV', 'BOOK_IT_FLOATING_FOOTER', 'URGENCY_COMMITMENT', 'BOOK_IT_CALENDAR_SHEET', 'CANCELLATION_POLICY_PICKER_MODAL'],
'p3ImpressionId' => $p3ImpressionId,
];
@@ -584,15 +584,56 @@
// Extract sharingConfig from the API response
$sharingConfig = Arr::get($data, 'data.presentation.stayProductDetailPage.sections.metadata.sharingConfig', []);
-
+
// Validate and return the sharingConfig if it has the correct structure
if (!empty($sharingConfig) && is_array($sharingConfig) && isset($sharingConfig['__typename']) && $sharingConfig['__typename'] === 'PdpSharingConfig') {
+ // The host's actual listing title lives in the title section, not in
+ // sharingConfig.title (which is a generated "Home in {location}" label).
+ // Attach it so saveBusinessInfo() can prefer it over the generic label.
+ $listingTitle = $this->extractListingTitle($data);
+ if (!empty($listingTitle)) {
+ $sharingConfig['listingTitle'] = $listingTitle;
+ }
+
return $sharingConfig;
}
return [];
}
+ /**
+ * Extract the host's actual listing title from the PDP title section.
+ *
+ * Airbnb's sharingConfig.title is a generated SEO/share label (e.g.
+ * "Home in Gloucestershire · ★4.95 · 2 bedrooms"), which is identical across
+ * many of a host's listings. The custom, human-readable title the host set
+ * lives in the TITLE_DEFAULT section instead.
+ *
+ * @param array $data Decoded StaysPdpSections GraphQL response.
+ * @return string The listing title, or '' when unavailable.
+ */
+ private function extractListingTitle($data)
+ {
+ $sections = Arr::get($data, 'data.presentation.stayProductDetailPage.sections.sections', []);
+ if (empty($sections) || !is_array($sections)) {
+ return '';
+ }
+
+ foreach ($sections as $section) {
+ $sectionId = Arr::get($section, 'sectionId');
+ $typename = Arr::get($section, 'section.__typename');
+
+ if ($sectionId === 'TITLE_DEFAULT' || $typename === 'PdpTitleSection') {
+ $title = Arr::get($section, 'section.title');
+ if (!empty($title) && is_string($title)) {
+ return trim($title);
+ }
+ }
+ }
+
+ return '';
+ }
+
public function formatData($review, $index)
{
$reviewData = $this->extractReviewData($review);
@@ -704,10 +745,18 @@
// Handle GraphQL sharingConfig format (rooms)
if (isset($data['__typename']) && $data['__typename'] === 'PdpSharingConfig') {
- $title = Arr::get($data, 'title');
- // Split the string by the delimiter " · "
- $title = explode(" · ", $title);
- $businessInfo['name'] = Arr::get($title, '0');
+ // Prefer the host's actual listing title when available; otherwise
+ // fall back to the generated sharingConfig.title label, taking the
+ // segment before the " · " delimiter.
+ $listingTitle = Arr::get($data, 'listingTitle');
+ if (!empty($listingTitle)) {
+ $businessInfo['name'] = $listingTitle;
+ } else {
+ $title = Arr::get($data, 'title');
+ // Split the string by the delimiter " · "
+ $title = explode(" · ", $title);
+ $businessInfo['name'] = Arr::get($title, '0');
+ }
$businessInfo['average_rating'] = Arr::get($data, 'starRating');
$businessInfo['total_rating'] = Arr::get($data, 'reviewCount');
} else if (isset($data['__typename']) && $data['__typename'] === 'PageInfoWithCount') {
@@ -987,7 +1036,8 @@
private function isAllowedAirbnbHost($host)
{
- return (bool) preg_match('/(^|.)airbnb.com(.[a-z]{2})?$/i', $host);
+ // Localized domains: airbnb.com, airbnb.com.au, airbnb.co.uk, airbnb.fr, airbnb.cat
+ return (bool) preg_match('/(^|.)airbnb.(?:com|cat|[a-z]{2}|(?:co|com).[a-z]{2})$/i', $host);
}
private function isAllowedAirbnbPort($port)
--- a/wp-social-reviews/app/Services/TranslationStrings.php
+++ b/wp-social-reviews/app/Services/TranslationStrings.php
@@ -161,6 +161,7 @@
'Build chat widgets for your whole site or specific pages. Add as many as you need.' => __('Build chat widgets for your whole site or specific pages. Add as many as you need.', 'wp-social-reviews'),
'Bulk Action' => __('Bulk Action', 'wp-social-reviews'),
'Bulk Actions' => __('Bulk Actions', 'wp-social-reviews'),
+ 'Business Basic' => __('Business Basic', 'wp-social-reviews'),
'Business Description' => __('Business Description', 'wp-social-reviews'),
'Business Logo' => __('Business Logo', 'wp-social-reviews'),
'Business Name' => __('Business Name', 'wp-social-reviews'),
@@ -243,25 +244,34 @@
'Connect' => __('Connect', 'wp-social-reviews'),
'Connect Accounts' => __('Connect Accounts', 'wp-social-reviews'),
'Connect All Products' => __('Connect All Products', 'wp-social-reviews'),
+ 'Connect Facebook Account' => __('Connect Facebook Account', 'wp-social-reviews'),
+ 'Connect Google Business Profile' => __('Connect Google Business Profile', 'wp-social-reviews'),
+ 'Connect Instagram Account' => __('Connect Instagram Account', 'wp-social-reviews'),
'Connect More Account' => __('Connect More Account', 'wp-social-reviews'),
'Connect More Accounts' => __('Connect More Accounts', 'wp-social-reviews'),
'Connect Pages' => __('Connect Pages', 'wp-social-reviews'),
+ 'Connect TikTok Account' => __('Connect TikTok Account', 'wp-social-reviews'),
'Connect Your Account-> Create Your Template-> Customize Your Template-> Save Template-> Copy shortcode-> Embed it on your website.' => __('Connect Your Account-> Create Your Template-> Customize Your Template-> Save Template-> Copy shortcode-> Embed it on your website.', 'wp-social-reviews'),
'Connect new account' => __('Connect new account', 'wp-social-reviews'),
+ 'Connect YouTube Channel' => __('Connect YouTube Channel', 'wp-social-reviews'),
'Connect this Account' => __('Connect this Account', 'wp-social-reviews'),
'Connect with your visitors through multiple chat widgets.' => __('Connect with your visitors through multiple chat widgets.', 'wp-social-reviews'),
+ 'Connect your Facebook account to display posts from the Pages you manage.' => __('Connect your Facebook account to display posts from the Pages you manage.', 'wp-social-reviews'),
+ 'Connect your Facebook account to display reviews from the Pages you manage.' => __('Connect your Facebook account to display reviews from the Pages you manage.', 'wp-social-reviews'),
+ 'Connect your Google account to show reviews from the locations you manage.' => __('Connect your Google account to show reviews from the locations you manage.', 'wp-social-reviews'),
+ 'Connect your TikTok account to show your videos and profile info on your site.' => __('Connect your TikTok account to show your videos and profile info on your site.', 'wp-social-reviews'),
+ 'Connect your YouTube account to show videos, playlists, and live streams from your channels.' => __('Connect your YouTube account to show videos, playlists, and live streams from your channels.', 'wp-social-reviews'),
'Connect your account to start displaying feeds and unlock all the features.' => __('Connect your account to start displaying feeds and unlock all the features.', 'wp-social-reviews'),
'Connected on' => __('Connected on', 'wp-social-reviews'),
'Connecting...' => __('Connecting...', 'wp-social-reviews'),
'Connection' => __('Connection', 'wp-social-reviews'),
+ 'Connects via Instagram' => __('Connects via Instagram', 'wp-social-reviews'),
'Consumer Key(API Key)' => __('Consumer Key(API Key)', 'wp-social-reviews'),
'Consumer Key(API Key):' => __('Consumer Key(API Key):', 'wp-social-reviews'),
'Consumer Secret(API Secret Key)' => __('Consumer Secret(API Secret Key)', 'wp-social-reviews'),
'Consumer Secret(API Secret Key):' => __('Consumer Secret(API Secret Key):', 'wp-social-reviews'),
'Content Language' => __('Content Language', 'wp-social-reviews'),
'Content Type' => __('Content Type', 'wp-social-reviews'),
- 'Continue with Instagram' => __('Continue with Instagram', 'wp-social-reviews'),
- 'Continue with TikTok' => __('Continue with TikTok', 'wp-social-reviews'),
'Copied to Clipboard!' => __('Copied to Clipboard!', 'wp-social-reviews'),
'Could not load Turnstile widget.' => __('Could not load Turnstile widget.', 'wp-social-reviews'),
'Country' => __('Country', 'wp-social-reviews'),
@@ -391,6 +401,9 @@
'Display X (Twitter) Card' => __('Display X (Twitter) Card', 'wp-social-reviews'),
'Display X (Twitter) Logo' => __('Display X (Twitter) Logo', 'wp-social-reviews'),
'Display X (Twitter) Player Card' => __('Display X (Twitter) Player Card', 'wp-social-reviews'),
+ 'Display Your Google Reviews' => __('Display Your Google Reviews', 'wp-social-reviews'),
+ 'Display Your TikTok Videos' => __('Display Your TikTok Videos', 'wp-social-reviews'),
+ 'Display Your YouTube Videos' => __('Display Your YouTube Videos', 'wp-social-reviews'),
'Display each notification for * seconds. (Time should be in Millisecond.)' => __('Display each notification for * seconds. (Time should be in Millisecond.)', 'wp-social-reviews'),
'Display posts with' => __('Display posts with', 'wp-social-reviews'),
'Dots' => __('Dots', 'wp-social-reviews'),
@@ -589,6 +602,7 @@
'Managed' => __('Managed', 'wp-social-reviews'),
'Management Settings' => __('Management Settings', 'wp-social-reviews'),
'Managers' => __('Managers', 'wp-social-reviews'),
+ 'Manual Setup' => __('Manual Setup', 'wp-social-reviews'),
'Manually enter your' => __('Manually enter your', 'wp-social-reviews'),
'Mark as Spam' => __('Mark as Spam', 'wp-social-reviews'),
'Masonry' => __('Masonry', 'wp-social-reviews'),
@@ -739,6 +753,7 @@
'QR Code' => __('QR Code', 'wp-social-reviews'),
'QR Codes' => __('QR Codes', 'wp-social-reviews'),
'QR code' => __('QR code', 'wp-social-reviews'),
+ 'Quick Connect' => __('Quick Connect', 'wp-social-reviews'),
'Random' => __('Random', 'wp-social-reviews'),
'Rating' => __('Rating', 'wp-social-reviews'),
'Rating Style' => __('Rating Style', 'wp-social-reviews'),
@@ -749,8 +764,10 @@
'Read our {reviews_count} Reviews' => __('Read our {reviews_count} Reviews', 'wp-social-reviews'),
'Recipient Email Address' => __('Recipient Email Address', 'wp-social-reviews'),
'Recipients' => __('Recipients', 'wp-social-reviews'),
+ 'Recommended · One-click setup' => __('Recommended · One-click setup', 'wp-social-reviews'),
'Recommends' => __('Recommends', 'wp-social-reviews'),
'Redirect to YouTube' => __('Redirect to YouTube', 'wp-social-reviews'),
+ 'Redirecting to' => __('Redirecting to', 'wp-social-reviews'),
'Regenerate AI Summary' => __('Regenerate AI Summary', 'wp-social-reviews'),
'Relative' => __('Relative', 'wp-social-reviews'),
'Require Login' => __('Require Login', 'wp-social-reviews'),
@@ -843,8 +860,6 @@
'Show Review Images' => __('Show Review Images', 'wp-social-reviews'),
'Show posts containing these words or hashtags' => __('Show posts containing these words or hashtags', 'wp-social-reviews'),
'Show reviews containing these words' => __('Show reviews containing these words', 'wp-social-reviews'),
- 'Sign In And Get Google Access Code' => __('Sign In And Get Google Access Code', 'wp-social-reviews'),
- 'Sign in And Get Google Access Code' => __('Sign in And Get Google Access Code', 'wp-social-reviews'),
'Single Album' => __('Single Album', 'wp-social-reviews'),
'Single Photo' => __('Single Photo', 'wp-social-reviews'),
'Site Key' => __('Site Key', 'wp-social-reviews'),
@@ -992,6 +1007,7 @@
'View on TikTok' => __('View on TikTok', 'wp-social-reviews'),
'Views' => __('Views', 'wp-social-reviews'),
'Views Counter' => __('Views Counter', 'wp-social-reviews'),
+ 'WP Social Ninja does not manage or post to your Facebook account. It only reads the public content you approve. Tokens and data are stored on your own site — our connection service only passes the authorization through and never stores your data.' => __('WP Social Ninja does not manage or post to your Facebook account. It only reads the public content you approve. Tokens and data are stored on your own site — our connection service only passes the authorization through and never stores your data.', 'wp-social-reviews'),
'Weight' => __('Weight', 'wp-social-reviews'),
'Welcome Message' => __('Welcome Message', 'wp-social-reviews'),
'Went' => __('Went', 'wp-social-reviews'),
@@ -1022,6 +1038,11 @@
'You can use this {total_reviews} shortcode to show dynamic value in a text. Ex: Based on {total_reviews} Reviews.' => __('You can use this {total_reviews} shortcode to show dynamic value in a text. Ex: Based on {total_reviews} Reviews.', 'wp-social-reviews'),
'You haven’t added any testimonials. Add one now to start showcasing them in your template.' => __('You haven’t added any testimonials. Add one now to start showcasing them in your template.', 'wp-social-reviews'),
'You need to upgrade to Pro to use this channel.' => __('You need to upgrade to Pro to use this channel.', 'wp-social-reviews'),
+ 'You will be redirected to WP Social Ninja, then to Facebook to approve access.' => __('You will be redirected to WP Social Ninja, then to Facebook to approve access.', 'wp-social-reviews'),
+ 'You will be redirected to WP Social Ninja, then to Google to approve access.' => __('You will be redirected to WP Social Ninja, then to Google to approve access.', 'wp-social-reviews'),
+ 'You will be redirected to WP Social Ninja, then to Instagram to approve access.' => __('You will be redirected to WP Social Ninja, then to Instagram to approve access.', 'wp-social-reviews'),
+ 'You will be redirected to WP Social Ninja, then to TikTok to approve access.' => __('You will be redirected to WP Social Ninja, then to TikTok to approve access.', 'wp-social-reviews'),
+ 'You will be redirected to our authentication portal. This may take up to 5 seconds.' => __('You will be redirected to our authentication portal. This may take up to 5 seconds.', 'wp-social-reviews'),
'Your' => __('Your', 'wp-social-reviews'),
'Your Account' => __('Your Account', 'wp-social-reviews'),
'Your Authorized Businesses List' => __('Your Authorized Businesses List', 'wp-social-reviews'),
--- a/wp-social-reviews/wp-social-reviews.php
+++ b/wp-social-reviews/wp-social-reviews.php
@@ -3,7 +3,7 @@
Plugin Name: WP Social Ninja
Plugin URI: https://wpsocialninja.com/
Description: Display your social feeds, reviews and chat widgets automatically and easily on your website with the all-in-one social media plugin.
-Version: 4.3.0
+Version: 4.3.1
Author: WPManageNinja LLC
Author URI: https://wpsocialninja.com/
License: GPLv2 or later
@@ -13,9 +13,9 @@
defined('ABSPATH') or die;
-define('WPSOCIALREVIEWS_VERSION', '4.3.0');
+define('WPSOCIALREVIEWS_VERSION', '4.3.1');
define('WPSOCIALREVIEWS_DB_VERSION', 121);
-define('WPSOCIALREVIEWS_PRO_MIN_VERSION', '4.3.0');
+define('WPSOCIALREVIEWS_PRO_MIN_VERSION', '4.3.1');
define('WPSOCIALREVIEWS_MAIN_FILE', __FILE__);
define('WPSOCIALREVIEWS_BASENAME', plugin_basename(__FILE__));
define('WPSOCIALREVIEWS_URL', plugin_dir_url(__FILE__));