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