PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.8.9
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.8.9
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / desktop-files / favicon.php

favicon.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 0.8.9, at includes/desktop-files/favicon.php

339 lines 10.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Desktop Mode — Favicon resolver.
4 *
5 * Resolves the favicon for an arbitrary http(s) URL, downloads the
6 * bytes server-side, and returns a base64 `data:` URI suitable for
7 * stuffing into a `placement.meta.iconUrl` so the tile renderer can
8 * paint it without the browser making a third-party request on
9 * every render.
10 *
11 * Pipeline:
12 *
13 * 1. Fetch the page HTML via `wp_safe_remote_get()` — the `_safe_`
14 * flavour blocks loopback / private-IP fetches, which prevents
15 * this user-supplied-URL endpoint from doubling as an SSRF
16 * pivot.
17 * 2. Parse the response with `DOMDocument` (libxml errors silenced
18 * because real-world HTML is gnarly). Walk for the first
19 * `<link rel="icon|shortcut icon|apple-touch-icon" href="…">`
20 * and resolve the href against the page URL.
21 * 3. Fall back to `<scheme>://<host>/favicon.ico` when no link tag
22 * is present.
23 * 4. Fetch the candidate icon via `wp_safe_remote_get()`. Reject
24 * anything that isn't `image/*`, anything bigger than the
25 * configured size cap, and anything `getimagesizefromstring()`
26 * can't recognize (catches HTML pages whose servers lie about
27 * `Content-Type`).
28 * 5. Base64-encode the body, return `data:image/<subtype>;base64,…`.
29 *
30 * Failure at any step returns `null` — the caller treats this as
31 * "no favicon, render the dashicons fallback". Never throws.
32 *
33 * Filter the final return value through `desktop_mode_resolve_favicon`
34 * so plugins can short-circuit (return `null` to force-skip, return
35 * a synthetic data URI to override).
36 *
37 * @package WPDesktopMode
38 * @since 0.20.0
39 */
40
41 defined( 'ABSPATH' ) || exit;
42
43 /**
44 * Maximum icon body size, in bytes. Favicons are tiny — most are
45 * under 4 KB. The 256 KB cap exists to keep `placement.meta` blobs
46 * sane and to avoid base64-encoding a multi-megabyte payload that
47 * a malicious or sloppy host might serve at `/favicon.ico`.
48 */
49 const DESKTOP_MODE_FAVICON_MAX_BYTES = 256 * 1024;
50
51 /**
52 * Per-request HTTP timeout, in seconds. Two fetches happen worst-
53 * case (page + icon) so the user-visible wait caps around 2× this
54 * value. Tune downward if QA finds the dialog "Create" button
55 * sitting too long.
56 */
57 const DESKTOP_MODE_FAVICON_TIMEOUT = 4;
58
59 /**
60 * Resolve a page URL to a base64 data URI of its favicon.
61 *
62 * @since 0.20.0
63 *
64 * @param string $page_url HTTP(S) URL of the target page.
65 * @return string|null Data URI on success; `null` on any failure.
66 */
67 function desktop_mode_resolve_favicon( $page_url ) {
68 $result = desktop_mode_resolve_favicon_internal( (string) $page_url );
69
70 /**
71 * Filters the favicon data URI before it is returned to the
72 * caller. Plugins can override (return a synthetic data URI),
73 * suppress (return `null`), or pass through.
74 *
75 * @since 0.20.0
76 *
77 * @param string|null $result Base64 data URI, or `null` if
78 * the resolver could not produce one.
79 * @param string $page_url The page URL that was resolved.
80 */
81 $filtered = apply_filters( 'desktop_mode_resolve_favicon', $result, (string) $page_url );
82
83 if ( null === $filtered ) {
84 return null;
85 }
86 return is_string( $filtered ) ? $filtered : null;
87 }
88
89 /**
90 * Internal resolver — see {@see desktop_mode_resolve_favicon}.
91 *
92 * Kept separate so the public function is the only place the
93 * `desktop_mode_resolve_favicon` filter runs (a plugin can't sneak
94 * its filter past the validation by hooking the internal helper).
95 *
96 * @since 0.20.0
97 * @internal
98 *
99 * @param string $page_url Page URL.
100 * @return string|null
101 */
102 function desktop_mode_resolve_favicon_internal( $page_url ) {
103 $parts = wp_parse_url( $page_url );
104 if ( ! is_array( $parts ) || empty( $parts['host'] ) ) {
105 return null;
106 }
107 $scheme = isset( $parts['scheme'] ) ? strtolower( $parts['scheme'] ) : '';
108 if ( 'http' !== $scheme && 'https' !== $scheme ) {
109 return null;
110 }
111
112 $page_response = wp_safe_remote_get( $page_url, desktop_mode_favicon_request_args() );
113 $page_body = '';
114 if ( ! is_wp_error( $page_response ) && 200 === (int) wp_remote_retrieve_response_code( $page_response ) ) {
115 $page_body = (string) wp_remote_retrieve_body( $page_response );
116 }
117
118 $candidate_url = '' !== $page_body
119 ? desktop_mode_favicon_extract_link_href( $page_body, $page_url )
120 : '';
121 if ( '' === $candidate_url ) {
122 $candidate_url = $scheme . '://' . $parts['host'] . ( isset( $parts['port'] ) ? ':' . $parts['port'] : '' ) . '/favicon.ico';
123 }
124
125 return desktop_mode_favicon_fetch_as_data_uri( $candidate_url );
126 }
127
128 /**
129 * Common request args for both the page fetch and the icon fetch.
130 *
131 * @since 0.20.0
132 * @internal
133 *
134 * @return array
135 */
136 function desktop_mode_favicon_request_args() {
137 return array(
138 'timeout' => DESKTOP_MODE_FAVICON_TIMEOUT,
139 'redirection' => 3,
140 'user-agent' => 'WP Desktop Mode favicon resolver/1.0',
141 'headers' => array(
142 'Accept' => 'text/html,application/xhtml+xml,image/*;q=0.9,*/*;q=0.5',
143 ),
144 );
145 }
146
147 /**
148 * Walk a chunk of HTML for the first `<link rel="icon|shortcut
149 * icon|apple-touch-icon" href="…">` and resolve `href` against
150 * `$base_url`. Returns the absolute icon URL, or `''` if none
151 * found.
152 *
153 * @since 0.20.0
154 * @internal
155 *
156 * @param string $html Page body.
157 * @param string $base_url URL of the page that produced `$html`.
158 * @return string
159 */
160 function desktop_mode_favicon_extract_link_href( $html, $base_url ) {
161 $dom = new DOMDocument();
162 $prev_errors = libxml_use_internal_errors( true );
163 // `LIBXML_NOWARNING | LIBXML_NOERROR` suppresses libxml's stderr
164 // chatter on malformed HTML; we already silence libxml errors above.
165 $dom->loadHTML( '<?xml encoding="UTF-8">' . $html, LIBXML_NOWARNING | LIBXML_NOERROR );
166 libxml_clear_errors();
167 libxml_use_internal_errors( $prev_errors );
168
169 $links = $dom->getElementsByTagName( 'link' );
170 if ( ! $links ) {
171 return '';
172 }
173
174 // Preference order: a plain `icon` rel beats `shortcut icon`
175 // beats `apple-touch-icon`. We collect candidates into buckets
176 // then return the highest-priority one. Higher-resolution
177 // `apple-touch-icon` images are nicer for retina displays but
178 // usually larger than the 256 KB cap so we only fall back to
179 // them when nothing else exists.
180 $buckets = array(
181 'icon' => '',
182 'shortcut icon' => '',
183 'apple-touch-icon' => '',
184 );
185
186 foreach ( $links as $link ) {
187 if ( ! ( $link instanceof DOMElement ) ) {
188 continue;
189 }
190 $rel = strtolower( trim( (string) $link->getAttribute( 'rel' ) ) );
191 $href = trim( (string) $link->getAttribute( 'href' ) );
192 if ( '' === $rel || '' === $href ) {
193 continue;
194 }
195 // `rel` may carry multiple tokens (`"shortcut icon"`,
196 // `"icon mask-icon"`); match against the bucket keys.
197 foreach ( $buckets as $key => $existing ) {
198 if ( '' !== $existing ) {
199 continue;
200 }
201 if ( $rel === $key || in_array( $key, preg_split( '/\s+/', $rel ), true ) ) {
202 $buckets[ $key ] = $href;
203 break;
204 }
205 }
206 }
207
208 foreach ( $buckets as $href ) {
209 if ( '' === $href ) {
210 continue;
211 }
212 $absolute = desktop_mode_favicon_absolutize_url( $href, $base_url );
213 if ( '' !== $absolute ) {
214 return $absolute;
215 }
216 }
217 return '';
218 }
219
220 /**
221 * Resolve a possibly-relative `href` against `$base_url`. Returns
222 * `''` if the result isn't an http(s) URL.
223 *
224 * @since 0.20.0
225 * @internal
226 *
227 * @param string $href Link href (absolute, scheme-relative, or path).
228 * @param string $base_url Page URL.
229 * @return string
230 */
231 function desktop_mode_favicon_absolutize_url( $href, $base_url ) {
232 $href = trim( $href );
233 if ( '' === $href ) {
234 return '';
235 }
236 if ( 0 === strpos( $href, 'data:' ) ) {
237 // Inline data URI — pass straight through; the fetch step
238 // would reject it. Emit empty so the caller falls back to
239 // `/favicon.ico`.
240 return '';
241 }
242 // Absolute URL.
243 if ( preg_match( '#^https?://#i', $href ) ) {
244 return $href;
245 }
246 $base = wp_parse_url( $base_url );
247 if ( ! is_array( $base ) || empty( $base['scheme'] ) || empty( $base['host'] ) ) {
248 return '';
249 }
250 $origin = $base['scheme'] . '://' . $base['host'] . ( isset( $base['port'] ) ? ':' . $base['port'] : '' );
251
252 // Scheme-relative.
253 if ( 0 === strpos( $href, '//' ) ) {
254 return $base['scheme'] . ':' . $href;
255 }
256 // Root-relative.
257 if ( 0 === strpos( $href, '/' ) ) {
258 return $origin . $href;
259 }
260 // Path-relative — resolve against the page's directory.
261 $path = isset( $base['path'] ) ? $base['path'] : '/';
262 $dir = '/' === substr( $path, -1 ) ? $path : ( '' === dirname( $path ) || '.' === dirname( $path ) ? '/' : dirname( $path ) . '/' );
263 return $origin . $dir . $href;
264 }
265
266 /**
267 * Fetch the candidate icon URL and encode it as a data URI.
268 *
269 * @since 0.20.0
270 * @internal
271 *
272 * @param string $icon_url Absolute http(s) URL of the icon.
273 * @return string|null
274 */
275 function desktop_mode_favicon_fetch_as_data_uri( $icon_url ) {
276 if ( '' === $icon_url || ! preg_match( '#^https?://#i', $icon_url ) ) {
277 return null;
278 }
279 $response = wp_safe_remote_get( $icon_url, desktop_mode_favicon_request_args() );
280 if ( is_wp_error( $response ) ) {
281 return null;
282 }
283 if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
284 return null;
285 }
286 $content_type = strtolower( (string) wp_remote_retrieve_header( $response, 'content-type' ) );
287 // Strip charset / boundary suffix.
288 $content_type = trim( explode( ';', $content_type )[0] );
289 if ( 0 !== strpos( $content_type, 'image/' ) ) {
290 return null;
291 }
292 $body = (string) wp_remote_retrieve_body( $response );
293 if ( '' === $body || strlen( $body ) > DESKTOP_MODE_FAVICON_MAX_BYTES ) {
294 return null;
295 }
296 $subtype = desktop_mode_favicon_subtype_from_content_type( $content_type );
297 if ( null === $subtype ) {
298 return null;
299 }
300 // Catch HTML / text bodies served with a lying `Content-Type:
301 // image/png` header — `getimagesizefromstring` returns false for
302 // anything it doesn't recognize as a supported image, including
303 // `.ico` files in some PHP builds. SVG is XML, not a recognized
304 // image format by getimagesize, so we skip the check for it.
305 if ( 'svg+xml' !== $subtype ) {
306 $dimensions = @getimagesizefromstring( $body );
307 if ( false === $dimensions ) {
308 return null;
309 }
310 }
311 return 'data:image/' . $subtype . ';base64,' . base64_encode( $body );
312 }
313
314 /**
315 * Map a `Content-Type` header to a known image subtype, or `null`
316 * if the type isn't on the allowlist.
317 *
318 * @since 0.20.0
319 * @internal
320 *
321 * @param string $content_type Lowercased `Content-Type` value
322 * (no parameters).
323 * @return string|null
324 */
325 function desktop_mode_favicon_subtype_from_content_type( $content_type ) {
326 $map = array(
327 'image/png' => 'png',
328 'image/jpeg' => 'jpeg',
329 'image/jpg' => 'jpeg',
330 'image/gif' => 'gif',
331 'image/webp' => 'webp',
332 'image/x-icon' => 'x-icon',
333 'image/vnd.microsoft.icon' => 'x-icon',
334 'image/ico' => 'x-icon',
335 'image/svg+xml' => 'svg+xml',
336 );
337 return isset( $map[ $content_type ] ) ? $map[ $content_type ] : null;
338 }
339