Commit: 068d8514af650469cdc566e4bbcd50d52cd5f7e5
Author: Peter Kokot <[email protected]> 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