aboutsummaryrefslogtreecommitdiffstats
path: root/includes/Html/TemplateParser.php
blob: 94b4bb7afe3ea6355c85a4e6864bf9597972fad5 (plain) (blame)
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
<?php

namespace MediaWiki\Html;

use FileContentsHasher;
use LightnCandy\LightnCandy;
use MediaWiki\MainConfigNames;
use MediaWiki\MediaWikiServices;
use RuntimeException;
use UnexpectedValueException;
use Wikimedia\ObjectCache\BagOStuff;

/**
 * Handles compiling Mustache templates into PHP rendering functions
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along
 * with this program; if not, write to the Free Software Foundation, Inc.,
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
 * http://www.gnu.org/copyleft/gpl.html
 *
 * @file
 * @since 1.25
 */
class TemplateParser {

	private const CACHE_VERSION = '2.2.0';
	private const CACHE_TTL = BagOStuff::TTL_WEEK;

	/**
	 * @var BagOStuff
	 */
	private $cache;

	/**
	 * @var string The path to the Mustache templates
	 */
	protected $templateDir;

	/**
	 * @var callable[] Array of cached rendering functions
	 */
	protected $renderers;

	/**
	 * @var int Compilation flags passed to LightnCandy
	 */
	protected $compileFlags;

	/**
	 * @param string|null $templateDir
	 * @param BagOStuff|null $cache Read-write cache
	 */
	public function __construct( $templateDir = null, ?BagOStuff $cache = null ) {
		$this->templateDir = $templateDir ?: __DIR__ . '/../templates';
		$this->cache = $cache ?: MediaWikiServices::getInstance()->getObjectCacheFactory()
			->getLocalServerInstance( CACHE_ANYTHING );

		// Do not add more flags here without discussion.
		// If you do add more flags, be sure to update unit tests as well.
		$this->compileFlags = LightnCandy::FLAG_ERROR_EXCEPTION | LightnCandy::FLAG_MUSTACHELOOKUP;
	}

	/**
	 * Enable/disable the use of recursive partials.
	 * @param bool $enable
	 */
	public function enableRecursivePartials( $enable ) {
		if ( $enable ) {
			$this->compileFlags |= LightnCandy::FLAG_RUNTIMEPARTIAL;
		} else {
			$this->compileFlags &= ~LightnCandy::FLAG_RUNTIMEPARTIAL;
		}
	}

	/**
	 * Constructs the location of the source Mustache template
	 * @param string $templateName The name of the template
	 * @return string
	 * @throws UnexpectedValueException If $templateName attempts upwards directory traversal
	 */
	protected function getTemplateFilename( $templateName ) {
		// Prevent path traversal. Based on LanguageNameUtils::isValidCode().
		// This is for paranoia. The $templateName should never come from
		// untrusted input.
		if ( strcspn( $templateName, ":/\\\000&<>'\"%" ) !== strlen( $templateName ) ) {
			throw new UnexpectedValueException( "Malformed \$templateName: $templateName" );
		}

		return "{$this->templateDir}/{$templateName}.mustache";
	}

	/**
	 * Returns a given template function if found, otherwise throws an exception.
	 * @param string $templateName The name of the template (without file suffix)
	 * @return callable
	 * @throws RuntimeException When the template file cannot be found
	 * @throws RuntimeException When the compiled template isn't callable. This is indicative of a
	 *  bug in LightnCandy
	 */
	protected function getTemplate( $templateName ) {
		$templateKey = $templateName . '|' . $this->compileFlags;

		// If a renderer has already been defined for this template, reuse it
		if ( isset( $this->renderers[$templateKey] ) &&
			is_callable( $this->renderers[$templateKey] )
		) {
			return $this->renderers[$templateKey];
		}

		// Fetch a secret key for building a keyed hash of the PHP code.
		// Note that this may be called before MediaWiki is fully initialized.
		$secretKey = MediaWikiServices::hasInstance()
			? MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::SecretKey )
			: null;

		if ( $secretKey ) {
			// See if the compiled PHP code is stored in the server-local cache.
			$key = $this->cache->makeKey(
				'lightncandy-compiled',
				self::CACHE_VERSION,
				$this->compileFlags,
				$this->templateDir,
				$templateName
			);
			$compiledTemplate = $this->cache->get( $key );

			// 1. Has the template changed since the compiled template was cached? If so, don't use
			// the cached code.
			if ( $compiledTemplate ) {
				$filesHash = FileContentsHasher::getFileContentsHash( $compiledTemplate['files'] );

				if ( $filesHash !== $compiledTemplate['filesHash'] ) {
					$compiledTemplate = null;
				}
			}

			// 2. Is the integrity of the cached PHP code compromised? If so, don't use the cached
			// code.
			if ( $compiledTemplate ) {
				$integrityHash = hash_hmac( 'sha256', $compiledTemplate['phpCode'], $secretKey );

				if ( $integrityHash !== $compiledTemplate['integrityHash'] ) {
					$compiledTemplate = null;
				}
			}

			// We're not using the cached code for whatever reason. Recompile the template and
			// cache it.
			if ( !$compiledTemplate ) {
				$compiledTemplate = $this->compile( $templateName );

				$compiledTemplate['integrityHash'] = hash_hmac(
					'sha256',
					$compiledTemplate['phpCode'],
					$secretKey
				);

				$this->cache->set( $key, $compiledTemplate, self::CACHE_TTL );
			}

		// If there is no secret key available, don't use cache
		} else {
			$compiledTemplate = $this->compile( $templateName );
		}

		// phpcs:ignore MediaWiki.Usage.ForbiddenFunctions.eval
		$renderer = eval( $compiledTemplate['phpCode'] );
		if ( !is_callable( $renderer ) ) {
			throw new RuntimeException( "Compiled template `{$templateName}` is not callable" );
		}
		$this->renderers[$templateKey] = $renderer;
		return $renderer;
	}

	/**
	 * Compile the Mustache template into PHP code using LightnCandy.
	 *
	 * The compilation step generates both PHP code and metadata, which is also returned in the
	 * result. An example result looks as follows:
	 *
	 *  ```php
	 *  [
	 *    'phpCode' => '...',
	 *    'files' => [
	 *      '/path/to/template.mustache',
	 *      '/path/to/partial1.mustache',
	 *      '/path/to/partial2.mustache',
	 *    'filesHash' => '...'
	 *  ]
	 *  ```
	 *
	 * The `files` entry is a list of the files read during the compilation of the template. Each
	 * entry is the fully-qualified filename, i.e. it includes path information.
	 *
	 * The `filesHash` entry can be used to determine whether the template has changed since it was
	 * last compiled without compiling the template again. Currently, the `filesHash` entry is
	 * generated with FileContentsHasher::getFileContentsHash.
	 *
	 * @param string $templateName The name of the template
	 * @return array An associative array containing the PHP code and metadata about its compilation
	 * @throws \Exception Thrown by LightnCandy if it could not compile the Mustache code
	 * @throws RuntimeException If LightnCandy could not compile the Mustache code but did not throw
	 *  an exception. This exception is indicative of a bug in LightnCandy
	 * @suppress PhanTypeMismatchArgument
	 */
	protected function compile( $templateName ) {
		$filename = $this->getTemplateFilename( $templateName );

		if ( !file_exists( $filename ) ) {
			throw new RuntimeException( "Could not find template `{$templateName}` at {$filename}" );
		}

		$files = [ $filename ];
		$contents = file_get_contents( $filename );
		$compiled = LightnCandy::compile(
			$contents,
			[
				'flags' => $this->compileFlags,
				'basedir' => $this->templateDir,
				'fileext' => '.mustache',
				'partialresolver' => function ( $cx, $partialName ) use ( $templateName, &$files ) {
					$filename = "{$this->templateDir}/{$partialName}.mustache";
					if ( !file_exists( $filename ) ) {
						throw new RuntimeException( sprintf(
							'Could not compile template `%s`: Could not find partial `%s` at %s',
							$templateName,
							$partialName,
							$filename
						) );
					}

					$fileContents = file_get_contents( $filename );

					if ( $fileContents === false ) {
						throw new RuntimeException( sprintf(
							'Could not compile template `%s`: Could not find partial `%s` at %s',
							$templateName,
							$partialName,
							$filename
						) );
					}

					$files[] = $filename;

					return $fileContents;
				}
			]
		);
		if ( !$compiled ) {
			// This shouldn't happen because LightnCandy::FLAG_ERROR_EXCEPTION is set
			// Errors should throw exceptions instead of returning false
			// Check anyway for paranoia
			throw new RuntimeException( "Could not compile template `{$filename}`" );
		}

		$files = array_values( array_unique( $files ) );

		return [
			'phpCode' => $compiled,
			'files' => $files,
			'filesHash' => FileContentsHasher::getFileContentsHash( $files ),
		];
	}

	/**
	 * Returns HTML for a given template by calling the template function with the given args
	 *
	 * @code
	 *     echo $templateParser->processTemplate(
	 *         'ExampleTemplate',
	 *         [
	 *             'username' => $user->getName(),
	 *             'message' => 'Hello!'
	 *         ]
	 *     );
	 * @endcode
	 * @param string $templateName The name of the template
	 * @param-taint $templateName exec_path
	 * @param mixed $args
	 * @param-taint $args none
	 * @param array $scopes
	 * @param-taint $scopes none
	 * @return string
	 */
	public function processTemplate( $templateName, $args, array $scopes = [] ) {
		$template = $this->getTemplate( $templateName );
		return $template( $args, $scopes );
	}
}