Skip to content
Merged
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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ Change these only with the reasoning in mind — each one exists because the obv

**`MAX_ICON_BYTES` applies to both fetch paths.** The local read checks `filesize()` *before* `file_get_contents()`, so an oversized original is never pulled into memory; the HTTP request passes `limit_response_size` as the cap **plus one**, because that truncates rather than errors and a body stopped exactly at the cap would look like one that fits. A Site Icon set with `wp option update site_icon <id>` generates no `site_icon-*` derivatives, so the URL resolves to the full-size upload — this is the common trigger, not a hypothetical one.

**The attachment is read before the network.** `read_local_icon()` decides "is this local?" by testing the URL against the uploads base URL, which answers no on every install behind an image service — Altis rewrites the Site Icon to `/tachyon/…`, so the check fails and the bytes get fetched back over HTTP from the site's own front end. That loopback is the fragile part: it is a three-second blocking request, it fails outright on a local environment whose certificate PHP will not trust, and when it fails the icon falls back to a 302 while the file sat on disk the whole time. `read_attachment_icon()` resolves the `site_icon` option to its file through `get_attached_file()` and the size metadata, which is the same source `get_site_icon_url()` itself starts from. It runs *after* the URL read, so installs where that already worked are untouched, and *before* the HTTP request, which is now only reached when there is no local file at all. The trade-off is deliberate: a `get_site_icon_url` filter pointing at a genuinely different image is no longer honoured on the byte path when the attachment resolves, because the option is what defines the Site Icon. Sizes resolve the way core resolves them — smallest square derivative at least as large, original when none is — so a request for 120 gets the 180 file rather than an exact 120 the image service would have generated.

**HTTP fetches send `Accept: image/png`.** Image services content-negotiate on `Accept` and will return WebP under a `.png` URL to anything that offers it.

**`get_site_icon_url()` does not always return a string.** It hands back whatever `wp_get_attachment_image_url()` returned, which is `false` when the `site_icon` option names an attachment that no longer exists — and nothing reliably clears that option, because the hook that would (`WP_Site_Icon::delete_attachment_data`) is registered only inside one admin AJAX action. A `get_site_icon_url` filter may return anything at all, which is why core's own callers test the result for truthiness rather than comparing it to `''`. Comparing `=== ''` therefore lets `false` past the guard, and passing it into a string parameter under `strict_types` is a fatal on the one code path built for anonymous traffic. `get_icon_url()` normalises it in one place so no caller has to.
Expand Down
89 changes: 82 additions & 7 deletions inc/icon-fetch.php
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,11 @@
* Reads from disk when the URL maps into the uploads directory, and falls back to HTTP
* when it does not — the case wherever a CDN or image service rewrites the URL.
*
* @param string $url Site Icon URL.
* @param string $url Site Icon URL.
* @param int $size Size in pixels the request resolved to.
* @return array{body: string, type: string}|null Null when the icon could not be read.
*/
function fetch_icon( string $url ): ?array {
function fetch_icon( string $url, int $size ): ?array {
$key = BYTES_TRANSIENT_PREFIX . md5( $url );
$cached = get_transient( $key );

Expand All @@ -91,7 +92,7 @@ function fetch_icon( string $url ): ?array {
return null;
}

$icon = read_local_icon( $url ) ?? request_icon( $url );
$icon = read_local_icon( $url ) ?? read_attachment_icon( $size ) ?? request_icon( $url );

if ( $icon === null ) {
set_transient( $key, FETCH_FAILED, SiteIconFallback\get_failure_cache_lifetime() );
Expand Down Expand Up @@ -125,15 +126,89 @@ function read_local_icon( string $url ): ?array {
return null;
}

$file = $uploads['basedir'] . substr( $path, strlen( $uploads['baseurl'] ) );
return read_icon_file( $uploads['basedir'] . substr( $path, strlen( $uploads['baseurl'] ) ) );
}

// phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_is_readable -- Reading a file inside the uploads directory this install owns.
/**
* Read the Site Icon from the attachment the `site_icon` option names.
*
* The URL-based read above cannot help wherever an image service rewrites the Site Icon URL,
* because the rewritten URL no longer sits under the uploads base URL — and on those installs
* the bytes are still on disk. See CLAUDE.md: "The attachment is read before the network."
*
* @param int $size Size in pixels the request resolved to.
* @return array{body: string, type: string}|null Null when no local file could be resolved.
*/
function read_attachment_icon( int $size ): ?array {
$id = (int) get_option( 'site_icon' );

if ( $id <= 0 ) {
return null;
}

$file = get_attachment_icon_path( $id, $size );

return $file === null ? null : read_icon_file( $file );
}

/**
* The path on disk to the derivative an attachment would serve at a given size.
*
* Mirrors how core resolves a requested size: the smallest square derivative at least as
* large, falling back to the full-size upload when none is.
*
* @param int $id Attachment ID.
* @param int $size Size in pixels.
* @return string|null Null when the attachment has no file on disk.
*/
function get_attachment_icon_path( int $id, int $size ): ?string {
$original = get_attached_file( $id );

if ( ! is_string( $original ) || $original === '' ) {
return null;
}

$meta = wp_get_attachment_metadata( $id );
$sizes = is_array( $meta ) && is_array( $meta['sizes'] ?? null ) ? $meta['sizes'] : [];
$best = null;

foreach ( $sizes as $data ) {
$width = (int) ( $data['width'] ?? 0 );
$height = (int) ( $data['height'] ?? 0 );
$file = $data['file'] ?? '';

// Non-square derivatives are some other image size registered on the site, not a
// Site Icon one, and would be served under a square filename.
if ( ! is_string( $file ) || $file === '' || $width !== $height || $width < $size ) {
continue;
}

if ( $best === null || $width < $best['width'] ) {
$best = [
'width' => $width,
'file' => $file,
];
}
}

// Derivatives sit beside the original, which is the only path metadata records them by.
return $best === null ? $original : dirname( $original ) . '/' . $best['file'];
}

/**
* Read an icon from a path on disk.
*
* @param string $file Absolute path.
* @return array{body: string, type: string}|null Null when unreadable, oversized or not servable.
*/
function read_icon_file( string $file ): ?array {
// phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_is_readable -- Reading a file this install owns, reached only via the uploads directory or the site_icon attachment.
if ( ! is_readable( $file ) ) {
return null;
}

// Checked before the read, so an oversized original is never pulled into memory. A Site
// Icon set with `wp option update site_icon <id>` generates no derivatives, so the URL
// Icon set with `wp option update site_icon <id>` generates no derivatives, so the path
// resolves to the full-size upload.
$bytes = filesize( $file );

Expand All @@ -149,7 +224,7 @@ function read_local_icon( string $url ): ?array {
return null;
}

// phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local uploads file, already constrained to the uploads directory above.
// phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local file, constrained above to the uploads directory or the site_icon attachment.
$body = file_get_contents( $file );

if ( ! is_string( $body ) || $body === '' ) {
Expand Down
2 changes: 1 addition & 1 deletion inc/root-handler.php
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ function serve_icon( int $size ): void {
}

if ( get_serve_mode() === 'stream' ) {
$icon = Icon_Fetch\fetch_icon( $url );
$icon = Icon_Fetch\fetch_icon( $url, $size );

if ( $icon !== null ) {
Icon_Stream\send_icon_bytes( $icon );
Expand Down
6 changes: 5 additions & 1 deletion readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Tags: favicon, site icon, apple-touch-icon, safari, ios
Requires at least: 6.7
Tested up to: 7.0
Requires PHP: 8.2
Stable tag: 0.1.1
Stable tag: 0.1.2
License: GPL-2.0-or-later
License URI: http://www.gnu.org/licenses/gpl-2.0.txt

Expand Down Expand Up @@ -96,6 +96,10 @@ The root paths return a 404. Notably this is *not* what core does for `/favicon.

== Changelog ==

= 0.1.2 =
* The Site Icon is now read from its attachment on disk when an image service has rewritten its URL, instead of being fetched back over HTTP from the site's own front end.
* Fixes icon requests falling back to a redirect on sites behind an image service, where that fetch could not succeed.

= 0.1.1 =
* Releases now include an installable site-icon-fallback.zip, instead of only the generated source archive.
* That archive extracts to a stable site-icon-fallback directory, so a manual upload no longer renames the plugin folder on every release.
Expand Down
4 changes: 2 additions & 2 deletions site-icon-fallback.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
/**
* Plugin Name: Site Icon Fallback
* Description: A lightweight fallback that serves your Site Icon from the site root, reducing 404s.
* Version: 0.1.1
* Version: 0.1.2
* Requires at least: 6.7
* Requires PHP: 8.2
* Author: Human Made
Expand All @@ -20,7 +20,7 @@
exit;
}

const VERSION = '0.1.1';
const VERSION = '0.1.2';

/**
* Absolute path to this file.
Expand Down
87 changes: 83 additions & 4 deletions tests/test-routing.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,24 @@ function wp_check_filetype( $f ) {
return $GLOBALS['__filetype'];
}

// The site_icon attachment. Zero by default, so every test that predates the attachment read
// keeps exercising the path it was written for.
$GLOBALS['__options'] = [ 'site_icon' => 0 ];
$GLOBALS['__attached'] = '';
$GLOBALS['__attachment'] = [];

function get_option( $name, $default_value = false ) {
return $GLOBALS['__options'][ $name ] ?? $default_value;
}

function get_attached_file( $id ) {
return $GLOBALS['__attached'];
}

function wp_get_attachment_metadata( $id ) {
return $GLOBALS['__attachment'];
}

// A scripted HTTP response, plus a record of what was asked for. Call counting is what
// makes "the failure was not retried" testable at all.
$GLOBALS['__http'] = [ 'code' => 0, 'body' => '', 'type' => '' ];
Expand Down Expand Up @@ -372,22 +390,83 @@ function route( string $uri ): ?int {
$gone = 'https://cdn.example.com/gone.png';
$gone_key = SiteIconFallback\Icon_Fetch\BYTES_TRANSIENT_PREFIX . md5( $gone );

check( 'a failed fetch returns null', SiteIconFallback\Icon_Fetch\fetch_icon( $gone ), null );
check( 'a failed fetch returns null', SiteIconFallback\Icon_Fetch\fetch_icon( $gone, 180 ), null );
check( 'one request was made', $GLOBALS['__http_calls'], 1 );
check( 'the next call also returns null', SiteIconFallback\Icon_Fetch\fetch_icon( $gone ), null );
check( 'the next call also returns null', SiteIconFallback\Icon_Fetch\fetch_icon( $gone, 180 ), null );
check( 'the request was not repeated', $GLOBALS['__http_calls'], 1 );
check( 'the failure is held for less time than the icon', ( $GLOBALS['__transients'][ $gone_key ]['ttl'] ?? 0 ) < SiteIconFallback\get_content_max_age(), true );

$GLOBALS['__http'] = [ 'code' => 200, 'body' => $png, 'type' => 'image/png' ];
$good = 'https://cdn.example.com/good.png';
$good_key = SiteIconFallback\Icon_Fetch\BYTES_TRANSIENT_PREFIX . md5( $good );

check( 'a successful fetch returns the bytes', ( SiteIconFallback\Icon_Fetch\fetch_icon( $good )['body'] ?? null ), $png );
check( 'a successful fetch returns the bytes', ( SiteIconFallback\Icon_Fetch\fetch_icon( $good, 180 )['body'] ?? null ), $png );
check( 'and is cached for the content lifetime', $GLOBALS['__transients'][ $good_key ]['ttl'] ?? null, SiteIconFallback\get_content_max_age() );
check( 'two requests in total', $GLOBALS['__http_calls'], 2 );
check( 'a cached icon is served from the cache', ( SiteIconFallback\Icon_Fetch\fetch_icon( $good )['body'] ?? null ), $png );
check( 'a cached icon is served from the cache', ( SiteIconFallback\Icon_Fetch\fetch_icon( $good, 180 )['body'] ?? null ), $png );
check( 'with no further request', $GLOBALS['__http_calls'], 2 );

echo "\nReading the icon from its attachment\n";
// The case this exists for: an image service has rewritten the Site Icon URL off the uploads
// path, so read_local_icon() cannot match it and the bytes would otherwise be fetched back
// over HTTP from the site's own front end.
$GLOBALS['__transients'] = [];
$GLOBALS['__http'] = [ 'code' => 0, 'body' => '', 'type' => '' ];
$GLOBALS['__http_calls'] = 0;

$icon_dir = $GLOBALS['__uploads']['basedir'] . '/2026/08';
@mkdir( $icon_dir, 0777, true );
file_put_contents( $icon_dir . '/favicon.png', $png . 'full' );
file_put_contents( $icon_dir . '/favicon-180x180.png', $png . '180' );
file_put_contents( $icon_dir . '/favicon-192x192.png', $png . '192' );

$GLOBALS['__options']['site_icon'] = 7;
$GLOBALS['__attached'] = $icon_dir . '/favicon.png';
$GLOBALS['__attachment'] = [
'file' => '2026/08/favicon.png',
'sizes' => [
'site_icon-180' => [ 'file' => 'favicon-180x180.png', 'width' => 180, 'height' => 180 ],
'site_icon-192' => [ 'file' => 'favicon-192x192.png', 'width' => 192, 'height' => 192 ],
// Not a Site Icon size, and not square. Picking it would serve a 300x200 image under
// a square filename, and the file does not exist so the read would fail outright.
'medium' => [ 'file' => 'favicon-300x200.png', 'width' => 300, 'height' => 200 ],
],
];

$rewritten = 'https://example.com/tachyon/2026/08/favicon.png?fit=180,180';

check( 'a rewritten URL is served from disk', ( SiteIconFallback\Icon_Fetch\fetch_icon( $rewritten, 180 )['body'] ?? null ), $png . '180' );
check( 'with no HTTP request at all', $GLOBALS['__http_calls'], 0 );

$GLOBALS['__transients'] = [];
check( 'a smaller size takes the next derivative up', ( SiteIconFallback\Icon_Fetch\fetch_icon( $rewritten . '&a', 120 )['body'] ?? null ), $png . '180' );
$GLOBALS['__transients'] = [];
check( 'an exact size takes its own derivative', ( SiteIconFallback\Icon_Fetch\fetch_icon( $rewritten . '&b', 192 )['body'] ?? null ), $png . '192' );
$GLOBALS['__transients'] = [];
check( 'a size above every derivative falls back to the original', ( SiteIconFallback\Icon_Fetch\fetch_icon( $rewritten . '&c', 270 )['body'] ?? null ), $png . 'full' );
check( 'and none of that touched the network', $GLOBALS['__http_calls'], 0 );

// Precedence: a URL that does map into uploads is still read by URL, so the existing path is
// unchanged wherever it already worked.
$GLOBALS['__transients'] = [];
check( 'an uploads URL still wins over the attachment', ( SiteIconFallback\Icon_Fetch\fetch_icon( 'https://example.com/wp-content/uploads/2018/12/icon.png', 180 )['body'] ?? null ), $png );

$GLOBALS['__transients'] = [];
$GLOBALS['__options']['site_icon'] = 0;
$GLOBALS['__http'] = [ 'code' => 200, 'body' => $png, 'type' => 'image/png' ];
check( 'without the option the network is still used', ( SiteIconFallback\Icon_Fetch\fetch_icon( $rewritten, 180 )['body'] ?? null ), $png );
check( 'and that took a request', $GLOBALS['__http_calls'], 1 );

$GLOBALS['__transients'] = [];
$GLOBALS['__options']['site_icon'] = 7;
$GLOBALS['__attached'] = '';
check( 'an attachment with no file on disk falls through', ( SiteIconFallback\Icon_Fetch\fetch_icon( $rewritten, 180 )['body'] ?? null ), $png );
check( 'which also took a request', $GLOBALS['__http_calls'], 2 );

$GLOBALS['__options']['site_icon'] = 0;
unlink( $icon_dir . '/favicon.png' );
unlink( $icon_dir . '/favicon-180x180.png' );
unlink( $icon_dir . '/favicon-192x192.png' );
unlink( $dir . '/icon.png' );

echo "\nnginx snippet\n";
Expand Down