From 77480f029f6c55ac123f2840c7e78be28f676e49 Mon Sep 17 00:00:00 2001 From: Alex Grintsvayg Date: Thu, 1 Sep 2016 18:48:05 -0400 Subject: [PATCH] add response class, caching --- controller/Actions.class.php | 2 +- controller/Controller.class.php | 112 ++------- controller/Request.class.php | 59 +++++ controller/action/ContentActions.class.php | 12 +- controller/action/DownloadActions.class.php | 4 +- controller/action/MailActions.class.php | 4 +- controller/action/NavActions.class.php | 2 +- lib/i18n.class.php | 2 +- lib/tools/Prefinery.class.php | 4 +- lib/tools/Slack.class.php | 2 +- model/Post.class.php | 2 +- view/Response.class.php | 264 +++++++++++++++++++- view/View.class.php | 10 +- view/template/mail/_joinList.php | 4 +- view/template/nav/_learnFooter.php | 6 +- web/index.php | 2 +- 16 files changed, 354 insertions(+), 137 deletions(-) create mode 100644 controller/Request.class.php diff --git a/controller/Actions.class.php b/controller/Actions.class.php index 7f54a6fa..9de926aa 100644 --- a/controller/Actions.class.php +++ b/controller/Actions.class.php @@ -34,6 +34,6 @@ class Actions 'okhttp', 'python' ]; - return preg_match('/(' . join('|', $bots) . ')/i', $_SERVER['HTTP_USER_AGENT']); + return preg_match('/(' . join('|', $bots) . ')/i', Request::getUserAgent()); } } \ No newline at end of file diff --git a/controller/Controller.class.php b/controller/Controller.class.php index 586b6a24..74284344 100644 --- a/controller/Controller.class.php +++ b/controller/Controller.class.php @@ -2,8 +2,6 @@ class Controller { - const HEADER_STATUS = 'Status'; - public static function dispatch($uri) { try @@ -11,29 +9,19 @@ class Controller $viewAndParams = static::execute($uri); $viewTemplate = $viewAndParams[0]; $viewParameters = isset($viewAndParams[1]) ? $viewAndParams[1] : []; - $headers = isset($viewAndParams[2]) ? $viewAndParams[2] : []; - - $defaultHeaders = [ - 'Content-Security-Policy' => "frame-ancestors 'none'", - 'X-Frame-Options' => 'DENY', - 'X-XSS-Protection'=> '1', - ]; - - if (IS_PRODUCTION) + if (!IS_PRODUCTION && isset($viewAndParams[2])) { - $defaultHeaders['Strict-Transport-Security'] = 'max-age=31536000'; + throw new Exception('use response::setheader instead of returning headers'); } - static::sendHeaders(array_merge($defaultHeaders, $headers)); - if ($viewTemplate === null) { - return ''; + return; } if (!$viewTemplate) { - throw new LogicException('All execute methods must return a template.'); + throw new LogicException('All execute methods must return a template or NULL.'); } $layout = !(isset($viewParameters['_no_layout']) && $viewParameters['_no_layout']); @@ -44,7 +32,9 @@ class Controller $content = View::render($viewTemplate, $viewParameters + ['fullPage' => true]); - echo $layout ? View::render('layout/basic', ['content' => $content] + $layoutParams) : $content; + Response::setContent($layout ? View::render('layout/basic', ['content' => $content] + $layoutParams) : $content); + Response::setDefaultSecurityHeaders(); + Response::send(); } catch (StopException $e) { @@ -99,6 +89,7 @@ class Controller $newsPattern = '#^' . ContentActions::URL_NEWS . '(/|$)#'; if (preg_match($newsPattern, $uri)) { + Response::enableHttpCache(180); $slug = preg_replace($newsPattern, '', $uri); if ($slug == ContentActions::RSS_SLUG) { @@ -110,6 +101,7 @@ class Controller $faqPattern = '#^' . ContentActions::URL_FAQ . '(/|$)#'; if (preg_match($faqPattern, $uri)) { + Response::enableHttpCache(180); $slug = preg_replace($faqPattern, '', $uri); return $slug ? ContentActions::executeFaqPost($uri) : ContentActions::executeFaq(); } @@ -117,6 +109,7 @@ class Controller $bountyPattern = '#^' . BountyActions::URL_BOUNTY_LIST . '(/|$)#'; if (preg_match($bountyPattern, $uri)) { + Response::enableHttpCache(180); $slug = preg_replace($bountyPattern, '', $uri); return $slug ? BountyActions::executeShow($uri) : BountyActions::executeList($uri); } @@ -131,11 +124,13 @@ class Controller $noSlashUri = ltrim($uri, '/'); if (View::exists('page/' . $noSlashUri)) { + Response::enableHttpCache(180); return ['page/' . $noSlashUri, []]; } else { - return ['page/404', [], [static::HEADER_STATUS => 404]]; + Response::setStatus(404); + return ['page/404', []]; } } @@ -148,88 +143,13 @@ class Controller $url = str_replace('&', '&', $url); - $headers = [static::HEADER_STATUS => $statusCode]; - + Response::setStatus($statusCode); if ($statusCode == 201 || ($statusCode >= 300 && $statusCode < 400)) { - $headers['Location'] = $url; + Response::setHeader(Response::HEADER_LOCATION, $url); } - return ['internal/redirect', ['url' => $url], $headers]; - } - - protected static function sendHeaders(array $headers) - { - if (isset($headers[static::HEADER_STATUS])) - { - $status = 'HTTP/1.0 ' . $headers[static::HEADER_STATUS] . ' ' . static::getStatusTextForCode($headers[static::HEADER_STATUS]); - header($status); - - if (substr(php_sapi_name(), 0, 3) == 'cgi') - { - // fastcgi servers cannot send this status information because it was sent by them already due to the HTT/1.0 line - // so we can safely unset them. see ticket #3191 - unset($headers[static::HEADER_STATUS]); - } - } - - foreach($headers as $name => $value) - { - header($name . ': ' . $value); - } - } - - public static function getStatusTextForCode($code) - { - $statusTexts = [ - '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', - '306' => '(Unused)', - '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', - '419' => 'Authentication Timeout', - '422' => 'Unprocessable Entity', - '426' => 'Upgrade Required', - '429' => 'Too Many Requests', - '500' => 'Internal Server Error', - '501' => 'Not Implemented', - '502' => 'Bad Gateway', - '503' => 'Service Unavailable', - '504' => 'Gateway Timeout', - '505' => 'HTTP Version Not Supported', - ]; - - return isset($statusTexts[$code]) ? $statusTexts[$code] : null; + return ['internal/redirect', ['url' => $url]]; } } diff --git a/controller/Request.class.php b/controller/Request.class.php new file mode 100644 index 00000000..73935ac5 --- /dev/null +++ b/controller/Request.class.php @@ -0,0 +1,59 @@ + CreditApi::getTotalDollarSales(), 'totalPeople' => CreditApi::getTotalPeople() @@ -70,11 +71,10 @@ class ContentActions extends Actions public static function executeRss(): array { $posts = Post::find(static::VIEW_FOLDER_NEWS, Post::SORT_DATE_DESC); + Response::setHeader(Response::HEADER_CONTENT_TYPE, 'text/xml; charset=utf-8'); return ['content/rss', [ 'posts' => array_slice($posts, 0, 10), '_no_layout' => true - ], [ - 'Content-Type' => 'text/xml; charset=utf-8' ]]; } @@ -161,14 +161,12 @@ class ContentActions extends Actions $zip->close(); + Response::enableHttpCache(180); + Response::setDownloadHttpHeaders($zipFileName, 'application/zip', filesize($zipPath)); + return ['internal/zip', [ '_no_layout' => true, 'zipPath' => $zipPath - ], [ - 'Content-Disposition' => 'attachment;filename=' . $zipFileName, - 'X-Content-Type-Options' => 'nosniff', - 'Content-Type' => 'application/zip', - 'Content-Length' => filesize($zipPath), ]]; } diff --git a/controller/action/DownloadActions.class.php b/controller/action/DownloadActions.class.php index 0a87c0f7..abe5be3f 100644 --- a/controller/action/DownloadActions.class.php +++ b/controller/action/DownloadActions.class.php @@ -169,7 +169,7 @@ class DownloadActions extends Actions protected static function guessOs() { //if exact OS is requested, use that - $uri = strtok($_SERVER['REQUEST_URI'], '?'); + $uri = strtok(Request::getRelativeUri(), '?'); foreach (static::getOses() as $os => $osChoice) { if ($osChoice[0] == $uri) @@ -184,7 +184,7 @@ class DownloadActions extends Actions } //otherwise guess from UA - $ua = $_SERVER['HTTP_USER_AGENT']; + $ua = Request::getUserAgent(); if (stripos($ua, 'OS X') !== false) { return strpos($ua, 'iPhone') !== false || stripos($ua, 'iPad') !== false ? static::OS_IOS : static::OS_OSX; diff --git a/controller/action/MailActions.class.php b/controller/action/MailActions.class.php index 6897eea9..f5a1fc92 100644 --- a/controller/action/MailActions.class.php +++ b/controller/action/MailActions.class.php @@ -11,7 +11,7 @@ class MailActions extends Actions { $nextUrl = isset($_POST['returnUrl']) && $_POST['returnUrl'] ? $_POST['returnUrl'] : '/join-list'; - if ($_SERVER['REQUEST_METHOD'] !== 'POST') + if (!Request::isPost()) { return Controller::redirect($nextUrl); } @@ -62,7 +62,7 @@ class MailActions extends Actions public static function prepareJoinListPartial(array $vars) { $vars['listSig'] = md5(serialize($vars)); - $vars += ['btnClass' => 'btn-primary', 'returnUrl' => $_SERVER['REQUEST_URI']]; + $vars += ['btnClass' => 'btn-primary', 'returnUrl' => Request::getRelativeUri()]; if (Session::get(Session::KEY_LIST_SUB_SIGNATURE) == $vars['listSig']) { diff --git a/controller/action/NavActions.class.php b/controller/action/NavActions.class.php index 7e84b5e0..5528bb8e 100644 --- a/controller/action/NavActions.class.php +++ b/controller/action/NavActions.class.php @@ -16,7 +16,7 @@ class NavActions extends Actions public static function getNavUri() { - return static::$navUri ?: $_SERVER['REQUEST_URI']; + return static::$navUri ?: Request::getRelativeUri(); } public static function prepareFooterPartial(array $vars) diff --git a/lib/i18n.class.php b/lib/i18n.class.php index 9771178d..3b70fc5d 100644 --- a/lib/i18n.class.php +++ b/lib/i18n.class.php @@ -24,7 +24,7 @@ class i18n { if ($culture === null) { - $urlTokens = $_SERVER['HTTP_HOST'] ? explode('.', $_SERVER['HTTP_HOST']) : []; + $urlTokens = Request::getHost() ? explode('.', Request::getHost()) : []; $code = $urlTokens ? reset($urlTokens) : 'en'; switch($code) { diff --git a/lib/tools/Prefinery.class.php b/lib/tools/Prefinery.class.php index f2c52713..de135823 100644 --- a/lib/tools/Prefinery.class.php +++ b/lib/tools/Prefinery.class.php @@ -61,8 +61,8 @@ class Prefinery if (!$user) { // dont record ip for lbry.io addresses, for testing - $ip = isset($_SERVER['REMOTE_ADDR']) && !preg_match('/@lbry\.io$/', $email) ? $_SERVER['REMOTE_ADDR'] : null; - $ua = isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : null; + $ip = !preg_match('/@lbry\.io$/', $email) ? Request::getOriginalIp() : null; + $ua = Request::getUserAgent(); $user = Prefinery::createTester(array_filter([ 'email' => $email, 'status' => $inviteCode ? static::STATE_ACTIVE: static::STATE_APPLIED, # yes, has to be ACTIVE to validate invite code diff --git a/lib/tools/Slack.class.php b/lib/tools/Slack.class.php index 5f233654..471105c9 100644 --- a/lib/tools/Slack.class.php +++ b/lib/tools/Slack.class.php @@ -13,7 +13,7 @@ class Slack $slackErrorNotificationUrl = Config::get('slack_error_notification_url'); if ($slackErrorNotificationUrl) { - Curl::post($slackErrorNotificationUrl, ['text' => ' ' . $_SERVER['REQUEST_URI'] . "\n" . $e], ['json_data' => true]); + Curl::post($slackErrorNotificationUrl, ['text' => ' ' . Request::getRelativeUri() . "\n" . $e], ['json_data' => true]); } } } diff --git a/model/Post.class.php b/model/Post.class.php index d2bd4da2..a8748d11 100644 --- a/model/Post.class.php +++ b/model/Post.class.php @@ -276,7 +276,7 @@ class Post $cover = $this->getCover(); if ($cover) { - $urls[] = 'https://' . $_SERVER['SERVER_NAME'] . '/img/blog-covers/' . $cover; + $urls[] = 'https://' . Request::getHost() . '/img/blog-covers/' . $cover; } $matches = []; diff --git a/view/Response.class.php b/view/Response.class.php index 17ffd7b7..10bb0b7b 100644 --- a/view/Response.class.php +++ b/view/Response.class.php @@ -1,4 +1,5 @@ [ - '/js/jquery-2.1.3.min.js', - '/js/global.js' - ], - 'css' => [] - ], + const HEADER_STATUS = 'Status'; + const HEADER_LOCATION = 'Location'; + + const HEADER_CACHE_CONTROL = 'Cache-Control'; + const HEADER_LAST_MODIFIED = 'Last-Modified'; + const HEADER_ETAG = 'Etag'; + + const HEADER_CONTENT_TYPE = 'Content-Type'; + const HEADER_CONTENT_LENGTH = 'Content-Length'; + const HEADER_CONTENT_DISPOSITION = 'Content-Disposition'; + const HEADER_CONTENT_TYPE_OPTIONS = 'X-Content-Type-Options'; + + protected static + $metaDescription = '', + $metaTitle = '', + $jsCalls = [], + $assets = [ + 'js' => [ + '/js/jquery-2.1.3.min.js', + '/js/global.js' + ], + 'css' => [] + ], + $headers = [], + $headersSent = false, + $content = '', + $contentSent = false, + $isHeadersOnly = false, // $bodyCssClasses = [], - $metaImages = []; + $metaImages = []; public static function setMetaDescription($description) { @@ -31,7 +50,7 @@ class Response public static function addMetaImages(array $urls) { - foreach($urls as $url) + foreach ($urls as $url) { static::addMetaImage($url); } @@ -61,7 +80,7 @@ class Response { $title = ''; preg_match_all('/]*>([^<]+) $headerValue) + foreach ($titleMatches[1] as $matchIndex => $headerValue) { if ($headerValue == '1' || !$title) { @@ -96,6 +115,225 @@ class Response return static::$assets['js']; } + public static function send() + { + static::sendHeaders(); + static::sendContent(); + } + + public static function setContent(string $content) + { + static::$content = $content; + } + + public static function getContent(): string + { + return static::$content; + } + + public static function sendContent() + { + if (static::$contentSent) + { + throw new LogicException('Content has already been sent. It cannot be sent twice'); + } + + if (!static::$isHeadersOnly) + { + echo static::$content; + } + + static::$contentSent = true; + } + + public static function setIsHeadersOnly(bool $isHeadersOnly = true) + { + static::$isHeadersOnly = $isHeadersOnly; + } + + public static function setDownloadHttpHeaders($name, $type = null, $size = null, $noSniff = true) + { + static::setHeaders(array_filter([ + 'Content-Disposition' => 'attachment;filename=' . $name, + 'Content-Type' => $type ? 'application/zip' : null, + 'Content-Length' => $size ?: null, + 'X-Content-Type-Options' => $noSniff ? 'nosniff' : null, + ])); + } + + public static function setContentEtag() + { + static::setHeader(static::HEADER_ETAG, md5(static::getContent())); + } + + public static function enableHttpCache(int $seconds = 300) + { + static::addCacheControlHeader('max-age', $seconds); + } + + public static function addCacheControlHeader(string $name, $value = null) + { + $cacheControl = static::getHeader(static::HEADER_CACHE_CONTROL); + $currentHeaders = []; + if ($cacheControl) + { + foreach (preg_split('/\s*,\s*/', $cacheControl) as $tmp) + { + $tmp = explode('=', $tmp); + $currentHeaders[$tmp[0]] = isset($tmp[1]) ? $tmp[1] : null; + } + } + $currentHeaders[strtr(strtolower($name), '_', '-')] = $value; + + $headers = []; + foreach ($currentHeaders as $key => $currentVal) + { + $headers[] = $key . ($currentVal !== null ? '=' . $currentVal : ''); + } + + static::setHeader(static::HEADER_CACHE_CONTROL, implode(', ', $headers)); + } + + public static function setHeader($name, $value) + { + static::$headers[$name] = $value; + } + + public static function setHeaders($headers, $overwrite = true) + { + foreach ($headers as $name => $value) + { + if ($overwrite || !static::getHeader($name)) + { + static::setHeader($name, $value); + } + } + } + + public static function getHeader($name, $default = null) + { + return isset(static::$headers[$name]) ? static::$headers[$name] : $default; + } + + public static function getHeaders(): array + { + return static::$headers; + } + + public static function setStatus($status) + { + static::setHeader(static::HEADER_STATUS, $status); + } + + public static function setDefaultSecurityHeaders() + { + $defaultHeaders = [ + 'Content-Security-Policy' => "frame-ancestors 'none'", + 'X-Frame-Options' => 'DENY', + 'X-XSS-Protection' => '1', + ]; + + if (IS_PRODUCTION) + { + $defaultHeaders['Strict-Transport-Security'] = 'max-age=31536000'; + } + + static::setHeaders($defaultHeaders, false); + } + + public static function sendHeaders() + { + if (static::$headersSent) + { + throw new LogicException('Headers have already been sent. They cannot be sent twice'); + } + + $headers = static::getHeaders(); + + if (isset($headers[static::HEADER_STATUS])) + { + $status = 'HTTP/1.0 ' . $headers[static::HEADER_STATUS] . ' ' . static::getStatusTextForCode($headers[static::HEADER_STATUS]); + header($status); + + if (substr(php_sapi_name(), 0, 3) == 'cgi') + { + // fastcgi servers cannot send this status information because it was sent by them already due to the HTT/1.0 line + // so we can safely unset them. see ticket #3191 + unset($headers[static::HEADER_STATUS]); + } + } + + foreach ($headers as $name => $value) + { + header($name . ': ' . $value); + } + + static::$headersSent = true; + } + + public static function getStatusTextForCode($code) + { + $statusTexts = [ + '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', + '306' => '(Unused)', + '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', + '419' => 'Authentication Timeout', + '422' => 'Unprocessable Entity', + '426' => 'Upgrade Required', + '429' => 'Too Many Requests', + '500' => 'Internal Server Error', + '501' => 'Not Implemented', + '502' => 'Bad Gateway', + '503' => 'Service Unavailable', + '504' => 'Gateway Timeout', + '505' => 'HTTP Version Not Supported', + ]; + + return isset($statusTexts[$code]) ? $statusTexts[$code] : null; + } + + protected static function normalizeHeaderName($name) + { + return preg_replace_callback( + '/\-(.)/', + function ($matches) { return '-' . strtoupper($matches[1]); }, + strtr(ucfirst(strtolower($name)), '_', '-') + ); + } + + // public static function addBodyCssClass($classOrClasses) // { // static::$bodyCssClasses = array_unique(array_merge(static::$bodyCssClasses, (array)$classOrClasses)); diff --git a/view/View.class.php b/view/View.class.php index a6c39a80..dff2f92f 100644 --- a/view/View.class.php +++ b/view/View.class.php @@ -78,17 +78,17 @@ class View return ParsedownExtra::instance()->text(trim(file_get_contents($path))); } - public static function exists($template) + public static function exists($template): bool { return is_readable(static::getFullPath($template)); } - protected static function isMarkdown($nameOrPath) + protected static function isMarkdown($nameOrPath): bool { return strlen($nameOrPath) > 3 && substr($nameOrPath, -3) == '.md'; } - protected static function getFullPath($template) + protected static function getFullPath($template): string { if ($template && $template[0] == '/') { @@ -103,12 +103,12 @@ class View return ROOT_DIR . '/view/template/' . $template . '.php'; } - public static function imagePath($image) + public static function imagePath($image): string { return '/img/' . $image; } - public static function parseMarkdown($template) + public static function parseMarkdown($template): array { $path = static::getFullPath($template); list($ignored, $frontMatter, $markdown) = explode('---', file_get_contents($path), 3); diff --git a/view/template/mail/_joinList.php b/view/template/mail/_joinList.php index 3b621815..97fc16ba 100644 --- a/view/template/mail/_joinList.php +++ b/view/template/mail/_joinList.php @@ -1,5 +1,6 @@
+
@@ -10,13 +11,14 @@
+
- + diff --git a/view/template/nav/_learnFooter.php b/view/template/nav/_learnFooter.php index 48b848fd..d9d668c7 100644 --- a/view/template/nav/_learnFooter.php +++ b/view/template/nav/_learnFooter.php @@ -23,13 +23,13 @@

diff --git a/web/index.php b/web/index.php index 5b57cb91..6ad8d6b1 100644 --- a/web/index.php +++ b/web/index.php @@ -21,7 +21,7 @@ try { View::compileCss(); } - Controller::dispatch(strtok($_SERVER['REQUEST_URI'], '?')); + Controller::dispatch(strtok(Request::getRelativeUri(), '?')); } catch(Throwable $e) {