Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
9ac207d
Implement image proxying for generated images to bypass adblockers
AlejandroPerezMartin Feb 8, 2026
fbaa04d
Apply suggestions from code review
AlejandroPerezMartin Feb 10, 2026
cdf7bb2
Remove domain restriction and update list of supported formats to jpg…
AlejandroPerezMartin Feb 10, 2026
2679f9f
Rename function and use addqueryvars
AlejandroPerezMartin Feb 10, 2026
a2ea6f8
Merge branch 'feature/GOOWOO-447-genai-adblocker-images' of github.co…
AlejandroPerezMartin Feb 10, 2026
8cbbbc7
Rename dependency comment
AlejandroPerezMartin Feb 10, 2026
03273e7
Adding tests for image proxy controller
AlejandroPerezMartin Feb 11, 2026
858253f
Merge branch 'feature/GOOWOO-383-genai-assets' into feature/GOOWOO-44…
jamesmorrison Feb 11, 2026
3f5bd07
Fix broken tests.
jamesmorrison Feb 11, 2026
850f4fa
PHPCS fixes.
jamesmorrison Feb 11, 2026
2554e6c
Update permission callback; added nonce verification to getProxiedIma…
jamesmorrison Feb 11, 2026
d2d8531
Merge branch 'feature/GOOWOO-447-genai-adblocker-images' of github.co…
AlejandroPerezMartin Feb 12, 2026
d0f2254
Rename variable and sort imports
AlejandroPerezMartin Feb 19, 2026
252f995
Add hook to detect adblocker, proxy images (wip)
AlejandroPerezMartin Feb 24, 2026
ab5c9cd
refactor(image-proxy): Consolidate ad blocker image logic
asvinb Feb 24, 2026
306f3be
style: Sort imports
asvinb Feb 24, 2026
558049f
refactor(images-selector): Refactor image selection and replacement l…
asvinb Feb 24, 2026
e411da8
Merge branch 'feature/GOOWOO-447-genai-adblocker-images' of github.co…
AlejandroPerezMartin Feb 26, 2026
70e79fd
fix(adblock): Add DOM and fetch fallbacks for adblock detection
asvinb Mar 5, 2026
a05b71a
Merge branch 'feature/GOOWOO-447-genai-adblocker-images' of github.co…
AlejandroPerezMartin Mar 6, 2026
6f6dd5f
Merge branch 'feature/GOOWOO-383-genai-assets' into feature/GOOWOO-44…
asvinb Mar 13, 2026
19afaf6
chore: Update google-listings-and-ads.zip max size
asvinb Mar 13, 2026
9ee6d5f
Merge branch 'feature/GOOWOO-383-genai-assets' of github.com:woocomme…
AlejandroPerezMartin Mar 16, 2026
67c2df0
Merge branch 'feature/GOOWOO-447-genai-adblocker-images' of github.co…
AlejandroPerezMartin Mar 16, 2026
7709122
Add list of allowed domains to fetch images, add test
AlejandroPerezMartin Mar 16, 2026
be924cd
Replace parse url function
AlejandroPerezMartin Mar 16, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import ImagesSelector from './images-selector';
import AssetField from './asset-field';
import Section from '~/components/section';
import AppDocumentationLink from '~/components/app-documentation-link';
import useAdBlockImage from '~/hooks/useAdBlockImage';
import { ASSET_IMAGE_SPECS } from '../../assetSpecs';

/**
Expand All @@ -36,6 +37,7 @@ const AssetGroupImagesSection = ( {
} ) => {
const { values, getInputProps, adapter } = useAdaptiveFormContext();
const showTip = adapter.hasAISuggestedMediaAssets;
const { getDisplayImageUrl } = useAdBlockImage();

return (
<Section
Expand Down Expand Up @@ -117,6 +119,9 @@ const AssetGroupImagesSection = ( {
) }
imageConfig={ spec.imageConfig }
onChange={ imageProps.onChange }
getDisplayImageUrl={
getDisplayImageUrl
}
generateButtonText={
spec.generateButtonText
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,14 @@ import './index.scss';
*
* @param {Object} props Component props.
* @param {string} props.assetKey Asset key.
* @param {(url: string) => string} props.getDisplayImageUrl Function to get the display URL for an image, useful for handling ad blockers.
* @param {Function} props.onAddSelectedImages Callback to add selected images.
*/
export default function GenAIImagePicker( { assetKey, onAddSelectedImages } ) {
export default function GenAIImagePicker( {
assetKey,
getDisplayImageUrl,
onAddSelectedImages,
} ) {
const { values } = useAdaptiveFormContext();
const addedImageUrls = values[ assetKey ] || [];
const { final_url: finalUrl } = values;
Expand Down Expand Up @@ -108,7 +113,7 @@ export default function GenAIImagePicker( { assetKey, onAddSelectedImages } ) {
>
<img
className="gla-media-selector__medium"
src={ src }
src={ getDisplayImageUrl( src ) }
alt=""
/>
</AppButton>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import GenAIImagePicker from './gen-ai-image-picker';
* @param {number} [props.maxNumberOfImages=-1] The maximum number of images. -1 by default and it means unlimited number.
* @param {string} [props.reachedMaxNumberTip] The tooltip content floating on the add button when reaching the max number of images.
* @param {JSX.Element} [props.children] Content to be rendered above the add button.
* @param {(url: string) => string} [props.getDisplayImageUrl] Function to get the display URL for an image, useful for handling ad blockers.
* @param {(urls: Array<string>) => void} [props.onChange] Callback function to be called when the texts are changed.
*/
export default function ImagesSelector( {
Expand All @@ -49,6 +50,7 @@ export default function ImagesSelector( {
maxNumberOfImages = -1,
reachedMaxNumberTip,
children,
getDisplayImageUrl = noop,
onChange = noop,
} ) {
const { values } = useAdaptiveFormContext();
Expand Down Expand Up @@ -87,24 +89,37 @@ export default function ImagesSelector( {
const nextImages = [ ...images ];

// Find if there is a duplicate image first.
let index = nextImages.findIndex( ( { id } ) => id === image.id );
const selectedIndex = nextImages.findIndex(
( { id } ) => id === image.id
);

if ( awaitingActionImage ) {
if ( index !== -1 && image.id !== awaitingActionImage.id ) {
// If the selected image already exists while replacing, it's considered a swap position.
nextImages.splice( index, 1, { ...awaitingActionImage } );
const awaitingIndex = nextImages.findIndex(
( { id } ) => id === awaitingActionImage.id
);

if ( selectedIndex !== -1 && selectedIndex !== awaitingIndex ) {
// Swap positions
nextImages[ selectedIndex ] = awaitingActionImage;
nextImages[ awaitingIndex ] = image;
} else if ( awaitingIndex !== -1 ) {
// Replace
nextImages[ awaitingIndex ] = image;
} else {
// Previously clicked image no longer exists, push
nextImages.push( image );
}
// Find the index to be replaced with the selected image.
index = nextImages.indexOf( awaitingActionImage );

setAwaitingActionImage( null );
updateImages( nextImages );
return;
}

if ( index === -1 ) {
// Normal add flow (not replacing)
if ( selectedIndex === -1 ) {
nextImages.push( image );
} else {
nextImages.splice( index, 1, image );
}

setAwaitingActionImage( null );
updateImages( nextImages );
},
} );
Expand Down Expand Up @@ -171,13 +186,17 @@ export default function ImagesSelector( {
return (
<div className="gla-images-selector">
<MediaSelector
media={ images }
media={ images.map( ( img ) => ( {
...img,
thumbnail: getDisplayImageUrl( img.url ),
} ) ) }
onMediumClick={ handleMediumClick }
onRemoveMedia={ handleRemoveImage }
/>

<GenAIImagePicker
assetKey={ assetKey }
getDisplayImageUrl={ getDisplayImageUrl }
onAddSelectedImages={ handleOnAddSelectedImages }
/>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,11 @@ describe( 'ImagesSelector', () => {
} );

it( 'When reaching the maximum number of images and the relevant tip is specified, it should use the tooltip', () => {
const props = { imageConfig, initialImageUrls: [ urlA ] };
const props = {
imageConfig,
initialImageUrls: [ urlA ],
getDisplayImageUrl: jest.fn( ( url ) => url ),
};
const tip = 'tip-content';
const { rerender } = render(
<ImagesSelector { ...props } maxNumberOfImages={ 2 } />
Expand Down
149 changes: 149 additions & 0 deletions js/src/hooks/useAdBlockImage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* External dependencies
*/
import { detectAnyAdblocker } from 'just-detect-adblock';
import { useCallback, useEffect, useRef, useState } from '@wordpress/element';

/**
* Internal dependencies
*/
import getProxiedImageUrl from '~/utils/getProxiedImageUrl';

/**
* Injects a hidden bait element with ad-like class names and checks if it gets
* hidden by cosmetic filtering (e.g. uBlock Origin). Resolves true if blocked.
*
* @return {Promise<boolean>} Resolves to true if an adblocker is detected, false otherwise.
*/
const detectViaDOM = () =>
new Promise( ( resolve ) => {
const bait = document.createElement( 'div' );
bait.className = 'adsbox pub_300x250 pub_300x250m';
bait.style.cssText =
'width:1px;height:1px;position:absolute;left:-9999px';
document.body.appendChild( bait );

setTimeout( () => {
const blocked =
bait.offsetHeight === 0 ||
window.getComputedStyle( bait ).display === 'none';
document.body.removeChild( bait );
resolve( blocked );
}, 100 );
} );

/**
* Fetches a known Google Ads URL and resolves true if the request is blocked.
* Uses no-cors + HEAD to avoid CORS errors on success.
*
* The hostname (tpc.googlesyndication.com) is what triggers blocklists —
* the path is irrelevant, but kept ad-like for consistency.
*
* @return {Promise<boolean>} Resolves to true if the request is blocked, false otherwise.
*/
const detectViaFetch = () =>
fetch(
`https://tpc.googlesyndication.com/pimgad/pagead?timestamp=${ Date.now() }`,
{
method: 'HEAD',
mode: 'no-cors',
credentials: 'omit',
redirect: 'manual',
cache: 'no-store',
}
)
.then( () => false )
.catch( () => true );

/**
* Hook that detects adblocker and returns a function to get display image URLs.
*
* This hook:
* - Detects adblocker on mount using just-detect-adblock library
* - Falls back to a DOM bait check to catch stubborn blockers
* (e.g. uBlock Origin, Privacy Badger)
* - Falls back to a fetch check against a known ad URL as a last resort,
* only if the DOM check also indicates blocking (to reduce false positives
* from network failures)
* - Returns a function that conditionally proxies URLs based on detection
* - Returns detection status (isDetected, isLoading)
*
* The returned function only proxies Google Ads images (tpc.googlesyndication.com)
* when an adblocker is detected. Otherwise, it returns the original URL to avoid
* unnecessary proxying.
*
* @return {Object} { getDisplayImageUrl: Function, isDetected: boolean, isLoading: boolean }
*
* @example
* const { getDisplayImageUrl, isDetected, isLoading } = useAdBlockImage();
*
* // Use in render
* <img src={ getDisplayImageUrl( imageUrl ) } />
*/
const useAdBlockImage = () => {
const [ isDetected, setIsDetected ] = useState( false );
const [ isLoading, setIsLoading ] = useState( true );
const hasDetectedRef = useRef( false );

useEffect( () => {
if ( hasDetectedRef.current ) {
return;
}
hasDetectedRef.current = true;

const detectAdblock = async () => {
try {
const detected = await detectAnyAdblocker();

if ( detected ) {
setIsDetected( true );
return;
}

// Secondary trap: DOM bait catches cosmetic filters (e.g. uBlock Origin)
// that don't block network requests.
const domBlocked = await detectViaDOM();

if ( domBlocked ) {
setIsDetected( true );
return;
}

// Last resort: fetch check against a known ad URL.
// Only reached if DOM check passed — avoids false positives from
// network failures alone.
const fetchBlocked = await detectViaFetch();

if ( fetchBlocked ) {
setIsDetected( true );
}
} catch {
// If detectAnyAdblocker throws, assume blocked
setIsDetected( true );
} finally {
setIsLoading( false );
}
};

detectAdblock();
}, [] );

const getDisplayImageUrl = useCallback(
( imageUrl ) => {
const isGoogleAd = imageUrl?.startsWith(
'https://tpc.googlesyndication.com/pimgad'
);

if ( ! imageUrl || ! isDetected || ! isGoogleAd ) {
return imageUrl;
}

return getProxiedImageUrl( imageUrl );
},
[ isDetected ]
);

return { getDisplayImageUrl, isDetected, isLoading };
};

export default useAdBlockImage;
35 changes: 35 additions & 0 deletions js/src/utils/getProxiedImageUrl.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* External dependencies
*/
import { addQueryArgs } from '@wordpress/url';

/**
* Converts an external image URL to use the WordPress image proxy endpoint.
* This bypasses ad blockers that might block direct access to AI-generated images.
*
* Use this utility function when rendering images in the UI to ensure they load
* even when users have ad blockers enabled. The original URLs are preserved in
* state and only proxied at render time.
*
* The function includes a WordPress REST API nonce in the URL to authenticate
* requests made by img tags, which cannot send custom HTTP headers.
*
* @param {string} imageUrl - The original image URL to proxy.
* @return {string} The proxied URL through the WordPress REST API.
*/
export default function getProxiedImageUrl( imageUrl ) {
if ( ! imageUrl ) {
return imageUrl;
}

const nonce = window.wpApiSettings?.nonce || '';
const root = window.wpApiSettings?.root || '/wp-json/';
const baseUrl = `${ root }wc/gla/ads/assets/image-proxy`;

const params = { url: imageUrl };
if ( nonce ) {
params._wpnonce = nonce;
}

return addQueryArgs( baseUrl, params );
}
7 changes: 7 additions & 0 deletions package-lock.json

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

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"@wordpress/viewport": "^6.22.0",
"classnames": "^2.5.1",
"gridicons": "^3.4.2",
"just-detect-adblock": "^1.1.0",
"lodash": "^4.17.21",
"prop-types": "^15.8.1",
"rememo": "^4.0.2"
Expand Down
Loading
Loading