WebComponent/src/FuzeWorks/Output.php

528 lines
15 KiB
PHP

<?php
/**
* FuzeWorks WebComponent.
*
* The FuzeWorks PHP FrameWork
*
* Copyright (C) 2013-2019 TechFuze
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* @author TechFuze
* @copyright Copyright (c) 2013 - 2019, TechFuze. (http://techfuze.net)
* @license https://opensource.org/licenses/MIT MIT License
*
* @link http://techfuze.net/fuzeworks
* @since Version 1.2.0
*
* @version Version 1.2.0
*/
namespace FuzeWorks;
use FuzeWorks\ConfigORM\ConfigORM;
use FuzeWorks\Exception\OutputException;
/**
* @todo Implement caching
*/
class Output
{
/**
* The internal Input class
*
* @var Input
*/
private $input;
/**
* The internal URI class
*
* @var URI
*/
private $uri;
/**
* WebCfg
*
* @var ConfigORM
*/
private $config;
/**
* Output to be sent to the client
*
* @var string
*/
protected $output;
/**
* Headers to be sent to the client
*
* @var array
*/
protected $headers = [];
protected $compressOutput = false;
/**
* List of mime types
*
* @var array
*/
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');
$zlib = (bool) ini_get('zlib.output_compression');
$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 && !is_null($output))
$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 output cache is disabled, don't return a cache result
if ($this->config->get('cache_output') !== true)
return false;
// Generate the full uri
$uri = $this->config->get('base_url') . (empty($selector) ? 'index' : $selector);
$getParams = $this->input->get();
// Determine the identifier
$identier = md5($uri . '|' . serialize($getParams));
// Determine the file that holds the cache
if ($this->compressOutput)
$file = Core::$tempDir . DS . 'OutputCache' . DS . $identier . '_gzip.fwcache';
else
$file = Core::$tempDir . DS . 'OutputCache' . DS . $identier . '.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)
{
// If output cache is disabled, don't create a cache entry
if ($this->config->get('cache_output') !== true)
return false;
// 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());
$getParams = $this->input->get();
// Determine the identifier
$identifier = md5($uri . '|' . serialize($getParams));
// Determine the file that holds the cache
if ($this->compressOutput)
$file = $cachePath . DS . $identifier . '_gzip.fwcache';
else
$file = $cachePath . DS . $identifier . '.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
*
* Returns the current output string.
*
* @return string
*/
public function getOutput(): string
{
return $this->output;
}
/**
* Set Output
*
* Sets the output string.
*
* @param string $output Output data
*/
public function setOutput(string $output)
{
$this->output = $output;
}
/**
* Append Output
*
* Appends data onto the output string.
*
* @param string $output Data to append
*/
public function appendOutput(string $output)
{
$this->output .= $output;
}
/**
* Set Header
*
* Lets you set a server header which will be sent with the final output.
*
* @param string $header Header
* @param bool $replace Whether to replace the old header value, if already set
*/
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];
}
/**
* Get Header
*
* @param string $headerName
* @return string|null
*/
public function getHeader(string $headerName)
{
// Combine sent headers with queued headers
$headers = array_merge(
array_map('array_shift', $this->headers),
headers_list()
);
if (empty($headers) || empty($headerName))
return null;
foreach ($headers as $header)
if (strncasecmp($headerName, $header, $l = strlen($headerName)) === 0)
return trim(substr($header, $l+1));
return null;
}
/**
* Set Content-Type Header
*
* @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)
{
$extension = ltrim($mimeType, '.');
if (isset($this->mimes[$extension]))
{
$mimeType = &$this->mimes[$extension];
if (is_array($mimeType))
$mimeType = current($mimeType);
}
}
$this->mimeType = $mimeType;
if (empty($charset))
$charset = $this->config->get('charset');
$header = 'Content-Type: ' . $mimeType . (empty($charset) ? '' : '; charset='.$charset);
$this->headers[] = [$header, true];
}
/**
* Get Current Content-Type Header
*
* @return string 'text/html', if not already set
*/
public function getContentType(): string
{
foreach ($this->headers as $header)
if (sscanf($header[0], 'Content-Type: %[^;]', $contentType) === 1)
return $contentType;
return 'text/html';
}
/**
* Set HTTP Status Header
*
* @param int $code
* @param string $text
* @throws OutputException
*/
public function setStatusHeader(int $code = 200, string $text = '')
{
$this->statusCode = $code;
if (!empty($text))
$this->statusText = $text;
else
{
$statusCodes = [
100 => 'Continue',
101 => 'Switching Protocols',
200 => 'OK',
201 => 'Created',
202 => 'Accepted',
203 => 'Non-Authoritative Information',
204 => 'No Content',
205 => 'Reset Content',
206 => 'Partial Content',
300 => 'Multiple Choices',
301 => 'Moved Permanently',
302 => 'Found',
303 => 'See Other',
304 => 'Not Modified',
305 => 'Use Proxy',
307 => 'Temporary Redirect',
400 => 'Bad Request',
401 => 'Unauthorized',
402 => 'Payment Required',
403 => 'Forbidden',
404 => 'Not Found',
405 => 'Method Not Allowed',
406 => 'Not Acceptable',
407 => 'Proxy Authentication Required',
408 => 'Request Timeout',
409 => 'Conflict',
410 => 'Gone',
411 => 'Length Required',
412 => 'Precondition Failed',
413 => 'Request Entity Too Large',
414 => 'Request-URI Too Long',
415 => 'Unsupported Media Type',
416 => 'Requested Range Not Satisfiable',
417 => 'Expectation Failed',
422 => 'Unprocessable Entity',
500 => 'Internal Server Error',
501 => 'Not Implemented',
502 => 'Bad Gateway',
503 => 'Service Unavailable',
504 => 'Gateway Timeout',
505 => 'HTTP Version Not Supported'
];
if (isset($statusCodes[$code]))
$this->statusText = $statusCodes[$code];
else
throw new OutputException("Could not set status header. Code '" . $code . "' not recognized");
}
}
}