Passed
Push — master ( f13f78...5c1b24 )
by Ismayil
04:22
created

engine/classes/Elgg/Application/CacheHandler.php (3 issues)

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

1
<?php
2
namespace Elgg\Application;
3
4
use Elgg\Application;
5
use Elgg\Config;
6
use Elgg\Http\Request;
7
use Symfony\Component\HttpFoundation\BinaryFileResponse;
8
use Symfony\Component\HttpFoundation\Response;
9
use Symfony\Component\HttpFoundation\StreamedResponse;
10
11
/**
12
 * Simplecache handler
13
 *
14
 * @access private
15
 */
16
class CacheHandler {
17
	
18
	public static $extensions = [
19
		'bmp' => "image/bmp",
20
		'css' => "text/css",
21
		'gif' => "image/gif",
22
		'html' => "text/html",
23
		'ico' => "image/x-icon",
24
		'jpeg' => "image/jpeg",
25
		'jpg' => "image/jpeg",
26
		'js' => "application/javascript",
27
		'json' => "application/json",
28
		'png' => "image/png",
29
		'svg' => "image/svg+xml",
30
		'swf' => "application/x-shockwave-flash",
31
		'tiff' => "image/tiff",
32
		'webp' => "image/webp",
33
		'xml' => "text/xml",
34
		'eot' => "application/vnd.ms-fontobject",
35
		'ttf' => "application/font-ttf",
36
		'woff' => "application/font-woff",
37
		'woff2' => "application/font-woff2",
38
		'otf' => "application/font-otf",
39
	];
40
41
	public static $utf8_content_types = [
42
		"text/css",
43
		"text/html",
44
		"application/javascript",
45
		"application/json",
46
		"image/svg+xml",
47
		"text/xml",
48
	];
49
50
	/** @var Config */
51
	private $config;
52
53
	/** @var Request */
54
	private $request;
55
56
	/**
57
	 * Constructor
58
	 *
59
	 * @param Config  $config              Elgg configuration
60
	 * @param Request $request             HTTP request
61
	 * @param bool    $simplecache_enabled Is the simplecache enabled?
62
	 */
63 9
	public function __construct(Config $config, Request $request, $simplecache_enabled) {
0 ignored issues
show
You have injected the Request via parameter $request. This is generally not recommended as there might be multiple instances during a request cycle (f.e. when using sub-requests). Instead, it is recommended to inject the RequestStack and retrieve the current request each time you need it via getCurrentRequest().
Loading history...
64 9
		$this->config = $config;
65 9
		$this->request = $request;
66 9
		$this->simplecache_enabled = $simplecache_enabled;
0 ignored issues
show
The property simplecache_enabled does not exist. Did you maybe forget to declare it?

In PHP it is possible to write to properties without declaring them. For example, the following is perfectly valid PHP code:

class MyClass { }

$x = new MyClass();
$x->foo = true;

Generally, it is a good practice to explictly declare properties to avoid accidental typos and provide IDE auto-completion:

class MyClass {
    public $foo;
}

$x = new MyClass();
$x->foo = true;
Loading history...
67 9
	}
68
69
	/**
70
	 * Handle a request for a cached view
71
	 *
72
	 * @param Request     $request Elgg request
73
	 * @param Application $app     Elgg application
74
	 * @return Response (unprepared)
75
	 */
76
	public function handleRequest(Request $request, Application $app) {
77
		$config = $this->config;
78
79
		$parsed = $this->parsePath($request->getElggPath());
80
		if (!$parsed) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $parsed of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
81
			return $this->send403();
82
		}
83
		
84
		$ts = $parsed['ts'];
85
		$view = $parsed['view'];
86
		$viewtype = $parsed['viewtype'];
87
88
		$content_type = $this->getContentType($view);
89
		if (empty($content_type)) {
90
			return $this->send403("Asset must have a valid file extension");
91
		}
92
93
		$response = Response::create();
94
		if (in_array($content_type, self::$utf8_content_types)) {
95
			$response->headers->set('Content-Type', "$content_type;charset=utf-8", true);
96
		} else {
97
			$response->headers->set('Content-Type', $content_type, true);
98
		}
99
100
		if (!$this->simplecache_enabled) {
101
			$app->bootCore();
102
			header_remove('Cache-Control');
103
			header_remove('Pragma');
104
			header_remove('Expires');
105
106
			if (!$this->isCacheableView($view)) {
107
				return $this->send403("Requested view is not an asset");
108
			}
109
110
			$content = $this->getProcessedView($view, $viewtype);
111
			if ($content === false) {
112
				return $this->send403();
113
			}
114
115
			$etag = '"' . md5($content) . '"';
116
			$this->setRevalidateHeaders($etag, $response);
117
			if ($this->is304($etag)) {
118
				return Response::create()->setNotModified();
119
			}
120
121
			return $response->setContent($content);
122
		}
123
124
		$etag = "\"$ts\"";
125
		if ($this->is304($etag)) {
126
			return Response::create()->setNotModified();
127
		}
128
129
		// trust the client but check for an existing cache file
130
		$filename = $config->cacheroot . "views_simplecache/$ts/$viewtype/$view";
131
		if (file_exists($filename)) {
132
			$this->sendCacheHeaders($etag, $response);
133
			return BinaryFileResponse::create($filename, 200, $response->headers->all());
134
		}
135
136
		// the hard way
137
		$app->bootCore();
138
		header_remove('Cache-Control');
139
		header_remove('Pragma');
140
		header_remove('Expires');
141
142
		elgg_set_viewtype($viewtype);
143
		if (!$this->isCacheableView($view)) {
144
			return $this->send403("Requested view is not an asset");
145
		}
146
147
		$lastcache = (int) $config->lastcache;
148
149
		$filename = $config->cacheroot . "views_simplecache/$lastcache/$viewtype/$view";
150
151
		if ($lastcache == $ts) {
152
			$this->sendCacheHeaders($etag, $response);
153
154
			$content = $this->getProcessedView($view, $viewtype);
155
156
			$dir_name = dirname($filename);
157
			if (!is_dir($dir_name)) {
158
				// PHP and the server accessing the cache symlink may be a different user. And here
159
				// it's safe to make everything readable anyway.
160
				mkdir($dir_name, 0775, true);
161
			}
162
163
			file_put_contents($filename, $content);
164
			chmod($filename, 0664);
165
		} else {
166
			// if wrong timestamp, don't send HTTP cache
167
			$content = $this->getProcessedView($view, $viewtype);
168
		}
169
170
		return $response->setContent($content);
171
	}
172
173
	/**
174
	 * Parse a request
175
	 *
176
	 * @param string $path Request URL path
177
	 * @return array Cache parameters (empty array if failure)
178
	 */
179 6
	public function parsePath($path) {
180
		// no '..'
181 6
		if (false !== strpos($path, '..')) {
182 1
			return [];
183
		}
184
		// only alphanumeric characters plus /, ., -, and _
185 5
		if (preg_match('#[^a-zA-Z0-9/\.\-_]#', $path)) {
186 1
			return [];
187
		}
188
189
		// testing showed regex to be marginally faster than array / string functions over 100000 reps
190
		// it won't make a difference in real life and regex is easier to read.
191
		// <ts>/<viewtype>/<name/of/view.and.dots>.<type>
192 4
		if (!preg_match('#^/cache/([0-9]+)/([^/]+)/(.+)$#', $path, $matches)) {
193 3
			return [];
194
		}
195
196
		return [
197 1
			'ts' => $matches[1],
198 1
			'viewtype' => $matches[2],
199 1
			'view' => $matches[3],
200
		];
201
	}
202
203
	/**
204
	 * Is the view cacheable. Language views are handled specially.
205
	 *
206
	 * @param string $view View name
207
	 *
208
	 * @return bool
209
	 */
210
	protected function isCacheableView($view) {
211
		if (preg_match('~^languages/(.*)\.js$~', $view, $m)) {
212
			return in_array($m[1],  _elgg_services()->translator->getAllLanguageCodes());
213
		}
214
		return _elgg_services()->views->isCacheableView($view);
215
	}
216
217
	/**
218
	 * Send cache headers
219
	 *
220
	 * @param string $etag ETag value
221
	 * @return void
222
	 */
223
	protected function sendCacheHeaders($etag, Response $response) {
224
		$response->setSharedMaxAge(86400 * 30 * 6);
225
		$response->setMaxAge(86400 * 30 * 6);
226
		$response->headers->set('ETag', $etag);
227
	}
228
229
	/**
230
	 * Send revalidate cache headers
231
	 *
232
	 * @param string $etag ETag value
233
	 * @return void
234
	 */
235
	protected function setRevalidateHeaders($etag, Response $response) {
236
		$response->headers->set('Cache-Control', "public, max-age=0, must-revalidate", true);
237
		$response->headers->set('ETag', $etag);
238
	}
239
240
	/**
241
	 * Send a 304 and exit() if the ETag matches the request
242
	 *
243
	 * @param string $etag ETag value
244
	 * @return bool
245
	 */
246
	protected function is304($etag) {
247
		$if_none_match = $this->request->headers->get('If-None-Match');
248
		if ($if_none_match === null) {
249
			return false;
250
		}
251
252
		// strip -gzip and leading /W
253
		$if_none_match = trim($if_none_match);
254
		if (0 === strpos($if_none_match, 'W/')) {
255
			$if_none_match = substr($if_none_match, 2);
256
		}
257
		$if_none_match = str_replace('-gzip', '', $if_none_match);
258
259
		return ($if_none_match === $etag);
260
	}
261
262
	/**
263
	 * Get the content type
264
	 *
265
	 * @param string $view The view name
266
	 *
267
	 * @return string|null
268
	 * @access private
269
	 */
270 1
	public function getContentType($view) {
271 1
		$extension = $this->getViewFileType($view);
272
		
273 1
		if (isset(self::$extensions[$extension])) {
274 1
			return self::$extensions[$extension];
275
		} else {
276
			return null;
277
		}
278
	}
279
	
280
	/**
281
	 * Returns the type of output expected from the view.
282
	 *
283
	 *  - view/name.extension returns "extension" if "extension" is valid
284
	 *  - css/view return "css"
285
	 *  - js/view return "js"
286
	 *  - Otherwise, returns "unknown"
287
	 *
288
	 * @param string $view The view name
289
	 * @return string
290
	 * @access private
291
	 */
292 2
	public function getViewFileType($view) {
293 2
		$extension = (new \SplFileInfo($view))->getExtension();
294 2
		$hasValidExtension = isset(self::$extensions[$extension]);
295
296 2
		if ($hasValidExtension) {
297 2
			return $extension;
298
		}
299
		
300
		if (preg_match('~(?:^|/)(css|js)(?:$|/)~', $view, $m)) {
301
			return $m[1];
302
		}
303
		
304
		return 'unknown';
305
	}
306
307
	/**
308
	 * Get the contents of a view for caching
309
	 *
310
	 * @param string $view     The view name
311
	 * @param string $viewtype The viewtype
312
	 * @return string|false
313
	 * @see CacheHandler::renderView()
314
	 */
315
	protected function getProcessedView($view, $viewtype) {
316
		$content = $this->renderView($view, $viewtype);
317
		if ($content === false) {
318
			return false;
319
		}
320
321
		if ($this->simplecache_enabled) {
322
			$hook_name = 'simplecache:generate';
323
		} else {
324
			$hook_name = 'cache:generate';
325
		}
326
		$hook_type = $this->getViewFileType($view);
327
		$hook_params = [
328
			'view' => $view,
329
			'viewtype' => $viewtype,
330
			'view_content' => $content,
331
		];
332
		return \_elgg_services()->hooks->trigger($hook_name, $hook_type, $hook_params, $content);
333
	}
334
335
	/**
336
	 * Render a view for caching. Language views are handled specially.
337
	 *
338
	 * @param string $view     The view name
339
	 * @param string $viewtype The viewtype
340
	 * @return string|false
341
	 */
342
	protected function renderView($view, $viewtype) {
343
		elgg_set_viewtype($viewtype);
344
345
		if ($viewtype === 'default' && preg_match("#^languages/(.*?)\\.js$#", $view, $matches)) {
346
			$view = "languages.js";
347
			$vars = ['language' => $matches[1]];
348
		} else {
349
			$vars = [];
350
		}
351
352
		if (!elgg_view_exists($view)) {
353
			return false;
354
		}
355
356
		// disable error reporting so we don't cache problems
357
		$this->config->debug = null;
358
359
		return elgg_view($view, $vars);
360
	}
361
362
	/**
363
	 * Send an error message to requestor
364
	 *
365
	 * @param string $msg Optional message text
366
	 * @return Response
367
	 */
368
	protected function send403($msg = 'Cache error: bad request') {
369
		return Response::create($msg, 403);
370
	}
371
}
372
373