PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.6
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.6
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.9.6, at includes/desktop-files/favicon.php

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