Commit: 068d8514af650469cdc566e4bbcd50d52cd5f7e5 Author: Peter Kokot <peterko...@gmail.com> Mon, 17 Dec 2018 17:28:48 +0100 Parents: 14c6ce8cd5511224a9435e42d668ab7f51c1e6ae Branches: master
Link: http://git.php.net/?p=web/bugs.git;a=commitdiff;h=068d8514af650469cdc566e4bbcd50d52cd5f7e5 Log: Add template engine This patch adds an initial simplistic template engine to separate logic from the presentation. Basic initial features: - escaping via Context::noHtml() and Context::e() methods - blocks - nesting options using includes and extending layouts - PHP syntax - variable scopes dedicated to template scope only - Appending blocks (when JS files are in need to be appended) - initial unit and functional tests - Main index page refactored as an example of usage - Very short intro docs how to use the template layer - Thanks to @nhlm for the code review and numerous suggestions to improve the usability and code stability, - Thanks to @KalleZ and for the code review and numerous common sense suggestions about templates themselves. - Thanks to @Maikuolan for the code review and numerous suggestions about the usability. - Moved hash ids redirection to aseparate JavaScript file - Use location instead of window.location in the JavaScript redirection Discussions: - http://news.php.net/php.webmaster/27603 - https://github.com/php/web-bugs/pull/66 Changed paths: M README.md A docs/README.md A docs/templates.md M include/prepend.php A src/Template/Context.php A src/Template/Engine.php A templates/layout.php A templates/pages/index.php A tests/Template/ContextTest.php A tests/Template/EngineTest.php A tests/fixtures/templates/base.php A tests/fixtures/templates/forms/form.php A tests/fixtures/templates/includes/banner.php A tests/fixtures/templates/includes/base.php A tests/fixtures/templates/includes/extends.php A tests/fixtures/templates/includes/variable.php A tests/fixtures/templates/layout.php A tests/fixtures/templates/pages/add_function.php A tests/fixtures/templates/pages/appending.php A tests/fixtures/templates/pages/assignments.php A tests/fixtures/templates/pages/extends.php A tests/fixtures/templates/pages/including.php A tests/fixtures/templates/pages/invalid_variables.php A tests/fixtures/templates/pages/no_layout.rss A tests/fixtures/templates/pages/overrides.php A tests/fixtures/templates/pages/view.php M www/index.php A www/js/redirect.js
diff --git a/README.md b/README.md index 890ec74..526ee6a 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ Source code of this application is structured in the following directories: ```bash <web-bugs>/ ├─ .git/ # Git configuration and source directory + ├─ docs/ # Application documentation └─ include/ # Application helper functions and configuration ├─ classes/ # PEAR class overrides ├─ prepend.php # Autoloader, DB connection, container, app initialization @@ -103,3 +104,7 @@ git remote add upstream git://github.com/php/web-bugs git config branch.master.remote upstream git pull --rebase ``` + +## Documentation + +More information about this application can be found in the [documentation](/docs). diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..1cc4c00 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,3 @@ +# Application documentation + +* [Templates](/docs/templates.md) diff --git a/docs/templates.md b/docs/templates.md new file mode 100644 index 0000000..66ca34d --- /dev/null +++ b/docs/templates.md @@ -0,0 +1,197 @@ +# Templates + +A simple template engine separates logic from the presentation and provides +methods for creating nested templates and escaping strings to protect against +too common XSS vulnerabilities. + +It is initialized in the application bootstrap: + +```php +$template = new App\Template\Engine(__DIR__.'/../path/to/templates'); +``` + +Site-wide configuration parameters can be assigned before rendering so they are +available in all templates: + +```php +$template->assign([ + 'siteUrl' => 'https://bugs.php.net', + // ... +]); +``` + +Page can be rendered in the controller: + +```php +echo $template->render('pages/how_to_report.php', [ + 'mainHeading' => 'How to report a bug?', +]); +``` + +The `templates/pages/how_to_report.php`: + +```php +<?php $this->extends('layout.php', ['title' => 'Reporting bugs']) ?> + +<?php $this->start('main_content') ?> + <h1><?= $this->noHtml($mainHeading) ?></h1> + + <p><?= $siteUrl ?></p> +<?php $this->end('main_content') ?> + +<?php $this->start('scripts') ?> + <script src="/js/feature.js"></script> +<?php $this->end('scripts') ?> +``` + +The `templates/layout.php`: + +```html +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8"> + <link rel="stylesheet" href="/css/style.css"> + <title>PHP Bug Tracking System :: <?= $title ?? '' ?></title> + </head> + <body> + <?= $this->block('main_content') ?> + + <div><?= $siteUrl ?></div> + + <script src="/js/app.js"></script> + <?= $this->block('scripts') ?> + </body> +</html> +``` + +## Including templates + +To include a partial template snippet file: + +```php +<?php $this->include('forms/report_bug.php') ?> +``` + +which is equivalent to `<?php include __DIR__.'/../forms/report_bug.php' ?>`. +The variable scope is inherited by the template that included the file. + +## Blocks + +Blocks are main building elements that contain template snippets and can be +included into the parent file(s). + +Block is started with the `$this->start('block_name')` call and ends with +`$this->end('block_name')`: + +```php +<?php $this->start('block_name') ?> + <h1>Heading</h1> + + <p>...</p> +<?php $this->end('block_name') ?> +``` + +### Appending blocks + +Block content can be appended to existing blocks by the +`$this->append('block_name')`. + +The `templates/layout.php`: + +```html +<html> +<head></head> +<body> + <?= $this->block('content'); ?> + + <?= $this->block('scripts'); ?> +</body> +</html> +``` + +The `templates/pages/index.php`: + +```php +<?php $this->extends('layout.php'); ?> + +<?php $this->start('scripts'); ?> + <script src="/js/foo.js"></script> +<?php $this->end('scripts'); ?> + +<?php $this->start('content') ?> + <?php $this->include('forms/form.php') ?> +<?php $this->end('content') ?> +``` + +The `templates/forms/form.php`: + +```php +<form> + <input type="text" name="title"> + <input type="submit" value="Submit"> +</form> + +<?php $this->append('scripts'); ?> + <script src="/js/bar.js"></script> +<?php $this->end('scripts'); ?> +``` + +The final rendered page: + +```html +<html> +<head></head> +<body> + <form> + <input type="text" name="title"> + <input type="submit" value="Submit"> + </form> + + <script src="/js/foo.js"></script> + <script src="/js/bar.js"></script> +</body> +</html> +``` + +## Helpers + +Registering additional template helpers can be useful when a custom function or +class method needs to be called in the template. + +### Registering function + +```php +$template->register('formatDate', function (int $timestamp): string { + return gmdate('Y-m-d H:i e', $timestamp - date('Z', $timestamp)); +}); +``` + +### Registering object method + +```php +$template->register('doSomething', [$object, 'methodName']); +``` + +Using helpers in templates: + +```php +<p>Time: <?= $this->formatDate(time()) ?></p> +<div><?= $this->doSomething('arguments') ?></div> +``` + +## Escaping + +When protecting against XSS there are two built-in methods provided. + +To replace all characters to their applicable HTML entities in the given string: + +```php +<?= $this->noHtml($var) ?> +``` + +To escape given string and still preserve certain characters as HTML: + +```php +<?= $this->e($var) ?> +``` diff --git a/include/prepend.php b/include/prepend.php index 388ebb1..5b94bf3 100644 --- a/include/prepend.php +++ b/include/prepend.php @@ -2,6 +2,7 @@ use App\Autoloader; use App\Database\Statement; +use App\Template\Engine; // Dual PSR-4 compatible class autoloader. When Composer is not available, an // application specific replacement class is used. Once Composer can be added @@ -83,3 +84,11 @@ $dbh = new \PDO( // Last Updated.. $tmp = filectime($_SERVER['SCRIPT_FILENAME']); $LAST_UPDATED = date('D M d H:i:s Y', $tmp - date('Z', $tmp)) . ' UTC'; + +// Initialize template engine. +$template = new Engine(__DIR__.'/../templates'); +$template->assign([ + 'lastUpdated' => $LAST_UPDATED, + 'siteScheme' => $site_method, + 'siteUrl' => $site_url, +]); diff --git a/src/Template/Context.php b/src/Template/Context.php new file mode 100644 index 0000000..eaf9403 --- /dev/null +++ b/src/Template/Context.php @@ -0,0 +1,178 @@ +<?php + +namespace App\Template; + +/** + * Context represents a template variable scope where $this pseudo-variable can + * be used in the templates and context methods can be called as $this->method(). + */ +class Context +{ + /** + * Templates directory. + * + * @var string + */ + private $dir; + + /** + * The current processed template or snippet file. + * + * @var string + */ + private $current; + + /** + * All assigned and set variables for the template. + * + * @var array + */ + private $variables = []; + + /** + * Pool of blocks for the template context. + * + * @var array + */ + private $blocks = []; + + /** + * Parent templates extended by child templates. + * + * @var array + */ + public $tree = []; + + /** + * Registered callables. + * + * @var array + */ + private $callables = []; + + /** + * Current nesting level of the output buffering mechanism. + * + * @var int + */ + private $bufferLevel = 0; + + /** + * Class constructor. + */ + public function __construct( + string $dir, + array $variables = [], + array $callables = [] + ) { + $this->dir = $dir; + $this->variables = $variables; + $this->callables = $callables; + } + + /** + * Sets a parent layout for the given template. Additional variables in the + * parent scope can be defined via the second argument. + */ + public function extends(string $parent, array $variables = []): void + { + if (isset($this->tree[$this->current])) { + throw new \Exception('Extending '.$parent.' is not possible.'); + } + + $this->tree[$this->current] = [$parent, $variables]; + } + + /** + * Return a block content from the pool by name. + */ + public function block(string $name): string + { + return $this->blocks[$name] ?? ''; + } + + /** + * Starts a new template block. Under the hood a simple separate output + * buffering is used to capture the block content. Content can be also + * appended to previously set same block name. + */ + public function start(string $name): void + { + $this->blocks[$name] = ''; + + ++$this->bufferLevel; + + ob_start(); + } + + /** + * Append content to a template block. If no block with the key name exists + * yet it starts a new one. + */ + public function append(string $name): void + { + if (!isset($this->blocks[$name])) { + $this->blocks[$name] = ''; + } + + ++$this->bufferLevel; + + ob_start(); + } + + /** + * Ends block output buffering and stores its content into the pool. + */ + public function end(string $name): void + { + --$this->bufferLevel; + + $content = ob_get_clean(); + + if (!empty($this->blocks[$name])) { + $this->blocks[$name] .= $content; + } else { + $this->blocks[$name] = $content; + } + } + + /** + * Include template file into existing template. + * + * @return mixed + */ + public function include(string $template) + { + return include $this->dir.'/'.$template; + } + + /** + * Scalpel when preventing XSS vulnerabilities. This escapes given string + * and still preserves certain characters as HTML. + */ + public function e(string $string): string + { + return htmlspecialchars($string, ENT_QUOTES, 'UTF-8'); + } + + /** + * Hammer when protecting against XSS. Sanitize strings and replace all + * characters to their applicable HTML entities from it. + */ + public function noHtml(string $string): string + { + return htmlentities($string, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + } + + /** + * A proxy to call registered callable. + * + * @return mixed + */ + public function __call(string $method, array $arguments) + { + if (isset($this->callables[$method])) { + return call_user_func_array($this->callables[$method], $arguments); + } + } +} diff --git a/src/Template/Engine.php b/src/Template/Engine.php new file mode 100644 index 0000000..4fee288 --- /dev/null +++ b/src/Template/Engine.php @@ -0,0 +1,161 @@ +<?php + +namespace App\Template; + +/** + * A simple template engine that assigns global variables to the templates and + * renders given template. + */ +class Engine +{ + /** + * Templates directory contains all application templates. + * + * @var string + */ + private $dir; + + /** + * Registered callables. + * + * @var array + */ + private $callables = []; + + /** + * Assigned variables after template initialization and before calling the + * render method. + * + * @var array + */ + private $variables = []; + + /** + * Template context. + * + * @var Context + */ + private $context; + + /** + * Class constructor. + */ + public function __construct(string $dir) + { + if (!is_dir($dir)) { + throw new \Exception($dir.' is missing or not a valid directory.'); + } + + $this->dir = $dir; + } + + /** + * This enables assigning new variables to the template scope right after + * initializing a template engine. Some variables in templates are like + * parameters or globals and should be added only on one place instead of + * repeating them at each ...->render() call. + */ + public function assign(array $variables = []): void + { + $this->variables = array_replace($this->variables, $variables); + } + + /** + * Get assigned variables of the template. + */ + public function getVariables(): array + { + return $this->variables; + } + + /** + * Add new template helper function as a callable defined in the (front) + * controller to the template scope. + */ + public function register(string $name, callable $callable): void + { + if (method_exists(Context::class, $name)) { + throw new \Exception( + $name.' is already registered by the template engine. Use a different name.' + ); + } + + $this->callables[$name] = $callable; + } + + /** + * Renders given template file and populates its scope with variables + * provided as array elements. Each array key is a variable name in template + * scope and array item value is set as a variable value. + */ + public function render(string $template, array $variables = []): string + { + $variables = array_replace($this->variables, $variables); + + $this->context = new Context( + $this->dir, + $variables, + $this->callables + ); + + $buffer = $this->bufferize($template, $variables); + + while (!empty($current = array_shift($this->context->tree))) { + $buffer = trim($buffer); + $buffer .= $this->bufferize($current[0], $current[1]); + } + + return $buffer; + } + + /** + * Processes given template file, merges variables into template scope using + * output buffering and returns the rendered content string. Note that $this + * pseudo-variable in the closure refers to the scope of the Context class. + */ + private function bufferize(string $template, array $variables = []): string + { + if (!is_file($this->dir.'/'.$template)) { + throw new \Exception($template.' is missing or not a valid template.'); + } + + $closure = \Closure::bind( + function ($template, $variables) { + $this->current = $template; + $this->variables = array_replace($this->variables, $variables); + unset($variables, $template); + + if (count($this->variables) > extract($this->variables, EXTR_SKIP)) { + throw new \Exception( + 'Variables with numeric names $0, $1... cannot be imported to scope '.$this->current + ); + } + + ++$this->bufferLevel; + + ob_start(); + + try { + include $this->dir.'/'.$this->current; + } catch (\Exception $e) { + // Close all opened buffers + while ($this->bufferLevel > 0) { + --$this->bufferLevel; + + ob_end_clean(); + } + + throw $e; + } + + --$this->bufferLevel; + + return ob_get_clean(); + }, + $this->context, + Context::class + ); + + return $closure($template, $variables); + } +} diff --git a/templates/layout.php b/templates/layout.php new file mode 100644 index 0000000..a0eb35f --- /dev/null +++ b/templates/layout.php @@ -0,0 +1,80 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <title>PHP :: <?= $this->e($title) ?></title> + <link rel="shortcut icon" href="<?= $siteScheme ?>://<?= $siteUrl ?>/images/favicon.ico"> + <link rel="stylesheet" href="/css/style.css"> +</head> +<body> +<table id="top" class="head" cellspacing="0" cellpadding="0"> + <tr> + <td class="head-logo"> + <a href="/"><img src="/images/logo.png" alt="Bugs" vspace="2" hspace="2"></a> + </td> + + <td class="head-menu"> + <a href="https://php.net/">php.net</a> | + <a href="https://php.net/support.php">support</a> | + <a href="https://php.net/docs.php">documentation</a> | + <a href="/report.php">report a bug</a> | + <a href="/search.php">advanced search</a> | + <a href="/search-howto.php">search howto</a> | + <a href="/stats.php">statistics</a> | + <a href="/random">random bug</a> | + <?php if ($authIsLoggedIn): ?> + <a href="/search.php?cmd=display&assign=<?= $this->e($authUsername) ?>">my bugs</a> | + <?php if ('developer' === $authRole): ?> + <a href="/admin/">admin</a> | + <?php endif ?> + <a href="/logout.php">logout</a> + <?php else: ?> + <a href="/login.php">login</a> + <?php endif ?> + </td> + </tr> + + <tr> + <td class="head-search" colspan="2"> + <form method="get" action="/search.php"> + <p class="head-search"> + <input type="hidden" name="cmd" value="display"> + <small>go to bug id or search bugs for</small> + <input class="small" type="text" name="search_for" value="<?= $this->e($_GET['search_for'] ?? '') ?>" size="30"> + <input type="image" src="/images/small_submit_white.gif" alt="search" style="vertical-align: middle;"> + </p> + </form> + </td> + </tr> +</table> + +<table class="middle" cellspacing="0" cellpadding="0"> + <tr> + <td class="content"> + <?= $this->block('content') ?> + </td> + </tr> +</table> + +<table class="foot" cellspacing="0" cellpadding="0"> + <tr> + <td class="foot-bar" colspan="2"> </td> + </tr> + + <tr> + <td class="foot-copy"> + <small> + <a href="https://php.net/"><img src="/images/logo-small.gif" align="left" valign="middle" hspace="3" alt="PHP"></a> + <a href="https://php.net/copyright.php">Copyright © 2001-<?= date('Y') ?> The PHP Group</a><br> + All rights reserved. + </small> + </td> + <td class="foot-source"> + <small>Last updated: <?= $lastUpdated ?></small> + </td> + </tr> +</table> + +<?= $this->block('scripts') ?> +</body> +</html> diff --git a/templates/pages/index.php b/templates/pages/index.php new file mode 100644 index 0000000..177e93f --- /dev/null +++ b/templates/pages/index.php @@ -0,0 +1,76 @@ +<?php $this->extends('layout.php', ['title' => 'Bugs homepage']) ?> + +<?php $this->start('content') ?> + +<h1>PHP Bug Tracking System</h1> + +<p>Before you report a bug, please make sure you have completed the following +steps:</p> + +<ul> + <li> + Used the form above or our <a href="/search.php">advanced search page</a> + to make sure nobody has reported the bug already. + </li> + + <li> + Make sure you are using the latest stable version or a build from Git, + if similar bugs have recently been fixed and committed. + </li> + + <li> + Read our tips on <a href="/how-to-report.php">how to report a bug that + someone will want to help fix</a>. + </li> + + <li> + Read the <a href="https://wiki.php.net/security">security guidelines</a>, + if you think an issue might be security related. + </li> + + <li> + See how to get a backtrace in case of a crash: + <a href="/bugs-generating-backtrace.php">for *NIX</a> and + <a href="/bugs-generating-backtrace-win32.php">for Windows</a>. + </li> + + <li> + Make sure it isn't a support question. For support, see the + <a href="https://php.net/support.php">support page</a>. + </li> +</ul> + +<p>Once you've double-checked that the bug you've found hasn't already been +reported, and that you have collected all the information you need to file an +excellent bug report, you can do so on our <a href="/report.php">bug reporting +page</a>.</p> + +<h1>Search the Bug System</h1> + +<p>You can search all of the bugs that have been reported on our +<a href="/search.php">advanced search page</a>, or use the form at the top of the +page for a basic default search. Read the <a href="/search-howto.php">search howto</a> +for instructions on how search works.</p> + +<p>If you have 10 minutes to kill and you want to help us out, grab a random +open bug and see if you can help resolve it. We have made it easy. Hit +<a href="/random">random</a> to go directly to a random open bug.</p> + +<p>Common searches</p> + +<ul> + <?php foreach ($searches as $title => $link): ?> + <li><a href="<?= $this->e($link) ?>"><?= $this->e($title) ?></a></li> + <?php endforeach ?> +</ul> + +<h1>Bug System Statistics</h1> + +<p>You can view a variety of statistics about the bugs that have been reported +on our <a href="/stats.php">bug statistics page</a>.</p> + +<?php $this->end('content') ?> + +<?php $this->start('scripts') ?> + <script src="/js/redirect.js"></script> +<?php $this->end('scripts') ?> diff --git a/tests/Template/ContextTest.php b/tests/Template/ContextTest.php new file mode 100644 index 0000000..5092f69 --- /dev/null +++ b/tests/Template/ContextTest.php @@ -0,0 +1,78 @@ +<?php + +namespace App\Tests\Template; + +use PHPUnit\Framework\TestCase; +use App\Template\Context; + +class ContextTest extends TestCase +{ + public function setUp() + { + $this->context = new Context(__DIR__.'/../fixtures/templates'); + } + + public function testBlock() + { + $this->context->start('foo'); + echo 'bar'; + $this->context->end('foo'); + + $this->assertEquals($this->context->block('foo'), 'bar'); + + $this->context->append('foo'); + echo 'baz'; + $this->context->end('foo'); + + $this->assertEquals($this->context->block('foo'), 'barbaz'); + + $this->context->start('foo'); + echo 'overridden'; + $this->context->end('foo'); + + $this->assertEquals($this->context->block('foo'), 'overridden'); + } + + public function testInclude() + { + ob_start(); + $this->context->include('includes/banner.php'); + $content = ob_get_clean(); + + $this->assertEquals(file_get_contents(__DIR__.'/../fixtures/templates/includes/banner.php'), $content); + } + + public function testIncludeReturn() + { + $variable = $this->context->include('includes/variable.php'); + + $this->assertEquals(include __DIR__.'/../fixtures/templates/includes/variable.php', $variable); + } + + /** + * @dataProvider attacksProvider + */ + public function testEscaping($malicious, $escaped, $noHtml) + { + $this->assertEquals($escaped, $this->context->e($malicious)); + } + + /** + * @dataProvider attacksProvider + */ + public function testNoHtml($malicious, $escaped, $noHtml) + { + $this->assertEquals($noHtml, $this->context->noHtml($malicious)); + } + + public function attacksProvider() + { + return [ + [ + '<iframe src="javascript:alert(\'Xss\')";></iframe>', + '<iframe src="javascript:alert('Xss')";></iframe>', + '<iframe src="javascript:alert('Xss')";></iframe>' + ] + ]; + } +} diff --git a/tests/Template/EngineTest.php b/tests/Template/EngineTest.php new file mode 100644 index 0000000..16174b5 --- /dev/null +++ b/tests/Template/EngineTest.php @@ -0,0 +1,186 @@ +<?php + +namespace App\Tests\Template; + +use PHPUnit\Framework\TestCase; +use App\Template\Engine; + +class EngineTest extends TestCase +{ + public function setUp() + { + $this->template = new Engine(__DIR__.'/../fixtures/templates'); + } + + public function testView() + { + $content = $this->template->render('pages/view.php', [ + 'foo' => 'Lorem ipsum dolor sit amet.', + 'sidebar' => 'PHP is a popular general-purpose scripting language that is especially suited to web development', + ]); + + $this->assertRegexp('/Lorem ipsum dolor sit amet/', $content); + $this->assertRegexp('/PHP is a popular general-purpose/', $content); + } + + public function testRegisterNew() + { + // Register callable function + $this->template->register('addAsterisks', function ($var) { + return '***'.$var.'***'; + }); + + // Register callable object and method + $object = new class { + public $property; + + public function doSomething($argument) {} + }; + $this->template->register('doSomething', [$object, 'doSomething']); + + $content = $this->template->render('pages/add_function.php', [ + 'foo' => 'Lorem ipsum dolor sit amet.', + ]); + + $this->assertRegexp('/\*\*\*Lorem ipsum dolor sit amet\.\*\*\*/', $content); + } + + public function testRegisterExisting() + { + $this->expectException(\Exception::class); + + $this->template->register('noHtml', function ($var) { + return $var; + }); + } + + public function testAssignments() + { + $this->template->assign([ + 'parameter' => 'FooBarBaz', + ]); + + $content = $this->template->render('pages/assignments.php', [ + 'foo' => 'Lorem ipsum dolor sit amet.', + ]); + + $this->assertRegexp('/Lorem ipsum dolor sit amet\./', $content); + $this->assertRegexp('/FooBarBaz/', $content); + } + + public function testMerge() + { + $this->template->assign([ + 'foo', + 'bar', + 'qux' => 'quux', + ]); + + $this->template->assign([ + 'baz', + 'qux' => 'quuz', + ]); + + $this->assertEquals(['baz', 'bar', 'qux' => 'quuz'], $this->template->getVariables()); + } + + public function testVariablesScope() + { + $this->template->assign([ + 'parameter' => 'Parameter value', + ]); + + $content = $this->template->render('pages/invalid_variables.php', [ + 'foo' => 'Lorem ipsum dolor sit amet', + ]); + + $expected = var_export([ + 'parameter' => 'Parameter value', + 'foo' => 'Lorem ipsum dolor sit amet', + ], true); + + $this->assertEquals($expected, $content); + } + + public function testInvalidVariables() + { + $this->template->assign([ + 'Invalid value with key 0', + 'parameter' => 'Parameter value', + 'Invalid value with key 1', + ]); + + $this->expectException(\Exception::class); + + $content = $this->template->render('pages/invalid_variables.php', [ + 'foo' => 'Lorem ipsum dolor sit amet', + 1 => 'Invalid overridden value with key 1', + ]); + } + + public function testOverrides() + { + $this->template->assign([ + 'pageParameter_1' => 'Page parameter 1', + 'pageParameter_2' => 'Page parameter 2', + 'layoutParameter_1' => 'Layout parameter 1', + 'layoutParameter_2' => 'Layout parameter 2', + 'layoutParameter_3' => 'Layout parameter 3', + ]); + + $content = $this->template->render('pages/overrides.php', [ + 'pageParameter_2' => 'Overridden parameter 2', + 'layoutParameter_2' => 'Layout overridden parameter 2', + ]); + + $this->assertRegexp('/Page parameter 1/', $content); + $this->assertRegexp('/^((?!Page parameter 2).)*$/s', $content); + $this->assertRegexp('/Overridden parameter 2/', $content); + $this->assertRegexp('/Layout parameter 1/', $content); + $this->assertRegexp('/^((?!Layout parameter 2).)*$/s', $content); + $this->assertRegexp('/Layout overridden parameter 2/', $content); + } + + public function testAppending() + { + $content = $this->template->render('pages/appending.php'); + + $this->assertRegexp('/file\_1\.js/', $content); + $this->assertRegexp('/file\_2\.js/', $content); + } + + public function testIncluding() + { + $content = $this->template->render('pages/including.php'); + + $this->assertRegexp('/\<form method\=\"post\"\>/', $content); + $this->assertRegexp('/Banner inclusion/', $content); + } + + public function testNoLayout() + { + $content = $this->template->render('pages/no_layout.rss'); + + $this->assertEquals(file_get_contents(__DIR__.'/../fixtures/templates/pages/no_layout.rss'), $content); + } + + public function testMissingTemplate() + { + $this->template->assign([ + 'parameter' => 'Parameter value', + ]); + + $this->expectException(\Exception::class); + + $content = $this->template->render('pages/this/does/not/exist.php', [ + 'foo' => 'Lorem ipsum dolor sit amet', + ]); + } + + public function testExtending() + { + $this->expectException(\Exception::class); + + $html = $this->template->render('pages/extends.php'); + } +} diff --git a/tests/fixtures/templates/base.php b/tests/fixtures/templates/base.php new file mode 100644 index 0000000..8b2ca42 --- /dev/null +++ b/tests/fixtures/templates/base.php @@ -0,0 +1,8 @@ +<html> +<head> + <title><?=$this->e($title ?? '')?></title> +</head> +<body> + <?= $this->block('body') ?> +</body> +</html> diff --git a/tests/fixtures/templates/forms/form.php b/tests/fixtures/templates/forms/form.php new file mode 100644 index 0000000..b3c64b0 --- /dev/null +++ b/tests/fixtures/templates/forms/form.php @@ -0,0 +1,8 @@ +<?php $this->append('scripts'); ?> +<script src="/path/to/file_2.js"></script> +<?php $this->end('scripts'); ?> + +<form method="post"> +<input type="text" name="foo"> +<input type="submit" value="Submit"> +</form> diff --git a/tests/fixtures/templates/includes/banner.php b/tests/fixtures/templates/includes/banner.php new file mode 100644 index 0000000..9a80ad1 --- /dev/null +++ b/tests/fixtures/templates/includes/banner.php @@ -0,0 +1,3 @@ +<h2>Banner inclusion</h2> + +<p>Lorem ipsum dolor sit amet</p> diff --git a/tests/fixtures/templates/includes/base.php b/tests/fixtures/templates/includes/base.php new file mode 100644 index 0000000..e7c452c --- /dev/null +++ b/tests/fixtures/templates/includes/base.php @@ -0,0 +1,3 @@ +<div id="item"> + <?= $this->block('item') ?> +</div> diff --git a/tests/fixtures/templates/includes/extends.php b/tests/fixtures/templates/includes/extends.php new file mode 100644 index 0000000..7bffe99 --- /dev/null +++ b/tests/fixtures/templates/includes/extends.php @@ -0,0 +1,5 @@ +<?php $this->extends('includes/base.php') ?> + +<?php $this->start('item') ?> + <div><a href="https://example.com">Link</a></div> +<?php $this->end('item') ?> diff --git a/tests/fixtures/templates/includes/variable.php b/tests/fixtures/templates/includes/variable.php new file mode 100644 index 0000000..eb22094 --- /dev/null +++ b/tests/fixtures/templates/includes/variable.php @@ -0,0 +1,3 @@ +<?php + +return [1, 2, 3]; diff --git a/tests/fixtures/templates/layout.php b/tests/fixtures/templates/layout.php new file mode 100644 index 0000000..0a83d6d --- /dev/null +++ b/tests/fixtures/templates/layout.php @@ -0,0 +1,17 @@ +<?php $this->extends('base.php') ?> + +<?php $this->start('body') ?> + <?= $this->block('sidebar') ?> + + <?= $this->block('content') ?> + + <?= $this->block('this_block_is_not_set') ?> + + <?= $layoutParameter_1 ?? '' ?> + <?= $layoutParameter_2 ?? '' ?> + <?= $layoutParameter_3 ?? '' ?> + + <?= $this->block('scripts') ?> + + <?= $this->include('includes/banner.php') ?> +<?php $this->end('body') ?> diff --git a/tests/fixtures/templates/pages/add_function.php b/tests/fixtures/templates/pages/add_function.php new file mode 100644 index 0000000..71413ee --- /dev/null +++ b/tests/fixtures/templates/pages/add_function.php @@ -0,0 +1,5 @@ +<?php $this->extends('layout.php', ['title' => 'Bugs homepage']) ?> + +<?php $this->start('content'); ?> +<?= $this->addAsterisks($foo); ?> +<?php $this->end('content'); ?> diff --git a/tests/fixtures/templates/pages/appending.php b/tests/fixtures/templates/pages/appending.php new file mode 100644 index 0000000..193a4b5 --- /dev/null +++ b/tests/fixtures/templates/pages/appending.php @@ -0,0 +1,7 @@ +<?php $this->extends('layout.php', ['title' => 'Testing blocks appends']) ?> + +<?php include __DIR__.'/../forms/form.php'; ?> + +<?php $this->append('scripts'); ?> +<script src="/path/to/file_1.js"></script> +<?php $this->end('scripts'); ?> diff --git a/tests/fixtures/templates/pages/assignments.php b/tests/fixtures/templates/pages/assignments.php new file mode 100644 index 0000000..bddc3a5 --- /dev/null +++ b/tests/fixtures/templates/pages/assignments.php @@ -0,0 +1,6 @@ +<?php $this->extends('layout.php', ['title' => 'Testing variables']) ?> + +<?php $this->start('content'); ?> +Defined parameter is <?= $parameter; ?>.<br> +<?= $foo; ?> +<?php $this->end('content'); ?> diff --git a/tests/fixtures/templates/pages/extends.php b/tests/fixtures/templates/pages/extends.php new file mode 100644 index 0000000..4a7f884 --- /dev/null +++ b/tests/fixtures/templates/pages/extends.php @@ -0,0 +1,5 @@ +<?php $this->extends('layout.php') ?> + +<?php $this->start('content') ?> + <?php $this->include('includes/extends.php') ?> +<?php $this->end('content') ?> diff --git a/tests/fixtures/templates/pages/including.php b/tests/fixtures/templates/pages/including.php new file mode 100644 index 0000000..746b59a --- /dev/null +++ b/tests/fixtures/templates/pages/including.php @@ -0,0 +1,5 @@ +<?php $this->extends('layout.php', ['title' => 'Testing blocks appends']) ?> + +<?php $this->start('content') ?> +<?php $this->include('forms/form.php') ?> +<?php $this->end('content') ?> diff --git a/tests/fixtures/templates/pages/invalid_variables.php b/tests/fixtures/templates/pages/invalid_variables.php new file mode 100644 index 0000000..175b090 --- /dev/null +++ b/tests/fixtures/templates/pages/invalid_variables.php @@ -0,0 +1 @@ +<?= var_export(get_defined_vars()) ?> diff --git a/tests/fixtures/templates/pages/no_layout.rss b/tests/fixtures/templates/pages/no_layout.rss new file mode 100644 index 0000000..6ef9e6b --- /dev/null +++ b/tests/fixtures/templates/pages/no_layout.rss @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<rss version="2.0"> +<channel> + <title>RSS Title</title> + <description>This is an example of an RSS feed</description> + <link>https://www.example.com/main.html</link> + <lastBuildDate>Mon, 06 Sep 2010 00:01:00 +0000 </lastBuildDate> + <pubDate>Sun, 06 Sep 2009 16:20:00 +0000</pubDate> + <ttl>1800</ttl> + + <item> + <title>Example entry</title> + <description>Here is some text containing an interesting description.</description> + <link>https://www.example.com/blog/post/1</link> + <guid isPermaLink="false">7bd204c6-1655-4c27-aeee-53f933c5395f</guid> + <pubDate>Sun, 06 Sep 2009 16:20:00 +0000</pubDate> + </item> +</channel> +</rss> diff --git a/tests/fixtures/templates/pages/overrides.php b/tests/fixtures/templates/pages/overrides.php new file mode 100644 index 0000000..45192ae --- /dev/null +++ b/tests/fixtures/templates/pages/overrides.php @@ -0,0 +1,6 @@ +<?php $this->extends('layout.php', ['title' => 'Testing variables', 'layoutParameter_3' => 'Layout overridden parameter 3']) ?> + +<?php $this->start('content'); ?> +<?= $pageParameter_1 ?> +<?= $pageParameter_2 ?> +<?php $this->end('content'); ?> diff --git a/tests/fixtures/templates/pages/view.php b/tests/fixtures/templates/pages/view.php new file mode 100644 index 0000000..e20c8a5 --- /dev/null +++ b/tests/fixtures/templates/pages/view.php @@ -0,0 +1,9 @@ +<?php $this->extends('layout.php', ['title' => 'Bugs homepage']) ?> + +<?php $this->start('content'); ?> +<?= $foo; ?> +<?php $this->end('content'); ?> + +<?php $this->start('sidebar'); ?> +<?= $sidebar; ?> +<?php $this->end('sidebar'); ?> diff --git a/www/index.php b/www/index.php index 03e4014..413e608 100644 --- a/www/index.php +++ b/www/index.php @@ -1,123 +1,72 @@ <?php -session_start(); - -/* The bug system home page */ - +/** + * The bug system home page. + */ use App\Repository\BugRepository; -// Obtain common includes -require_once '../include/prepend.php'; +// Application bootstrap +require_once __DIR__.'/../include/prepend.php'; -// If 'id' is passed redirect to the bug page -$id = !empty($_GET['id']) ? (int) $_GET['id'] : 0; -if ($id) { - redirect("bug.php?id={$id}"); -} - -if($_SERVER['REQUEST_URI'] == '/random') { - $id = (new BugRepository($dbh))->findRandom(); - redirect("bug.php?id={$id[0]}"); -} +// Start session +session_start(); // Authenticate bugs_authenticate($user, $pw, $logged_in, $user_flags); -response_header('Bugs'); - -?> - -<script> -var bugid = window.location.hash.substr(1) * 1; -if (bugid > 0) { - var loc = window.location; - loc.href = loc.protocol + '//' + loc.host+(loc.port ? ':'+loc.port : '')+'/'+bugid; +// TODO: Refactor this into a better authentication service +if ('developer' === $logged_in) { + $isLoggedIn = true; + $username = $auth_user->handle; +} elseif (!empty($_SESSION['user'])) { + $isLoggedIn = true; + $username = $_SESSION['user']; +} else { + $isLoggedIn = false; + $username = ''; } -</script> - -<h1>PHP Bug Tracking System</h1> - -<p>Before you report a bug, please make sure you have completed the following steps:</p> - -<ul> - <li> - Used the form above or our <a href="search.php">advanced search page</a> - to make sure nobody has reported the bug already. - </li> - - <li> - Make sure you are using the latest stable version or a build from Git, if - similar bugs have recently been fixed and committed. - </li> - <li> - Read our tips on <a href="how-to-report.php">how to report a bug that someone will want to help fix</a>. - </li> +$template->assign([ + 'authIsLoggedIn' => $isLoggedIn, + 'authUsername' => $username, + 'authRole' => $logged_in, +]); - <li> - Read the <a href="https://wiki.php.net/security">security guidelines</a>, if you think an issue might be security related. - </li> - - <li> - See how to get a backtrace in case of a crash: - <a href="bugs-generating-backtrace.php">for *NIX</a> and - <a href="bugs-generating-backtrace-win32.php">for Windows</a>. - </li> - - <li> - Make sure it isn't a support question. For support, - see the <a href="https://php.net/support.php">support page</a>. - </li> -</ul> - -<p>Once you've double-checked that the bug you've found hasn't already been -reported, and that you have collected all the information you need to file an -excellent bug report, you can do so on our <a href="report.php">bug reporting -page</a>.</p> - -<h1>Search the Bug System</h1> - -<p>You can search all of the bugs that have been reported on our -<a href="search.php">advanced search page</a>, or use the form -at the top of the page for a basic default search. Read the -<a href="search-howto.php">search howto</a> for instructions on -how search works.</p> - -<p>If you have 10 minutes to kill and you want to help us out, grab a -random open bug and see if you can help resolve it. We have made it -easy. Hit <a href="<?php echo $site_method?>://<?php echo $site_url?>/random"> -<?php echo $site_method?>://<?php echo $site_url?>/random</a> to go directly -to a random open bug.</p> - -<p>Common searches</p> -<ul> -<?php - $base_default = "{$site_method}://{$site_url}/search.php?limit=30&order_by=id&direction=DESC&cmd=display&status=Open"; - - $searches = [ - 'Most recent open bugs (all)' => '&bug_type=All', - 'Most recent open bugs (all) with patch or pull request' => '&bug_type=All&patch=Y&pull=Y', - 'Most recent open bugs (PHP 5.6)' => '&bug_type=All&phpver=5.6', - 'Most recent open bugs (PHP 7.1)' => '&bug_type=All&phpver=7.1', - 'Most recent open bugs (PHP 7.2)' => '&bug_type=All&phpver=7.2', - 'Most recent open bugs (PHP 7.3)' => '&bug_type=All&phpver=7.3', - 'Open Documentation bugs' => '&bug_type=Documentation+Problem', - 'Open Documentation bugs (with patches)' => '&bug_type=Documentation+Problem&patch=Y' - ]; - - if (!empty($_SESSION["user"])) { - $searches['Your assigned open bugs'] = '&assign='.urlencode($_SESSION['user']); - } +// If 'id' is passed redirect to the bug page +$id = (int) ($_GET['id'] ?? 0); - foreach ($searches as $title => $sufix) { - echo '<li><a href="' . $base_default . htmlspecialchars($sufix) . '">' . $title . '</a></li>' . "\n"; - } -?> -</ul> +if (0 !== $id) { + redirect('bug.php?id='.$id); +} -<h1>Bug System Statistics</h1> +if ('/random' === $_SERVER['REQUEST_URI']) { + $id = (new BugRepository($dbh))->findRandom(); + redirect('bug.php?id='.$id[0]); +} -<p>You can view a variety of statistics about the bugs that have been -reported on our <a href="stats.php">bug statistics page</a>.</p> +$searches = [ + 'Most recent open bugs (all)' => '&bug_type=All', + 'Most recent open bugs (all) with patch or pull request' => '&bug_type=All&patch=Y&pull=Y', + 'Most recent open bugs (PHP 5.6)' => '&bug_type=All&phpver=5.6', + 'Most recent open bugs (PHP 7.1)' => '&bug_type=All&phpver=7.1', + 'Most recent open bugs (PHP 7.2)' => '&bug_type=All&phpver=7.2', + 'Most recent open bugs (PHP 7.3)' => '&bug_type=All&phpver=7.3', + 'Open Documentation bugs' => '&bug_type=Documentation+Problem', + 'Open Documentation bugs (with patches)' => '&bug_type=Documentation+Problem&patch=Y', +]; + +if (!empty($_SESSION['user'])) { + $searches['Your assigned open bugs'] = '&assign='.urlencode($_SESSION['user']); +} -<?php response_footer(); +// Prefix query strings with base URL +$searches = preg_filter( + '/^/', + '/search.php?limit=30&order_by=id&direction=DESC&cmd=display&status=Open', + $searches +); + +// Output template with given template variables. +echo $template->render('pages/index.php', [ + 'searches' => $searches, +]); diff --git a/www/js/redirect.js b/www/js/redirect.js new file mode 100644 index 0000000..fcbcc82 --- /dev/null +++ b/www/js/redirect.js @@ -0,0 +1,18 @@ +'use strict'; + +/** + * Servers can't deal properly with URLs containing hash. This redirects bug id + * passed as #id to the front controller. For example, + * https://bugs.php.net/#12345 + * + * This is implemented for convenience if typo happens when entering bugs.php.net + * url with id, since bugs are prefixed with hashes in bug reporting guidelines, + * PHP commit messages and similar places. + */ + +var bugId = location.hash.substr(1) * 1; + +if (bugId > 0) { + var loc = location; + loc.replace(loc.protocol + '//' + loc.host + (loc.port ? ':' + loc.port : '') + '/' + bugId); +}
-- PHP Webmaster List Mailing List (http://www.php.net/) To unsubscribe, visit: http://www.php.net/unsub.php