wiki.techinc.nl/includes/TemplateParser.php
Sam Smith 5607158ba7 TemplateParser: Make cache value include metadata
Update TemplateParser::compile to return the PHP code and metadata. The
metadata includes details about the compilation that can be used to
validate that PHP code fetched from a cache is fresh without having to
recompile the original template.

TemplateParser::getTemplate did have such an invalidation mechanism but:

- It stored the hash of the template file in the key

- It stored the hash used for checking the integrity of the cached PHP
  code in the first 64 bytes of the value

This tight coupling between the compilation result and the contents of
the cache key/value made changing either the compilation or cache
invalidation steps difficult.

After this change, arbitrary metadata can be added or removed from the
compilation result.

Changes:

- Move all filename generation and reading to TemplateParser::compile
  and align its signature with ::getTemplate

- Update TemplateParser::compile to generate the file hash with
  FileContentsHasher::getFileContentsHash, which is used by
  Resource Loader to generate file hashes, and return it as part of its
  result

- Update TemplateParser::getTemplate to store the integrity hash in the
  compilation result prior to caching it

- Bump the cache key major version as both the key and value have
  changed

Note well that TemplateParser isn't expected to be subclassed, and
::getTemplate and ::compile are protected members and are therefore out
of the scope of MediaWiki's deprecation policy.

Bug: T113095
Change-Id: Ifb5e122c6ae238fd300cd60f2b1ab33b7ece8e71
2020-03-02 21:21:43 +00:00

280 lines
8.8 KiB
PHP

<?php
use LightnCandy\LightnCandy;
use MediaWiki\MediaWikiServices;
/**
* 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 {
/**
* @var string The path to the Mustache templates
*/
protected $templateDir;
/**
* @var callable[] Array of cached rendering functions
*/
protected $renderers;
/**
* @var bool Always compile template files
*/
protected $forceRecompile = false;
/**
* @var int Compilation flags passed to LightnCandy
*/
protected $compileFlags;
private static $cacheVersion = '2.0.0';
/**
* @param string|null $templateDir
* @param bool $forceRecompile
*/
public function __construct( $templateDir = null, $forceRecompile = false ) {
$this->templateDir = $templateDir ?: __DIR__ . '/templates';
$this->forceRecompile = $forceRecompile;
// 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 Language::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
$config = MediaWikiServices::getInstance()->getMainConfig();
$secretKey = $config->get( 'SecretKey' );
if ( $secretKey ) {
// See if the compiled PHP code is stored in the server-local cache.
$cache = ObjectCache::getLocalServerInstance( CACHE_ANYTHING );
$key = $cache->makeKey(
'lightncandy-compiled',
self::$cacheVersion,
$this->compileFlags,
$templateName
);
$compiledTemplate = $this->forceRecompile ? null : $cache->get( $key );
// 1. Has the template changed since the compiled template was cached? If so, don't use
// the cached code.
if ( $compiledTemplate ) {
$fileHash = FileContentsHasher::getFileContentsHash( $compiledTemplate['filename'] );
if ( $fileHash !== $compiledTemplate['fileHash'] ) {
$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 whathever reason. Recompile the template and
// cache it.
if ( !$compiledTemplate ) {
$compiledTemplate = $this->compile( $templateName );
$compiledTemplate['integrityHash'] = hash_hmac(
'sha256',
$compiledTemplate['phpCode'],
$secretKey
);
$cache->set( $key, $compiledTemplate );
}
// If there is no secret key available, don't use cache
} else {
$compiledTemplate = $this->compile( $templateName );
}
$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' => '...',
* 'filename' => '/path/to/template.mustache',
* 'fileHash' => '...'
* ]
* ```
*
* The `filename` entry is the fully-qualified name of the template file, i.e. it includes path
* information.
*
* The `fileHash` entry can be used to determine whether the template has changed since it was
* last compiled without compiling the template again. Currently, the `fileHash` 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}" );
}
$contents = file_get_contents( $filename );
$compiled = LightnCandy::compile(
$contents,
[
'flags' => $this->compileFlags,
'basedir' => $this->templateDir,
'fileext' => '.mustache',
'partialresolver' => function ( $cx, $partialName ) use ( $templateName ) {
$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
) );
}
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}`" );
}
return [
'phpCode' => $compiled,
'filename' => $filename,
'fileHash' => FileContentsHasher::getFileContentsHash( $filename ),
];
}
/**
* 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_misc
* @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 );
}
}