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 @@ [![SensioLabsInsight](https://insight.sensiolabs.com/projects/cfd20465-d0f8-4a0a-8444-467f5b5f16ad/mini.png)](https://insight.sensiolabs.com/projects/cfd20465-d0f8-4a0a-8444-467f5b5f16ad) [![Gitter](https://badges.gitter.im/Join Chat.svg)](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 {