Elgg  Version master
CacheHandler.php
Go to the documentation of this file.
1 <?php
2 
3 namespace Elgg\Application;
4 
7 use Elgg\Config;
12 
18 class CacheHandler {
19 
20  public static $extensions = [
21  'bmp' => 'image/bmp',
22  'css' => 'text/css',
23  'eot' => 'application/vnd.ms-fontobject',
24  'gif' => 'image/gif',
25  'html' => 'text/html',
26  'ico' => 'image/x-icon',
27  'jpeg' => 'image/jpeg',
28  'jpg' => 'image/jpeg',
29  'js' => 'text/javascript',
30  'json' => 'application/json',
31  'map' => 'application/json',
32  'mjs' => 'text/javascript',
33  'otf' => 'application/font-otf',
34  'png' => 'image/png',
35  'svg' => 'image/svg+xml',
36  'swf' => 'application/x-shockwave-flash',
37  'tiff' => 'image/tiff',
38  'ttf' => 'application/font-ttf',
39  'webp' => 'image/webp',
40  'woff' => 'application/font-woff',
41  'woff2' => 'application/font-woff2',
42  'xml' => 'text/xml',
43  ];
44 
45  public static $utf8_content_types = [
46  'text/css',
47  'text/html',
48  'text/javascript',
49  'application/json',
50  'image/svg+xml',
51  'text/xml',
52  ];
53 
54  protected bool $simplecache_enabled;
55 
64  public function __construct(
65  protected Config $config,
66  protected Request $request,
67  protected SimpleCache $simplecache,
68  ConfigTable $config_table
69  ) {
70  $this->simplecache_enabled = $config->simplecache_enabled;
71  if (!$this->config->hasInitialValue('simplecache_enabled')) {
72  $db_value = $config_table->get('simplecache_enabled');
73  if (isset($db_value)) {
74  $this->simplecache_enabled = (bool) $db_value;
75  }
76  }
77  }
78 
87  public function handleRequest(Request $request, Application $app) {
88  $parsed = $this->parsePath($request->getElggPath());
89  if (!$parsed) {
90  return $this->send403();
91  }
92 
93  $ts = $parsed['ts'];
94  $view = $parsed['view'];
95  $viewtype = $parsed['viewtype'];
96 
97  $content_type = $this->getContentType($view);
98  if (empty($content_type)) {
99  return $this->send403('Asset must have a valid file extension');
100  }
101 
102  $response = new Response();
103  if (in_array($content_type, self::$utf8_content_types)) {
104  $response->headers->set('Content-Type', "{$content_type};charset=utf-8", true);
105  } else {
106  $response->headers->set('Content-Type', $content_type, true);
107  }
108 
109  $response->headers->set('X-Content-Type-Options', 'nosniff', true);
110 
111  if (!$this->simplecache_enabled) {
112  $app->bootCore();
113  if (!headers_sent()) {
114  header_remove('Cache-Control');
115  header_remove('Pragma');
116  header_remove('Expires');
117  }
118 
119  if (!$this->isCacheableView($view)) {
120  return $this->send403("Requested view ({$view}) is not an asset");
121  }
122 
124  if ($content === false) {
125  return $this->send403();
126  }
127 
128  $etag = '"' . md5($content) . '"';
129  $this->setRevalidateHeaders($etag, $response);
130  if ($this->is304($etag)) {
131  $response = new Response();
132  $response->setNotModified();
133 
134  return $response;
135  }
136 
137  return $response->setContent($content);
138  }
139 
140  $etag = "\"{$ts}\"";
141  if ($this->is304($etag)) {
142  $response = new Response();
143  $response->setNotModified();
144 
145  return $response;
146  }
147 
148  // trust the client but check for an existing cache file
149  $filename = $this->simplecache->getCachedAssetLocation($ts, $viewtype, $view);
150  if (!empty($filename)) {
151  $this->sendCacheHeaders($etag, $response);
152 
153  return new BinaryFileResponse($filename, 200, $response->headers->all());
154  }
155 
156  // the hard way
157  $app->bootCore();
158  header_remove('Cache-Control');
159  header_remove('Pragma');
160  header_remove('Expires');
161 
163  if (!$this->isCacheableView($view)) {
164  return $this->send403('Requested view is not an asset');
165  }
166 
167  if ((int) $this->config->lastcache === $ts) {
168  $this->sendCacheHeaders($etag, $response);
169 
171 
172  // store in simplecache for use later
173  $this->simplecache->cacheAsset($viewtype, $view, $content);
174  } else {
175  // if wrong timestamp, don't send HTTP cache
176  // also report that the resource has gone away
177  $response->setStatusCode(ELGG_HTTP_GONE);
178 
180  }
181 
182  return $response->setContent($content);
183  }
184 
192  public function parsePath($path) {
193  // no '..'
194  if (str_contains($path, '..')) {
195  return [];
196  }
197 
198  // only alphanumeric characters plus /, ., -, and _
199  if (preg_match('#[^a-zA-Z0-9/\.\-_]#', $path)) {
200  return [];
201  }
202 
203  // testing showed regex to be marginally faster than array / string functions over 100000 reps
204  // it won't make a difference in real life and regex is easier to read.
205  // <ts>/<viewtype>/<name/of/view.and.dots>.<type>
206  $matches = [];
207  if (!preg_match('#^/cache/([0-9]+)/([^/]+)/(.+)$#', $path, $matches)) {
208  return [];
209  }
210 
211  return [
212  'ts' => (int) $matches[1],
213  'viewtype' => $matches[2],
214  'view' => $matches[3],
215  ];
216  }
217 
225  protected function isCacheableView($view) {
226  $matches = [];
227  if (preg_match('~^languages/(.*)\.js$~', $view, $matches)) {
228  return in_array($matches[1], _elgg_services()->locale->getLanguageCodes());
229  }
230 
231  return _elgg_services()->simpleCache->isCacheableView($view);
232  }
233 
242  protected function sendCacheHeaders($etag, Response $response) {
243  $response->setSharedMaxAge(86400 * 30 * 6);
244  $response->setMaxAge(86400 * 30 * 6);
245  $response->headers->set('ETag', $etag);
246  }
247 
256  protected function setRevalidateHeaders($etag, Response $response) {
257  $response->headers->set('Cache-Control', 'public, max-age=0, must-revalidate', true);
258  $response->headers->set('ETag', $etag);
259  }
260 
268  protected function is304($etag) {
269  $if_none_match = $this->request->headers->get('If-None-Match');
270  if ($if_none_match === null) {
271  return false;
272  }
273 
274  // strip leading W/
275  $if_none_match = trim($if_none_match);
276  if (str_starts_with($if_none_match, 'W/')) {
277  $if_none_match = substr($if_none_match, 2);
278  }
279 
280  // strip -gzip
281  $if_none_match = str_replace('-gzip', '', $if_none_match);
282 
283  return ($if_none_match === $etag);
284  }
285 
293  public function getContentType($view) {
294  $extension = $this->getViewFileType($view);
295 
296  return self::$extensions[$extension] ?? null;
297  }
298 
311  public function getViewFileType($view) {
312  $extension = (new \SplFileInfo($view))->getExtension();
313  if (isset(self::$extensions[$extension])) {
314  return $extension;
315  }
316 
317  $matches = [];
318  if (preg_match('~(?:^|/)(css|js)(?:$|/)~', $view, $matches)) {
319  return $matches[1];
320  }
321 
322  return 'unknown';
323  }
324 
334  protected function getProcessedView($view, $viewtype) {
335  $content = $this->renderView($view, $viewtype);
336  if ($content === false) {
337  return false;
338  }
339 
340  $name = $this->simplecache_enabled ? 'simplecache:generate' : 'cache:generate';
341  $type = $this->getViewFileType($view);
342 
343  // treat mjs as js
344  if ($type === 'mjs') {
345  $type = 'js';
346  }
347 
348  $params = [
349  'view' => $view,
350  'viewtype' => $viewtype,
351  'view_content' => $content,
352  ];
353  return _elgg_services()->events->triggerResults($name, $type, $params, $content);
354  }
355 
364  protected function renderView($view, $viewtype) {
366 
367  $matches = [];
368  if ($viewtype === 'default' && preg_match('#^languages/(.*?)\\.js$#', $view, $matches)) {
369  $view = 'languages.js';
370  $vars = ['language' => $matches[1]];
371  } else {
372  $vars = [];
373  }
374 
375  if (!elgg_view_exists($view)) {
376  return false;
377  }
378 
379  // disable error reporting so we don't cache problems
380  $this->config->debug = null;
381 
382  return elgg_view($view, $vars);
383  }
384 
392  protected function send403($msg = 'Cache error: bad request') {
393  return new Response($msg, ELGG_HTTP_FORBIDDEN);
394  }
395 }
getElggPath()
Get the Request URI minus querystring.
Definition: Request.php:290
const ELGG_HTTP_FORBIDDEN
Definition: constants.php:67
Elgg HTTP request.
Definition: Request.php:17
$params
Saves global plugin settings.
Definition: save.php:13
parsePath($path)
Parse a request.
send403($msg= 'Cache error:bad request')
Send an error message to requestor.
if(!$user||!$user->canDelete()) $name
Definition: delete.php:22
$response
Definition: content.php:10
$request
Definition: livesearch.php:12
$type
Definition: delete.php:21
get(string $name)
Gets a configuration value.
getViewFileType($view)
Returns the type of output expected from the view.
getContentType($view)
Get the content type.
$config
Advanced site settings, debugging section.
Definition: debugging.php:6
const ELGG_HTTP_GONE
Definition: constants.php:74
$path
Definition: details.php:70
elgg_view(string $view, array $vars=[], string $viewtype= '')
Return a parsed view.
Definition: views.php:156
Simplecache handler.
bootCore()
Bootstrap the Elgg engine, loads plugins, and calls initial system events.
if(!empty($avatar)&&!$avatar->isValid()) elseif(empty($avatar)) if(!$owner->saveIconFromUploadedFile('avatar')) if(!elgg_trigger_event('profileiconupdate', $owner->type, $owner)) $view
Definition: upload.php:39
is304($etag)
Send a 304 and exit() if the ETag matches the request.
renderView($view, $viewtype)
Render a view for caching.
elgg_set_viewtype(string $viewtype= '')
Manually set the viewtype.
Definition: views.php:60
__construct(protected Config $config, protected Request $request, protected SimpleCache $simplecache, ConfigTable $config_table)
Constructor.
setRevalidateHeaders($etag, Response $response)
Set revalidate cache headers.
$viewtype
Definition: default.php:11
isCacheableView($view)
Is the view cacheable.
$extensions
Load, boot, and implement a front controller for an Elgg application.
Definition: Application.php:47
$ts
CSRF security token view for use with secure forms.
Simple cache service.
Definition: SimpleCache.php:15
$vars
Definition: theme.php:5
$content
Set robots.txt action.
Definition: set_robots.php:6
_elgg_services()
Get the global service provider.
Definition: elgglib.php:351
sendCacheHeaders($etag, Response $response)
Sets cache headers.
getProcessedView($view, $viewtype)
Get the contents of a view for caching.
Manipulates values in the dbprefix_config table.
Definition: ConfigTable.php:16
elgg_view_exists(string $view, string $viewtype= '', bool $recurse=true)
Returns whether the specified view exists.
Definition: views.php:131
handleRequest(Request $request, Application $app)
Handle a request for a cached view.
$extension
Definition: default.php:25