TemplateParser.php 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  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. protected $compileFlags;
  41. /**
  42. * @param string|null $templateDir
  43. * @param bool $forceRecompile
  44. */
  45. public function __construct( $templateDir = null, $forceRecompile = false ) {
  46. $this->templateDir = $templateDir ?: __DIR__ . '/templates';
  47. $this->forceRecompile = $forceRecompile;
  48. // Do not add more flags here without discussion.
  49. // If you do add more flags, be sure to update unit tests as well.
  50. $this->compileFlags = LightnCandy::FLAG_ERROR_EXCEPTION | LightnCandy::FLAG_MUSTACHELOOKUP;
  51. }
  52. /**
  53. * Enable/disable the use of recursive partials.
  54. * @param bool $enable
  55. */
  56. public function enableRecursivePartials( $enable ) {
  57. if ( $enable ) {
  58. $this->compileFlags |= LightnCandy::FLAG_RUNTIMEPARTIAL;
  59. } else {
  60. $this->compileFlags &= ~LightnCandy::FLAG_RUNTIMEPARTIAL;
  61. }
  62. }
  63. /**
  64. * Constructs the location of the source Mustache template
  65. * @param string $templateName The name of the template
  66. * @return string
  67. * @throws UnexpectedValueException If $templateName attempts upwards directory traversal
  68. */
  69. protected function getTemplateFilename( $templateName ) {
  70. // Prevent path traversal. Based on Language::isValidCode().
  71. // This is for paranoia. The $templateName should never come from
  72. // untrusted input.
  73. if (
  74. strcspn( $templateName, ":/\\\000&<>'\"%" ) !== strlen( $templateName )
  75. ) {
  76. throw new UnexpectedValueException( "Malformed \$templateName: $templateName" );
  77. }
  78. return "{$this->templateDir}/{$templateName}.mustache";
  79. }
  80. /**
  81. * Returns a given template function if found, otherwise throws an exception.
  82. * @param string $templateName The name of the template (without file suffix)
  83. * @return callable
  84. * @throws RuntimeException
  85. */
  86. protected function getTemplate( $templateName ) {
  87. $templateKey = $templateName . '|' . $this->compileFlags;
  88. // If a renderer has already been defined for this template, reuse it
  89. if ( isset( $this->renderers[$templateKey] ) &&
  90. is_callable( $this->renderers[$templateKey] )
  91. ) {
  92. return $this->renderers[$templateKey];
  93. }
  94. $filename = $this->getTemplateFilename( $templateName );
  95. if ( !file_exists( $filename ) ) {
  96. throw new RuntimeException( "Could not locate template: {$filename}" );
  97. }
  98. // Read the template file
  99. $fileContents = file_get_contents( $filename );
  100. // Generate a quick hash for cache invalidation
  101. $fastHash = md5( $this->compileFlags . '|' . $fileContents );
  102. // Fetch a secret key for building a keyed hash of the PHP code
  103. $config = MediaWikiServices::getInstance()->getMainConfig();
  104. $secretKey = $config->get( 'SecretKey' );
  105. if ( $secretKey ) {
  106. // See if the compiled PHP code is stored in cache.
  107. $cache = ObjectCache::getLocalServerInstance( CACHE_ANYTHING );
  108. $key = $cache->makeKey( 'template', $templateName, $fastHash );
  109. $code = $this->forceRecompile ? null : $cache->get( $key );
  110. if ( $code ) {
  111. // Verify the integrity of the cached PHP code
  112. $keyedHash = substr( $code, 0, 64 );
  113. $code = substr( $code, 64 );
  114. if ( $keyedHash !== hash_hmac( 'sha256', $code, $secretKey ) ) {
  115. // If the integrity check fails, don't use the cached code
  116. // We'll update the invalid cache below
  117. $code = null;
  118. }
  119. }
  120. if ( !$code ) {
  121. $code = $this->compileForEval( $fileContents, $filename );
  122. // Prefix the cached code with a keyed hash (64 hex chars) as an integrity check
  123. $cache->set( $key, hash_hmac( 'sha256', $code, $secretKey ) . $code );
  124. }
  125. // If there is no secret key available, don't use cache
  126. } else {
  127. $code = $this->compileForEval( $fileContents, $filename );
  128. }
  129. $renderer = eval( $code );
  130. if ( !is_callable( $renderer ) ) {
  131. throw new RuntimeException( "Requested template, {$templateName}, is not callable" );
  132. }
  133. $this->renderers[$templateKey] = $renderer;
  134. return $renderer;
  135. }
  136. /**
  137. * Wrapper for compile() function that verifies successful compilation and strips
  138. * out the '<?php' part so that the code is ready for eval()
  139. * @param string $fileContents Mustache code
  140. * @param string $filename Name of the template
  141. * @return string PHP code (without '<?php')
  142. * @throws RuntimeException
  143. */
  144. protected function compileForEval( $fileContents, $filename ) {
  145. // Compile the template into PHP code
  146. $code = $this->compile( $fileContents );
  147. if ( !$code ) {
  148. throw new RuntimeException( "Could not compile template: {$filename}" );
  149. }
  150. // Strip the "<?php" added by lightncandy so that it can be eval()ed
  151. if ( substr( $code, 0, 5 ) === '<?php' ) {
  152. $code = substr( $code, 5 );
  153. }
  154. return $code;
  155. }
  156. /**
  157. * Compile the Mustache code into PHP code using LightnCandy
  158. * @param string $code Mustache code
  159. * @return string PHP code (with '<?php')
  160. * @throws RuntimeException
  161. */
  162. protected function compile( $code ) {
  163. if ( !class_exists( 'LightnCandy' ) ) {
  164. throw new RuntimeException( 'LightnCandy class not defined' );
  165. }
  166. return LightnCandy::compile(
  167. $code,
  168. [
  169. 'flags' => $this->compileFlags,
  170. 'basedir' => $this->templateDir,
  171. 'fileext' => '.mustache',
  172. ]
  173. );
  174. }
  175. /**
  176. * Returns HTML for a given template by calling the template function with the given args
  177. *
  178. * @code
  179. * echo $templateParser->processTemplate(
  180. * 'ExampleTemplate',
  181. * [
  182. * 'username' => $user->getName(),
  183. * 'message' => 'Hello!'
  184. * ]
  185. * );
  186. * @endcode
  187. * @param string $templateName The name of the template
  188. * @param-taint $templateName exec_misc
  189. * @param mixed $args
  190. * @param-taint $args none
  191. * @param array $scopes
  192. * @param-taint $scopes none
  193. * @return string
  194. */
  195. public function processTemplate( $templateName, $args, array $scopes = [] ) {
  196. $template = $this->getTemplate( $templateName );
  197. return $template( $args, $scopes );
  198. }
  199. }