Code Snippet: How to list all WordPress posts and pages that contain a specific image

I was working on a WordPress site recently and needed to remove some old images from the Media Library. Obviously, I didn’t want to remove any images that were still being used in posts or pages. My first thought was to use the Media Cleaner plugin but that was flagging images for deletion that I knew were being used in image gallery blocks on several pages so I came up with another solution that checks if an image exists in the post or page content or if the image is used as a featured image. I created this function to check if an image url is in use.

Add the following code to your site’s functions.php file.

function get_pages_with_image($image_url) {
    // Get the attachment ID from the image URL
    $attachment_id = attachment_url_to_postid($image_url);
    if (!$attachment_id) {
        return 'No image found with that URL.';
	}
    // Query all pages
    $pages = get_posts(array(
        'post_type'   => 'post',  // Set post type 
        'numberposts' => -1,  // Retrieve all pages
    ));
    $pages_with_image = array();
    foreach ($pages as $page) {
        // Check if the page has a featured image
        if (has_post_thumbnail($page->ID)) {
            $thumbnail_id = get_post_thumbnail_id($page->ID);
            
            // If the thumbnail ID matches the image attachment ID
            if ($thumbnail_id == $attachment_id) {
                $pages_with_image[] = array(
                    'ID'    => $page->ID,
                    'title' => get_the_title($page->ID),
                    'url'   => get_permalink($page->ID),
                );
            }
        }
        // Check if the image is attached within the page content (e.g., in the post content)
        $content_str = 'wp-image-' . $attachment_id;
        $content = get_post_field('post_content', $page->ID);
        if (strpos($content, $content_str) !== false) {
            $pages_with_image[] = array(
                'ID'    => $page->ID,
                'title' => get_the_title($page->ID),
                'url'   => get_permalink($page->ID),
            );
        }
    }
    // Return the pages that have the image attached
    if (count($pages_with_image) > 0) {
        return $pages_with_image;
    } else {
        return 'No pages found with this image.';
    }
}

You can call the function by passing the image url to the function which will return a list of pages that contain the image in question.

In future I might expand this to check for WooCommerce product images and turn the whole thing in to a plugin.

How to change the WordPress login logo to match your site branding.

If you want to take your site to a new level and add a touch of personalisation to your WordPress install, you can replace the default WordPress logo on the login page with your site’s logo. This following snippet changes the default logo to your site icon (as defined in Settings > General > Site Icon). Simply place the following code in your site’s functions.php file (make sure you are using a child theme to protect against theme updates).


/** change the WordPress logo on the login page to the site icon */
function denial_login_logo() {
	/** Get the site icon and store it in a variable
	 *  first parameter is size, change this to match your desired dimensions, 
	 * second parameter is a fallback url, set to the default WordPress icon, if the site icon is not set, 
	 * the third is the blog_id, used for multisites, 0 for default or single sites */
	$fallback_logo = get_site_url() .'/wp-admin/images/wordpress-logo.svg';
        $site_logo = get_site_icon_url(84, $fallback_logo, 0);
        
	/** change the login stylesheet to use the site icon */
	?>
		<style>
			#login h1 a {
				background-image: url(<?php echo $site_logo ?>);
				background-repeat: no-repeat;
				background-size: 84px; /* change this to match your desired dimensions */
				height: 84px; /* change this to match your desired dimensions */
				width: 84px;  /* change this to match your desired dimensions */
			}
	</style>
	<?php
}
add_action('login_enqueue_scripts', 'denial_login_logo');

The default WordPress logo on the login page is loaded as a background image so in order to replace the image we need to set our own background image which is done by inserting our own css stylesheet in to the login_enqueue_scripts hook. Using the get_site_icon_url function we can get our site icon url and add that to our new stylesheet. You can adjust the icon size and the padding / margins around the icon here as well.

WordPress login with custom logo

Blocking Card Testing Attacks In Woocommerce

A number of my clients’ sites that run Woocommerce have recently come under attack from “Card Testing Attacks” and so far Woocommerce have not been able to offer any practical advice that will stop these attacks other than “buy our security plugin!”

What is a Card Testing Attack?

Card Testing Attacks are attempts by bots (usually) to test if credit cards details are valid and usually take the form of an attempted purchase on a Woocommerce site of a single low value item. My clients’s sites were being hit with hundreds of these attacks per hour (600+) as the bots try to discover which of the thousands of nefariously obtained credit card details they have on file could be used.

Why Is This An Issue?

In the event of a card testing attack succeeding and the purchase being successfully placed then the retailer should refund the order and cancel it. Unfortunately this is not without cost to the retailer (my clients in this case) as the retailer will incur a card processing fee for the order and if the order is not refunded and the credit card owner disputes the transaction the retailer could be on the hook for a chargeback fee (usually about £15 per chargeback).

Tracing And Blocking Card Testing Attacks

Blocking these card testing attacks was harder than we (my clients and I) thought it would be. We tried blocking by IP address initially as all the original wave of attacks came from the same IP address. A few days later we blocked by domain / email address as a lot of the orders came from “@x.com” email address which were all placed using different IP addresses. That changed to “@gmail.com” email addresses which meant we couldn’t block that domain as thousands of legitimate customers use gmail addresses. All the gmail addresses were in the same format though, “firstname.surname.randomnumber@gmail.com”, so something like “james.smith.134565832@gmail.com.” Blocking this email address format was a possibility but obviously the attackers would just move on to the next format and we’d be back to square one.

So I looked for similarities between the attacks and noticed the Woocommerce order attribution all marked these order attempts as “unknown.” Maybe I could block “unknown” orders. This lead me to this thread of the WordPress forums: https://wordpress.org/support/topic/attcked-by-card-testing-decline-orders-with-origin-unknown/ Someone else had the same thought as me but unfortunately Woocommerce support could offer no help other than “install ReCAPTCHA, try our paid-for security plugin”.

We tried adding removing guest checkout from the Woocommerce settings which had no effect, the attacks continued and now they had fake customer accounts too. We tried adding ReCAPTCHA to the checkout with no effect. This indicated to me that the attacks were not actually hitting the Woocommerce pages (account, card, checkout) and were probably being placed by a script of some sort. So I dug in to the log files and cross referenced the IP addresses against the access logs.

Below is a sample of the log files with the client’s domain obscured. I’ve left the IP address of this order intact, it resolves to a VPN company, Perfect Privacy. I’ve also left the User-Agent intact but this also changes with every order attempt. These are the only lines in the log for this sample order.

82.199.130.34 www.domain.co.uk - [26/Nov/2024:21:37:51 +0000] "GET /wp-json/wc/store/products?stock_status=instock&order=asc&orderby=price&min_price=1&max_price=5000&type=simple&page=1&per_page=100 HTTP/1.1" 200 30258 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.80 Safari/537.36" | TLSv1.3 | - - 0.000 HIT 0 NC:000000 UP:-
82.199.130.34 www.domain.co.uk - [26/Nov/2024:21:37:52 +0000] "GET /wp-json/wc/store/cart HTTP/1.1" 200 447 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.80 Safari/537.36" | TLSv1.3 | 0.246 0.246 0.246 BYPASS 0 NC:000100 UP:-
82.199.130.34 www.domain.co.uk - [26/Nov/2024:21:37:53 +0000] "POST /wp-json/wc/store/cart/add-item HTTP/1.1" 201 4663 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.80 Safari/537.36" | TLSv1.3 | 0.273 0.273 0.274 - 0 NC:000100 UP:SKIP_CACHE_SET_COOKIEDT
82.199.130.34 www.domain.co.uk - [26/Nov/2024:21:37:53 +0000] "POST /wp-json/wc/store/cart/update-customer HTTP/1.1" 200 1484 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.80 Safari/537.36" | TLSv1.3 | 0.291 0.291 0.290 - 0 NC:300100 UP:SKIP_CACHE_SET_COOKIEDT
82.199.130.34 www.domain.co.uk - [26/Nov/2024:21:37:54 +0000] "POST /wp-json/wc/store/cart/select-shipping-rate HTTP/1.1" 200 1484 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.80 Safari/537.36" | TLSv1.3 | 0.285 0.285 0.285 - 0 NC:300100 UP:-DT
82.199.130.34 www.domain.co.uk - [26/Nov/2024:21:37:55 +0000] "GET /checkout/ HTTP/1.1" 200 58156 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.80 Safari/537.36" | TLSv1.3 | 0.697 0.702 0.702 BYPASS W NC:320000 UP:SKIP_CACHE_SET_COOKIE
82.199.130.34 www.domain.co.uk - [26/Nov/2024:21:37:56 +0000] "POST /?wc-ajax=ppc-data-client-id HTTP/1.1" 200 455 "https://www.domain.co.uk/checkout/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.80 Safari/537.36" | TLSv1.3 | 0.606 0.606 0.606 - 0 NC:300000 UP:-DT
82.199.130.34 www.domain.co.uk - [26/Nov/2024:21:37:57 +0000] "POST /?wc-ajax=ppc-create-order HTTP/1.1" 200 129 "https://www.domain.co.uk/checkout/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.80 Safari/537.36" | TLSv1.3 | 0.678 0.678 0.678 - 0 NC:300000 UP:-DT
82.199.130.34 www.domain.co.uk - [26/Nov/2024:21:38:00 +0000] "POST /?wc-ajax=ppc-approve-order HTTP/1.1" 200 16 "https://www.domain.co.uk/checkout/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.80 Safari/537.36" | TLSv1.3 | 0.555 0.555 0.555 - 0 NC:300000 UP:-DT
82.199.130.34 www.domain.co.uk - [26/Nov/2024:21:38:04 +0000] "POST /wp-json/wc/store/v1/checkout HTTP/1.1" 400 1122 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.80 Safari/537.36" | TLSv1.3 | 4.147 4.147 4.146 - 0 NC:300000 UP:SKIP_CACHE_SET_COOKIEDT

So what does the log file tell us? The first line shows us the attacker is using the Woocommerce WP REST API (/wp-json/wc/) to query the product catalogue and is ordering that query by price, getting the cheapest product. The next lines add the product to the cart, then sets the customer details and the shipping rate before proceeding to the checkout. Then the payment is processed by PayPal (in this case) which is not shown in the logs. The payment is rejected and the “customer” is returned to the checkout page. You can also see the “-” entry in the logs that shows the request has no referrer, hence the “unknown” order attribution in Woocommerce.

This lead me to the idea that if I can block the WP REST API I can stop the attacks. The legacy Woocommerce API was already disabled in the Woocommerce settings and the new REST API states “Access is granted only to those with valid API keys.” However the WP REST API documentation states:

Because the WordPress REST API does not verify the Origin header of incoming requests, public REST API endpoints may therefore be accessed from any site.

This is an intentional design decision…

The documentation also says not to disable the REST API as it will break your site.

Stopping Card Testing Attacks

I wrote a little function to place in your theme’s functions.php page.

/** disable wc_endpoint to stop carding attacks **/
function disable_wc_endpoint_v1() {
    $current_url = $_SERVER['REQUEST_URI'];
    if (strpos($current_url, '/wp-json/wc/store/v1/checkout') !== false) {
        wp_redirect(home_url('/404.php'));
        exit;
    }
}
add_action('rest_api_init', 'disable_wc_endpoint_v1');

The code checks if someone is using the API to access the checkout endpoint and redirects the request to a 404 page instead. No more fake orders! I’ve tested this code on a couple of sites now for a week and all fake orders have been blocked. Of course if your site needs to allow orders via the API then this function won’t help. If your log files show the attacks are not using the v1 endpoints use this code instead (or as well).

/** disable wc_endpoint to stop carding attacks **/
function disable_wc_endpoint() {
    $current_url = $_SERVER['REQUEST_URI'];
    if (strpos($current_url, '/wp-json/wc/store/checkout') !== false) {
        wp_redirect(home_url('/404.php'));
        exit;
    }
}
add_action('rest_api_init', 'disable_wc_endpoint');

Let us know if this code has helped your site or if you have a better solution! The WP REST API documentation does say:

if you wish to prevent your site from being accessed from unknown origins you may unhook the default rest_send_cors_headers function from the rest_pre_serve_request filter hook, then hook in your own function to that same filter to specify stricter CORS headers.

I will look in to these functions and see if a better solution can be found.

*** UPDATE – September 2025 ***

One method to reduce the amount of card testing attacks on your store is to use rate limiting. This is especially useful if your site is being bombarded with attacks or you cannot use the code above to disable the API endpoint.

In December 2024 WooCommerce confirmed that rate limiting for Store API endpoints was included in WooCommerce (8.9.0) as standard but was disabled by default. As of WooCommerce 9.6 (I think) users can enable rate limiting through the WooCommerce Settings area (WooCommerce > Settings > Features > Rate limit Checkout). Woo published their own blog article on ways to limit card testing attacks and also acknowledge that many CAPTCHA plugins do not protect the Checkout Block or Store API.

If you’ve found this post useful please consider using the “buy me a coffee” widget to show your appreciation.

Code Snippet: Swap Column Order On Mobile Devices

Ever wanted to swap the order of column’s contents on a mobile device so it displays in a different order to the desktop site? For example you may have a desktop layout like this:

Desktop column layout

However on a mobile device you’d want the image to display first, then the text. In the example above the image would normally render after the text on a standard responsive layout but with a little css we can change the order of the elements to display how we want them to. Add the class “swap-on-mobile” to the parent element and then use the following code to reverse the columns using flexbox.

@media screen and (max-width: 767px) {
    .swap-on-mobile {
	display: flex !important;
	flex-direction: column-reverse;
    }
}

When viewed on a mobile device the layout now looks like the example below:

mobile column layout reversed

Happy Anniversary To Me!

21 years ago (almost to the minute at time of writing) I took the decision to become self-employed and quit the design firm I was a part of. It was time, I was sick of commuting, sick of doing work for the sake of working. So I headed home and set up Denial Design. 21 years ago.

Being self-employed is hard work, very stressful at times and despite what many, many people say, it is not an easy life where you can do as please. At least not at first!

The first few years were very, very hard. I was massively in debt, bordering on the poverty line more times than I care to remember. Some weeks I was surviving on a bag of rice with soy sauce and some smart price biscuits. If I could scrape the change together! On the plus side work was usually enough to keep me occupied all day (sometimes 16 hours a day or more) and just cover the bills but there were many months (usually around Christmas and school holidays) where work would unsurprisingly dry up. And with no savings in the bank getting through these months were very rough, financially and mentally.

More recently I have a great stable of regular clients and a much better financial footing! There’s still the odd quiet week or month but it’s no longer a disaster.

So what have I learned in the last 21 years of being my own boss?

Meetings are a waste of time.

I stopped doing meetings, chats and even phone calls about 15 years ago and never regretted it. Every single meeting I had in the first 6 years of running my own business and the 4 or 5 years before that working for design agencies made me realise that nothing was being said in these meetings that couldn’t be said in a very short email. Every. Single. Meeting. Same with phone calls. On the plus side you and the client both have a written record of what has been asked for with emails that both of you can refer back to. I also stopped collaborating with clients over instant messenger as it’s way too annoying working on something and the client’s chatting away asking for a dozen changes on different pages whilst your still working on the first change they asked for! So, IM, Zoom, Phone, Meetings, all gone.

Also bear in mind that this meeting time is time you could be spending doing something productive to bring in money to your company. No more spending an hour travelling to see a prospective client, an hour meeting and an hour back only to find out the client changed their mind a week later and decided not to proceed with the project and you’ve wasted 3+ hours (plus travel expenses) that you could have spent working on something that’s actually billable!

Keep the lights on.

Prioritise your bills. If neccessary put off anything that won’t keep the business going. That means keep the electricity and broadband on and a roof over your head. Everything else can wait a few weeks (or months if need be!). Extreme, but it got me through many tough months. I spent a long time catching up with bills when work picked up though! Cut back on everything you don’t absolutely need. If your clothes still fit and aren’t full of holes you don’t need new ones!

Take a break now and again.

Going for a quick walk can unblock most problems. If you’re struggling to work out a piece of code or other problem, leave it alone for half an hour or so. Do the washing up or go for a walk or do the shopping. Your unconcious brain will figure it out most of the time whilst you’re doing something else.

Make time for family / friends.

Spend some quality time with those you love. It might just keep your marriage intact. If you’re starting a new business the chances are you’ll be stressed about a whole heap of things, money, bills, not enough work, too much work! Don’t take the stress out on your family, use them to unwind a bit.

Word of mouth is the best form of advertising. By far.

I’ve tried print advertising, newspapers, flyers, Google Ads, business forums, meet-ups, social media, salesmen, the works. Hardly any of it works, if at all, and usually costs a fortune. Hey, even this site is basically a vanity exercise!

The best way to get work is to do a good job and let your clients spread the word. Businesses deal with other businesses who are all looking for someone reliable to work with. Same with non-business people. How many times have you been asked if you could recommend a plumber, electrician, plasterer, garage, etc? Word of mouth rules.

Keep your customers happy and they’ll keep coming back. For years.

Most of my clients keep working with me for many, many years. I’d estimate at least 7 years on average. Then circumstances usually change for the client either professionally or personally and they’ll move on. Sometimes they’ll tell you they’re moving to a different firm, sometimes you find out the hard way! Sometimes they come back though!

If a client needs a small change making on job, like 5 minutes or less, just do it. It’ll take you longer to invoice them than do the job and the client will be happy. Just make sure they don’t take the piss!

Respond quickly and courteously to emails. Tone of voice is very important in written communication as you have no visual signals to pick up on. So be polite, clear and concise. It’s very easy to upset someone with the wrong phrasing. For example “Can you please fix this issue?” can come across as angry (“Can you please fix this issue?”) whereas “Can you fix this issue, please?” is less difficult to misconstrue.

Don’t do work for free.

I know this contradicts the point above about doing small jobs for nothing but in this case I’m talking about big jobs, full projects. Don’t do anything because “it will look good in your portfolio.” You’re better off taking a “lesser” job that actually pays. Your “portfolio” won’t keep the lights on.

Contracts are usually a waste of time.

Get a clear idea (or even better a full written brief) for a job and deliver on the brief and you’ll get paid. Be clear upfront about your costs. I charge 50% up front that’s non-refundable to cover the time I spend on a job. If the job gets cancelled after a week or two that’s up to the client and they’re out of pocket and you’re not. Balance payable within a week of the site going live (longer for regular customers). No contract needed. Ever.

So there we are, 21 years of self-employed “wisdom”. It’s still long hours, a lot of effort and people still ask when I’m going to get a “proper” job, but I wouldn’t change it for anything. It was the best decision I ever made.