diff --git a/assets/bookmarklet.js b/assets/bookmarklet.js index 234cda1..e409a97 100644 --- a/assets/bookmarklet.js +++ b/assets/bookmarklet.js @@ -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; + } } )( window, document, top.location.href, window.pt_url ); diff --git a/assets/bookmarklet.min.js b/assets/bookmarklet.min.js index 00bb55c..005686b 100644 --- a/assets/bookmarklet.min.js +++ b/assets/bookmarklet.min.js @@ -1 +1 @@ -!function(e,t,i,a){var n,o,r,l,c,s,g,m,d,f,h,u=e.encodeURIComponent,p=t.getElementsByTagName("head")[0],y={};if(a)if(i.match(/^https?:/)){a+="&u="+u(i),e.getSelection?r=e.getSelection()+"":t.getSelection?r=t.getSelection()+"":t.selection&&(r=t.selection.createRange().text||""),a+="&buster="+(new Date).getTime(),a+="&pm=1",n=(n=e.outerWidth||t.documentElement.clientWidth||600)<800||n>5e3?600:.7*n,o=(o=e.outerHeight||t.documentElement.clientHeight||700)<800||o>3e3?700:.9*o,T("pt_version",11),l=p.getElementsByTagName("meta")||[];for(var v=0;v200);v++){var b=l[v],O=b.getAttribute("name"),_=b.getAttribute("property"),x=b.getAttribute("content");x&&(O?T("_meta["+O+"]",x):_&&(T("_meta["+_+"]",x),"og:video"!==_&&"og:video:url"!==_&&"og:video:secure_url"!==_||T("_og_video[]",x)))}c=p.getElementsByTagName("link")||[];for(var E=0;E=50);E++){var A=c[E],w=A.getAttribute("rel");"canonical"!==w&&"icon"!==w&&"shortlink"!==w||T("_links["+w+"]",A.getAttribute("href")),"alternate"===w&&"x-default"===A.getAttribute("hreflang")&&T("_links[alternate_canonical]",A.getAttribute("href"))}!function(){f=t.querySelectorAll('script[type="application/ld+json"]');for(var e=0;e=100);N++)(d=g[N]).src.indexOf("avatar")>-1||d.className.indexOf("avatar")>-1||d.width&&d.width<256||d.height&&d.height<128||d.src&&0!==d.src.indexOf("data:")&&T("_images[]",d.src);m=t.body.getElementsByTagName("iframe")||[];for(var j=0;j=50);j++){var B=m[j].src;B&&"about:blank"!==B&&(B.indexOf("jetpack-comment")>-1||B.indexOf("disqus.com")>-1||B.indexOf("facebook.com/plugins")>-1||B.indexOf("platform.twitter.com/widgets")>-1||B.indexOf("google.com/recaptcha")>-1||B.indexOf("googletagmanager.com")>-1||B.indexOf("doubleclick.net")>-1||B.indexOf("googlesyndication.com")>-1||B.indexOf("amazon-adsystem.com")>-1||B.indexOf("quantserve.com")>-1||B.indexOf("scorecardresearch.com")>-1||B.indexOf("addthis.com")>-1||B.indexOf("sharethis.com")>-1||B.indexOf("addtoany.com")>-1||T("_embeds[]",B))}var k,P;t.title&&T("t",t.title),r&&T("s",r),h=e.open(a,"_press_this_app","location,resizable,scrollbars,width="+n+",height="+o),k=0,P=a.match(/^https?:\/\/[^\/]+/)[0],setTimeout(function e(){if(k++,h&&!h.closed){try{h.postMessage({type:"press-this-data",version:11,data:y},P)}catch(e){}k<50&&setTimeout(e,100)}},200)}else top.location.href=a;function T(e,t){if(null!=t&&""!==t){var i=e.match(/^(.+)\[\]$/);if(i){var a=i[1];return y[a]||(y[a]=[]),void y[a].push(t)}var n=e.match(/^(.+)\[(.+)\]$/);if(n){var o=n[1],r=n[2];return y[o]||(y[o]={}),void(y[o][r]=t)}y[e]=t}}function S(e){if(e&&"object"==typeof e){var t=e["@type"];if("VideoObject"===t&&(e.embedUrl&&T("_embeds[]",e.embedUrl),e.contentUrl&&!e.embedUrl&&T("_embeds[]",e.contentUrl)),"Article"!==t&&"WebPage"!==t&&"NewsArticle"!==t&&"BlogPosting"!==t||(e.mainEntityOfPage&&"string"==typeof e.mainEntityOfPage?T("_jsonld[canonical]",e.mainEntityOfPage):e.mainEntityOfPage&&e.mainEntityOfPage["@id"]&&T("_jsonld[canonical]",e.mainEntityOfPage["@id"]),e.headline&&T("_jsonld[headline]",e.headline),e.description&&T("_jsonld[description]",e.description)),e.image){var i="";"string"==typeof e.image?i=e.image:e.image.url?i=e.image.url:Array.isArray(e.image)&&e.image[0]&&(i="string"==typeof e.image[0]?e.image[0]:e.image[0].url),i&&T("_jsonld[image]",i)}}}}(window,document,top.location.href,window.pt_url); \ No newline at end of file +!function(e,t,i,a){var n,o,r,l,s,c,m,g,d,f,h,p=e.encodeURIComponent,_=t.getElementsByTagName("head")[0],u={};if(a)if(i.match(/^https?:/)){a+="&u="+p(i),e.getSelection?r=e.getSelection()+"":t.getSelection?r=t.getSelection()+"":t.selection&&(r=t.selection.createRange().text||""),a+="&buster="+(new Date).getTime(),a+="&pm=1",n=(n=e.outerWidth||t.documentElement.clientWidth||600)<800||n>5e3?600:.7*n,o=(o=e.outerHeight||t.documentElement.clientHeight||700)<800||o>3e3?700:.9*o,q("pt_version",11),l=_.getElementsByTagName("meta")||[];for(var y=0;y200);y++){var v=l[y],b=v.getAttribute("name"),O=v.getAttribute("property"),k=v.getAttribute("content");k&&(b?q("_meta["+b+"]",k):O&&(q("_meta["+O+"]",k),"og:video"!==O&&"og:video:url"!==O&&"og:video:secure_url"!==O||q("_og_video[]",k)))}s=_.getElementsByTagName("link")||[];for(var x=0;x=50);x++){var E=s[x],A=E.getAttribute("rel");"canonical"!==A&&"icon"!==A&&"shortlink"!==A||q("_links["+A+"]",E.getAttribute("href")),"alternate"===A&&"x-default"===E.getAttribute("hreflang")&&q("_links[alternate_canonical]",E.getAttribute("href"))}!function(){f=t.querySelectorAll('script[type="application/ld+json"]');for(var e=0;e=100);w++)(d=m[w]).src.indexOf("avatar")>-1||d.className.indexOf("avatar")>-1||d.width&&d.width<256||d.height&&d.height<128||d.src&&0!==d.src.indexOf("data:")&&q("_images[]",d.src);g=t.body.getElementsByTagName("iframe")||[];for(var N=0;N=50);N++){var j=g[N].src;j&&"about:blank"!==j&&(j.indexOf("jetpack-comment")>-1||j.indexOf("disqus.com")>-1||j.indexOf("facebook.com/plugins")>-1||j.indexOf("platform.twitter.com/widgets")>-1||j.indexOf("google.com/recaptcha")>-1||j.indexOf("googletagmanager.com")>-1||j.indexOf("doubleclick.net")>-1||j.indexOf("googlesyndication.com")>-1||j.indexOf("amazon-adsystem.com")>-1||j.indexOf("quantserve.com")>-1||j.indexOf("scorecardresearch.com")>-1||j.indexOf("addthis.com")>-1||j.indexOf("sharethis.com")>-1||j.indexOf("addtoany.com")>-1||q("_embeds[]",j))}if(t.title&&q("t",t.title),r&&q("s",r),h=e.open(a,"_press_this_app","location,resizable,scrollbars,width="+n+",height="+o))U=0,C=a.match(/^https?:\/\/[^\/]+/)[0],setTimeout(function e(){if(U++,h&&!h.closed){try{h.postMessage({type:"press-this-data",version:11,data:u},C)}catch(e){}U<50&&setTimeout(e,100)}},200);else{var B={t:u.t,s:u.s,pt_version:u.pt_version};if(u._images&&(B._images=u._images.slice(0,5)),u._embeds&&(B._embeds=u._embeds.slice(0,3)),u._meta){var P=["og:title","og:description","og:site_name","og:video","og:video:url","og:video:secure_url","twitter:title","twitter:description","description","title"];B._meta={};for(var S=0;S7500&&(delete B._meta,delete B._links,T=a.replace("&pm=1","&pm=0")+"&_data="+p(JSON.stringify(B))),top.location.href=T}var U,C}else top.location.href=a;function q(e,t){if(null!=t&&""!==t){var i=e.match(/^(.+)\[\]$/);if(i){var a=i[1];return u[a]||(u[a]=[]),void u[a].push(t)}var n=e.match(/^(.+)\[(.+)\]$/);if(n){var o=n[1],r=n[2];return u[o]||(u[o]={}),void(u[o][r]=t)}u[e]=t}}function J(e){if(e&&"object"==typeof e){var t=e["@type"];if("VideoObject"===t&&(e.embedUrl&&q("_embeds[]",e.embedUrl),e.contentUrl&&!e.embedUrl&&q("_embeds[]",e.contentUrl)),"Article"!==t&&"WebPage"!==t&&"NewsArticle"!==t&&"BlogPosting"!==t||(e.mainEntityOfPage&&"string"==typeof e.mainEntityOfPage?q("_jsonld[canonical]",e.mainEntityOfPage):e.mainEntityOfPage&&e.mainEntityOfPage["@id"]&&q("_jsonld[canonical]",e.mainEntityOfPage["@id"]),e.headline&&q("_jsonld[headline]",e.headline),e.description&&q("_jsonld[description]",e.description)),e.image){var i="";"string"==typeof e.image?i=e.image:e.image.url?i=e.image.url:Array.isArray(e.image)&&e.image[0]&&(i="string"==typeof e.image[0]?e.image[0]:e.image[0].url),i&&q("_jsonld[image]",i)}}}}(window,document,top.location.href,window.pt_url); \ No newline at end of file diff --git a/class-wp-press-this-plugin.php b/class-wp-press-this-plugin.php index f446d35..3ba1cd8 100644 --- a/class-wp-press-this-plugin.php +++ b/class-wp-press-this-plugin.php @@ -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. * @@ -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; + } + } + // phpcs:enable WordPress.Security.NonceVerification.Missing, WordPress.Security.NonceVerification.Recommended /** @@ -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 { @@ -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() ) { diff --git a/package-lock.json b/package-lock.json index 16d2baf..4d8a0c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27186,7 +27186,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "peer": true, "bin": { diff --git a/src/App.js b/src/App.js index 32ba56f..e10de1e 100644 --- a/src/App.js +++ b/src/App.js @@ -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 ); @@ -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 + ) { return; } @@ -267,6 +281,7 @@ export default function App() { }; }, [ data.postMessageMode, + data.inlineDataMode, data.restUrl, data.restNonce, data.sourceUrl, diff --git a/tests/bookmarklet/bookmarklet.test.js b/tests/bookmarklet/bookmarklet.test.js index 2e0263e..b18a1ed 100644 --- a/tests/bookmarklet/bookmarklet.test.js +++ b/tests/bookmarklet/bookmarklet.test.js @@ -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' ); diff --git a/tests/php/test-integration.php b/tests/php/test-integration.php index 7e7db75..3ba1efc 100644 --- a/tests/php/test-integration.php +++ b/tests/php/test-integration.php @@ -283,4 +283,78 @@ public function test_rest_save_with_external_images_succeeds() { remove_filter( 'pre_http_request', $block_http, 1 ); } + + /** + * Test: Inline data parameter (_data) is parsed by merge_or_fetch_data. + * + * When the bookmarklet's popup is blocked (mobile browsers), it falls back + * to passing scraped data as JSON in the _data URL parameter. + */ + public function test_inline_data_parameter_is_parsed() { + $scraped_data = array( + 'u' => 'https://example.com/article', + 't' => 'Example Article', + 's' => 'Selected text', + '_images' => array( 'https://example.com/image.jpg' ), + '_embeds' => array( 'https://www.youtube.com/embed/abc?si=123' ), + '_meta' => array( + 'og:title' => 'OG Title', + 'og:description' => 'OG Description', + ), + '_links' => array( + 'canonical' => 'https://example.com/article', + ), + ); + + $_GET['_data'] = wp_json_encode( $scraped_data ); + + $data = $this->plugin->merge_or_fetch_data(); + + $this->assertEquals( 'https://example.com/article', $data['u'] ); + $this->assertEquals( 'Example Article', $data['t'] ); + $this->assertEquals( 'Selected text', $data['s'] ); + $this->assertContains( 'https://example.com/image.jpg', $data['_images'] ); + // limit_embed() transforms YouTube embed URLs to watch URLs. + $this->assertContains( 'https://www.youtube.com/watch?v=abc', $data['_embeds'] ); + $this->assertEquals( 'https://example.com/article', $data['_links']['canonical'] ); + + unset( $_GET['_data'] ); + } + + /** + * Test: Inline data does not override existing GET/POST parameters. + */ + public function test_inline_data_does_not_override_existing_params() { + $_GET['u'] = 'https://existing.com/page'; + + $scraped_data = array( + 'u' => 'https://example.com/different', + 't' => 'Inline Title', + ); + + $_GET['_data'] = wp_json_encode( $scraped_data ); + + $data = $this->plugin->merge_or_fetch_data(); + + // Existing 'u' param should take precedence. + $this->assertEquals( 'https://existing.com/page', $data['u'] ); + // 't' should come from inline data since it wasn't in GET. + $this->assertEquals( 'Inline Title', $data['t'] ); + + unset( $_GET['_data'], $_GET['u'] ); + } + + /** + * Test: Invalid JSON in _data parameter is handled gracefully. + */ + public function test_invalid_inline_data_is_handled_gracefully() { + $_GET['_data'] = 'not valid json{{{'; + + $data = $this->plugin->merge_or_fetch_data(); + + // Should not crash, should return normal empty-ish data. + $this->assertIsArray( $data ); + + unset( $_GET['_data'] ); + } }