diff --git a/CHANGELOG.md b/CHANGELOG.md index 49789ba71d..54d02ea79a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +# v0.9.24 +## 04/15/2015 + +1. [](#new) + * Added support for chunked downloads of Assets + * Added new `onBeforeDownload()` event + * Added new `download()` and `getMimeType()` methods to Utils class + * Added configuration option for supported page types + * Added assets and media timestamp options (off by default) + * Added page expires configuration option +2. [](#bugfix) + * Fixed issue with Nginx/Gzip and `ob_flush()` throwing error + * Fixed assets actions on 'direct media' URLs + * Fix for 'direct assets` with any parameters + # v0.9.23 ## 04/09/2015 diff --git a/composer.json b/composer.json index 53bcf01130..6d4f3362b0 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,7 @@ "maximebf/debugbar": "dev-master", "filp/whoops": "1.2.*@dev", "monolog/monolog": "~1.0", - "gregwar/image": "~2.0", + "gregwar/image": "~2.0", "ircmaxell/password-compat": "1.0.*", "mrclay/minify": "dev-master", "donatj/phpuseragentparser": "dev-master", diff --git a/system/config/system.yaml b/system/config/system.yaml index 59f4b36a9f..a62b37b804 100644 --- a/system/config/system.yaml +++ b/system/config/system.yaml @@ -30,6 +30,8 @@ pages: special_chars: # List of special characters to automatically convert to entities '>': '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) cache: enabled: true # Set to true to enable caching @@ -40,6 +42,7 @@ cache: lifetime: 604800 # Lifetime of cached data in seconds (0 = infinite) gzip: false # GZip compress the page output + twig: cache: true # Set to true to enable twig caching debug: false # Enable Twig debug @@ -55,6 +58,7 @@ assets: # Configuration for Assets Manager (JS, C css_rewrite: true # Rewrite any CSS relative URLs during pipelining js_pipeline: false # The JS pipeline is the unification of multiple JS resources into one file js_minify: true # Minify the JS during pipelining + enable_asset_timestamp: false # Enable asset timetsamps collections: jquery: system://assets/jquery/jquery-2.1.3.min.js @@ -71,3 +75,6 @@ debugger: images: default_image_quality: 85 # Default image quality to use when resampling images (85%) debug: false # Show an overlay over images indicating the pixel depth of the image when working with retina for example + +media: + enable_media_timestamp: false # Enable media timetsamps diff --git a/system/defines.php b/system/defines.php index a9f6bc5e4c..04f8ac6dfb 100644 --- a/system/defines.php +++ b/system/defines.php @@ -2,7 +2,7 @@ // Some standard defines define('GRAV', true); -define('GRAV_VERSION', '0.9.23'); +define('GRAV_VERSION', '0.9.24'); define('DS', '/'); // Directories and Paths diff --git a/system/src/Grav/Common/Assets.php b/system/src/Grav/Common/Assets.php index a63a08d3d3..3b232f4576 100644 --- a/system/src/Grav/Common/Assets.php +++ b/system/src/Grav/Common/Assets.php @@ -71,6 +71,7 @@ class Assets // Some configuration variables protected $config; protected $base_url; + protected $timestamp = ''; // Default values for pipeline settings protected $css_minify = true; @@ -82,7 +83,6 @@ class Assets protected $css_no_pipeline = array(); protected $js_no_pipeline = array(); - public function __construct(array $options = array()) { // Forward config options @@ -154,6 +154,12 @@ public function config(array $config) } } + // Set timestamp + if (isset($config['enable_asset_timestamp']) && $config['enable_asset_timestamp'] === true) { + $this->timestamp = '?' . self::getGrav()['cache']->getKey(); + } + + return $this; } @@ -422,11 +428,11 @@ public function css($attributes = []) $output .= '' . "\n"; foreach ($this->css_no_pipeline as $file) { - $output .= '' . "\n"; + $output .= '' . "\n"; } } else { foreach ($this->css as $file) { - $output .= '' . "\n"; + $output .= '' . "\n"; } } @@ -480,11 +486,11 @@ public function js($attributes = []) if ($this->js_pipeline) { $output .= '' . "\n"; foreach ($this->js_no_pipeline as $file) { - $output .= '' . "\n"; + $output .= '' . "\n"; } } else { foreach ($this->js as $file) { - $output .= '' . "\n"; + $output .= '' . "\n"; } } diff --git a/system/src/Grav/Common/Grav.php b/system/src/Grav/Common/Grav.php index e0a91a06a3..df584fe4b1 100644 --- a/system/src/Grav/Common/Grav.php +++ b/system/src/Grav/Common/Grav.php @@ -2,6 +2,7 @@ namespace Grav\Common; use Grav\Common\Filesystem\Folder; +use Grav\Common\Page\Medium\ImageMedium; use Grav\Common\Page\Pages; use Grav\Common\Service\ConfigServiceProvider; use Grav\Common\Service\ErrorServiceProvider; @@ -10,7 +11,6 @@ use RocketTheme\Toolbox\DI\Container; use RocketTheme\Toolbox\Event\Event; use RocketTheme\Toolbox\Event\EventDispatcher; -use Grav\Common\Page\Medium\Medium; /** * Grav @@ -99,32 +99,34 @@ protected static function load(array $values) /** @var Pages $pages */ $pages = $c['pages']; - // If base URI is set, we want to remove it from the URL. - $path = '/' . ltrim(Folder::getRelativePath($c['uri']->route(), $pages->base()), '/'); + /** @var Uri $uri */ + $uri = $c['uri']; + + $path = $uri->path(); $page = $pages->dispatch($path); if (!$page || !$page->routable()) { - - // special case where a media file is requested $path_parts = pathinfo($path); - $page = $c['pages']->dispatch($path_parts['dirname'], true); if ($page) { $media = $page->media()->all(); - $media_file = urldecode($path_parts['basename']); + + $parsed_url = parse_url(urldecode($uri->basename())); + + $media_file = $parsed_url['path']; + + // if this is a media object, try actions first if (isset($media[$media_file])) { $medium = $media[$media_file]; - - // loop through actions for the image and call them - foreach ($c['uri']->query(null, true) as $action => $params) { - if (in_array($action, Medium::$valid_actions)) { + foreach ($uri->query(null, true) as $action => $params) { + if (in_array($action, ImageMedium::$magic_actions)) { call_user_func_array(array(&$medium, $action), explode(',', $params)); } } - header('Content-type: '. $medium->get('mime')); - echo file_get_contents($medium->path()); - die; + Utils::download($medium->path(), false); + } else { + Utils::download($page->path() . DIRECTORY_SEPARATOR . $uri->basename(), true); } } @@ -296,6 +298,8 @@ public function header() $extension = $this['uri']->extension(); 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'))); + // Set debugger data in headers if (!($extension === null || $extension == 'html')) { $this['debugger']->enabled(false); @@ -345,7 +349,7 @@ public function shutdown() header("Connection: close\r\n"); ob_end_flush(); // regular buffer - ob_flush(); + @ob_flush(); flush(); if (function_exists('fastcgi_finish_request')) { diff --git a/system/src/Grav/Common/Page/Medium/ImageMedium.php b/system/src/Grav/Common/Page/Medium/ImageMedium.php index ede065f09a..17a9663dec 100644 --- a/system/src/Grav/Common/Page/Medium/ImageMedium.php +++ b/system/src/Grav/Common/Page/Medium/ImageMedium.php @@ -42,7 +42,7 @@ class ImageMedium extends Medium public static $magic_actions = [ 'resize', 'forceResize', 'cropResize', 'crop', 'zoomCrop', 'negate', 'brightness', 'contrast', 'grayscale', 'emboss', - 'smooth', 'sharp', 'edge', 'colorize', 'sepia' + 'smooth', 'sharp', 'edge', 'colorize', 'sepia', 'enableProgressive' ]; /** @@ -127,7 +127,7 @@ public function url($reset = true) $this->reset(); } - return self::$grav['base_url'] . $output . $this->urlHash(); + return self::$grav['base_url'] . $output . $this->querystring() . $this->urlHash(); } @@ -299,7 +299,7 @@ public function __call($method, $args) } if (!in_array($method, self::$magic_actions)) { - return $this; + return parent::__call($method, $args); } // Always initialize image. diff --git a/system/src/Grav/Common/Page/Medium/Medium.php b/system/src/Grav/Common/Page/Medium/Medium.php index e001ec3c6c..5c210e563d 100644 --- a/system/src/Grav/Common/Page/Medium/Medium.php +++ b/system/src/Grav/Common/Page/Medium/Medium.php @@ -60,6 +60,10 @@ public function __construct($items = [], Blueprint $blueprint = null) { parent::__construct($items, $blueprint); + if (self::getGrav()['config']->get('media.enable_media_timestamp', true)) { + $this->querystring('&' . self::getGrav()['cache']->getKey()); + } + $this->def('mime', 'application/octet-stream'); $this->reset(); } @@ -129,7 +133,33 @@ public function url($reset = true) $this->reset(); } - return self::$grav['base_url'] . $output . $this->urlHash(); + return self::$grav['base_url'] . $output . $this->querystring() . $this->urlHash(); + } + + /** + * Get/set querystring for the file's url + * + * @param string $hash + * @param boolean $withHash + * @return string + */ + public function querystring($querystring = null, $withQuestionmark = true) + { + if ($querystring) { + $this->set('querystring', ltrim($querystring, '?&')); + + foreach ($this->alternatives as $alt) { + $alt->querystring($querystring, $withQuestionmark); + } + } + + $querystring = $this->get('querystring', ''); + + if ($withQuestionmark && !empty($querystring)) { + return '?' . $querystring; + } else { + return $querystring; + } } /** @@ -337,6 +367,17 @@ public function lightbox($width = null, $height = null, $reset = true) */ public function __call($method, $args) { + $qs = $method; + if (count($args) > 1 || (count($args) == 1 && !empty($args[0]))) { + $qs .= '=' . implode(',', array_map(function ($a) { return urlencode($a); }, $args)); + } + + if (!empty($qs)) { + $this->querystring($this->querystring(null, false) . '&' . $qs); + } + + self::$grav['debugger']->addMessage($this->querystring()); + return $this; } diff --git a/system/src/Grav/Common/Page/Medium/MediumFactory.php b/system/src/Grav/Common/Page/Medium/MediumFactory.php index 9ff05d4b95..ffa6b8f50f 100644 --- a/system/src/Grav/Common/Page/Medium/MediumFactory.php +++ b/system/src/Grav/Common/Page/Medium/MediumFactory.php @@ -131,8 +131,7 @@ public static function scaledFromMedium($medium, $from, $to) $debug = $medium->get('debug'); $medium->set('debug', false); - $file = $medium->resize($width, $height)->setPrettyName($basename)->url(); - $file = preg_replace('|'. preg_quote(self::getGrav()['base_url_relative']) .'$|', '', GRAV_ROOT) . $file; + $file = $medium->resize($width, $height)->path(); $medium->set('debug', $debug); diff --git a/system/src/Grav/Common/Uri.php b/system/src/Grav/Common/Uri.php index d1dad9fda7..e2b7b66704 100644 --- a/system/src/Grav/Common/Uri.php +++ b/system/src/Grav/Common/Uri.php @@ -11,6 +11,7 @@ class Uri { public $url; + protected $basename; protected $base; protected $root; protected $bits; @@ -64,6 +65,7 @@ public function __construct() $this->base = $base; $this->root = $base . $root_path; $this->url = $base . $uri; + } /** @@ -84,7 +86,11 @@ public function init() // remove the extension if there is one set $parts = pathinfo($uri); - if (preg_match("/\.(txt|xml|html|json|rss|atom)$/", $parts['basename'])) { + + // set the original basename + $this->basename = $parts['basename']; + + if (preg_match("/\.(".$config->get('system.pages.types').")$/", $parts['basename'])) { $uri = rtrim($parts['dirname'], '/').'/'.$parts['filename']; $this->extension = $parts['extension']; } @@ -282,6 +288,17 @@ public function environment() return $this->host(); } + + /** + * Return the basename of the URI + * + * @return String The basename of the URI + */ + public function basename() + { + return $this->basename; + } + /** * Return the base of the URI * diff --git a/system/src/Grav/Common/Utils.php b/system/src/Grav/Common/Utils.php index c82aabf333..4d7163e357 100644 --- a/system/src/Grav/Common/Utils.php +++ b/system/src/Grav/Common/Utils.php @@ -1,6 +1,8 @@ fireEvent('onBeforeDownload', new Event(['file' => $file])); + + $file_parts = pathinfo($file); + $filesize = filesize($file); + $range = false; + + set_time_limit(0); + ignore_user_abort(false); + ini_set('output_buffering', 0); + ini_set('zlib.output_compression', 0); + + if ($force_download) { + header('Content-Description: File Transfer'); + header('Content-Type: application/octet-stream'); + header('Content-Disposition: attachment; filename='.$file_parts['basename']); + header('Content-Transfer-Encoding: binary'); + header('Expires: 0'); + header('Cache-Control: must-revalidate, post-check=0, pre-check=0'); + header('Pragma: public'); + } else { + header("Content-Type: " . Utils::getMimeType($file_parts['extension'])); + } + header('Content-Length: ' . $filesize); + + // 8kb chunks for now + $chunk = 8 * 1024; + + $fh = fopen($file, "rb"); + + if ($fh === false) { + return; + } + + // Repeat reading until EOF + while (!feof($fh)) { + echo fread($fh, $chunk); + + ob_flush(); // flush output + flush(); + } + + exit; + } + } + + /** + * Return the mimetype based on filename + * + * @param $extension Extension of file (eg .txt) + * + * @return string + */ + public static function getMimeType($extension) + { + $extension = strtolower($extension); + + switch($extension) + { + case "js": + return "application/x-javascript"; + + case "json": + return "application/json"; + + case "jpg": + case "jpeg": + case "jpe": + return "image/jpg"; + + case "png": + case "gif": + case "bmp": + case "tiff": + return "image/" . $extension; + + case "css": + return "text/css"; + + case "xml": + return "application/xml"; + + case "doc": + case "docx": + return "application/msword"; + + case "xls": + case "xlt": + case "xlm": + case "xld": + case "xla": + case "xlc": + case "xlw": + case "xll": + return "application/vnd.ms-excel"; + + case "ppt": + case "pps": + return "application/vnd.ms-powerpoint"; + + case "rtf": + return "application/rtf"; + + case "pdf": + return "application/pdf"; + + case "html": + case "htm": + case "php": + return "text/html"; + + case "txt": + return "text/plain"; + + case "mpeg": + case "mpg": + case "mpe": + return "video/mpeg"; + + case "mp3": + return "audio/mpeg3"; + + case "wav": + return "audio/wav"; + + case "aiff": + case "aif": + return "audio/aiff"; + + case "avi": + return "video/msvideo"; + + case "wmv": + return "video/x-ms-wmv"; + + case "mov": + return "video/quicktime"; + + case "zip": + return "application/zip"; + + case "tar": + return "application/x-tar"; + + case "swf": + return "application/x-shockwave-flash"; + + default: + return "application/octet-stream"; + } + } }