MediaWiki  master
TemplateParser.php
Go to the documentation of this file.
1 <?php
2 
3 namespace MediaWiki\Html;
4 
5 use BagOStuff;
7 use LightnCandy\LightnCandy;
10 use ObjectCache;
11 use RuntimeException;
12 use UnexpectedValueException;
13 
36 
37  private const CACHE_VERSION = '2.2.0';
38  private const CACHE_TTL = BagOStuff::TTL_WEEK;
39 
43  private $cache;
44 
48  protected $templateDir;
49 
53  protected $renderers;
54 
58  protected $compileFlags;
59 
64  public function __construct( $templateDir = null, ?BagOStuff $cache = null ) {
65  $this->templateDir = $templateDir ?: __DIR__ . '/../templates';
66  $this->cache = $cache ?: ObjectCache::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 
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 
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 
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()
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  $renderer = eval( $compiledTemplate['phpCode'] );
177  if ( !is_callable( $renderer ) ) {
178  throw new RuntimeException( "Compiled template `{$templateName}` is not callable" );
179  }
180  $this->renderers[$templateKey] = $renderer;
181  return $renderer;
182  }
183 
215  protected function compile( $templateName ) {
216  $filename = $this->getTemplateFilename( $templateName );
217 
218  if ( !file_exists( $filename ) ) {
219  throw new RuntimeException( "Could not find template `{$templateName}` at {$filename}" );
220  }
221 
222  $files = [ $filename ];
223  $contents = file_get_contents( $filename );
224  $compiled = LightnCandy::compile(
225  $contents,
226  [
227  'flags' => $this->compileFlags,
228  'basedir' => $this->templateDir,
229  'fileext' => '.mustache',
230  'partialresolver' => function ( $cx, $partialName ) use ( $templateName, &$files ) {
231  $filename = "{$this->templateDir}/{$partialName}.mustache";
232  if ( !file_exists( $filename ) ) {
233  throw new RuntimeException( sprintf(
234  'Could not compile template `%s`: Could not find partial `%s` at %s',
235  $templateName,
236  $partialName,
237  $filename
238  ) );
239  }
240 
241  $fileContents = file_get_contents( $filename );
242 
243  if ( $fileContents === false ) {
244  throw new RuntimeException( sprintf(
245  'Could not compile template `%s`: Could not find partial `%s` at %s',
246  $templateName,
247  $partialName,
248  $filename
249  ) );
250  }
251 
252  $files[] = $filename;
253 
254  return $fileContents;
255  }
256  ]
257  );
258  if ( !$compiled ) {
259  // This shouldn't happen because LightnCandy::FLAG_ERROR_EXCEPTION is set
260  // Errors should throw exceptions instead of returning false
261  // Check anyway for paranoia
262  throw new RuntimeException( "Could not compile template `{$filename}`" );
263  }
264 
265  $files = array_values( array_unique( $files ) );
266 
267  return [
268  'phpCode' => $compiled,
269  'files' => $files,
270  'filesHash' => FileContentsHasher::getFileContentsHash( $files ),
271  ];
272  }
273 
294  public function processTemplate( $templateName, $args, array $scopes = [] ) {
295  $template = $this->getTemplate( $templateName );
296  return $template( $args, $scopes );
297  }
298 }
299 
300 class_alias( TemplateParser::class, 'TemplateParser' );
const CACHE_ANYTHING
Definition: Defines.php:85
Class representing a cache/ephemeral data store.
Definition: BagOStuff.php:85
static getFileContentsHash( $filePaths)
Get a hash of the combined contents of one or more files, either by retrieving a previously-computed ...
__construct( $templateDir=null, ?BagOStuff $cache=null)
compile( $templateName)
Compile the Mustache template into PHP code using LightnCandy.
string $templateDir
The path to the Mustache templates.
getTemplate( $templateName)
Returns a given template function if found, otherwise throws an exception.
int $compileFlags
Compilation flags passed to LightnCandy.
getTemplateFilename( $templateName)
Constructs the location of the source Mustache template.
enableRecursivePartials( $enable)
Enable/disable the use of recursive partials.
callable[] $renderers
Array of cached rendering functions.
processTemplate( $templateName, $args, array $scopes=[])
Returns HTML for a given template by calling the template function with the given args.
A class containing constants representing the names of configuration variables.
const SecretKey
Name constant for the SecretKey setting, for use with Config::get()
Service locator for MediaWiki core services.
static hasInstance()
Returns true if an instance has already been initialized.
static getInstance()
Returns the global default instance of the top level service locator.
Functions to get cache objects.
Definition: ObjectCache.php:66
static getLocalServerInstance( $fallback=CACHE_NONE)
Factory function for CACHE_ACCEL (referenced from configuration)