diff --git a/CHANGELOG.md b/CHANGELOG.md
index 54d02ea79..584027e5f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,19 @@
+# v0.9.25
+## 04/24/2015
+
+1. [](#new)
+ * Added support for E-Tag, Last-Modified, Cache-Control and Page-based expires headers
+2. [](#improved)
+ * Refactored media image handling to make it more flexible and support absolute paths
+ * Refactored page modification check process to make it faster
+ * User account improvements in preparation for Admin plugin
+ * Protect against timing attacks
+ * Reset default system expires time to 0 seconds (can override if you need to)
+3. [](#bugfix)
+ * Fix issues with spaces in webroot when using `bin/grav install`
+ * Fix for spaces in relative directory
+ * Bug fix in collection filtering
+
# v0.9.24
## 04/15/2015
diff --git a/README.md b/README.md
index 965c57fdd..fcbe06d1f 100644
--- a/README.md
+++ b/README.md
@@ -2,9 +2,9 @@
[](https://insight.sensiolabs.com/projects/cfd20465-d0f8-4a0a-8444-467f5b5f16ad) [](https://gitter.im/getgrav/grav?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
-Grav is a **Fast**, **Simple**, and **Flexible**, file-based Web-platform. There is **Zero** installation required. Just extract the ZIP archive, and you are already up and running. It follows similar principals to other flat-file CMS platforms, but has a different design philosophy than most. Grav comes with a powerful **Package Management System** to allow for simple installation and upgrading of plugins and themes, as well as simple updating of Grav itself.
+Grav is a **Fast**, **Simple**, and **Flexible**, file-based Web-platform. There is **Zero** installation required. Just extract the ZIP archive, and you are already up and running. It follows similar principles to other flat-file CMS platforms, but has a different design philosophy than most. Grav comes with a powerful **Package Management System** to allow for simple installation and upgrading of plugins and themes, as well as simple updating of Grav itself.
-The underlying architecture of Grav has been designed to use well-established and _best-in-class_ technologies, where applicable, to ensure that Grav is simple to use and easy to extend. Some of these key technologies include:
+The underlying architecture of Grav is designed to use well-established and _best-in-class_ technologies, to ensure that Grav is simple to use and easy to extend. Some of these key technologies include:
* [Twig Templating](http://twig.sensiolabs.org/): for powerful control of the user interface
* [Markdown](http://en.wikipedia.org/wiki/Markdown): for easy content creation
diff --git a/composer.json b/composer.json
index 6d4f3362b..b4422074b 100644
--- a/composer.json
+++ b/composer.json
@@ -21,15 +21,8 @@
"mrclay/minify": "dev-master",
"donatj/phpuseragentparser": "dev-master",
"pimple/pimple": "~3.0",
- "rockettheme/toolbox": "dev-develop"
+ "rockettheme/toolbox": "1.0.*"
},
- "repositories": [
- {
- "type": "vcs",
- "no-api": true,
- "url": "https://github.com/rockettheme/toolbox"
- }
- ],
"autoload": {
"psr-4": {
"Grav\\": "system/src/Grav"
diff --git a/system/config/system.yaml b/system/config/system.yaml
index a62b37b80..38550d690 100644
--- a/system/config/system.yaml
+++ b/system/config/system.yaml
@@ -31,7 +31,7 @@ pages:
'>': 'gt'
'<': 'lt'
types: 'txt|xml|html|json|rss|atom' # Pipe separated list of valid page types
- expires: 604800 # Page expires time in seconds (default 7 days)
+ expires: 0 # Page expires time in seconds (604800 seconds = 7 days)
cache:
enabled: true # Set to true to enable caching
@@ -78,3 +78,6 @@ images:
media:
enable_media_timestamp: false # Enable media timetsamps
+
+security:
+ default_hash: $2y$10$kwsyMVwM8/7j0K/6LHT.g.Fs49xOCTp2b8hh/S5.dPJuJcJB6T.UK
diff --git a/system/defines.php b/system/defines.php
index 04f8ac6df..da3f94687 100644
--- a/system/defines.php
+++ b/system/defines.php
@@ -2,7 +2,7 @@
// Some standard defines
define('GRAV', true);
-define('GRAV_VERSION', '0.9.24');
+define('GRAV_VERSION', '0.9.25');
define('DS', '/');
// Directories and Paths
diff --git a/system/src/Grav/Common/Filesystem/Folder.php b/system/src/Grav/Common/Filesystem/Folder.php
index 3d30d12eb..9355038b8 100644
--- a/system/src/Grav/Common/Filesystem/Folder.php
+++ b/system/src/Grav/Common/Filesystem/Folder.php
@@ -42,17 +42,16 @@ abstract class Folder
*/
public static function lastModifiedFile($path)
{
+ // pipe separated list of extensions to search for changes with
+ $extensions = 'md|yaml';
$last_modified = 0;
- $dirItr = new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS);
- $filterItr = new RecursiveFileFilterIterator($dirItr);
- $itr = new \RecursiveIteratorIterator($filterItr, \RecursiveIteratorIterator::SELF_FIRST);
+ $dirItr = new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS);
+ $itrItr = new \RecursiveIteratorIterator($dirItr, \RecursiveIteratorIterator::SELF_FIRST);
+ $itr = new \RegexIterator($itrItr, '/^.+\.'.$extensions.'$/i');
/** @var \RecursiveDirectoryIterator $file */
- foreach ($itr as $file) {
- if ($file->isDir()) {
- continue;
- }
+ foreach ($itr as $filepath => $file) {
$file_modified = $file->getMTime();
if ($file_modified > $last_modified) {
$last_modified = $file_modified;
diff --git a/system/src/Grav/Common/Filesystem/RecursiveFileFilterIterator.php b/system/src/Grav/Common/Filesystem/RecursiveFileFilterIterator.php
deleted file mode 100644
index be9667c3b..000000000
--- a/system/src/Grav/Common/Filesystem/RecursiveFileFilterIterator.php
+++ /dev/null
@@ -1,13 +0,0 @@
-current()->getFilename(), self::$FILTERS, true);
- }
-}
diff --git a/system/src/Grav/Common/Grav.php b/system/src/Grav/Common/Grav.php
index df584fe4b..42085cd29 100644
--- a/system/src/Grav/Common/Grav.php
+++ b/system/src/Grav/Common/Grav.php
@@ -296,9 +296,27 @@ class Grav extends Container
public function header()
{
$extension = $this['uri']->extension();
+
+ /** @var Page $page */
+ $page = $this['page'];
+
header('Content-type: ' . $this->mime($extension));
- header('Expires: '.gmdate('D, d M Y H:i:s \G\M\T', time() + $this['config']->get('system.pages.expires')));
+ // Calculate Expires Headers if set to > 0
+ $expires = $page->expires();
+
+ if ($expires > 0) {
+ $expires_date = gmdate('D, d M Y H:i:s', time() + $expires) . ' GMT';
+ header('Cache-Control: max-age=' . $expires_date);
+ header('Expires: '. $expires_date);
+ }
+
+ // Set the last modified time
+ $last_modified_date = gmdate('D, d M Y H:i:s', $page->modified()) . ' GMT';
+ header('Last-Modified: ' . $last_modified_date);
+
+ // Calculate a Hash based on the raw file
+ header('ETag: ' . md5($page->raw().$page->modified()));
// Set debugger data in headers
if (!($extension === null || $extension == 'html')) {
diff --git a/system/src/Grav/Common/Markdown/Parsedown.php b/system/src/Grav/Common/Markdown/Parsedown.php
index 1db2d1707..f453a544a 100644
--- a/system/src/Grav/Common/Markdown/Parsedown.php
+++ b/system/src/Grav/Common/Markdown/Parsedown.php
@@ -5,9 +5,9 @@ class Parsedown extends \Parsedown
{
use ParsedownGravTrait;
- public function __construct($page)
+ public function __construct($page, $defaults)
{
- $this->init($page);
+ $this->init($page, $defaults);
}
}
diff --git a/system/src/Grav/Common/Markdown/ParsedownExtra.php b/system/src/Grav/Common/Markdown/ParsedownExtra.php
index da20ca1e0..526e5f905 100644
--- a/system/src/Grav/Common/Markdown/ParsedownExtra.php
+++ b/system/src/Grav/Common/Markdown/ParsedownExtra.php
@@ -5,9 +5,9 @@ class ParsedownExtra extends \ParsedownExtra
{
use ParsedownGravTrait;
- public function __construct($page)
+ public function __construct($page, $defaults)
{
parent::__construct();
- $this->init($page);
+ $this->init($page, $defaults);
}
}
diff --git a/system/src/Grav/Common/Markdown/ParsedownGravTrait.php b/system/src/Grav/Common/Markdown/ParsedownGravTrait.php
index dd4380b5c..b3d204232 100644
--- a/system/src/Grav/Common/Markdown/ParsedownGravTrait.php
+++ b/system/src/Grav/Common/Markdown/ParsedownGravTrait.php
@@ -6,6 +6,7 @@ use Grav\Common\Debugger;
use Grav\Common\GravTrait;
use Grav\Common\Page\Medium\Medium;
use Grav\Common\Uri;
+use Grav\Common\Utils;
/**
* A trait to add some custom processing to the identifyLink() method in Parsedown and ParsedownExtra
@@ -25,8 +26,9 @@ trait ParsedownGravTrait
* Initialiazation function to setup key variables needed by the MarkdownGravLinkTrait
*
* @param $page
+ * @param $defaults
*/
- protected function init($page)
+ protected function init($page, $defaults)
{
$this->page = $page;
$this->pages = self::getGrav()['pages'];
@@ -35,7 +37,9 @@ trait ParsedownGravTrait
$this->pages_dir = self::getGrav()['locator']->findResource('page://');
$this->special_chars = array('>' => 'gt', '<' => 'lt', '"' => 'quot');
- $defaults = self::getGrav()['config']->get('system.pages.markdown');
+ if ($defaults == null) {
+ $defaults = self::getGrav()['config']->get('system.pages.markdown');
+ }
$this->setBreaksEnabled($defaults['auto_line_breaks']);
$this->setUrlsLinked($defaults['auto_url_links']);
@@ -116,7 +120,6 @@ trait ParsedownGravTrait
// if this is an image
if (isset($excerpt['element']['attributes']['src'])) {
-
$alt = $excerpt['element']['attributes']['alt'] ?: '';
$title = $excerpt['element']['attributes']['title'] ?: '';
$class = isset($excerpt['element']['attributes']['class']) ? $excerpt['element']['attributes']['class'] : '';
@@ -124,10 +127,9 @@ trait ParsedownGravTrait
//get the url and parse it
$url = parse_url(htmlspecialchars_decode($excerpt['element']['attributes']['src']));
- $path_parts = pathinfo($url['path']);
-
// if there is no host set but there is a path, the file is local
if (!isset($url['host']) && isset($url['path'])) {
+ $path_parts = pathinfo($url['path']);
// get the local path to page media if possible
if ($path_parts['dirname'] == $this->page->url()) {
@@ -136,7 +138,6 @@ trait ParsedownGravTrait
$media = $this->page->media();
} else {
-
// see if this is an external page to this one
$page_route = str_replace($this->base_url, '', $path_parts['dirname']);
@@ -219,37 +220,58 @@ trait ParsedownGravTrait
protected function convertUrl($markdown_url)
{
// if absolute and starts with a base_url move on
- if ($this->base_url != '' && strpos($markdown_url, $this->base_url) === 0) {
+ if ($this->base_url != '' && Utils::startsWith($markdown_url, $this->base_url)) {
+ return $markdown_url;
+ // if contains only a fragment
+ } elseif (Utils::startsWith($markdown_url, '#')) {
return $markdown_url;
- // if its absolute and starts with /
- } elseif (strpos($markdown_url, '/') === 0) {
- return $this->base_url . $markdown_url;
} else {
- $relative_path = $this->base_url . $this->page->route();
- $real_path = $this->page->path() . '/' . parse_url($markdown_url, PHP_URL_PATH);
-
- // strip numeric order from markdown path
- if (($real_path)) {
- $markdown_url = preg_replace('/^([\d]+\.)/', '', preg_replace('/\/([\d]+\.)/', '/', trim(preg_replace('/[^\/]+(\.md$)/', '', $markdown_url), '/')));
+ $target = null;
+ // see if page is relative to this or absolute
+ if (Utils::startsWith($markdown_url, '/')) {
+ $normalized_path = Utils::normalizePath($this->pages_dir . $markdown_url);
+ $normalized_url = Utils::normalizePath($this->base_url . $markdown_url);
+ } else {
+ // contains path, so need to normalize it
+ if (Utils::contains($markdown_url, '/')) {
+ $normalized_path = Utils::normalizePath($this->page->path() . '/' . $markdown_url);
+ } else {
+ $normalized_path = false;
+ }
+ $normalized_url = $this->base_url . Utils::normalizePath($this->page->route() . '/' . $markdown_url);
}
- // else its a relative path already
- $newpath = array();
- $paths = explode('/', $markdown_url);
+ // if this file exits, get the page and work with that
+ if ($normalized_path) {
+ $url_bits = parse_url($normalized_path);
+ $full_path = $url_bits['path'];
- // remove the updirectory references (..)
- foreach ($paths as $path) {
- if ($path == '..') {
- $relative_path = dirname($relative_path);
- } else {
- $newpath[] = $path;
+ if ($full_path && file_exists($full_path)) {
+ $path_info = pathinfo($full_path);
+ $page_path = $path_info['dirname'];
+ $filename = '';
+
+ // save the filename if a file is part of the path
+ $filename_regex = "/([\w\d-_]+\.([a-zA-Z]{2,4}))$/";
+ if (preg_match($filename_regex, $full_path, $matches)) {
+ if ($matches[2] != 'md') {
+ $filename = '/' . $matches[1];
+ }
+ } else {
+ $page_path = $full_path;
+ }
+
+ // get page instances and try to find one that fits
+ $instances = $this->pages->instances();
+ if (isset($instances[$page_path])) {
+ $target = $instances[$page_path];
+ $url_bits['path'] = $this->base_url . $target->route() . $filename;
+ return Uri::buildUrl($url_bits);
+ }
}
}
- // build the new url
- $new_url = rtrim($relative_path, '/') . '/' . implode('/', $newpath);
+ return $normalized_url;
}
-
- return $new_url;
}
}
diff --git a/system/src/Grav/Common/Page/Collection.php b/system/src/Grav/Common/Page/Collection.php
index 59302b781..9cd237c04 100644
--- a/system/src/Grav/Common/Page/Collection.php
+++ b/system/src/Grav/Common/Page/Collection.php
@@ -361,12 +361,13 @@ class Collection extends Iterator
{
$routable = [];
- foreach (array_keys($this->items) as $path => $slug) {
+ foreach ($this->items as $path => $slug) {
$page = $this->pages->get($path);
if ($page->routable()) {
$routable[$path] = $slug;
}
}
+
$this->items = $routable;
return $this;
}
diff --git a/system/src/Grav/Common/Page/Medium/ParsedownHtmlTrait.php b/system/src/Grav/Common/Page/Medium/ParsedownHtmlTrait.php
index 2567705d7..7052dea82 100644
--- a/system/src/Grav/Common/Page/Medium/ParsedownHtmlTrait.php
+++ b/system/src/Grav/Common/Page/Medium/ParsedownHtmlTrait.php
@@ -23,7 +23,7 @@ trait ParsedownHtmlTrait
$element = $this->parsedownElement($title, $alt, $class, $reset);
if (!$this->parsedown) {
- $this->parsedown = new Parsedown(null);
+ $this->parsedown = new Parsedown(null, null);
}
return $this->parsedown->elementToHtml($element);
diff --git a/system/src/Grav/Common/Page/Page.php b/system/src/Grav/Common/Page/Page.php
index 95b558f18..e0c7ca963 100644
--- a/system/src/Grav/Common/Page/Page.php
+++ b/system/src/Grav/Common/Page/Page.php
@@ -47,6 +47,7 @@ class Page
protected $parent;
protected $template;
+ protected $expires;
protected $visible;
protected $published;
protected $publish_date;
@@ -273,6 +274,10 @@ class Page
if (isset($this->header->unpublish_date)) {
$this->unpublish_date = strtotime($this->header->unpublish_date);
}
+ if (isset($this->header->expires)) {
+ $this->expires = intval($this->header->expires);
+ }
+
}
return $this->header;
@@ -442,9 +447,9 @@ class Page
// Initialize the preferred variant of Parsedown
if ($defaults['extra']) {
- $parsedown = new ParsedownExtra($this);
+ $parsedown = new ParsedownExtra($this, $defaults);
} else {
- $parsedown = new Parsedown($this);
+ $parsedown = new Parsedown($this, $defaults);
}
$this->content = $parsedown->text($this->content);
@@ -781,6 +786,20 @@ class Page
return $this->template;
}
+ /**
+ * Gets and sets the expires field. If not set will return the default
+ *
+ * @param string $var The name of this page.
+ * @return string The name of this page.
+ */
+ public function expires($var = null)
+ {
+ if ($var !== null) {
+ $this->expires = $var;
+ }
+ return empty($this->expires) ? self::getGrav()['config']->get('system.pages.expires') : $this->expires;
+ }
+
/**
* Gets and sets the title for this Page. If no title is set, it will use the slug() to get a name
*
diff --git a/system/src/Grav/Common/Page/Pages.php b/system/src/Grav/Common/Page/Pages.php
index fd70981d6..ac0e9e70f 100644
--- a/system/src/Grav/Common/Page/Pages.php
+++ b/system/src/Grav/Common/Page/Pages.php
@@ -331,6 +331,28 @@ class Pages
return $blueprint;
}
+ /**
+ * Get all pages
+ *
+ * @param \Grav\Common\Page\Page $current
+ * @return \Grav\Common\Page\Collection
+ */
+ public function all(Page $current = null)
+ {
+ $all = new Collection();
+ $current = $current ?: $this->root();
+
+ if ($current->routable()) {
+ $all[$current->path()] = [ 'slug' => $current->slug() ];
+ }
+
+ foreach ($current->children() as $next) {
+ $all->append($this->all($next));
+ }
+
+ return $all;
+ }
+
/**
* Get list of route/title of all pages.
*
diff --git a/system/src/Grav/Common/TwigExtension.php b/system/src/Grav/Common/TwigExtension.php
index 64ab73f66..cfec33ba7 100644
--- a/system/src/Grav/Common/TwigExtension.php
+++ b/system/src/Grav/Common/TwigExtension.php
@@ -337,9 +337,9 @@ class TwigExtension extends \Twig_Extension
// Initialize the preferred variant of Parsedown
if ($defaults['extra']) {
- $parsedown = new ParsedownExtra($page);
+ $parsedown = new ParsedownExtra($page, $defaults);
} else {
- $parsedown = new Parsedown($page);
+ $parsedown = new Parsedown($page, $defaults);
}
$string = $parsedown->text($string);
diff --git a/system/src/Grav/Common/Uri.php b/system/src/Grav/Common/Uri.php
index e2b7b6670..60546dc39 100644
--- a/system/src/Grav/Common/Uri.php
+++ b/system/src/Grav/Common/Uri.php
@@ -34,8 +34,7 @@ class Uri
$port = isset($_SERVER['SERVER_PORT']) ? $_SERVER['SERVER_PORT'] : 80;
$uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '';
- $root_path = rtrim(substr($_SERVER['PHP_SELF'], 0, strpos($_SERVER['PHP_SELF'], 'index.php')), '/');
-
+ $root_path = str_replace(' ', '%20', rtrim(substr($_SERVER['PHP_SELF'], 0, strpos($_SERVER['PHP_SELF'], 'index.php')), '/'));
if (isset($_SERVER['HTTPS'])) {
$base = (@$_SERVER['HTTPS'] == 'on') ? 'https://' : 'http://';
diff --git a/system/src/Grav/Common/User/Authentication.php b/system/src/Grav/Common/User/Authentication.php
index 41b23122b..fc26bf8d7 100644
--- a/system/src/Grav/Common/User/Authentication.php
+++ b/system/src/Grav/Common/User/Authentication.php
@@ -13,11 +13,22 @@ abstract class Authentication
* Create password hash from plaintext password.
*
* @param string $password Plaintext password.
+ * @throws \RuntimeException
* @return string|bool
*/
public static function create($password)
{
- return password_hash($password, PASSWORD_DEFAULT);
+ if (!$password) {
+ throw new \RuntimeException('Password hashing failed: no password provided.');
+ }
+
+ $hash = password_hash($password, PASSWORD_DEFAULT);
+
+ if (!$hash) {
+ throw new \RuntimeException('Password hashing failed: internal error.');
+ }
+
+ return $hash;
}
/**
@@ -29,13 +40,8 @@ abstract class Authentication
*/
public static function verify($password, $hash)
{
- // Always accept plaintext passwords (needs an update).
- if ($password && $password == $hash) {
- return 2;
- }
-
- // Fail if hash doesn't match.
- if (!$password || !password_verify($password, $hash)) {
+ // Fail if hash doesn't match
+ if (!$password || !$hash || !password_verify($password, $hash)) {
return 0;
}
diff --git a/system/src/Grav/Common/User/User.php b/system/src/Grav/Common/User/User.php
index b3382b13d..13dc5d8e8 100644
--- a/system/src/Grav/Common/User/User.php
+++ b/system/src/Grav/Common/User/User.php
@@ -53,11 +53,36 @@ class User extends Data
*/
public function authenticate($password)
{
- $result = Authentication::verify($password, $this->password);
+ $save = false;
+
+ // Plain-text is still stored
+ if ($this->password) {
+
+ if ($password !== $this->password) {
+ // Plain-text passwords do not match, we know we should fail but execute
+ // verify to protect us from timing attacks and return false regardless of
+ // the result
+ Authentication::verify($password, self::getGrav()['config']->get('system.security.default_hash'));
+ return false;
+ } else {
+ // Plain-text does match, we can update the hash and proceed
+ $save = true;
+
+ $this->hashed_password = Authentication::create($this->password);
+ unset($this->password);
+ }
+
+ }
+
+ $result = Authentication::verify($password, $this->hashed_password);
// Password needs to be updated, save the file.
if ($result == 2) {
- $this->password = Authentication::create($password);
+ $save = true;
+ $this->hashed_password = Authentication::create($password);
+ }
+
+ if ($save) {
$this->save();
}
diff --git a/system/src/Grav/Common/Utils.php b/system/src/Grav/Common/Utils.php
index 4d7163e35..eee0ee4f9 100644
--- a/system/src/Grav/Common/Utils.php
+++ b/system/src/Grav/Common/Utils.php
@@ -295,7 +295,6 @@ abstract class Utils
* Return the mimetype based on filename
*
* @param $extension Extension of file (eg .txt)
- *
* @return string
*/
public static function getMimeType($extension)
@@ -396,4 +395,29 @@ abstract class Utils
return "application/octet-stream";
}
}
+
+ /**
+ * Normalize path by processing relative `.` and `..` syntax and merging path
+ *
+ * @param $path
+ * @return string
+ */
+ public static function normalizePath($path)
+ {
+ $root = ($path[0] === '/') ? '/' : '';
+
+ $segments = explode('/', trim($path, '/'));
+ $ret = array();
+ foreach ($segments as $segment) {
+ if (($segment == '.') || empty($segment)) {
+ continue;
+ }
+ if ($segment == '..') {
+ array_pop($ret);
+ } else {
+ array_push($ret, $segment);
+ }
+ }
+ return $root . implode('/', $ret);
+ }
}
diff --git a/system/src/Grav/Console/Cli/InstallCommand.php b/system/src/Grav/Console/Cli/InstallCommand.php
index 56963c240..f0510a51f 100644
--- a/system/src/Grav/Console/Cli/InstallCommand.php
+++ b/system/src/Grav/Console/Cli/InstallCommand.php
@@ -125,7 +125,7 @@ class InstallCommand extends Command
foreach ($this->config['git'] as $repo => $data) {
$path = $this->destination . DS . $data['path'];
if (!file_exists($path)) {
- exec('cd ' . $this->destination . ' && git clone -b ' . $data['branch'] . ' ' . $data['url'] . ' ' . $data['path']);
+ exec('cd "' . $this->destination . '" && git clone -b ' . $data['branch'] . ' ' . $data['url'] . ' ' . $data['path']);
$output->writeln('SUCCESS cloned ' . $data['url'] . ' -> ' . $path . '');
$output->writeln('');
} else {