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

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