Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 56 additions & 2 deletions assets/bookmarklet.js
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,60 @@
// Open popup window directly (GET request sends session cookies).
popup = window.open( pt_url, target, 'location,resizable,scrollbars,width=' + windowWidth + ',height=' + windowHeight );

// Send scraped data via postMessage.
sendDataToPopup();
if ( popup ) {
// Send scraped data via postMessage.
sendDataToPopup();
} else {
// Popup blocked (common on mobile browsers). Navigate current window with inline data.
// Build a minimal payload to stay within URL length limits (~8KB in modern browsers).
var fallbackData = {
t: scrapedData.t,
s: scrapedData.s,
pt_version: scrapedData.pt_version
};

if ( scrapedData._images ) {
fallbackData._images = scrapedData._images.slice( 0, 5 );
}
if ( scrapedData._embeds ) {
fallbackData._embeds = scrapedData._embeds.slice( 0, 3 );
}

// Only include the specific meta keys Press This uses.
if ( scrapedData._meta ) {
var usedMetaKeys = [ 'og:title', 'og:description', 'og:site_name', 'og:video', 'og:video:url', 'og:video:secure_url', 'twitter:title', 'twitter:description', 'description', 'title' ];
fallbackData._meta = {};
for ( var mk = 0; mk < usedMetaKeys.length; mk++ ) {
if ( scrapedData._meta[ usedMetaKeys[ mk ] ] ) {
fallbackData._meta[ usedMetaKeys[ mk ] ] = scrapedData._meta[ usedMetaKeys[ mk ] ];
}
}
}

// Include only canonical and shortlink from links.
if ( scrapedData._links ) {
fallbackData._links = {};
if ( scrapedData._links.canonical ) {
fallbackData._links.canonical = scrapedData._links.canonical;
}
if ( scrapedData._links.shortlink ) {
fallbackData._links.shortlink = scrapedData._links.shortlink;
}
}

// Skip _jsonld entirely — low value for the URL budget.

// Override pm flag so the app doesn't wait for postMessage.
var fallbackUrl = pt_url.replace( '&pm=1', '&pm=0' ) + '&_data=' + encURI( JSON.stringify( fallbackData ) );

// Enforce a max URL length to avoid truncation.
if ( fallbackUrl.length > 7500 ) {
// Strip meta/links and try again with just title, selection, and media.
delete fallbackData._meta;
delete fallbackData._links;
fallbackUrl = pt_url.replace( '&pm=1', '&pm=0' ) + '&_data=' + encURI( JSON.stringify( fallbackData ) );
}

top.location.href = fallbackUrl;
}
Comment on lines +347 to +398
} )( window, document, top.location.href, window.pt_url );
2 changes: 1 addition & 1 deletion assets/bookmarklet.min.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

116 changes: 115 additions & 1 deletion class-wp-press-this-plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ class WP_Press_This_Plugin {
*/
private $domain = '';

/**
* Whether data was received inline via URL (popup-blocked fallback).
*
* @var bool
*/
private $inline_data_mode = false;

/**
* Constructor.
*
Expand Down Expand Up @@ -768,6 +775,109 @@ public function merge_or_fetch_data() {
}
}

// Support inline data from bookmarklet fallback (popup blocked on mobile).
// When window.open() fails, the bookmarklet encodes scraped data as JSON in the URL.
if ( ! empty( $_GET['_data'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$inline_data = json_decode( wp_unslash( $_GET['_data'] ), true ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized

if ( is_array( $inline_data ) ) {
// Process simple string fields.
$field_map = array(
'u' => 'u',
't' => 't',
's' => 's',
'pt_version' => 'v',
);
foreach ( $field_map as $inline_key => $data_key ) {
if ( ! empty( $inline_data[ $inline_key ] ) && empty( $data[ $data_key ] ) ) {
if ( 'u' === $data_key ) {
$data[ $data_key ] = $this->limit_url( $inline_data[ $inline_key ] );
} else {
$data[ $data_key ] = $this->limit_string( $inline_data[ $inline_key ] );
}
}
}

// Process media and metadata only if media discovery is enabled.
/** This filter is documented above in the POST data section. */
if ( apply_filters( 'enable_press_this_media_discovery', true ) ) {
// Process media arrays.
foreach ( array( '_images', '_embeds', '_og_video' ) as $type ) {
if ( empty( $inline_data[ $type ] ) || ! is_array( $inline_data[ $type ] ) ) {
continue;
}

if ( ! isset( $data[ $type ] ) ) {
$data[ $type ] = array();
}

$items = $this->limit_array( $inline_data[ $type ] );

foreach ( $items as $value ) {
if ( '_images' === $type ) {
$value = $this->limit_img( $value );
} else {
$value = $this->limit_embed( $value );
}

if ( ! empty( $value ) && ! in_array( $value, $data[ $type ], true ) ) {
if ( '_og_video' === $type ) {
if ( ! isset( $data['_embeds'] ) ) {
$data['_embeds'] = array();
}
if ( ! in_array( $value, $data['_embeds'], true ) ) {
$data['_embeds'][] = $value;
}
} else {
$data[ $type ][] = $value;
}
}
}
}

// Process metadata objects.
foreach ( array( '_meta', '_links', '_jsonld' ) as $type ) {
if ( empty( $inline_data[ $type ] ) || ! is_array( $inline_data[ $type ] ) ) {
continue;
}

if ( ! isset( $data[ $type ] ) ) {
$data[ $type ] = array();
}

$items = $this->limit_array( $inline_data[ $type ] );

foreach ( $items as $key => $value ) {
if ( empty( $key ) || strlen( $key ) > 100 ) {
continue;
}

if ( '_meta' === $type ) {
$value = $this->limit_string( $value );
if ( ! empty( $value ) ) {
$data = $this->process_meta_entry( $key, $value, $data );
}
} elseif ( '_links' === $type ) {
if ( in_array( $key, array( 'canonical', 'shortlink', 'icon', 'alternate_canonical' ), true ) ) {
$data[ $type ][ $key ] = $this->limit_url( $value );
}
} elseif ( '_jsonld' === $type ) {
if ( in_array( $key, array( 'canonical', 'headline', 'description', 'image' ), true ) ) {
if ( 'canonical' === $key || 'image' === $key ) {
$data[ $type ][ $key ] = $this->limit_url( $value );
} else {
$data[ $type ][ $key ] = $this->limit_string( $value );
}
}
}
}
}
}

$this->inline_data_mode = true;
}
}
Comment on lines +877 to +879

// phpcs:enable WordPress.Security.NonceVerification.Missing, WordPress.Security.NonceVerification.Recommended

/**
Expand Down Expand Up @@ -1467,7 +1577,7 @@ public function html() {
$proxy_enabled = press_this_is_proxy_enabled();
$has_get_selection = ! empty( $data['s'] ) && 'GET' === $_SERVER['REQUEST_METHOD'];

if ( $is_post_request || ! $proxy_enabled || $has_get_selection ) {
if ( $is_post_request || $this->inline_data_mode || ! $proxy_enabled || $has_get_selection ) {
$post_title = $this->get_suggested_title( $data );
$post_content = $this->get_suggested_content( $data );
} else {
Expand Down Expand Up @@ -1623,6 +1733,10 @@ public function html() {
// PostMessage mode (bookmarklet v11+).
// When true, the app waits for scraped data via postMessage from the opener.
'postMessageMode' => $is_post_message_mode,

// Inline data mode (popup-blocked fallback).
// When true, scraped data was passed via URL and already merged into $data above.
'inlineDataMode' => $this->inline_data_mode,
);

if ( ! headers_sent() ) {
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 16 additions & 1 deletion src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ function getInitialData() {
export default function App() {
const data = useMemo( () => getInitialData(), [] );

// Strip _data param from browser history to avoid leaking scraped content.
useEffect( () => {
if ( data.inlineDataMode && window.history.replaceState ) {
const url = new URL( window.location.href );
url.searchParams.delete( '_data' );
window.history.replaceState( null, '', url.toString() );
}
}, [ data.inlineDataMode ] );

// State for pending scraped content to append.
const [ pendingScrape, setPendingScrape ] = useState( null );

Expand Down Expand Up @@ -182,7 +191,12 @@ export default function App() {
*/
useEffect( () => {
// Only listen if we're in postMessage mode and haven't received data yet.
if ( ! data.postMessageMode || postMessageReceived ) {
// Skip if inline data mode — data was passed via URL, not postMessage.
if (
! data.postMessageMode ||
data.inlineDataMode ||
postMessageReceived
) {
Comment on lines +194 to +199
return;
}

Expand Down Expand Up @@ -267,6 +281,7 @@ export default function App() {
};
}, [
data.postMessageMode,
data.inlineDataMode,
data.restUrl,
data.restNonce,
data.sourceUrl,
Expand Down
29 changes: 29 additions & 0 deletions tests/bookmarklet/bookmarklet.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,35 @@ describe( 'Bookmarklet Functionality', () => {
).toBe( true );
} );

test( 'Falls back to current-window navigation when popup is blocked', () => {
// Check for popup null check (fallback for mobile browsers).
expect( bookmarkletSource ).toContain( 'if ( popup )' );

// Check for inline data encoding via JSON.stringify.
expect( bookmarkletSource ).toContain( 'JSON.stringify' );

// Check for _data URL parameter in fallback path.
expect( bookmarkletSource ).toContain( '_data=' );

// Check for top.location.href fallback navigation.
expect( bookmarkletSource ).toContain( 'top.location.href' );
} );

test( 'Builds minimal fallback payload to limit URL length', () => {
// Check that fallback builds a separate minimal payload.
expect( bookmarkletSource ).toContain( 'fallbackData' );

// Check for image and embed trimming via slice.
expect( bookmarkletSource ).toContain( '.slice( 0, 5 )' );
expect( bookmarkletSource ).toContain( '.slice( 0, 3 )' );

// Check for URL length enforcement.
expect( bookmarkletSource ).toContain( 'fallbackUrl.length > 7500' );

// Check that pm flag is overridden in fallback.
expect( bookmarkletSource ).toContain( "replace( '&pm=1', '&pm=0' )" );
} );

test( 'Enhanced data extraction - JSON-LD structured data', () => {
// Check for JSON-LD script tag query.
expect( bookmarkletSource ).toContain( 'application/ld+json' );
Expand Down
Loading