Adding TemplateParser class providing interface to Mustache templates
authorkaldari <rkaldari@wikimedia.org>
Fri, 30 Jan 2015 19:31:44 +0000 (11:31 -0800)
committerkaldari <rkaldari@wikimedia.org>
Fri, 20 Feb 2015 01:41:45 +0000 (17:41 -0800)
The TemplateParser class provides a server-side interface to cachable
dynamically-compiled Mustache templates. It currently uses the
lightncandy library to do compilation (which is already included in
the vendor repo).

Also converting NoLocalSettings.php to use it as a proof-of-concept.

Bug: T379
Change-Id: I28cd13d4d1132bd386e2ae2f4f0d1dd88ad9162b

RELEASE-NOTES-1.25
autoload.php
includes/NoLocalSettings.php [new file with mode: 0644]
includes/TemplateParser.php [new file with mode: 0644]
includes/WebStart.php
includes/templates/NoLocalSettings.mustache [new file with mode: 0644]
includes/templates/NoLocalSettings.php [deleted file]
tests/phpunit/includes/TemplateParserTest.php [new file with mode: 0644]

index 10e49ae..6d816fe 100644 (file)
@@ -99,6 +99,8 @@ production.
   tags.
 * Added 'ChangeTagsListActive' hook, to separate the concepts of "defined" and
   "active" formerly conflated by the 'ListDefinedTags' hook.
+* Added TemplateParser class that provides a server-side interface to cachable
+  dynamically-compiled Mustache templates (currently uses lightncandy library).
 * Clickable anchors for each section heading in the content are now generated
   and appear in the gutter on hovering over the heading.
 
index 01dba44..bdfbee2 100644 (file)
@@ -1180,6 +1180,7 @@ $wgAutoloadLocalClasses = array(
        'TablePager' => __DIR__ . '/includes/pager/TablePager.php',
        'TempFSFile' => __DIR__ . '/includes/filebackend/TempFSFile.php',
        'TempFileRepo' => __DIR__ . '/includes/filerepo/FileRepo.php',
+       'TemplateParser' => __DIR__ . '/includes/TemplateParser.php',
        'TestFileOpPerformance' => __DIR__ . '/maintenance/fileOpPerfTest.php',
        'TextContent' => __DIR__ . '/includes/content/TextContent.php',
        'TextContentHandler' => __DIR__ . '/includes/content/TextContentHandler.php',
diff --git a/includes/NoLocalSettings.php b/includes/NoLocalSettings.php
new file mode 100644 (file)
index 0000000..6de9bfc
--- /dev/null
@@ -0,0 +1,63 @@
+<?php
+/**
+ * Display an error page when there is no LocalSettings.php file.
+ *
+ * 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
+ */
+
+# bug 30219 : can not use pathinfo() on URLs since slashes do not match
+$matches = array();
+$ext = 'php';
+$path = '/';
+foreach ( array_filter( explode( '/', $_SERVER['PHP_SELF'] ) ) as $part ) {
+       if ( !preg_match( '/\.(php5?)$/', $part, $matches ) ) {
+               $path .= "$part/";
+       } else {
+               $ext = $matches[1] == 'php5' ? 'php5' : 'php';
+               break;
+       }
+}
+
+# Check to see if the installer is running
+if ( !function_exists( 'session_name' ) ) {
+       $installerStarted = false;
+} else {
+       session_name( 'mw_installer_session' );
+       $oldReporting = error_reporting( E_ALL & ~E_NOTICE );
+       $success = session_start();
+       error_reporting( $oldReporting );
+       $installerStarted = ( $success && isset( $_SESSION['installData'] ) );
+}
+
+$templateParser = new TemplateParser();
+
+# Render error page if no LocalSettings file can be found
+try {
+       echo $templateParser->processTemplate(
+               'NoLocalSettings',
+               array(
+                       'wgVersion' => ( isset( $wgVersion ) ? $wgVersion : 'VERSION' ),
+                       'path' => $path,
+                       'ext' => $ext,
+                       'localSettingsExists' => file_exists( MW_CONFIG_FILE ),
+                       'installerStarted' => $installerStarted
+               )
+       );
+} catch ( Exception $e ) {
+       echo 'Error: ' . htmlspecialchars( $e->getMessage() );
+}
diff --git a/includes/TemplateParser.php b/includes/TemplateParser.php
new file mode 100644 (file)
index 0000000..57fcc24
--- /dev/null
@@ -0,0 +1,175 @@
+<?php
+/**
+ * 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
+ */
+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;
+
+       /**
+        * @param string $templateDir
+        * @param boolean $forceRecompile
+        */
+       public function __construct( $templateDir = null, $forceRecompile = false ) {
+               $this->templateDir = $templateDir ? $templateDir : __DIR__.'/templates';
+               $this->forceRecompile = $forceRecompile;
+       }
+
+       /**
+        * Constructs the location of the the source Mustache template
+        * @param string $templateName The name of the template
+        * @return string
+        * @throws Exception Disallows upwards directory traversal via $templateName
+        */
+       public function getTemplateFilename( $templateName ) {
+               // Prevent upwards directory traversal using same methods as Title::secureAndSplit
+               if (
+                       strpos( $templateName, '.' ) !== false &&
+                       (
+                               $templateName === '.' || $templateName === '..' ||
+                               strpos( $templateName, './' ) === 0 ||
+                               strpos( $templateName, '../' ) === 0 ||
+                               strpos( $templateName, '/./' ) !== false ||
+                               strpos( $templateName, '/../' ) !== false ||
+                               substr( $templateName, -2 ) === '/.' ||
+                               substr( $templateName, -3 ) === '/..'
+                       )
+               ) {
+                       throw new Exception( "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 Function
+        * @throws Exception
+        */
+       public function getTemplate( $templateName ) {
+               global $wgSecretKey;
+
+               // If a renderer has already been defined for this template, reuse it
+               if ( isset( $this->renderers[$templateName] ) ) {
+                       return $this->renderers[$templateName];
+               }
+
+               $filename = $this->getTemplateFilename( $templateName );
+
+               if ( !file_exists( $filename ) ) {
+                       throw new Exception( "Could not locate template: {$filename}" );
+               }
+
+               // Read the template file
+               $fileContents = file_get_contents( $filename );
+
+               // Generate a quick hash for cache invalidation
+               $fastHash = md5( $fileContents );
+
+               // See if the compiled PHP code is stored in cache.
+               // CACHE_ACCEL throws an exception if no suitable object cache is present, so fall
+               // back to CACHE_ANYTHING.
+               try {
+                       $cache = wfGetCache( CACHE_ACCEL );
+               } catch ( Exception $e ) {
+                       $cache = wfGetCache( CACHE_ANYTHING );
+               }
+               $key = wfMemcKey( 'template', $templateName, $fastHash );
+               $code = $this->forceRecompile ? null : $cache->get( $key );
+
+               if ( !$code ) {
+                       // Compile the template into PHP code
+                       $code = self::compile( $fileContents );
+
+                       if ( !$code ) {
+                               throw new Exception( "Could not compile template: {$filename}" );
+                       }
+
+                       // Strip the "<?php" added by lightncandy so that it can be eval()ed
+                       if ( substr( $code, 0, 5 ) === '<?php' ) {
+                               $code = substr( $code, 5 );
+                       }
+
+                       $renderer = eval( $code );
+
+                       // Prefix the code with a keyed hash (64 hex chars) as an integrity check
+                       $code = hash_hmac( 'sha256', $code, $wgSecretKey ) . $code;
+
+                       // Cache the compiled PHP code
+                       $cache->set( $key, $code );
+               } else {
+                       // Verify the integrity of the cached PHP code
+                       $keyedHash = substr( $code, 0, 64 );
+                       $code = substr( $code, 64 );
+                       if ( $keyedHash === hash_hmac( 'sha256', $code, $wgSecretKey ) ) {
+                               $renderer = eval( $code );
+                       } else {
+                               throw new Exception( "Template failed integrity check: {$filename}" );
+                       }
+               }
+
+               return $this->renderers[$templateName] = $renderer;
+       }
+
+       /**
+        * Compile the Mustache code into PHP code using LightnCandy
+        * @param string $code Mustache code
+        * @return string PHP code
+        * @throws Exception
+        */
+       public static function compile( $code ) {
+               if ( !class_exists( 'LightnCandy' ) ) {
+                       throw new Exception( 'LightnCandy class not defined' );
+               }
+               return LightnCandy::compile(
+                       $code,
+                       array(
+                               // Do not add more flags here without discussion.
+                               // If you do add more flags, be sure to update unit tests as well.
+                               'flags' => LightnCandy::FLAG_ERROR_EXCEPTION
+                       )
+               );
+       }
+
+       /**
+        * Returns HTML for a given template by calling the template function with the given args
+        * @param string $templateName The name of the template
+        * @param mixed $args
+        * @param array $scopes
+        * @return string
+        */
+       public function processTemplate( $templateName, $args, array $scopes = array() ) {
+               $template = $this->getTemplate( $templateName );
+               return call_user_func( $template, $args, $scopes );
+       }
+}
index 125e544..da4bc87 100644 (file)
@@ -98,7 +98,7 @@ if ( defined( 'MW_CONFIG_CALLBACK' ) ) {
        # the wiki installer needs to be launched or the generated file uploaded to
        # the root wiki directory. Give a hint, if it is not readable by the server.
        if ( !is_readable( MW_CONFIG_FILE ) ) {
-               require_once "$IP/includes/templates/NoLocalSettings.php";
+               require_once "$IP/includes/NoLocalSettings.php";
                die();
        }
 
diff --git a/includes/templates/NoLocalSettings.mustache b/includes/templates/NoLocalSettings.mustache
new file mode 100644 (file)
index 0000000..1649e3b
--- /dev/null
@@ -0,0 +1,39 @@
+<!DOCTYPE html>
+<html lang="en" dir="ltr">
+       <head>
+               <meta charset="UTF-8" />
+               <title>MediaWiki {{wgVersion}}</title>
+               <style media="screen">
+                       html, body {
+                               color: #000;
+                               background-color: #fff;
+                               font-family: sans-serif;
+                               text-align: center;
+                       }
+
+                       h1 {
+                               font-size: 150%;
+                       }
+               </style>
+       </head>
+       <body>
+               <img src="{{path}}resources/assets/mediawiki.png" alt="The MediaWiki logo" />
+
+               <h1>MediaWiki {{wgVersion}}</h1>
+               <div class="error">
+               {{#localSettingsExists}}
+                       <p>LocalSettings.php not readable.</p>
+                       <p>Please correct file permissions and try again.</p>
+               {{/localSettingsExists}}
+               {{^localSettingsExists}}
+                       <p>LocalSettings.php not found.</p>
+                       {{#installerStarted}}
+                               <p>Please <a href="{{path}}mw-config/index{{ext}}">complete the installation</a> and download LocalSettings.php.</p>
+                       {{/installerStarted}}
+                       {{^installerStarted}}
+                               <p>Please <a href="{{path}}mw-config/index{{ext}}">set up the wiki</a> first!</p>
+                       {{/installerStarted}}
+               {{/localSettingsExists}}
+               </div>
+       </body>
+</html>
\ No newline at end of file
diff --git a/includes/templates/NoLocalSettings.php b/includes/templates/NoLocalSettings.php
deleted file mode 100644 (file)
index 824a315..0000000
+++ /dev/null
@@ -1,98 +0,0 @@
-<?php
-// @codingStandardsIgnoreFile
-/**
- * Template used when there is no LocalSettings.php file.
- *
- * 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
- * @ingroup Templates
- */
-
-if ( !defined( 'MEDIAWIKI' ) ) {
-       die( "NoLocalSettings.php is not a valid MediaWiki entry point\n" );
-}
-
-if ( !isset( $wgVersion ) ) {
-       $wgVersion = 'VERSION';
-}
-
-# bug 30219 : can not use pathinfo() on URLs since slashes do not match
-$matches = array();
-$ext = 'php';
-$path = '/';
-foreach ( array_filter( explode( '/', $_SERVER['PHP_SELF'] ) ) as $part ) {
-       if ( !preg_match( '/\.(php5?)$/', $part, $matches ) ) {
-               $path .= "$part/";
-       } else {
-               $ext = $matches[1] == 'php5' ? 'php5' : 'php';
-               break;
-       }
-}
-
-# Check to see if the installer is running
-if ( !function_exists( 'session_name' ) ) {
-       $installerStarted = false;
-} else {
-       session_name( 'mw_installer_session' );
-       $oldReporting = error_reporting( E_ALL & ~E_NOTICE );
-       $success = session_start();
-       error_reporting( $oldReporting );
-       $installerStarted = ( $success && isset( $_SESSION['installData'] ) );
-}
-?>
-<!DOCTYPE html>
-<html lang="en" dir="ltr">
-       <head>
-               <meta charset="UTF-8" />
-               <title>MediaWiki <?php echo htmlspecialchars( $wgVersion ) ?></title>
-               <style media='screen'>
-                       html, body {
-                               color: #000;
-                               background-color: #fff;
-                               font-family: sans-serif;
-                               text-align: center;
-                       }
-
-                       h1 {
-                               font-size: 150%;
-                       }
-               </style>
-       </head>
-       <body>
-               <img src="<?php echo htmlspecialchars( $path ) ?>resources/assets/mediawiki.png" alt='The MediaWiki logo' />
-
-               <h1>MediaWiki <?php echo htmlspecialchars( $wgVersion ) ?></h1>
-               <div class='error'>
-               <?php if ( !file_exists( MW_CONFIG_FILE ) ) { ?>
-                       <p>LocalSettings.php not found.</p>
-                       <p>
-                       <?php
-                       if ( $installerStarted ) {
-                               echo "Please <a href=\"" . htmlspecialchars( $path ) . "mw-config/index." . htmlspecialchars( $ext ) . "\">complete the installation</a> and download LocalSettings.php.";
-                       } else {
-                               echo "Please <a href=\"" . htmlspecialchars( $path ) . "mw-config/index." . htmlspecialchars( $ext ) . "\">set up the wiki</a> first.";
-                       }
-                       ?>
-                       </p>
-               <?php } else { ?>
-                       <p>LocalSettings.php not readable.</p>
-                       <p>Please correct file permissions and try again.</p>
-               <?php } ?>
-
-               </div>
-       </body>
-</html>
diff --git a/tests/phpunit/includes/TemplateParserTest.php b/tests/phpunit/includes/TemplateParserTest.php
new file mode 100644 (file)
index 0000000..ccfccd1
--- /dev/null
@@ -0,0 +1,30 @@
+<?php
+
+/**
+ * @group Templates
+ */
+class TemplateParserTest extends MediaWikiTestCase {
+       /**
+        * @covers TemplateParser::compile
+        */
+       public function testTemplateCompilation() {
+               $this->assertRegExp(
+                       '/^<\?php return function/',
+                       TemplateParser::compile( "test" ),
+                       'compile a simple mustache template'
+               );
+       }
+
+       /**
+        * @covers TemplateParser::compile
+        */
+       public function testTemplateCompilationWithVariable() {
+               $this->assertRegExp(
+                       '/return \'\'\.htmlentities\(\(string\)\(\(isset\(\$in\[\'value\'\]\) && '
+                               . 'is_array\(\$in\)\) \? \$in\[\'value\'\] : null\), ENT_QUOTES, '
+                               . '\'UTF-8\'\)\.\'\';/',
+                       TemplateParser::compile( "{{value}}" ),
+                       'compile a mustache template with an escaped variable'
+               );
+       }
+}