TemplateParser.php 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  1. <?php
  2. use MediaWiki\MediaWikiServices;
  3. /**
  4. * Handles compiling Mustache templates into PHP rendering functions
  5. *
  6. * This program is free software; you can redistribute it and/or modify
  7. * it under the terms of the GNU General Public License as published by
  8. * the Free Software Foundation; either version 2 of the License, or
  9. * (at your option) any later version.
  10. *
  11. * This program is distributed in the hope that it will be useful,
  12. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. * GNU General Public License for more details.
  15. *
  16. * You should have received a copy of the GNU General Public License along
  17. * with this program; if not, write to the Free Software Foundation, Inc.,
  18. * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  19. * http://www.gnu.org/copyleft/gpl.html
  20. *
  21. * @file
  22. * @since 1.25
  23. */
  24. class TemplateParser {
  25. /**
  26. * @var string The path to the Mustache templates
  27. */
  28. protected $templateDir;
  29. /**
  30. * @var callable[] Array of cached rendering functions
  31. */
  32. protected $renderers;
  33. /**
  34. * @var bool Always compile template files
  35. */
  36. protected $forceRecompile = false;
  37. /**
  38. * @var int Compilation flags passed to LightnCandy
  39. */
  40. // Do not add more flags here without discussion.
  41. // If you do add more flags, be sure to update unit tests as well.
  42. protected $compileFlags = LightnCandy::FLAG_ERROR_EXCEPTION;
  43. /**
  44. * @param string $templateDir
  45. * @param bool $forceRecompile
  46. */
  47. public function __construct( $templateDir = null, $forceRecompile = false ) {
  48. $this->templateDir = $templateDir ?: __DIR__ . '/templates';
  49. $this->forceRecompile = $forceRecompile;
  50. }
  51. /**
  52. * Enable/disable the use of recursive partials.
  53. * @param bool $enable
  54. */
  55. public function enableRecursivePartials( $enable ) {
  56. if ( $enable ) {
  57. $this->compileFlags = $this->compileFlags | LightnCandy::FLAG_RUNTIMEPARTIAL;
  58. } else {
  59. $this->compileFlags = $this->compileFlags & ~LightnCandy::FLAG_RUNTIMEPARTIAL;
  60. }
  61. }
  62. /**
  63. * Constructs the location of the the source Mustache template
  64. * @param string $templateName The name of the template
  65. * @return string
  66. * @throws UnexpectedValueException If $templateName attempts upwards directory traversal
  67. */
  68. protected function getTemplateFilename( $templateName ) {
  69. // Prevent path traversal. Based on Language::isValidCode().
  70. // This is for paranoia. The $templateName should never come from
  71. // untrusted input.
  72. if (
  73. strcspn( $templateName, ":/\\\000&<>'\"%" ) !== strlen( $templateName )
  74. ) {
  75. throw new UnexpectedValueException( "Malformed \$templateName: $templateName" );
  76. }
  77. return "{$this->templateDir}/{$templateName}.mustache";
  78. }
  79. /**
  80. * Returns a given template function if found, otherwise throws an exception.
  81. * @param string $templateName The name of the template (without file suffix)
  82. * @return callable
  83. * @throws RuntimeException
  84. */
  85. protected function getTemplate( $templateName ) {
  86. $templateKey = $templateName . '|' . $this->compileFlags;
  87. // If a renderer has already been defined for this template, reuse it
  88. if ( isset( $this->renderers[$templateKey] ) &&
  89. is_callable( $this->renderers[$templateKey] )
  90. ) {
  91. return $this->renderers[$templateKey];
  92. }
  93. $filename = $this->getTemplateFilename( $templateName );
  94. if ( !file_exists( $filename ) ) {
  95. throw new RuntimeException( "Could not locate template: {$filename}" );
  96. }
  97. // Read the template file
  98. $fileContents = file_get_contents( $filename );
  99. // Generate a quick hash for cache invalidation
  100. $fastHash = md5( $this->compileFlags . '|' . $fileContents );
  101. // Fetch a secret key for building a keyed hash of the PHP code
  102. $config = MediaWikiServices::getInstance()->getMainConfig();
  103. $secretKey = $config->get( 'SecretKey' );
  104. if ( $secretKey ) {
  105. // See if the compiled PHP code is stored in cache.
  106. $cache = ObjectCache::getLocalServerInstance( CACHE_ANYTHING );
  107. $key = $cache->makeKey( 'template', $templateName, $fastHash );
  108. $code = $this->forceRecompile ? null : $cache->get( $key );
  109. if ( $code ) {
  110. // Verify the integrity of the cached PHP code
  111. $keyedHash = substr( $code, 0, 64 );
  112. $code = substr( $code, 64 );
  113. if ( $keyedHash !== hash_hmac( 'sha256', $code, $secretKey ) ) {
  114. // If the integrity check fails, don't use the cached code
  115. // We'll update the invalid cache below
  116. $code = null;
  117. }
  118. }
  119. if ( !$code ) {
  120. $code = $this->compileForEval( $fileContents, $filename );
  121. // Prefix the cached code with a keyed hash (64 hex chars) as an integrity check
  122. $cache->set( $key, hash_hmac( 'sha256', $code, $secretKey ) . $code );
  123. }
  124. // If there is no secret key available, don't use cache
  125. } else {
  126. $code = $this->compileForEval( $fileContents, $filename );
  127. }
  128. $renderer = eval( $code );
  129. if ( !is_callable( $renderer ) ) {
  130. throw new RuntimeException( "Requested template, {$templateName}, is not callable" );
  131. }
  132. $this->renderers[$templateKey] = $renderer;
  133. return $renderer;
  134. }
  135. /**
  136. * Wrapper for compile() function that verifies successful compilation and strips
  137. * out the '<?php' part so that the code is ready for eval()
  138. * @param string $fileContents Mustache code
  139. * @param string $filename Name of the template
  140. * @return string PHP code (without '<?php')
  141. * @throws RuntimeException
  142. */
  143. protected function compileForEval( $fileContents, $filename ) {
  144. // Compile the template into PHP code
  145. $code = $this->compile( $fileContents );
  146. if ( !$code ) {
  147. throw new RuntimeException( "Could not compile template: {$filename}" );
  148. }
  149. // Strip the "<?php" added by lightncandy so that it can be eval()ed
  150. if ( substr( $code, 0, 5 ) === '<?php' ) {
  151. $code = substr( $code, 5 );
  152. }
  153. return $code;
  154. }
  155. /**
  156. * Compile the Mustache code into PHP code using LightnCandy
  157. * @param string $code Mustache code
  158. * @return string PHP code (with '<?php')
  159. * @throws RuntimeException
  160. */
  161. protected function compile( $code ) {
  162. if ( !class_exists( 'LightnCandy' ) ) {
  163. throw new RuntimeException( 'LightnCandy class not defined' );
  164. }
  165. return LightnCandy::compile(
  166. $code,
  167. [
  168. 'flags' => $this->compileFlags,
  169. 'basedir' => $this->templateDir,
  170. 'fileext' => '.mustache',
  171. ]
  172. );
  173. }
  174. /**
  175. * Returns HTML for a given template by calling the template function with the given args
  176. *
  177. * @code
  178. * echo $templateParser->processTemplate(
  179. * 'ExampleTemplate',
  180. * [
  181. * 'username' => $user->getName(),
  182. * 'message' => 'Hello!'
  183. * ]
  184. * );
  185. * @endcode
  186. * @param string $templateName The name of the template
  187. * @param mixed $args
  188. * @param array $scopes
  189. * @return string
  190. */
  191. public function processTemplate( $templateName, $args, array $scopes = [] ) {
  192. $template = $this->getTemplate( $templateName );
  193. return call_user_func( $template, $args, $scopes );
  194. }
  195. }