diff --git a/composer.json b/composer.json index 954b4a0..012f6d5 100644 --- a/composer.json +++ b/composer.json @@ -14,10 +14,11 @@ ], "require": { "php": ">=7.1.0", - "fuzeworks/mvcr": "1.2.0-RC2", - "fuzeworks/core": "1.2.0-RC2" + "fuzeworks/mvcr": "1.2.0-RC3", + "fuzeworks/core": "1.2.0-RC3" }, "minimum-stability": "RC", + "prefer-stable": true, "require-dev": { "phpunit/phpunit": "^7" }, diff --git a/src/FuzeWorks/Input.php b/src/FuzeWorks/Input.php index 795737a..0e3ad12 100644 --- a/src/FuzeWorks/Input.php +++ b/src/FuzeWorks/Input.php @@ -1,4 +1,4 @@ -restoreGlobalArrays(); - Debugger::exceptionHandler($exception, $exit); - } - - /** - * Used to restore global arrays before handling errors by Tracy - * - * @param $severity - * @param $message - * @param $file - * @param $line - * @param array $context - * @throws \ErrorException - * @internal - */ - public function tracyErrorHandler($severity, $message, $file, $line, $context = []) - { - $this->restoreGlobalArrays(); - Debugger::errorHandler($severity, $message, $file, $line, $context); - } - /** * Restores global arrays before handling by processes outside of FuzeWorks * diff --git a/src/FuzeWorks/Output.php b/src/FuzeWorks/Output.php index b77197d..a623f14 100644 --- a/src/FuzeWorks/Output.php +++ b/src/FuzeWorks/Output.php @@ -53,6 +53,13 @@ class Output */ private $input; + /** + * The internal URI class + * + * @var URI + */ + private $uri; + /** * WebCfg * @@ -84,12 +91,38 @@ class Output public $mimes = []; protected $mimeType = 'text/html'; + /** + * The amount of time the current page is cached + * + * @var int $cacheTime + */ + protected $cacheTime = 0; + + /** + * Whether a cache file is being used now + * + * @var bool + */ + protected $usingCache = false; + + /** + * The status code that will be sent to the client + * + * @var int $statusCode + */ protected $statusCode = 200; + + /** + * The status code text that will be sent along with $statusCode + * + * @var string $statusText + */ protected $statusText = 'OK'; public function init() { $this->input = Factory::getInstance()->input; + $this->uri = Factory::getInstance()->uri; $this->mimes = Factory::getInstance()->config->getConfig('mimes')->toArray(); $this->config = Factory::getInstance()->config->getConfig('web'); @@ -97,6 +130,200 @@ class Output $this->compressOutput = (!$zlib && $this->config->get('compress_output') && extension_loaded('zlib')); } + /** + * Display Output + * + * Processes and sends finalized output data to the browser along + * with any server headers. + * + * @param string $output Output data override + * @return void + */ + public function display(string $output = null) + { + // Set the output data + $output = is_null($output) ? $this->output : $output; + + // Write cache if requested to do so + if ($this->cacheTime > 0) + $this->writeCache($output); + + // First send status code + http_response_code($this->statusCode); + @header('Status: ' . $this->statusCode . ' ' . $this->statusText, true); + + // If compression is requested, start buffering + if ( + $this->compressOutput && !$this->usingCache && + !is_null($this->input->server('HTTP_ACCEPT_ENCODING')) && + strpos($this->input->server('HTTP_ACCEPT_ENCODING'), 'gzip') !== false + ) + { + Logger::log("Compressing output..."); + ob_start('ob_gzhandler'); + } + + // Send gzip headers when using cache + if ($this->usingCache && $this->compressOutput) + { + if (!is_null($this->input->server('HTTP_ACCEPT_ENCODING')) && + strpos($this->input->server('HTTP_ACCEPT_ENCODING'), 'gzip') !== false) + { + header('Content-Encoding: gzip'); + header('Content-Length: '.strlen($output)); + } + // If the cache is zipped, but the client doesn't support it, decompress the output + else + $output = gzinflate(substr($output, 10, -8)); + } + + // Send all available headers + if (!empty($this->headers)) + foreach ($this->headers as $header) + @header($header[0], $header[1]); + + echo $output; + + Logger::log('Output sent to browser'); + } + + /** + * Enable the current page to be cached + * + * Set the amount of time with the $time parameter. + * + * @param int $time In minutes + */ + public function cache(int $time) + { + $this->cacheTime = $time > 0 ? $time : 0; + } + + public function getCache(string $selector): bool + { + // If empty, index page is requested + $selector = empty($selector) ? 'index' : $selector; + + // Generate the full uri + $uri = $this->config->get('base_url') . $selector; + + // Determine the file that holds the cache + if ($this->compressOutput) + $file = Core::$tempDir . DS . 'OutputCache' . DS . md5($uri) . '_gzip.fwcache'; + else + $file = Core::$tempDir . DS . 'OutputCache' . DS . md5($uri) . '.fwcache'; + + + // Determine if file exists + if (!file_exists($file)) + return false; + + // Retrieve cache + $cache = file_get_contents($file); + + // Verify that this is a cache file + if (!preg_match('/^(.*)EndFuzeWorksCache--->/', $cache, $match)) + return false; + + // Retrieve data from cache file + $cacheInfo = unserialize($match[1]); + + // Test if the cache has expired + if (time() > $cacheInfo['expire']) + { + // If not writeable, log warning and do not remove + if (!Core::isReallyWritable($file)) + { + Logger::logWarning("Found expired output cache. Could not remove!"); + return false; + } + + // Delete file if expired + @unlink($file); + Logger::logInfo("Found expired output cache. Removed."); + return false; + } + + // @todo Send cache header + + // Send all the headers cached in the file + foreach ($cacheInfo['headers'] as $header) + $this->setHeader($header[0], $header[1]); + + // And save the output + $this->usingCache = true; + $this->setOutput(substr($cache, strlen($match[0]))); + Logger::logInfo("Found output cache. Set output."); + return true; + } + + public function writeCache(string $output) + { + // First create cache directory + $cachePath = Core::$tempDir . DS . 'OutputCache'; + + // Attempt to create the OutputCache directory in the TempDirectory + if (!is_dir($cachePath) && !mkdir($cachePath, 0777, false)) + { + Logger::logError("Could not write output cache. Cannot create directory. Are permissions set correctly?"); + return false; + } + + // If directory is not writable, return error + if (!Core::isReallyWritable($cachePath)) + { + Logger::logError("Could not write output cache. No file permissions. Are permissions set correctly?"); + return false; + } + + // Generate the full uri + $uri = $this->config->get('base_url') . (empty($this->uri->uriString()) ? 'index' : $this->uri->uriString()); + + // Determine the file that holds the cache + if ($this->compressOutput) + $file = $cachePath . DS . md5($uri) . '_gzip.fwcache'; + else + $file = $cachePath . DS . md5($uri) . '.fwcache'; + + + // If compression is enabled, compress the output + if ($this->compressOutput) + { + $output = gzencode($output); + if ($this->getHeader('content-type') === null) + $this->setContentType($this->mimeType); + } + + // Calculate expiry time + $expire = time() + ($this->cacheTime * 60); + + // Prepare the cache contents + $cache = [ + 'expire' => $expire, + 'headers' => $this->headers + ]; + + // Create cache file contents + $cache = serialize($cache) . 'EndFuzeWorksCache--->' . $output; + + // Write the cache + if (file_put_contents($file, $cache, LOCK_EX) === false) + { + @unlink($file); + Logger::logError("Could not write output cache. File error. Deleting cache file."); + return false; + } + + // Lowering permissions to read only + chmod($cachePath, 0640); + + // And report back + Logger::logInfo("Output cache has been saved."); + + // @todo Set cache header + return true; + } + /** * Get Output * @@ -134,48 +361,6 @@ class Output $this->output .= $output; } - /** - * Display Output - * - * Processes and sends finalized output data to the browser along - * with any server headers. - * - * @param string $output Output data override - * @return void - */ - public function display(string $output = null) - { - // Set the output data - $output = is_null($output) ? $this->output : $output; - - // First send status code - http_response_code($this->statusCode); - @header('Status: ' . $this->statusCode . ' ' . $this->statusText, true); - - // If compression is requested, start buffering - if ( - $this->compressOutput && - !is_null($this->input->server('HTTP_ACCEPT_ENCODING')) && - strpos($this->input->server('HTTP_ACCEPT_ENCODING'), 'gzip') !== false - ) - { - Logger::log("Compressing output..."); - ob_start('ob_gzhandler'); - } - - // Remove the X-Powered-By header, since it's a security risk - header_remove("X-Powered-By"); - - // Send all available headers - if (!empty($this->headers)) - foreach ($this->headers as $header) - @header($header[0], $header[1]); - - echo $output; - - Logger::log('Output sent to browser'); - } - /** * Set Header * @@ -186,6 +371,11 @@ class Output */ public function setHeader(string $header, bool $replace = true) { + // If compression is enabled content-length should be suppressed, since it won't match the length + // of the compressed output. + if ($this->compressOutput && strncasecmp($header, 'content-length', 14) === 0) + return; + $this->headers[] = [$header, $replace]; } @@ -219,7 +409,6 @@ class Output * @param string $mimeType Extension of the file we're outputting * @param string $charset Character set (default: NULL) */ - public function setContentType(string $mimeType, $charset = null) { if (strpos($mimeType, '/') === false) diff --git a/src/FuzeWorks/WebComponent.php b/src/FuzeWorks/WebComponent.php index f40723d..13714e8 100644 --- a/src/FuzeWorks/WebComponent.php +++ b/src/FuzeWorks/WebComponent.php @@ -39,6 +39,7 @@ namespace FuzeWorks; use FuzeWorks\Event\HaltExecutionEvent; use FuzeWorks\Event\LayoutLoadEvent; use FuzeWorks\Event\RouterCallViewEvent; +use FuzeWorks\Exception\ConfigException; use FuzeWorks\Exception\CSRFException; use FuzeWorks\Exception\EventException; use FuzeWorks\Exception\Exception; @@ -133,10 +134,10 @@ class WebComponent implements iComponent * appends output and adds listener to view output on shutdown. * * @return bool - * @throws HaltException * @throws OutputException * @throws RouterException * @throws WebException + * @throws EventException */ public function routeWebRequest(): bool { @@ -158,6 +159,9 @@ class WebComponent implements iComponent throw new WebException("Could not route web request. coreShutdownEvent threw EventException: '".$e->getMessage()."'"); } + // Remove the X-Powered-By header, since it's a security risk + header_remove("X-Powered-By"); + /** @var Router $router */ /** @var URI $uri */ /** @var Output $output */ @@ -170,6 +174,11 @@ class WebComponent implements iComponent // And start logging the request Logger::newLevel("Routing web request..."); + // First check if a cached page is available + $uriString = $uri->uriString(); + if ($output->getCache($uriString)) + return true; + // First test for Cross Site Request Forgery try { $security->csrf_verify(); @@ -183,7 +192,6 @@ class WebComponent implements iComponent // Attempt to load the requested page try { - $uriString = $uri->uriString(); $viewOutput = $router->route($uriString); } catch (NotFoundException $e) { Logger::logWarning("Requested page not found. Requesting Error/error404 View"); @@ -256,6 +264,8 @@ class WebComponent implements iComponent * Fired when FuzeWorks halts it's execution. Loads an error 500 page. * * @param $event + * @throws EventException + * @TODO remove FuzeWorks\Layout dependency */ public function haltEventListener(HaltExecutionEvent $event) { @@ -263,12 +273,17 @@ class WebComponent implements iComponent /** @var Output $output */ /** @var Router $router */ /** @var Event $event */ + /** @var Layout $layout */ $output = Factory::getInstance()->output; $router = Factory::getInstance()->router; + $layout = Factory::getInstance()->layouts; // Cancel event $event->setCancelled(true); + // Reset the layout engine + $layout->reset(); + // Remove listener so that error pages won't be intercepted Events::removeListener([$this, 'callViewEventListener'], 'routerCallViewEvent',Priority::HIGHEST); @@ -291,10 +306,10 @@ class WebComponent implements iComponent * * Assigns variables from the WebComponent to Layout engines. * - * @param $event - * @throws Exception\ConfigException + * @param LayoutLoadEvent $event + * @throws ConfigException */ - public function layoutLoadEventListener(LayoutLoadEvent $event) + public function layoutLoadEventListener($event) { // Dependencies /** @var Security $security */