From 0bc617a0b9c420bf4334345637efacbffb1d5583 Mon Sep 17 00:00:00 2001 From: Alex Grintsvayg Date: Mon, 5 Sep 2016 18:19:12 -0400 Subject: [PATCH] routing --- controller/Controller.class.php | 150 ++++---- controller/Request.class.php | 2 +- controller/action/BountyActions.class.php | 36 +- controller/action/ContentActions.class.php | 177 +++++----- controller/action/DownloadActions.class.php | 4 +- controller/action/NavActions.class.php | 11 +- data/i18n/en.yaml | 3 +- lib/routing/BadRouteException.class.php | 5 + lib/routing/Dispatcher.class.php | 230 +++++++++++++ lib/routing/HandlerResolver.class.php | 16 + .../HandlerResolverInterface.class.php | 8 + lib/routing/HttpException.class.php | 5 + .../HttpMethodNotAllowedException.class.php | 5 + .../HttpRouteNotFoundException.class.php | 6 + lib/routing/Route.class.php | 26 ++ lib/routing/RouteCollector.class.php | 325 ++++++++++++++++++ lib/routing/RouteData.class.php | 59 ++++ lib/routing/RouteParser.class.php | 214 ++++++++++++ posts/faq/exchanges.md | 3 + view/template/content/rss.php | 2 +- view/template/download/get.php | 2 +- view/template/layout/basic.php | 2 +- view/template/page/learn.php | 5 +- 23 files changed, 1100 insertions(+), 196 deletions(-) create mode 100644 lib/routing/BadRouteException.class.php create mode 100644 lib/routing/Dispatcher.class.php create mode 100644 lib/routing/HandlerResolver.class.php create mode 100644 lib/routing/HandlerResolverInterface.class.php create mode 100644 lib/routing/HttpException.class.php create mode 100644 lib/routing/HttpMethodNotAllowedException.class.php create mode 100644 lib/routing/HttpRouteNotFoundException.class.php create mode 100644 lib/routing/Route.class.php create mode 100644 lib/routing/RouteCollector.class.php create mode 100644 lib/routing/RouteData.class.php create mode 100644 lib/routing/RouteParser.class.php diff --git a/controller/Controller.class.php b/controller/Controller.class.php index f070e81a..4d0d2132 100644 --- a/controller/Controller.class.php +++ b/controller/Controller.class.php @@ -6,8 +6,8 @@ class Controller { try { - $viewAndParams = static::execute($uri); - $viewTemplate = $viewAndParams[0]; + $viewAndParams = static::execute(Request::getMethod(), $uri); + $viewTemplate = $viewAndParams[0]; $viewParameters = $viewAndParams[1] ?? []; if (!IS_PRODUCTION && isset($viewAndParams[2])) { @@ -46,96 +46,86 @@ class Controller } } - public static function execute($uri) + public static function execute($method, $uri) { - switch($uri) + $router = static::getRouterWithRoutes(); + try { - case '/': - return ContentActions::executeHome(); - case '/get': - case '/windows': - case '/ios': - case '/android': - case '/linux': - case '/osx': - return DownloadActions::executeGet(); - case '/postcommit': - return OpsActions::executePostCommit(); - case '/log-upload': - return OpsActions::executeLogUpload(); - case '/list-subscribe': - return MailActions::executeListSubscribe(); - case '/press-kit.zip': - return ContentActions::executePressKit(); - case '/LBRY-deck.pdf': - case '/deck.pdf': - return static::redirect('https://www.dropbox.com/s/0xj4vgucsbi8rtv/lbry-deck.pdf?dl=1'); - case '/pln.pdf': - case '/plan.pdf': - return static::redirect('https://www.dropbox.com/s/uevjrwnyr672clj/lbry-pln.pdf?dl=1'); - case '/lbry-osx-latest.dmg': - case '/lbry-linux-latest.deb': - case '/dl/lbry_setup.sh': - return static::redirect('/get', 301); - case '/get/lbry.dmg': - return static::redirect(DownloadActions::getDownloadUrl(DownloadActions::OS_OSX) ?: '/get'); - case '/get/lbry.deb': - return static::redirect(DownloadActions::getDownloadUrl(DownloadActions::OS_LINUX) ?: '/get'); - case '/art': - return static::redirect('/what', 301); - case '/why': - case '/feedback': - return static::redirect('/learn', 301); - case '/faq/when-referral-payouts': - return static::redirect('/faq/referrals', 301); + $dispatcher = new Routing\Dispatcher($router->getData()); + return $dispatcher->dispatch($method, $uri); } - - $newsPattern = '#^' . ContentActions::URL_NEWS . '(/|$)#'; - if (preg_match($newsPattern, $uri)) + catch (\Routing\HttpRouteNotFoundException $e) { - Response::enableHttpCache(); - $slug = preg_replace($newsPattern, '', $uri); - if ($slug == ContentActions::RSS_SLUG) + return NavActions::execute404(); + } + } + + protected static function getRouterWithRoutes(): \Routing\RouteCollector + { + $router = new Routing\RouteCollector(); + + $router->get(['/', 'home'], 'ContentActions::executeHome'); + + $router->get(['/get', 'get'], 'DownloadActions::executeGet'); + $router->get(['/windows', 'get-windows'], 'DownloadActions::executeGet'); + $router->get(['/linux', 'get-linux'], 'DownloadActions::executeGet'); + $router->get(['/osx', 'get-osx'], 'DownloadActions::executeGet'); + $router->get(['/android', 'get-android'], 'DownloadActions::executeGet'); + $router->get(['/ios', 'get-ios'], 'DownloadActions::executeGet'); + + $router->get(['/press-kit.zip', 'press-kit'], 'ContentActions::executePressKit'); + + $router->post('/postcommit', 'OpsActions::executePostCommit'); + $router->post('/log-upload', 'OpsActions::executeLogUpload'); + $router->post(['/list-subscribe', 'list-subscribe'], 'MailActions::executeListSubscribe'); + + $permanentRedirects = [ + '/lbry-osx-latest.dmg' => '/get', + '/lbry-linux-latest.deb' => '/get', + '/dl/lbry_setup.sh' => '/get', + '/art' => '/what', + '/why' => '/learn', + '/feedback' => '/learn', + '/faq/when-referral-payouts' => '/faq/referrals', + ]; + + $tempRedirects = [ + '/LBRY-deck.pdf' => 'https://www.dropbox.com/s/0xj4vgucsbi8rtv/lbry-deck.pdf?dl=1', + '/deck.pdf' => 'https://www.dropbox.com/s/0xj4vgucsbi8rtv/lbry-deck.pdf?dl=1', + '/pln.pdf' => 'https://www.dropbox.com/s/uevjrwnyr672clj/lbry-pln.pdf?dl=1', + '/plan.pdf' => 'https://www.dropbox.com/s/uevjrwnyr672clj/lbry-pln.pdf?dl=1', + '/get/lbry.dmg' => DownloadActions::getDownloadUrl(DownloadActions::OS_OSX) ?: '/get', + '/get/lbry.deb' => DownloadActions::getDownloadUrl(DownloadActions::OS_LINUX) ?: '/get', + ]; + + foreach ([302 => $tempRedirects, 301 => $permanentRedirects] as $code => $redirects) + { + foreach ($redirects as $src => $target) { - return ContentActions::executeRss(); + $router->any($src, function () use ($target, $code) { return static::redirect($target, $code); }); } - return $slug ? ContentActions::executeNewsPost($uri) : ContentActions::executeNews(); } - $faqPattern = '#^' . ContentActions::URL_FAQ . '(/|$)#'; - if (preg_match($faqPattern, $uri)) - { - Response::enableHttpCache(); - $slug = preg_replace($faqPattern, '', $uri); - return $slug ? ContentActions::executeFaqPost($uri) : ContentActions::executeFaq(); - } + $router->get([ContentActions::URL_NEWS . '/{slug:c}?', 'news'], 'ContentActions::executeNews'); + $router->get([ContentActions::URL_FAQ . '/{slug:c}?', 'faq'], 'ContentActions::executeFaq'); + $router->get([BountyActions::URL_BOUNTY . '/{slug:c}?', 'bounty'], 'BountyActions::executeShow'); - $bountyPattern = '#^' . BountyActions::URL_BOUNTY_LIST . '(/|$)#'; - if (preg_match($bountyPattern, $uri)) - { - Response::enableHttpCache(); - $slug = preg_replace($bountyPattern, '', $uri); - return $slug ? BountyActions::executeShow($uri) : BountyActions::executeList($uri); - } + $router->any(['/signup{whatever}?', 'signup'], 'DownloadActions::executeSignup'); - $accessPattern = '#^/signup#'; - if (preg_match($accessPattern, $uri)) + $router->get('/{slug}', function (string $slug) { - return DownloadActions::executeSignup(); - } + if (View::exists('page/' . $slug)) + { + Response::enableHttpCache(); + return ['page/' . $slug, []]; + } + else + { + return NavActions::execute404(); + } + }); - - $noSlashUri = ltrim($uri, '/'); - if (View::exists('page/' . $noSlashUri)) - { - Response::enableHttpCache(); - return ['page/' . $noSlashUri, []]; - } - else - { - Response::setStatus(404); - return ['page/404', []]; - } + return $router; } public static function redirect($url, $statusCode = 302) diff --git a/controller/Request.class.php b/controller/Request.class.php index 96de0774..969e3ecd 100644 --- a/controller/Request.class.php +++ b/controller/Request.class.php @@ -54,7 +54,7 @@ class Request public static function getRelativeUri(): string { - return $_SERVER['REQUEST_URI'] ?? ''; + return isset($_SERVER['REQUEST_URI']) ? parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) : ''; } public static function isGzipAccepted(): bool diff --git a/controller/action/BountyActions.class.php b/controller/action/BountyActions.class.php index 8388f224..cb227709 100644 --- a/controller/action/BountyActions.class.php +++ b/controller/action/BountyActions.class.php @@ -2,10 +2,29 @@ class BountyActions extends Actions { - const URL_BOUNTY_LIST = '/bounty'; + const + SLUG_BOUNTY = 'bounty', + URL_BOUNTY = '/' . self::SLUG_BOUNTY, + VIEW_FOLDER_FAQ = ROOT_DIR . '/posts/' . self::SLUG_BOUNTY; - public static function executeList() + public static function executeList(string $slug = null): array { + Response::enableHttpCache(); + + if ($slug) + { + list($metadata, $postHtml) = View::parseMarkdown(static::VIEW_FOLDER_FAQ . '/' . $slug . '.md'); + if (!$postHtml) + { + return NavActions::execute404(); + } + + return ['bounty/show', [ + 'postHtml' => $postHtml, + 'metadata' => $metadata + ]]; + } + $allBounties = Post::find(ROOT_DIR . '/posts/bounty'); $allCategories = ['' => ''] + Post::collectMetadata($allBounties, 'category'); @@ -41,17 +60,4 @@ class BountyActions extends Actions 'selectedStatus' => $selectedStatus ]]; } - - public static function executeShow($relativeUri) - { - list($metadata, $postHtml) = View::parseMarkdown(ROOT_DIR . '/posts/' . $relativeUri . '.md'); - if (!$postHtml) - { - return ['page/404', []]; - } - return ['bounty/show', [ - 'postHtml' => $postHtml, - 'metadata' => $metadata - ]]; - } } \ No newline at end of file diff --git a/controller/action/ContentActions.class.php b/controller/action/ContentActions.class.php index 8d8601e1..2a410a22 100644 --- a/controller/action/ContentActions.class.php +++ b/controller/action/ContentActions.class.php @@ -2,11 +2,16 @@ class ContentActions extends Actions { - const RSS_SLUG = 'rss.xml', - URL_NEWS = '/news', - URL_FAQ = '/faq', - VIEW_FOLDER_NEWS = ROOT_DIR . '/posts/news', - VIEW_FOLDER_FAQ = ROOT_DIR . '/posts/faq'; + const + SLUG_RSS = 'rss.xml', + SLUG_NEWS = 'news', + SLUG_FAQ = 'faq', + + URL_NEWS = '/' . self::SLUG_NEWS, + URL_FAQ = '/' . self::SLUG_FAQ, + + VIEW_FOLDER_NEWS = ROOT_DIR . '/posts/' . self::SLUG_NEWS, + VIEW_FOLDER_FAQ = ROOT_DIR . '/posts/' . self::SLUG_FAQ; public static function executeHome(): array { @@ -14,104 +19,107 @@ class ContentActions extends Actions return ['page/home']; } - public static function executeFaq(): array + public static function executeNews(string $slug = null): array { - $allPosts = Post::find(static::VIEW_FOLDER_FAQ); + Response::enableHttpCache(); - $allCategories = array_merge(['' => ''] + Post::collectMetadata($allPosts, 'category'), [ - 'getstarted' => 'Getting Started', - 'install' => 'Installing LBRY', - 'running' => 'Running LBRY', - 'wallet' => 'The LBRY Wallet', - 'hosting' => 'Hosting Content', - 'mining' => 'Mining LBC', - 'policy' => 'Policies', - 'developer' => 'Developers', - 'other' => 'Other Questions', - ]); - $selectedCategory = static::param('category'); - $filters = array_filter([ - 'category' => $selectedCategory && isset($allCategories[$selectedCategory]) ? $selectedCategory : null, - ]); - - asort($allCategories); - - $posts = $filters ? Post::filter($allPosts, $filters) : $allPosts ; - - - $groups = array_fill_keys(array_keys($allCategories), []); - - foreach($posts as $post) + if (!$slug) { - $groups[$post->getCategory()][] = $post; + $posts = Post::find(static::VIEW_FOLDER_NEWS, Post::SORT_DATE_DESC); + return ['content/news', [ + 'posts' => $posts, + View::LAYOUT_PARAMS => [ + 'showRssLink' => true + ] + ]]; } - return ['content/faq', [ - 'categories' => $allCategories, - 'selectedCategory' => $selectedCategory, - 'postGroups' => $groups - ]]; - } + if ($slug == static::SLUG_RSS) + { + $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 + ]]; + } - public static function executeNews(): array - { - $posts = Post::find(static::VIEW_FOLDER_NEWS, Post::SORT_DATE_DESC); - return ['content/news', [ - 'posts' => $posts, - View::LAYOUT_PARAMS => [ - 'showRssLink' => true - ] - ]]; - } - - - 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 - ]]; - } - - public static function executeNewsPost($relativeUri): array - { try { - $post = Post::load(ltrim($relativeUri, '/')); + $post = Post::load(static::SLUG_NEWS . '/' . ltrim($slug, '/')); } catch (PostNotFoundException $e) { - return ['page/404', []]; + return NavActions::execute404(); } + return ['content/news-post', [ - 'post' => $post, + 'post' => $post, View::LAYOUT_PARAMS => [ 'showRssLink' => true ] ]]; } - public static function executeFaqPost($relativeUri): array + + public static function executeFaq(string $slug = null): array { + Response::enableHttpCache(); + + if (!$slug) + { + $allPosts = Post::find(static::VIEW_FOLDER_FAQ); + + $allCategories = array_merge(['' => ''] + Post::collectMetadata($allPosts, 'category'), [ + 'getstarted' => 'Getting Started', + 'install' => 'Installing LBRY', + 'running' => 'Running LBRY', + 'wallet' => 'The LBRY Wallet', + 'hosting' => 'Hosting Content', + 'mining' => 'Mining LBC', + 'policy' => 'Policies', + 'developer' => 'Developers', + 'other' => 'Other Questions', + ]); + $selectedCategory = static::param('category'); + $filters = array_filter([ + 'category' => $selectedCategory && isset($allCategories[$selectedCategory]) ? $selectedCategory : null, + ]); + + asort($allCategories); + + $posts = $filters ? Post::filter($allPosts, $filters) : $allPosts; + + + $groups = array_fill_keys(array_keys($allCategories), []); + + foreach ($posts as $post) + { + $groups[$post->getCategory()][] = $post; + } + + return ['content/faq', [ + 'categories' => $allCategories, + 'selectedCategory' => $selectedCategory, + 'postGroups' => $groups + ]]; + } + try { - $post = Post::load(ltrim($relativeUri, '/')); + $post = Post::load(static::SLUG_FAQ . '/' . ltrim($slug, '/')); } catch (PostNotFoundException $e) { - return ['page/404', []]; + return NavActions::execute404(); } - return ['content/faq-post', [ - 'post' => $post, - ]]; + return ['content/faq-post', ['post' => $post,]]; } public static function executePressKit(): array { $zipFileName = 'lbry-press-kit-' . date('Y-m-d') . '.zip'; - $zipPath = tempnam('/tmp', $zipFileName); + $zipPath = tempnam('/tmp', $zipFileName); $zip = new ZipArchive(); $zip->open($zipPath, ZipArchive::OVERWRITE); @@ -133,25 +141,26 @@ class ContentActions extends Actions // // $zip->addFromString('press.html', $html); - foreach(glob(ROOT_DIR . '/web/img/press/*') as $productImgPath) + foreach (glob(ROOT_DIR . '/web/img/press/*') as $productImgPath) { $imgPathTokens = explode('/', $productImgPath); - $imgName = $imgPathTokens[count($imgPathTokens) - 1]; + $imgName = $imgPathTokens[count($imgPathTokens) - 1]; $zip->addFile($productImgPath, '/logo_and_product/' . $imgName); } - foreach(glob(ROOT_DIR . '/posts/bio/*.md') as $bioPath) + foreach (glob(ROOT_DIR . '/posts/bio/*.md') as $bioPath) { list($metadata, $bioHtml) = View::parseMarkdown($bioPath); $zip->addFile($bioPath, '/team_bios/' . $metadata['name'] . ' - ' . $metadata['role'] . '.txt'); } - foreach(array_filter(glob(ROOT_DIR . '/web/img/team/*.jpg'), function($path) { + foreach (array_filter(glob(ROOT_DIR . '/web/img/team/*.jpg'), function ($path) + { return strpos($path, 'spooner') === false; }) as $bioImgPath) { $imgPathTokens = explode('/', $bioImgPath); - $imgName = str_replace('644x450', 'lbry', $imgPathTokens[count($imgPathTokens) - 1]); + $imgName = str_replace('644x450', 'lbry', $imgPathTokens[count($imgPathTokens) - 1]); $zip->addFile($bioImgPath, '/team_photos/' . $imgName); } @@ -163,20 +172,20 @@ class ContentActions extends Actions return ['internal/zip', [ '_no_layout' => true, - 'zipPath' => $zipPath + 'zipPath' => $zipPath ]]; } public static function prepareBioPartial(array $vars): array { $person = $vars['person']; - $path = 'bio/' . $person . '.md'; + $path = 'bio/' . $person . '.md'; list($metadata, $bioHtml) = View::parseMarkdown($path); $relativeImgSrc = '/img/team/' . $person . '-644x450.jpg'; - $imgSrc = file_exists(ROOT_DIR . '/web' . $relativeImgSrc) ? $relativeImgSrc : '/img/team/spooner-644x450.jpg'; + $imgSrc = file_exists(ROOT_DIR . '/web' . $relativeImgSrc) ? $relativeImgSrc : '/img/team/spooner-644x450.jpg'; return $vars + $metadata + [ - 'imgSrc' => $imgSrc, - 'bioHtml' => $bioHtml, + 'imgSrc' => $imgSrc, + 'bioHtml' => $bioHtml, 'orientation' => 'vertical' ]; } @@ -185,8 +194,8 @@ class ContentActions extends Actions { $post = $vars['post']; return [ - 'authorName' => $post->getAuthorName(), - 'photoImgSrc' => $post->getAuthorPhoto(), + 'authorName' => $post->getAuthorName(), + 'photoImgSrc' => $post->getAuthorPhoto(), 'authorBioHtml' => $post->getAuthorBioHtml() ]; } diff --git a/controller/action/DownloadActions.class.php b/controller/action/DownloadActions.class.php index abe5be3f..025ef910 100644 --- a/controller/action/DownloadActions.class.php +++ b/controller/action/DownloadActions.class.php @@ -49,7 +49,7 @@ class DownloadActions extends Actions if (!Session::get(Session::KEY_DOWNLOAD_ALLOWED)) { - return ['download/get', ['os' => static::guessOs()]]; + return ['download/get']; } $osChoices = static::getOses(); @@ -169,7 +169,7 @@ class DownloadActions extends Actions protected static function guessOs() { //if exact OS is requested, use that - $uri = strtok(Request::getRelativeUri(), '?'); + $uri = Request::getRelativeUri(); foreach (static::getOses() as $os => $osChoice) { if ($osChoice[0] == $uri) diff --git a/controller/action/NavActions.class.php b/controller/action/NavActions.class.php index 5528bb8e..6de028e2 100644 --- a/controller/action/NavActions.class.php +++ b/controller/action/NavActions.class.php @@ -1,10 +1,5 @@ true ]; } + + public static function execute404() + { + Response::setStatus(404); + return ['page/404']; + } } \ No newline at end of file diff --git a/data/i18n/en.yaml b/data/i18n/en.yaml index c17ede01..84feb73a 100644 --- a/data/i18n/en.yaml +++ b/data/i18n/en.yaml @@ -72,8 +72,7 @@ learn: 100: LBRY in 100 Seconds art: Art in the Internet Age essay: Read the Essay - exchange: Bittrex Exchange - exchange2: Poloniex Exchange + exchange_faq: Buy/Sell LBRY Credits explore: Explore explorer: Block Explorer how: Learn how LBRY will forever improve how we create and share with one another. diff --git a/lib/routing/BadRouteException.class.php b/lib/routing/BadRouteException.class.php new file mode 100644 index 00000000..33d07f1e --- /dev/null +++ b/lib/routing/BadRouteException.class.php @@ -0,0 +1,5 @@ +staticRouteMap = $data->getStaticRoutes(); + + $this->variableRouteData = $data->getVariableRoutes(); + + $this->filters = $data->getFilters(); + + if ($resolver === null) + { + $this->handlerResolver = new HandlerResolver(); + } + else + { + $this->handlerResolver = $resolver; + } + } + + /** + * Dispatch a route for the given HTTP Method / URI. + * + * @param $httpMethod + * @param $uri + * + * @return mixed|null + */ + public function dispatch($httpMethod, $uri) + { + list($handler, $filters, $vars) = $this->dispatchRoute($httpMethod, trim($uri, '/')); + + list($beforeFilter, $afterFilter) = $this->parseFilters($filters); + + if (($response = $this->dispatchFilters($beforeFilter)) !== null) + { + return $response; + } + + $resolvedHandler = $this->handlerResolver->resolve($handler); + + $response = call_user_func_array($resolvedHandler, $vars); + + return $this->dispatchFilters($afterFilter, $response); + } + + /** + * Dispatch a route filter. + * + * @param $filters + * @param null $response + * + * @return mixed|null + */ + private function dispatchFilters($filters, $response = null) + { + while ($filter = array_shift($filters)) + { + $handler = $this->handlerResolver->resolve($filter); + + if (($filteredResponse = call_user_func($handler, $response)) !== null) + { + return $filteredResponse; + } + } + + return $response; + } + + /** + * Normalise the array filters attached to the route and merge with any global filters. + * + * @param $filters + * + * @return array + */ + private function parseFilters($filters) + { + $beforeFilter = []; + $afterFilter = []; + + if (isset($filters[Route::BEFORE])) + { + $beforeFilter = array_intersect_key($this->filters, array_flip((array)$filters[Route::BEFORE])); + } + + if (isset($filters[Route::AFTER])) + { + $afterFilter = array_intersect_key($this->filters, array_flip((array)$filters[Route::AFTER])); + } + + return [$beforeFilter, $afterFilter]; + } + + /** + * Perform the route dispatching. Check static routes first followed by variable routes. + * + * @param $httpMethod + * @param $uri + * + * @throws Exception\HttpRouteNotFoundException + */ + private function dispatchRoute($httpMethod, $uri) + { + if (isset($this->staticRouteMap[$uri])) + { + return $this->dispatchStaticRoute($httpMethod, $uri); + } + + return $this->dispatchVariableRoute($httpMethod, $uri); + } + + /** + * Handle the dispatching of static routes. + * + * @param $httpMethod + * @param $uri + * + * @return mixed + * @throws Exception\HttpMethodNotAllowedException + */ + private function dispatchStaticRoute($httpMethod, $uri) + { + $routes = $this->staticRouteMap[$uri]; + + if (!isset($routes[$httpMethod])) + { + $httpMethod = $this->checkFallbacks($routes, $httpMethod); + } + + return $routes[$httpMethod]; + } + + /** + * Check fallback routes: HEAD for GET requests followed by the ANY attachment. + * + * @param $routes + * @param $httpMethod + * + * @throws Exception\HttpMethodNotAllowedException + */ + private function checkFallbacks($routes, $httpMethod) + { + $additional = [Route::ANY]; + + if ($httpMethod === Route::HEAD) + { + $additional[] = Route::GET; + } + + foreach ($additional as $method) + { + if (isset($routes[$method])) + { + return $method; + } + } + + $this->matchedRoute = $routes; + + throw new HttpMethodNotAllowedException('Allow: ' . implode(', ', array_keys($routes))); + } + + /** + * Handle the dispatching of variable routes. + * + * @param $httpMethod + * @param $uri + * + * @throws Exception\HttpMethodNotAllowedException + * @throws Exception\HttpRouteNotFoundException + */ + private function dispatchVariableRoute($httpMethod, $uri) + { + foreach ($this->variableRouteData as $data) + { + if (!preg_match($data['regex'], $uri, $matches)) + { + continue; + } + + $count = count($matches); + + while (!isset($data['routeMap'][$count++])) + { + ; + } + + $routes = $data['routeMap'][$count - 1]; + + if (!isset($routes[$httpMethod])) + { + $httpMethod = $this->checkFallbacks($routes, $httpMethod); + } + + foreach (array_values($routes[$httpMethod][2]) as $i => $varName) + { + if (!isset($matches[$i + 1]) || $matches[$i + 1] === '') + { + unset($routes[$httpMethod][2][$varName]); + } + else + { + $routes[$httpMethod][2][$varName] = $matches[$i + 1]; + } + } + + return $routes[$httpMethod]; + } + + throw new HttpRouteNotFoundException('Route ' . $uri . ' does not exist'); + } +} diff --git a/lib/routing/HandlerResolver.class.php b/lib/routing/HandlerResolver.class.php new file mode 100644 index 00000000..9918d2f7 --- /dev/null +++ b/lib/routing/HandlerResolver.class.php @@ -0,0 +1,16 @@ +routeParser = $routeParser ?: new RouteParser(); + } + + public function hasRoute(string $name): bool + { + return isset($this->reverse[$name]); + } + + public function route(string $name, array $args = null): string + { + $url = []; + + $replacements = is_null($args) ? [] : array_values($args); + + $variable = 0; + + foreach ($this->reverse[$name] as $part) + { + if (!$part['variable']) + { + $url[] = $part['value']; + } + elseif (isset($replacements[$variable])) + { + if ($part['optional']) + { + $url[] = '/'; + } + + $url[] = $replacements[$variable++]; + } + elseif (!$part['optional']) + { + throw new BadRouteException("Expecting route variable '{$part['name']}'"); + } + } + + return implode('', $url); + } + + public function addRoute(string $httpMethod, $route, $handler, array $filters = []): RouteCollector + { + + if (is_array($route)) + { + list($route, $name) = $route; + } + + $route = $this->addPrefix($this->trim($route)); + + list($routeData, $reverseData) = $this->routeParser->parse($route); + + if (isset($name)) + { + $this->reverse[$name] = $reverseData; + } + + $filters = array_merge_recursive($this->globalFilters, $filters); + + isset($routeData[1]) ? + $this->addVariableRoute($httpMethod, $routeData, $handler, $filters) : + $this->addStaticRoute($httpMethod, $routeData, $handler, $filters); + + return $this; + } + + private function addStaticRoute(string $httpMethod, $routeData, $handler, $filters) + { + $routeStr = $routeData[0]; + + if (isset($this->staticRoutes[$routeStr][$httpMethod])) + { + throw new BadRouteException("Cannot register two routes matching '$routeStr' for method '$httpMethod'"); + } + + foreach ($this->regexToRoutesMap as $regex => $routes) + { + if (isset($routes[$httpMethod]) && preg_match('~^' . $regex . '$~', $routeStr)) + { + throw new BadRouteException("Static route '$routeStr' is shadowed by previously defined variable route '$regex' for method '$httpMethod'"); + } + } + + $this->staticRoutes[$routeStr][$httpMethod] = [$handler, $filters, []]; + } + + private function addVariableRoute(string $httpMethod, $routeData, $handler, $filters) + { + list($regex, $variables) = $routeData; + + if (isset($this->regexToRoutesMap[$regex][$httpMethod])) + { + throw new BadRouteException("Cannot register two routes matching '$regex' for method '$httpMethod'"); + } + + $this->regexToRoutesMap[$regex][$httpMethod] = [$handler, $filters, $variables]; + } + + public function group(array $filters, \Closure $callback) + { + $oldGlobalFilters = $this->globalFilters; + + $oldGlobalPrefix = $this->globalRoutePrefix; + + $this->globalFilters = + array_merge_recursive($this->globalFilters, array_intersect_key($filters, [Route::AFTER => 1, Route::BEFORE => 1])); + + $newPrefix = isset($filters[Route::PREFIX]) ? $this->trim($filters[Route::PREFIX]) : null; + + $this->globalRoutePrefix = $this->addPrefix($newPrefix); + + $callback($this); + + $this->globalFilters = $oldGlobalFilters; + + $this->globalRoutePrefix = $oldGlobalPrefix; + } + + private function addPrefix(string $route) + { + return $this->trim($this->trim($this->globalRoutePrefix) . '/' . $route); + } + + public function filter(string $name, $handler) + { + $this->filters[$name] = $handler; + } + + + public function get($route, $handler, array $filters = []) + { + return $this->addRoute(Route::GET, $route, $handler, $filters); + } + + public function head($route, $handler, array $filters = []) + { + return $this->addRoute(Route::HEAD, $route, $handler, $filters); + } + + public function post($route, $handler, array $filters = []) + { + return $this->addRoute(Route::POST, $route, $handler, $filters); + } + + public function put($route, $handler, array $filters = []) + { + return $this->addRoute(Route::PUT, $route, $handler, $filters); + } + + public function patch($route, $handler, array $filters = []) + { + return $this->addRoute(Route::PATCH, $route, $handler, $filters); + } + + public function delete($route, $handler, array $filters = []) + { + return $this->addRoute(Route::DELETE, $route, $handler, $filters); + } + + public function options($route, $handler, array $filters = []) + { + return $this->addRoute(Route::OPTIONS, $route, $handler, $filters); + } + + public function any($route, $handler, array $filters = []) + { + return $this->addRoute(Route::ANY, $route, $handler, $filters); + } + + + public function controller(string $route, string $classname, array $filters = []): RouteCollector + { + $reflection = new ReflectionClass($classname); + + $validMethods = $this->getValidMethods(); + + $sep = $route === '/' ? '' : '/'; + + foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) + { + foreach ($validMethods as $valid) + { + if (stripos($method->name, $valid) === 0) + { + $methodName = $this->camelCaseToDashed(substr($method->name, strlen($valid))); + + $params = $this->buildControllerParameters($method); + + if ($methodName === self::DEFAULT_CONTROLLER_ROUTE) + { + $this->addRoute($valid, $route . $params, [$classname, $method->name], $filters); + } + + $this->addRoute($valid, $route . $sep . $methodName . $params, [$classname, $method->name], $filters); + + break; + } + } + } + + return $this; + } + + private function buildControllerParameters(ReflectionMethod $method): string + { + $params = ''; + + foreach ($method->getParameters() as $param) + { + $params .= "/{" . $param->getName() . "}" . ($param->isOptional() ? '?' : ''); + } + + return $params; + } + + private function camelCaseToDashed(string $string): string + { + return strtolower(preg_replace('/([A-Z])/', '-$1', lcfirst($string))); + } + + public function getValidMethods(): array + { + return [ + Route::ANY, + Route::GET, + Route::POST, + Route::PUT, + Route::PATCH, + Route::DELETE, + Route::HEAD, + Route::OPTIONS, + ]; + } + + public function getData(): RouteData + { + return new RouteData($this->staticRoutes, $this->regexToRoutesMap ? $this->generateVariableRouteData() : [], $this->filters); + } + + private function trim(string $route): string + { + return trim($route, '/'); + } + + private function generateVariableRouteData(): array + { + $chunkSize = $this->computeChunkSize(count($this->regexToRoutesMap)); + $chunks = array_chunk($this->regexToRoutesMap, $chunkSize, true); + return array_map([$this, 'processChunk'], $chunks); + } + + private function computeChunkSize(int $count): float + { + $numParts = max(1, round($count / self::APPROX_CHUNK_SIZE)); + return ceil($count / $numParts); + } + + private function processChunk($regexToRoutesMap): array + { + $routeMap = []; + $regexes = []; + $numGroups = 0; + foreach ($regexToRoutesMap as $regex => $routes) + { + $firstRoute = reset($routes); + $numVariables = count($firstRoute[2]); + $numGroups = max($numGroups, $numVariables); + + $regexes[] = $regex . str_repeat('()', $numGroups - $numVariables); + + foreach ($routes as $httpMethod => $route) + { + $routeMap[$numGroups + 1][$httpMethod] = $route; + } + + $numGroups++; + } + + $regex = '~^(?|' . implode('|', $regexes) . ')$~'; + return ['regex' => $regex, 'routeMap' => $routeMap]; + } +} diff --git a/lib/routing/RouteData.class.php b/lib/routing/RouteData.class.php new file mode 100644 index 00000000..1a53709f --- /dev/null +++ b/lib/routing/RouteData.class.php @@ -0,0 +1,59 @@ +staticRoutes = $staticRoutes; + + $this->variableRoutes = $variableRoutes; + + $this->filters = $filters; + } + + /** + * @return array + */ + public function getStaticRoutes() + { + return $this->staticRoutes; + } + + /** + * @return array + */ + public function getVariableRoutes() + { + return $this->variableRoutes; + } + + /** + * @return mixed + */ + public function getFilters() + { + return $this->filters; + } +} diff --git a/lib/routing/RouteParser.class.php b/lib/routing/RouteParser.class.php new file mode 100644 index 00000000..c6d9ad17 --- /dev/null +++ b/lib/routing/RouteParser.class.php @@ -0,0 +1,214 @@ + ':[0-9]+}', + ':a}' => ':[0-9A-Za-z]+}', + ':h}' => ':[0-9A-Fa-f]+}', + ':c}' => ':[a-zA-Z0-9+_\-\.]+}' + ]; + + /** + * Parse a route returning the correct data format to pass to the dispatch engine. + * + * @param $route + * + * @return array + */ + public function parse($route) + { + $this->reset(); + + $route = strtr($route, $this->regexShortcuts); + + if (!$matches = $this->extractVariableRouteParts($route)) + { + $reverse = [ + 'variable' => false, + 'value' => $route + ]; + + return [[$route], [$reverse]]; + } + + foreach ($matches as $set) + { + + $this->staticParts($route, $set[0][1]); + + $this->validateVariable($set[1][0]); + + $regexPart = (isset($set[2]) ? trim($set[2][0]) : self::DEFAULT_DISPATCH_REGEX); + + $this->regexOffset = $set[0][1] + strlen($set[0][0]); + + $match = '(' . $regexPart . ')'; + + $isOptional = substr($set[0][0], -1) === '?'; + + if ($isOptional) + { + $match = $this->makeOptional($match); + } + + $this->reverseParts[$this->partsCounter] = [ + 'variable' => true, + 'optional' => $isOptional, + 'name' => $set[1][0] + ]; + + $this->parts[$this->partsCounter++] = $match; + } + + $this->staticParts($route, strlen($route)); + + return [[implode('', $this->parts), $this->variables], array_values($this->reverseParts)]; + } + + /** + * Reset the parser ready for the next route. + */ + private function reset() + { + $this->parts = []; + + $this->reverseParts = []; + + $this->partsCounter = 0; + + $this->variables = []; + + $this->regexOffset = 0; + } + + /** + * Return any variable route portions from the given route. + * + * @param $route + * + * @return mixed + */ + private function extractVariableRouteParts($route) + { + if (preg_match_all(self::VARIABLE_REGEX, $route, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER)) + { + return $matches; + } + } + + /** + * @param $route + * @param $nextOffset + */ + private function staticParts($route, $nextOffset) + { + $static = preg_split('~(/)~u', substr($route, $this->regexOffset, $nextOffset - $this->regexOffset), 0, PREG_SPLIT_DELIM_CAPTURE); + + foreach ($static as $staticPart) + { + if ($staticPart) + { + $quotedPart = $this->quote($staticPart); + + $this->parts[$this->partsCounter] = $quotedPart; + + $this->reverseParts[$this->partsCounter] = [ + 'variable' => false, + 'value' => $staticPart + ]; + + $this->partsCounter++; + } + } + } + + /** + * @param $varName + */ + private function validateVariable($varName) + { + if (isset($this->variables[$varName])) + { + throw new BadRouteException("Cannot use the same placeholder '$varName' twice"); + } + + $this->variables[$varName] = $varName; + } + + /** + * @param $match + * + * @return string + */ + private function makeOptional($match) + { + $previous = $this->partsCounter - 1; + + if (isset($this->parts[$previous]) && $this->parts[$previous] === '/') + { + $this->partsCounter--; + $match = '(?:/' . $match . ')'; + } + + return $match . '?'; + } + + /** + * @param $part + * + * @return string + */ + private function quote($part) + { + return preg_quote($part, '~'); + } +} diff --git a/posts/faq/exchanges.md b/posts/faq/exchanges.md index 7c0a2f0c..0bd3eb5d 100644 --- a/posts/faq/exchanges.md +++ b/posts/faq/exchanges.md @@ -7,3 +7,6 @@ We are listed on several exchanges. You can buy or sell credits at one of these: - [Bittrex](https://bittrex.com/Market/Index?MarketName=BTC-LBC) - [Poloniex](https://poloniex.com/exchange#btc_lbc) +- [Shapeshift](https://shapeshift.io) +- [Changelly](https://changelly.com/exchange/BTC/LBC/1) +- [BitSquare](https://bitsquare.io/) \ No newline at end of file diff --git a/view/template/content/rss.php b/view/template/content/rss.php index 076b3f17..5deb064b 100644 --- a/view/template/content/rss.php +++ b/view/template/content/rss.php @@ -7,7 +7,7 @@ https://github.com/lbryio/lbry.io {{rss.lang}} Sat, 07 Sep 2002 09:42:31 GMT ?> - + <?php echo htmlspecialchars($post->getTitle()) ?> diff --git a/view/template/download/get.php b/view/template/download/get.php index 93a700e3..f0a91254 100644 --- a/view/template/download/get.php +++ b/view/template/download/get.php @@ -1,4 +1,4 @@ - + false]) ?> diff --git a/view/template/layout/basic.php b/view/template/layout/basic.php index 0bb5925b..ce145081 100644 --- a/view/template/layout/basic.php +++ b/view/template/layout/basic.php @@ -26,7 +26,7 @@ - + diff --git a/view/template/page/learn.php b/view/template/page/learn.php index 5bf08571..10726f6c 100644 --- a/view/template/page/learn.php +++ b/view/template/page/learn.php @@ -30,10 +30,7 @@ {{learn.explorer}} -