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>&nbsp;|&nbsp;
+            <a href="https://php.net/support.php";>support</a>&nbsp;|&nbsp;
+            <a href="https://php.net/docs.php";>documentation</a>&nbsp;|&nbsp;
+            <a href="/report.php">report a bug</a>&nbsp;|&nbsp;
+            <a href="/search.php">advanced search</a>&nbsp;|&nbsp;
+            <a href="/search-howto.php">search howto</a>&nbsp;|&nbsp;
+            <a href="/stats.php">statistics</a>&nbsp;|&nbsp;
+            <a href="/random">random bug</a>&nbsp;|&nbsp;
+            <?php if ($authIsLoggedIn): ?>
+                <a href="/search.php?cmd=display&assign=<?= 
$this->e($authUsername) ?>">my bugs</a>&nbsp;|&nbsp;
+                    <?php if ('developer' === $authRole): ?>
+                        <a href="/admin/">admin</a>&nbsp;|&nbsp;
+                    <?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">&nbsp;</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 &copy; 
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>',
+                '&lt;iframe 
src=&quot;javascript:alert(&#039;Xss&#039;)&quot;;&gt;&lt;/iframe&gt;',
+                '&lt;iframe 
src&equals;&quot;javascript&colon;alert&lpar;&apos;Xss&apos;&rpar;&quot;&semi;&gt;&lt;&sol;iframe&gt;'
+            ]
+        ];
+    }
+}
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&amp;order_by=id&amp;direction=DESC&amp;cmd=display&amp;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

Reply via email to