Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
89.13% |
82 / 92 |
|
66.67% |
4 / 6 |
CRAP | |
0.00% |
0 / 1 |
TemplateParser | |
90.11% |
82 / 91 |
|
66.67% |
4 / 6 |
24.56 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
enableRecursivePartials | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
getTemplateFilename | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
getTemplate | |
94.74% |
36 / 38 |
|
0.00% |
0 / 1 |
11.02 | |||
compile | |
82.93% |
34 / 41 |
|
0.00% |
0 / 1 |
5.12 | |||
processTemplate | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Html; |
4 | |
5 | use FileContentsHasher; |
6 | use LightnCandy\LightnCandy; |
7 | use MediaWiki\MainConfigNames; |
8 | use MediaWiki\MediaWikiServices; |
9 | use RuntimeException; |
10 | use UnexpectedValueException; |
11 | use Wikimedia\ObjectCache\BagOStuff; |
12 | |
13 | /** |
14 | * Handles compiling Mustache templates into PHP rendering functions |
15 | * |
16 | * This program is free software; you can redistribute it and/or modify |
17 | * it under the terms of the GNU General Public License as published by |
18 | * the Free Software Foundation; either version 2 of the License, or |
19 | * (at your option) any later version. |
20 | * |
21 | * This program is distributed in the hope that it will be useful, |
22 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
23 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
24 | * GNU General Public License for more details. |
25 | * |
26 | * You should have received a copy of the GNU General Public License along |
27 | * with this program; if not, write to the Free Software Foundation, Inc., |
28 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
29 | * http://www.gnu.org/copyleft/gpl.html |
30 | * |
31 | * @file |
32 | * @since 1.25 |
33 | */ |
34 | class TemplateParser { |
35 | |
36 | private const CACHE_VERSION = '2.2.0'; |
37 | private const CACHE_TTL = BagOStuff::TTL_WEEK; |
38 | |
39 | /** |
40 | * @var BagOStuff |
41 | */ |
42 | private $cache; |
43 | |
44 | /** |
45 | * @var string The path to the Mustache templates |
46 | */ |
47 | protected $templateDir; |
48 | |
49 | /** |
50 | * @var callable[] Array of cached rendering functions |
51 | */ |
52 | protected $renderers; |
53 | |
54 | /** |
55 | * @var int Compilation flags passed to LightnCandy |
56 | */ |
57 | protected $compileFlags; |
58 | |
59 | /** |
60 | * @param string|null $templateDir |
61 | * @param BagOStuff|null $cache Read-write cache |
62 | */ |
63 | public function __construct( $templateDir = null, ?BagOStuff $cache = null ) { |
64 | $this->templateDir = $templateDir ?: __DIR__ . '/../templates'; |
65 | $this->cache = $cache ?: MediaWikiServices::getInstance()->getObjectCacheFactory() |
66 | ->getLocalServerInstance( CACHE_ANYTHING ); |
67 | |
68 | // Do not add more flags here without discussion. |
69 | // If you do add more flags, be sure to update unit tests as well. |
70 | $this->compileFlags = LightnCandy::FLAG_ERROR_EXCEPTION | LightnCandy::FLAG_MUSTACHELOOKUP; |
71 | } |
72 | |
73 | /** |
74 | * Enable/disable the use of recursive partials. |
75 | * @param bool $enable |
76 | */ |
77 | public function enableRecursivePartials( $enable ) { |
78 | if ( $enable ) { |
79 | $this->compileFlags |= LightnCandy::FLAG_RUNTIMEPARTIAL; |
80 | } else { |
81 | $this->compileFlags &= ~LightnCandy::FLAG_RUNTIMEPARTIAL; |
82 | } |
83 | } |
84 | |
85 | /** |
86 | * Constructs the location of the source Mustache template |
87 | * @param string $templateName The name of the template |
88 | * @return string |
89 | * @throws UnexpectedValueException If $templateName attempts upwards directory traversal |
90 | */ |
91 | protected function getTemplateFilename( $templateName ) { |
92 | // Prevent path traversal. Based on LanguageNameUtils::isValidCode(). |
93 | // This is for paranoia. The $templateName should never come from |
94 | // untrusted input. |
95 | if ( strcspn( $templateName, ":/\\\000&<>'\"%" ) !== strlen( $templateName ) ) { |
96 | throw new UnexpectedValueException( "Malformed \$templateName: $templateName" ); |
97 | } |
98 | |
99 | return "{$this->templateDir}/{$templateName}.mustache"; |
100 | } |
101 | |
102 | /** |
103 | * Returns a given template function if found, otherwise throws an exception. |
104 | * @param string $templateName The name of the template (without file suffix) |
105 | * @return callable |
106 | * @throws RuntimeException When the template file cannot be found |
107 | * @throws RuntimeException When the compiled template isn't callable. This is indicative of a |
108 | * bug in LightnCandy |
109 | */ |
110 | protected function getTemplate( $templateName ) { |
111 | $templateKey = $templateName . '|' . $this->compileFlags; |
112 | |
113 | // If a renderer has already been defined for this template, reuse it |
114 | if ( isset( $this->renderers[$templateKey] ) && |
115 | is_callable( $this->renderers[$templateKey] ) |
116 | ) { |
117 | return $this->renderers[$templateKey]; |
118 | } |
119 | |
120 | // Fetch a secret key for building a keyed hash of the PHP code. |
121 | // Note that this may be called before MediaWiki is fully initialized. |
122 | $secretKey = MediaWikiServices::hasInstance() |
123 | ? MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::SecretKey ) |
124 | : null; |
125 | |
126 | if ( $secretKey ) { |
127 | // See if the compiled PHP code is stored in the server-local cache. |
128 | $key = $this->cache->makeKey( |
129 | 'lightncandy-compiled', |
130 | self::CACHE_VERSION, |
131 | $this->compileFlags, |
132 | $this->templateDir, |
133 | $templateName |
134 | ); |
135 | $compiledTemplate = $this->cache->get( $key ); |
136 | |
137 | // 1. Has the template changed since the compiled template was cached? If so, don't use |
138 | // the cached code. |
139 | if ( $compiledTemplate ) { |
140 | $filesHash = FileContentsHasher::getFileContentsHash( $compiledTemplate['files'] ); |
141 | |
142 | if ( $filesHash !== $compiledTemplate['filesHash'] ) { |
143 | $compiledTemplate = null; |
144 | } |
145 | } |
146 | |
147 | // 2. Is the integrity of the cached PHP code compromised? If so, don't use the cached |
148 | // code. |
149 | if ( $compiledTemplate ) { |
150 | $integrityHash = hash_hmac( 'sha256', $compiledTemplate['phpCode'], $secretKey ); |
151 | |
152 | if ( $integrityHash !== $compiledTemplate['integrityHash'] ) { |
153 | $compiledTemplate = null; |
154 | } |
155 | } |
156 | |
157 | // We're not using the cached code for whatever reason. Recompile the template and |
158 | // cache it. |
159 | if ( !$compiledTemplate ) { |
160 | $compiledTemplate = $this->compile( $templateName ); |
161 | |
162 | $compiledTemplate['integrityHash'] = hash_hmac( |
163 | 'sha256', |
164 | $compiledTemplate['phpCode'], |
165 | $secretKey |
166 | ); |
167 | |
168 | $this->cache->set( $key, $compiledTemplate, self::CACHE_TTL ); |
169 | } |
170 | |
171 | // If there is no secret key available, don't use cache |
172 | } else { |
173 | $compiledTemplate = $this->compile( $templateName ); |
174 | } |
175 | |
176 | // phpcs:ignore MediaWiki.Usage.ForbiddenFunctions.eval |
177 | $renderer = eval( $compiledTemplate['phpCode'] ); |
178 | if ( !is_callable( $renderer ) ) { |
179 | throw new RuntimeException( "Compiled template `{$templateName}` is not callable" ); |
180 | } |
181 | $this->renderers[$templateKey] = $renderer; |
182 | return $renderer; |
183 | } |
184 | |
185 | /** |
186 | * Compile the Mustache template into PHP code using LightnCandy. |
187 | * |
188 | * The compilation step generates both PHP code and metadata, which is also returned in the |
189 | * result. An example result looks as follows: |
190 | * |
191 | * ```php |
192 | * [ |
193 | * 'phpCode' => '...', |
194 | * 'files' => [ |
195 | * '/path/to/template.mustache', |
196 | * '/path/to/partial1.mustache', |
197 | * '/path/to/partial2.mustache', |
198 | * 'filesHash' => '...' |
199 | * ] |
200 | * ``` |
201 | * |
202 | * The `files` entry is a list of the files read during the compilation of the template. Each |
203 | * entry is the fully-qualified filename, i.e. it includes path information. |
204 | * |
205 | * The `filesHash` entry can be used to determine whether the template has changed since it was |
206 | * last compiled without compiling the template again. Currently, the `filesHash` entry is |
207 | * generated with FileContentsHasher::getFileContentsHash. |
208 | * |
209 | * @param string $templateName The name of the template |
210 | * @return array An associative array containing the PHP code and metadata about its compilation |
211 | * @throws \Exception Thrown by LightnCandy if it could not compile the Mustache code |
212 | * @throws RuntimeException If LightnCandy could not compile the Mustache code but did not throw |
213 | * an exception. This exception is indicative of a bug in LightnCandy |
214 | * @suppress PhanTypeMismatchArgument |
215 | */ |
216 | protected function compile( $templateName ) { |
217 | $filename = $this->getTemplateFilename( $templateName ); |
218 | |
219 | if ( !file_exists( $filename ) ) { |
220 | throw new RuntimeException( "Could not find template `{$templateName}` at {$filename}" ); |
221 | } |
222 | |
223 | $files = [ $filename ]; |
224 | $contents = file_get_contents( $filename ); |
225 | $compiled = LightnCandy::compile( |
226 | $contents, |
227 | [ |
228 | 'flags' => $this->compileFlags, |
229 | 'basedir' => $this->templateDir, |
230 | 'fileext' => '.mustache', |
231 | 'partialresolver' => function ( $cx, $partialName ) use ( $templateName, &$files ) { |
232 | $filename = "{$this->templateDir}/{$partialName}.mustache"; |
233 | if ( !file_exists( $filename ) ) { |
234 | throw new RuntimeException( sprintf( |
235 | 'Could not compile template `%s`: Could not find partial `%s` at %s', |
236 | $templateName, |
237 | $partialName, |
238 | $filename |
239 | ) ); |
240 | } |
241 | |
242 | $fileContents = file_get_contents( $filename ); |
243 | |
244 | if ( $fileContents === false ) { |
245 | throw new RuntimeException( sprintf( |
246 | 'Could not compile template `%s`: Could not find partial `%s` at %s', |
247 | $templateName, |
248 | $partialName, |
249 | $filename |
250 | ) ); |
251 | } |
252 | |
253 | $files[] = $filename; |
254 | |
255 | return $fileContents; |
256 | } |
257 | ] |
258 | ); |
259 | if ( !$compiled ) { |
260 | // This shouldn't happen because LightnCandy::FLAG_ERROR_EXCEPTION is set |
261 | // Errors should throw exceptions instead of returning false |
262 | // Check anyway for paranoia |
263 | throw new RuntimeException( "Could not compile template `{$filename}`" ); |
264 | } |
265 | |
266 | $files = array_values( array_unique( $files ) ); |
267 | |
268 | return [ |
269 | 'phpCode' => $compiled, |
270 | 'files' => $files, |
271 | 'filesHash' => FileContentsHasher::getFileContentsHash( $files ), |
272 | ]; |
273 | } |
274 | |
275 | /** |
276 | * Returns HTML for a given template by calling the template function with the given args |
277 | * |
278 | * @code |
279 | * echo $templateParser->processTemplate( |
280 | * 'ExampleTemplate', |
281 | * [ |
282 | * 'username' => $user->getName(), |
283 | * 'message' => 'Hello!' |
284 | * ] |
285 | * ); |
286 | * @endcode |
287 | * @param string $templateName The name of the template |
288 | * @param-taint $templateName exec_path |
289 | * @param mixed $args |
290 | * @param-taint $args none |
291 | * @param array $scopes |
292 | * @param-taint $scopes none |
293 | * @return string |
294 | */ |
295 | public function processTemplate( $templateName, $args, array $scopes = [] ) { |
296 | $template = $this->getTemplate( $templateName ); |
297 | return $template( $args, $scopes ); |
298 | } |
299 | } |
300 | |
301 | /** @deprecated class alias since 1.40 */ |
302 | class_alias( TemplateParser::class, 'TemplateParser' ); |