This repository was archived by the owner on Aug 11, 2025. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathhttp2push.php
More file actions
365 lines (348 loc) · 14.5 KB
/
http2push.php
File metadata and controls
365 lines (348 loc) · 14.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
<?php declare(strict_types = 1);
/**
* This file is responsible for facilitating HTTP/2 preloads.
*
* This plugin generates preload/preconnect information for the 'Link' header so
* that the web server can push resources to the client using HTTP/2.
*
* @author Clay Freeman <git@clayfreeman.com>
* @copyright 2018 Bluewall, LLC. All rights reserved.
* @license GNU General Public License v3 (GPL-3.0).
*/
use \Joomla\CMS\Factory;
use \Joomla\CMS\Plugin\CMSPlugin;
/**
* The HTTP/2 Push automated Joomla! system plugin.
*/
final class plgSystemHttp2Push extends CMSPlugin {
/**
* A reference to Joomla's application instance.
*
* @var \Joomla\CMS\Application\CMSApplication
*/
protected $app;
/**
* A mapping of element type to an associated 'Link' header clause renderer.
*
* This array is keyed by the element type (tag name; e.g. 'img' or 'link')
* and the value consists of a `callable` referring to the method handler.
*
* @var array
*/
protected $methods;
/**
* Fetches a reference to Joomla's application instance and calls the
* constructor of the parent class.
*/
public function __construct(&$subject, $config = array()) {
// Fetch a reference to Joomla's application instance
$this->app = Factory::getApplication();
// Initialize the class method mapping
$this->methods = [
'img' => \Closure::fromCallable([$this, 'processImage']),
'link' => \Closure::fromCallable([$this, 'processStyle']),
'preconnect' => \Closure::fromCallable([$this, 'processPreconnect']),
'script' => \Closure::fromCallable([$this, 'processScript'])
];
// Call the parent class constructor to finish initializing the plugin
parent::__construct($subject, $config);
}
/**
* Builds a canonicalized file path (with query, if provided).
*
* This method is useful for generating absolute file paths for the 'Link'
* header where the hostname is not required.
*
* @param array $cpts An array of URL components from `parse_url(...)`.
*
* @return string A localized path-only absolute URL.
*/
public function buildFilePath(array $cpts): ?string {
return (isset($cpts['path']) ? $cpts['path'].
(isset($cpts['query']) ? '?'.$cpts['query'] : '') : NULL);
}
/**
* Builds a canonicalized host-only URL.
*
* This method will omit any path related information so that only
* connectivity information is conveyed in the resulting URL.
*
* @param array $cpts An array of URL components from `parse_url(...)`.
*
* @return string A canonicalized host-only URL.
*/
public function buildHostURL(array $cpts): ?string {
// Create a substring representing any available login information
$user = ($cpts['user'] ?? '');
$pass = ($cpts['pass'] ?? '');
$auth = $user.(\strlen($pass) > 0 ? ':'.$pass : '');
$auth .= (\strlen($auth) > 0 ? '@' : '');
// Attempt to determine whether the default scheme should be HTTPS
$default_scheme = (!empty($_SERVER['HTTPS']) &&
$_SERVER['HTTPS'] !== 'off' ? 'https' : 'http');
// Determine the scheme of the resulting URL
$scheme = ($cpts['scheme'] ?? $default_scheme);
// Determine the port of the resulting URL
$port = ($cpts['port'] ?? '');
$port = (($scheme === 'http' && $port == 80) ? '' :
(($scheme === 'https' && $port == 443) ? '' : $port));
$port = (\strlen($port) > 0 ? ':'.$port : '');
// Assemble the constituent components of this URL
return (isset($cpts['host']) ? $scheme.'://'.$auth.
$cpts['host'].$port : NULL);
}
/**
* Builds a 'Link' header value from an array of resource clauses.
*
* If `$limit` is `TRUE`, any clauses that cause the full 'Link' header to
* overrun 8 KiB will be ignored.
*
* @param array $clauses An array of pre-rendered 'Link' clauses.
* @param bool $limit Whether to limit the size of the result.
*
* @return string A 'Link' header value.
*/
protected function buildLinkHeader(array $clauses, bool $limit): string {
// Calculate the max value length of the Link header (just short of 8 KiB)
$max = (8192 - \strlen("Link: \r\n"));
// Reduce the clause list until the header size is under max if desired
while (\strlen($link = \implode(', ', $clauses)) > $max && $limit) {
// Attempt to remove a clause and try again
\array_pop($clauses);
}
// Return the resulting 'Link' header value
return $link;
}
/**
* Extract resource candidate descriptors from the document body.
*
* The document body is searched with XPath to find all resources which may be
* applicable for preload/preconnect. '<link>' tags are not filtered at this
* stage for `rel='stylesheet'` equivalence.
*
* Once a result set is obtained using XPath, each resource candidate is
* reduced to a simplified descriptor that details how the resource should be
* handled when rendering its clause for the 'Link' header.
*
* @param DOMDocument $body A document containing preload/preconnect
* resource candidates.
*
* @return array An array of objects describing how each
* resource should be handled.
*/
protected function extractResources(\DOMDocument $body): array {
// Create an instance of `\DOMXPath` to support searching the document tree
// with XPath (similar to `\SimpleXMLElement`)
$xpath = new \DOMXPath($body);
// First, locate all possible resources for preload/preconnect
$res = $xpath->query('//img[@src]|//link[@href and @rel]|//script[@src]');
// Reduce the array of resource candidates to a list of item descriptors
return \array_map(function(\DOMElement $item): object {
// Fetch the name of the element node later used to satisfy
// element-specific preconditions
$type = $item->nodeName;
// Canonicalize the 'rel' attribute to the best of our ability
$rel = \strtolower($item->getAttribute('rel'));
// Attempt to determine the name of the attribute holding the URL
$attr = ($type === 'link' ? 'href' : 'src');
// Fetch the URL from the item and attempt to sanitize it
$url = $this->sanitizeURL($item->getAttribute($attr));
// Create an item descriptor to describe how it should be rendered
return (object)['rel' => $rel, 'type' => $type, 'url' => $url ?? ''];
}, \array_filter(\iterator_to_array($res), function(\DOMNode $node): bool {
// Ensure that we only process elements as opposed to all nodes
return $node instanceof \DOMElement;
}));
}
/**
* Determines whether the host portion of a URL matches this server.
*
* This method canonicalizes the host-only portion of the provided URL and
* compares it to a host URL for this server. If the two URL's match, then the
* provided URL represents a "self-hosted" resource.
*
* If there is no host component in the provided argument, then the URL is
* self-hosted.
*
* @param array $cpts An array of URL components for a possible
* self-hosted resource.
*
* @return bool Whether the provided URL is self-hosted.
*/
public function isSelfHosted(array $cpts): bool {
if (isset($cpts['host'])) {
// Build a host URL from the provided URL components
$target = $this->buildHostURL($cpts);
// Build a host URL using the server environment for this request
$server = $this->buildHostURL([
'host' => ($_SERVER['SERVER_NAME'] ?? ''),
'port' => ($_SERVER['SERVER_PORT'] ?? ''),
'user' => ($_SERVER['PHP_AUTH_USER'] ?? ''),
'pass' => ($_SERVER['PHP_AUTH_PW'] ?? '')
]);
// If the target and server host URL's match, the provided URL
// is self-hosted
return (isset($target, $server) && $target === $server);
}
// Assume that the URL is self hosted if there is no host component
return TRUE;
}
/**
* Analyzes the rendered HTTP response body for possible HTTP/2 push items.
*
* This method is triggered on the 'onAfterRender' Joomla! system plugin
* event. Once triggered, the application's HTTP response body should be
* available for parsing.
*
* The response is parsed to find external script tags, stylesheets, and
* images that can be preloaded (or preconnected) using HTTP/2 server push.
*
* After finding all applicable resources, their URL's are parsed for validity
* and a 'Link' header is generated to inform the web server of the
* push-capable resources.
*/
public function onAfterRender(): void {
// Don't execute this plugin on the back end; we don't want the site to
// accidentally become administratively inaccessible due to this plugin
if (!$this->app->isAdmin()) {
// Attempt to parse the document body into a `\DOMDocument` instance
$document = $this->parseDocumentBody($this->app->getBody());
// Ensure that the document body was successfully parsed before running
if ($document instanceof \DOMDocument) {
// Extract and prepare all applicable resources from the parsed
// document body
$resources = $this->extractResources($document);
$resources = $this->prepareResources($resources);
// Build the 'Link' header, keeping the configured size limit in check
$limit = \boolval($this->params->get('header_limit', FALSE));
$header = $this->buildLinkHeader($resources, $limit);
// Set the header using the Joomla! application framework
$this->app->setHeader('Link', $header, FALSE);
}
}
}
/**
* Attempt to parse the provided HTML document body into a DOMDocument.
*
* If the provided document body declares a document type of "HTML"
* (case-insensitive), this method will attempt to silently parse it using
* `\DOMDocument::loadHTML()`. Error reporting is disabled to prevent overflow
* of `stderr` in FPM-based hosting environments.
*
* @param string $body An HTML document body to be parsed.
*
* @return DOMDocument `\DOMDocument` instance on success,
* `NULL` on failure.
*/
protected function parseDocumentBody(?string $body): ?\DOMDocument {
// Create a status variable used to determine whether the document was
// successfully parsed by `\DOMDocument`
$parsed = FALSE;
// Ensure that the document body supposes HTML format before continuing
if (\preg_match('/^\\s*<!DOCTYPE\\s+html\\b/i', $body)) {
// Configure libxml to use its internal logging mechanism and preserve the
// current libxml logging preference for later restoration
$logging = \libxml_use_internal_errors(TRUE);
// Create a DOMDocument instance to facilitate parsing the document body
$document = new \DOMDocument();
// Attempt to parse the document body as HTML
$parsed = ($document->loadHTML($body) === TRUE);
// Restore the previous logging preference for libxml
\libxml_use_internal_errors($logging);
}
// Check if the document body was parsed successfully
return $parsed === TRUE ? $document : NULL;
}
/**
* Create a unique array of 'Link' header clauses for each given resource.
*
* This method filters non-applicable '<link>' tags from the provided array.
*
* Resources with URL's that are not self hosted are converted to preconnect
* items since remote resources cannot be preloaded.
*
* @param array $resources An array of resource descriptors.
*
* @return array An array of strings representing 'Link'
* header clauses.
*/
protected function prepareResources(array $resources): array {
// Map each applicable resource to a 'Link' header clause
return \array_unique(\array_map(function(object $item): string {
// Parse the URL into its constituent components
$url = \parse_url($item->url);
// Determine whether this resource should be converted to a preconnect
$type = $this->isSelfHosted($url) ? $item->type : 'preconnect';
// Render this item using the appropriate method handler
return $this->methods[$type]($url);
}, \array_filter($resources, function(object $item): bool {
// Ensure that this item contains a valid URL
$valid = \is_string($item->url) && \strlen($item->url) > 0;
// Ensure that the item type has a method handler mapping
$valid &= (\array_key_exists($item->type, $this->methods));
// Verify that '<link>' tags are stylesheets before continuing
$valid &= ($item->type !== 'link' || $item->rel === 'stylesheet');
return \boolval($valid);
})));
}
/**
* Formats the provided URL as a single 'Link' header image preload.
*
* @param array $cpts A URL referring to an image.
*
* @return string A 'Link' header clause.
*/
public function processImage(array $cpts): string {
// Convert the URL to a path-only URL
$url = $this->buildFilePath($cpts);
return '<'.$url.'>; rel=preload; as=image';
}
/**
* Formats the provided host as a single 'Link' header preconnect.
*
* @param array $cpts A URL referring to a resource.
*
* @return string A 'Link' header clause.
*/
public function processPreconnect(array $cpts): string {
// Convert the URL to a host-only URL
$url = $this->buildHostURL($cpts);
return '<'.$url.'>; rel=preconnect';
}
/**
* Formats the provided URL as a single 'Link' header script preload.
*
* @param array $cpts A URL referring to a script.
*
* @return string A 'Link' header clause.
*/
public function processScript(array $cpts): string {
// Convert the URL to a path-only URL
$url = $this->buildFilePath($cpts);
return '<'.$url.'>; rel=preload; as=script';
}
/**
* Formats the provided URL as a single 'Link' header style preload.
*
* @param array $cpts A URL referring to a stylesheet.
*
* @return string A 'Link' header clause.
*/
public function processStyle(array $cpts): string {
// Convert the URL to a path-only URL
$url = $this->buildFilePath($cpts);
return '<'.$url.'>; rel=preload; as=style';
}
/**
* Attempts to sanitize the provided URL for consistency.
*
* If the provided URL fails sanitization, `NULL` is returned instead.
*
* @param string $url The input URL to be sanitized.
*
* @return string The resulting sanitized URL.
*/
public function sanitizeURL(string $url): ?string {
return \filter_var($url, \FILTER_SANITIZE_URL) ?: NULL;
}
}